Compare commits
11 Commits
069b829294
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d146d2f319 | |||
| 3d408e43c9 | |||
| 4e411c727e | |||
| f4c2fa6ba3 | |||
| ef019eecee | |||
| a7ea4fa803 | |||
| 2313618c05 | |||
| 09db861064 | |||
| 31b18e1f52 | |||
| d699609aa1 | |||
| 5ac629c23d |
@@ -13,6 +13,8 @@ flutter run -d <device> # Run app on device
|
|||||||
dart run build_runner build # Generate ObjectBox code after entity changes
|
dart run build_runner build # Generate ObjectBox code after entity changes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Versioning:** The Flutter app version is maintained in `pubspec.yaml` (`version:`); the settings view shows it at runtime via `package_info_plus`. The web app/backend version is maintained separately in `backend/pom.xml` (`<revision>`) and surfaced through the `app.version` property.
|
||||||
|
|
||||||
**Important:** Run `flutter analyze` after every task and fix any reported issues before committing.
|
**Important:** Run `flutter analyze` after every task and fix any reported issues before committing.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|||||||
@@ -258,6 +258,40 @@ class Job {
|
|||||||
return (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0);
|
return (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tasks embedded in deliveryStations are snapshots from job creation
|
||||||
|
// and never reflect later completions. The top-level tasks list comes from
|
||||||
|
// the server's task collection and carries the current completion state,
|
||||||
|
// so overlay it onto the station tasks by id.
|
||||||
|
final topLevelTasksRaw = json['tasks'] ?? jobJson['tasks'];
|
||||||
|
if (deliveryStations.isNotEmpty && topLevelTasksRaw is List) {
|
||||||
|
final freshTasksById = <String, Task>{};
|
||||||
|
for (final rawTask in topLevelTasksRaw) {
|
||||||
|
if (rawTask is! Map) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final task = Task.fromJson(Map<String, dynamic>.from(rawTask));
|
||||||
|
if (task.id.isNotEmpty) {
|
||||||
|
freshTasksById[task.id] = task;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore unparseable entries; the station snapshot stays in place.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final station in deliveryStations) {
|
||||||
|
for (var i = 0; i < station.tasks.length; i++) {
|
||||||
|
final fresh = freshTasksById[station.tasks[i].id];
|
||||||
|
if (fresh != null && fresh.completed && !station.tasks[i].completed) {
|
||||||
|
station.tasks[i] = station.tasks[i].copyWith(
|
||||||
|
completed: true,
|
||||||
|
completedAt: fresh.completedAt,
|
||||||
|
completedBy: fresh.completedBy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse tasks array
|
// Parse tasks array
|
||||||
List<Task> tasks = [];
|
List<Task> tasks = [];
|
||||||
if (deliveryStations.isNotEmpty) {
|
if (deliveryStations.isNotEmpty) {
|
||||||
|
|||||||
@@ -116,9 +116,16 @@ class DatabaseService {
|
|||||||
final jobBox = _store!.box<JobEntity>();
|
final jobBox = _store!.box<JobEntity>();
|
||||||
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
final taskStatusBox = _store!.box<TaskStatusEntity>();
|
||||||
|
|
||||||
// Clear existing jobs and related task statuses before inserting new ones
|
// Clear existing jobs before inserting new ones. Task statuses are NOT
|
||||||
|
// cleared wholesale: locally completed tasks may not be confirmed by the
|
||||||
|
// server yet (offline buffering), so only statuses of tasks that no
|
||||||
|
// longer exist in any job are removed below.
|
||||||
jobBox.removeAll();
|
jobBox.removeAll();
|
||||||
taskStatusBox.removeAll();
|
|
||||||
|
final existingStatuses = {
|
||||||
|
for (final entity in taskStatusBox.getAll()) entity.taskId: entity,
|
||||||
|
};
|
||||||
|
final knownTaskIds = <String>{};
|
||||||
|
|
||||||
// Save new jobs
|
// Save new jobs
|
||||||
for (final job in jobs) {
|
for (final job in jobs) {
|
||||||
@@ -134,7 +141,8 @@ class DatabaseService {
|
|||||||
// Also persist task completion states from JSON (adopt status on load)
|
// Also persist task completion states from JSON (adopt status on load)
|
||||||
// Only set completed=true entries to avoid overwriting local progress with false
|
// Only set completed=true entries to avoid overwriting local progress with false
|
||||||
for (final task in normalized.tasks) {
|
for (final task in normalized.tasks) {
|
||||||
if (task.completed) {
|
knownTaskIds.add(task.id);
|
||||||
|
if (task.completed && !existingStatuses.containsKey(task.id)) {
|
||||||
final taskStatusEntity = TaskStatusEntity(
|
final taskStatusEntity = TaskStatusEntity(
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
completed: true,
|
completed: true,
|
||||||
@@ -147,6 +155,13 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop statuses of tasks that no longer belong to any assigned job
|
||||||
|
for (final entry in existingStatuses.entries) {
|
||||||
|
if (!knownTaskIds.contains(entry.key)) {
|
||||||
|
taskStatusBox.remove(entry.value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'Jobs and task statuses saved successfully',
|
'Jobs and task statuses saved successfully',
|
||||||
name: 'DatabaseService',
|
name: 'DatabaseService',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
import 'app_theme.dart';
|
import 'app_theme.dart';
|
||||||
@@ -26,11 +27,23 @@ class SettingsView extends StatefulWidget {
|
|||||||
class _SettingsViewState extends State<SettingsView> {
|
class _SettingsViewState extends State<SettingsView> {
|
||||||
late String _selectedLanguageCode;
|
late String _selectedLanguageCode;
|
||||||
final AppState _appState = AppState();
|
final AppState _appState = AppState();
|
||||||
|
String _appVersion = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedLanguageCode = _appState.languageCode;
|
_selectedLanguageCode = _appState.languageCode;
|
||||||
|
_loadAppVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAppVersion() async {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_appVersion = info.version;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLanguageSelected(String languageCode) async {
|
void _onLanguageSelected(String languageCode) async {
|
||||||
@@ -213,7 +226,7 @@ class _SettingsViewState extends State<SettingsView> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.info_outline, color: AppColors.textMuted),
|
leading: Icon(Icons.info_outline, color: AppColors.textMuted),
|
||||||
title: Text(l10n.version),
|
title: Text(l10n.version),
|
||||||
subtitle: const Text('0.9.2'),
|
subtitle: Text(_appVersion),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, indent: 72),
|
const Divider(height: 1, indent: 72),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.9.16+1
|
version: 0.9.20+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
sdk: ^3.7.0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.9.16</revision>
|
<revision>0.9.20</revision>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
@@ -44,6 +44,31 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Mockito + ByteBuddy hochziehen, weil die Spring-Boot-3.4.3-Defaults
|
||||||
|
(Mockito 5.14.2 / ByteBuddy 1.15.11) den Inline-Mock-Maker auf
|
||||||
|
JDK 25 nicht laden können — die JVM lehnt die Class-Modifikation
|
||||||
|
von java.lang.Object ab. Mockito 5.18 + ByteBuddy 1.17.5 sind die
|
||||||
|
erste stabile Kombination, die JDK 25 offiziell trägt. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<version>5.18.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
|
<version>5.18.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.bytebuddy</groupId>
|
||||||
|
<artifactId>byte-buddy</artifactId>
|
||||||
|
<version>1.17.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.bytebuddy</groupId>
|
||||||
|
<artifactId>byte-buddy-agent</artifactId>
|
||||||
|
<version>1.17.5</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
@@ -159,10 +184,11 @@
|
|||||||
<version>5.0.5</version>
|
<version>5.0.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
|
<!-- Anthropic Claude SDK -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>com.anthropic</groupId>
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
<artifactId>anthropic-java</artifactId>
|
||||||
|
<version>2.34.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- MCP Server mit WebMVC Transport -->
|
<!-- MCP Server mit WebMVC Transport -->
|
||||||
|
|||||||
Binary file not shown.
@@ -40,6 +40,36 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var elements = window.profileInvoiceState.elements;
|
var elements = window.profileInvoiceState.elements;
|
||||||
var selectedElement = window.profileInvoiceState.selectedElement;
|
var selectedElement = window.profileInvoiceState.selectedElement;
|
||||||
var elementCounter = window.profileInvoiceState.elementCounter;
|
var elementCounter = window.profileInvoiceState.elementCounter;
|
||||||
|
|
||||||
|
// Undo history: snapshots of the elements array, max 10 steps back.
|
||||||
|
// Reset on every init so a canvas never restores states of the other
|
||||||
|
// generator page (profile vs. admin system template).
|
||||||
|
var undoStack = [];
|
||||||
|
var MAX_UNDO_STEPS = 10;
|
||||||
|
|
||||||
|
function pushUndoState() {
|
||||||
|
try {
|
||||||
|
var snapshot = JSON.stringify(elements);
|
||||||
|
if (undoStack.length && undoStack[undoStack.length - 1] === snapshot) return;
|
||||||
|
undoStack.push(snapshot);
|
||||||
|
if (undoStack.length > MAX_UNDO_STEPS) undoStack.shift();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Could not record undo state:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.undoProfileCanvas = function() {
|
||||||
|
if (!undoStack.length) return false;
|
||||||
|
var snapshot = JSON.parse(undoStack.pop());
|
||||||
|
// Keep the same array reference (shared with profileInvoiceState)
|
||||||
|
elements.length = 0;
|
||||||
|
snapshot.forEach(function(el) { elements.push(el); });
|
||||||
|
selectedElement = null;
|
||||||
|
saveState();
|
||||||
|
notifyElementDeselected();
|
||||||
|
draw();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
var isDragging = false;
|
var isDragging = false;
|
||||||
var dragStart = { x: 0, y: 0 };
|
var dragStart = { x: 0, y: 0 };
|
||||||
var elementStart = { x: 0, y: 0 };
|
var elementStart = { x: 0, y: 0 };
|
||||||
@@ -306,8 +336,23 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var colVatX = x + colNameWidth;
|
var colVatX = x + colNameWidth;
|
||||||
var colNetX = colVatX + colVatWidth;
|
var colNetX = colVatX + colVatWidth;
|
||||||
|
|
||||||
|
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
||||||
|
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
||||||
|
|
||||||
|
// Rows come from window.profileInvoiceServiceData when provided (system
|
||||||
|
// invoice template: the three admin price table positions); otherwise
|
||||||
|
// fall back to sample data (per-user profile invoice generator)
|
||||||
|
var serviceData = window.profileInvoiceServiceData;
|
||||||
|
var rows = (serviceData && serviceData.rows && serviceData.rows.length)
|
||||||
|
? serviceData.rows
|
||||||
|
: [
|
||||||
|
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
||||||
|
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
||||||
|
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
||||||
|
];
|
||||||
|
|
||||||
// Calculate actual content height based on table + summary
|
// Calculate actual content height based on table + summary
|
||||||
var tableOnlyHeight = rowHeight * 4; // Header + 3 data rows
|
var tableOnlyHeight = rowHeight * (rows.length + 1); // Header + data rows
|
||||||
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
var summaryOnlyHeight = summaryGap + (summaryRowHeight * 3) + summaryGap + summaryRowHeight + summaryGap;
|
||||||
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
var calculatedContentHeight = tableOnlyHeight + summaryOnlyHeight;
|
||||||
// Ensure background covers at least the element height or the calculated content
|
// Ensure background covers at least the element height or the calculated content
|
||||||
@@ -346,21 +391,11 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Nettobetrag column header (right-aligned)
|
// Nettobetrag column header (right-aligned)
|
||||||
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
ctx.fillText('Nettobetrag', colNetX + colNetWidth - padding, y + rowHeight / 2);
|
||||||
|
|
||||||
var vatRate = (window.profileInvoiceVatRate != null) ? window.profileInvoiceVatRate : 0.19;
|
|
||||||
var vatPctLabel = (Math.round(vatRate * 10000) / 100).toString().replace('.', ',') + '%';
|
|
||||||
|
|
||||||
// Sample data rows (placeholder)
|
|
||||||
var sampleData = [
|
|
||||||
{ name: 'Umzugsleistung inkl. Verpackung', vat: vatPctLabel, net: '450,00 €' },
|
|
||||||
{ name: 'Entsorgung Möbel', vat: vatPctLabel, net: '85,00 €' },
|
|
||||||
{ name: 'Montage/De-Montage', vat: vatPctLabel, net: '120,00 €' }
|
|
||||||
];
|
|
||||||
|
|
||||||
var currentY = y + rowHeight;
|
var currentY = y + rowHeight;
|
||||||
|
|
||||||
// Draw sample rows
|
// Draw data rows
|
||||||
ctx.font = fontSize + 'px Arial';
|
ctx.font = fontSize + 'px Arial';
|
||||||
sampleData.forEach(function(row, index) {
|
rows.forEach(function(row, index) {
|
||||||
// Draw row background (alternating)
|
// Draw row background (alternating)
|
||||||
if (index % 2 === 1) {
|
if (index % 2 === 1) {
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
ctx.fillStyle = 'rgba(0,0,0,0.02)';
|
||||||
@@ -416,10 +451,19 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
var labelX = x + colNameWidth + colVatWidth * 0.3; // Label column (left-aligned)
|
||||||
var valueX = x + w - padding; // Value column (right-aligned)
|
var valueX = x + w - padding; // Value column (right-aligned)
|
||||||
|
|
||||||
// Calculate totals from sample data
|
// Totals: provided with the service data or calculated from sample data
|
||||||
var netTotal = 655.00; // 450 + 85 + 120
|
var netTotalLabel, vatTotalLabel, grossTotalLabel;
|
||||||
var vatTotal = netTotal * vatRate;
|
if (serviceData && serviceData.netTotal) {
|
||||||
var grossTotal = netTotal + vatTotal;
|
netTotalLabel = serviceData.netTotal;
|
||||||
|
vatTotalLabel = serviceData.vatTotal;
|
||||||
|
grossTotalLabel = serviceData.grossTotal;
|
||||||
|
} else {
|
||||||
|
var netTotal = 655.00; // 450 + 85 + 120
|
||||||
|
var vatTotal = netTotal * vatRate;
|
||||||
|
netTotalLabel = netTotal.toFixed(2).replace('.', ',') + ' €';
|
||||||
|
vatTotalLabel = vatTotal.toFixed(2).replace('.', ',') + ' €';
|
||||||
|
grossTotalLabel = (netTotal + vatTotal).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
// Draw summary lines
|
// Draw summary lines
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -431,7 +475,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('Nettosumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(netTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(netTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||||
summaryY += summaryRowHeight;
|
summaryY += summaryRowHeight;
|
||||||
|
|
||||||
// Umsatzsteuer - label left, value right
|
// Umsatzsteuer - label left, value right
|
||||||
@@ -440,7 +484,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('zzgl. ' + vatPctLabel + ' USt:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.font = 'bold ' + fontSize + 'px Arial';
|
ctx.font = 'bold ' + fontSize + 'px Arial';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(vatTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(vatTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||||
summaryY += summaryRowHeight;
|
summaryY += summaryRowHeight;
|
||||||
|
|
||||||
// Gesamtsumme - label left, value right
|
// Gesamtsumme - label left, value right
|
||||||
@@ -450,7 +494,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
ctx.fillText('Gesamtsumme:', labelX, summaryY + summaryRowHeight / 2);
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(grossTotal.toFixed(2).replace('.', ',') + ' €', valueX, summaryY + summaryRowHeight / 2);
|
ctx.fillText(grossTotalLabel, valueX, summaryY + summaryRowHeight / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSelection(el) {
|
function drawSelection(el) {
|
||||||
@@ -630,6 +674,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
var handle = getResizeHandle(x, y, selectedElement);
|
var handle = getResizeHandle(x, y, selectedElement);
|
||||||
if (handle >= 0) {
|
if (handle >= 0) {
|
||||||
|
pushUndoState();
|
||||||
isResizing = true;
|
isResizing = true;
|
||||||
resizeHandle = handle;
|
resizeHandle = handle;
|
||||||
resizeStart = {
|
resizeStart = {
|
||||||
@@ -655,6 +700,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (clickedElement) {
|
if (clickedElement) {
|
||||||
|
pushUndoState();
|
||||||
selectedElement = clickedElement;
|
selectedElement = clickedElement;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
dragStart = { x: x, y: y };
|
dragStart = { x: x, y: y };
|
||||||
@@ -795,8 +841,19 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
canvas.setAttribute('tabindex', '0');
|
canvas.setAttribute('tabindex', '0');
|
||||||
canvas.addEventListener('keydown', function(e) {
|
canvas.addEventListener('keydown', function(e) {
|
||||||
|
// Undo with Ctrl+Z / Cmd+Z (works without a selected element)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||||
|
window.undoProfileCanvas();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedElement) return;
|
if (!selectedElement) return;
|
||||||
|
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Delete'].indexOf(e.key) > -1) {
|
||||||
|
pushUndoState();
|
||||||
|
}
|
||||||
|
|
||||||
var moved = false;
|
var moved = false;
|
||||||
|
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
@@ -856,6 +913,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
// Add element function
|
// Add element function
|
||||||
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
|
window.addProfileElement = function(type, label, dropX, dropY, isStatic, staticText, variable, isCustomer) {
|
||||||
|
pushUndoState();
|
||||||
elementCounter++;
|
elementCounter++;
|
||||||
var id = 'element-' + elementCounter;
|
var id = 'element-' + elementCounter;
|
||||||
|
|
||||||
@@ -972,6 +1030,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementText = function(id, text) {
|
window.updateProfileElementText = function(id, text) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
el.text = text;
|
el.text = text;
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -980,6 +1039,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementPosition = function(id, x, y) {
|
window.updateProfileElementPosition = function(id, x, y) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
if (x !== null) el.x = snapToGrid(x);
|
if (x !== null) el.x = snapToGrid(x);
|
||||||
if (y !== null) el.y = snapToGrid(y);
|
if (y !== null) el.y = snapToGrid(y);
|
||||||
draw();
|
draw();
|
||||||
@@ -989,6 +1049,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementFontSize = function(id, size) {
|
window.updateProfileElementFontSize = function(id, size) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
el.fontSize = size;
|
el.fontSize = size;
|
||||||
// Update height based on text content and new font size
|
// Update height based on text content and new font size
|
||||||
if (el.type !== 'line' && el.type !== 'image') {
|
if (el.type !== 'line' && el.type !== 'image') {
|
||||||
@@ -1004,6 +1065,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementColor = function(id, color) {
|
window.updateProfileElementColor = function(id, color) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
el.color = color;
|
el.color = color;
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -1012,6 +1074,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.updateProfileElementSize = function(id, width, height) {
|
window.updateProfileElementSize = function(id, width, height) {
|
||||||
var el = elements.find(function(e) { return e.id === id; });
|
var el = elements.find(function(e) { return e.id === id; });
|
||||||
if (el) {
|
if (el) {
|
||||||
|
pushUndoState();
|
||||||
if (width !== null) el.width = width;
|
if (width !== null) el.width = width;
|
||||||
if (height !== null) el.height = height;
|
if (height !== null) el.height = height;
|
||||||
draw();
|
draw();
|
||||||
@@ -1025,6 +1088,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
// Load image to get dimensions
|
// Load image to get dimensions
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
img.onload = function() {
|
img.onload = function() {
|
||||||
|
pushUndoState();
|
||||||
var MAX_WIDTH = 1090;
|
var MAX_WIDTH = 1090;
|
||||||
var width = img.width;
|
var width = img.width;
|
||||||
var height = img.height;
|
var height = img.height;
|
||||||
@@ -1057,6 +1121,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
window.deleteProfileElement = function(id) {
|
window.deleteProfileElement = function(id) {
|
||||||
var index = elements.findIndex(function(e) { return e.id === id; });
|
var index = elements.findIndex(function(e) { return e.id === id; });
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
|
pushUndoState();
|
||||||
elements.splice(index, 1);
|
elements.splice(index, 1);
|
||||||
if (selectedElement && selectedElement.id === id) {
|
if (selectedElement && selectedElement.id === id) {
|
||||||
selectedElement = null;
|
selectedElement = null;
|
||||||
@@ -1075,10 +1140,13 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
|
|
||||||
// Clear canvas function
|
// Clear canvas function
|
||||||
window.clearProfileCanvas = function() {
|
window.clearProfileCanvas = function() {
|
||||||
elements = [];
|
if (elements.length) {
|
||||||
|
pushUndoState();
|
||||||
|
}
|
||||||
|
// Keep the same array reference (shared with profileInvoiceState and undo)
|
||||||
|
elements.length = 0;
|
||||||
selectedElement = null;
|
selectedElement = null;
|
||||||
window.profileInvoiceState.elements = [];
|
saveState();
|
||||||
window.profileInvoiceState.selectedElement = null;
|
|
||||||
notifyElementDeselected();
|
notifyElementDeselected();
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,124 +1,57 @@
|
|||||||
package de.assecutor.votianlt.ai.config;
|
package de.assecutor.votianlt.ai.config;
|
||||||
|
|
||||||
|
import com.anthropic.client.AnthropicClient;
|
||||||
|
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
|
||||||
|
import com.anthropic.models.messages.MessageCountTokensParams;
|
||||||
|
import com.anthropic.models.messages.MessageTokensCount;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
import java.net.HttpURLConnection;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for LLM integration via LM Studio. The LM Studio instance
|
* Configuration for LLM integration via the Anthropic Claude API.
|
||||||
* exposes an OpenAI-compatible API at {@code /v1/chat/completions}.
|
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LlmConfig {
|
public class LlmConfig {
|
||||||
|
|
||||||
@Value("${app.ai.lmstudio.base-url}")
|
@Value("${app.ai.anthropic.api-key}")
|
||||||
private String lmstudioBaseUrl;
|
private String apiKey;
|
||||||
|
|
||||||
@Value("${app.ai.lmstudio.model}")
|
@Value("${app.ai.anthropic.model:claude-opus-4-8}")
|
||||||
private String lmstudioModel;
|
private String model;
|
||||||
|
|
||||||
@Value("${app.ai.lmstudio.htaccess-username}")
|
|
||||||
private String lmstudioHtaccessUsername;
|
|
||||||
|
|
||||||
@Value("${app.ai.lmstudio.htaccess-password}")
|
|
||||||
private String lmstudioHtaccessPassword;
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void logConfig() {
|
public void logConfig() {
|
||||||
log.info("=== LLM Configuration ===");
|
log.info("=== LLM Configuration ===");
|
||||||
log.info("Provider: lmstudio");
|
log.info("Provider: Anthropic Claude");
|
||||||
log.info("Base URL: {}", lmstudioBaseUrl);
|
log.info("Model: {}", model);
|
||||||
log.info("Model: {}", lmstudioModel);
|
log.info("API key: {}", apiKey != null && !apiKey.isBlank() ? "configured" : "NOT configured");
|
||||||
log.info("HTACCESS auth: {}", hasHtaccessCredentials() ? "configured" : "not configured");
|
testConnection();
|
||||||
testConnection(lmstudioBaseUrl, lmstudioModel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void testConnection(String baseUrl, String model) {
|
/**
|
||||||
log.info("Testing LLM connection to: {}", baseUrl);
|
* Validate API key and connectivity using the free count_tokens endpoint (no
|
||||||
|
* tokens are billed).
|
||||||
// Test 1: Models endpoint
|
*/
|
||||||
testEndpoint(baseUrl + "/v1/models", "GET", null);
|
private void testConnection() {
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
// Test 2: Chat completions (no streaming)
|
log.warn("Skipping Claude connection test - no API key configured");
|
||||||
String testPayload = "{\"model\":\"" + model
|
return;
|
||||||
+ "\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1,\"stream\":false}";
|
}
|
||||||
testEndpoint(baseUrl + "/v1/chat/completions", "POST", testPayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void testEndpoint(String endpoint, String method, String payload) {
|
|
||||||
try {
|
try {
|
||||||
log.info("Testing endpoint: {} {}", method, endpoint);
|
AnthropicClient client = AnthropicOkHttpClient.builder().apiKey(apiKey).build();
|
||||||
URL url = URI.create(endpoint).toURL();
|
MessageTokensCount count = client.messages()
|
||||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
.countTokens(MessageCountTokensParams.builder().model(model).addUserMessage("ping").build());
|
||||||
connection.setRequestMethod(method);
|
log.info("Claude connection test -> SUCCESS (count_tokens returned {} input tokens)",
|
||||||
connection.setConnectTimeout(5000);
|
count.inputTokens());
|
||||||
connection.setReadTimeout(10000);
|
|
||||||
|
|
||||||
if (hasHtaccessCredentials()) {
|
|
||||||
String credentials = lmstudioHtaccessUsername + ":" + lmstudioHtaccessPassword;
|
|
||||||
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
|
|
||||||
connection.setRequestProperty("Authorization", "Basic " + encoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload != null) {
|
|
||||||
connection.setDoOutput(true);
|
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
|
||||||
try (var os = connection.getOutputStream()) {
|
|
||||||
os.write(payload.getBytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int responseCode = connection.getResponseCode();
|
|
||||||
String responseMessage = connection.getResponseMessage();
|
|
||||||
|
|
||||||
if (responseCode >= 200 && responseCode < 300) {
|
|
||||||
log.info(" -> SUCCESS (HTTP {} {})", responseCode, responseMessage);
|
|
||||||
} else {
|
|
||||||
String errorBody = "";
|
|
||||||
try (var is = connection.getErrorStream()) {
|
|
||||||
if (is != null) {
|
|
||||||
errorBody = new String(is.readAllBytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.warn(" -> HTTP {} {} - {}", responseCode, responseMessage, errorBody);
|
|
||||||
}
|
|
||||||
connection.disconnect();
|
|
||||||
} catch (java.net.ConnectException e) {
|
|
||||||
log.error(" -> FAILED - Connection refused: {}", e.getMessage());
|
|
||||||
} catch (java.net.SocketTimeoutException e) {
|
|
||||||
log.error(" -> FAILED - Timeout: {}", e.getMessage());
|
|
||||||
} catch (java.net.UnknownHostException e) {
|
|
||||||
log.error(" -> FAILED - Unknown host: {}", e.getMessage());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error(" -> FAILED: {} - {}", e.getClass().getSimpleName(), e.getMessage());
|
log.error("Claude connection test -> FAILED: {} - {}", e.getClass().getSimpleName(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getBaseUrl() {
|
|
||||||
return lmstudioBaseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getModel() {
|
public String getModel() {
|
||||||
return lmstudioModel;
|
return model;
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasHtaccessCredentials() {
|
|
||||||
return lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank()
|
|
||||||
&& lmstudioHtaccessPassword != null && !lmstudioHtaccessPassword.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getLmstudioHtaccessUsername() {
|
|
||||||
return lmstudioHtaccessUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getLmstudioHtaccessPassword() {
|
|
||||||
return lmstudioHtaccessPassword;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
package de.assecutor.votianlt.ai.service;
|
package de.assecutor.votianlt.ai.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.anthropic.client.AnthropicClient;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
|
||||||
|
import com.anthropic.models.messages.Message;
|
||||||
|
import com.anthropic.models.messages.MessageCreateParams;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direct REST client for LM Studio LLM API. Communicates via the
|
* LLM client backed by the Anthropic Claude API (official Java SDK). Keeps the
|
||||||
* OpenAI-compatible /v1/chat/completions endpoint.
|
* same public interface as the previous LM-Studio-based implementation so
|
||||||
|
* callers (AiStatisticsService, TranslationService) stay unchanged.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -27,34 +23,14 @@ public class LlmRestClient {
|
|||||||
private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is)<think>.*?</think>");
|
private static final Pattern THINK_BLOCK_PATTERN = Pattern.compile("(?is)<think>.*?</think>");
|
||||||
private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)</?think>");
|
private static final Pattern THINK_TAG_PATTERN = Pattern.compile("(?is)</?think>");
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final AnthropicClient client;
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final String model;
|
private final String model;
|
||||||
|
|
||||||
public LlmRestClient(@Value("${app.ai.lmstudio.base-url}") String lmstudioBaseUrl,
|
public LlmRestClient(@Value("${app.ai.anthropic.api-key}") String apiKey,
|
||||||
@Value("${app.ai.lmstudio.model}") String lmstudioModel,
|
@Value("${app.ai.anthropic.model:claude-opus-4-8}") String model) {
|
||||||
@Value("${app.ai.lmstudio.htaccess-username}") String lmstudioHtaccessUsername,
|
this.model = model;
|
||||||
@Value("${app.ai.lmstudio.htaccess-password}") String lmstudioHtaccessPassword, ObjectMapper objectMapper) {
|
this.client = AnthropicOkHttpClient.builder().apiKey(apiKey).build();
|
||||||
|
log.info("LlmRestClient initialized - Provider: Anthropic Claude, Model: {}", model);
|
||||||
this.model = lmstudioModel;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
|
|
||||||
WebClient.Builder builder = WebClient.builder();
|
|
||||||
builder.baseUrl(lmstudioBaseUrl + "/v1/chat/completions");
|
|
||||||
|
|
||||||
if (lmstudioHtaccessUsername != null && !lmstudioHtaccessUsername.isBlank() && lmstudioHtaccessPassword != null
|
|
||||||
&& !lmstudioHtaccessPassword.isBlank()) {
|
|
||||||
String credentials = lmstudioHtaccessUsername + ":" + lmstudioHtaccessPassword;
|
|
||||||
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
|
|
||||||
builder.defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encoded);
|
|
||||||
log.info("LlmRestClient initialized (with HTACCESS auth) - URL: {}/v1/chat/completions, Model: {}",
|
|
||||||
lmstudioBaseUrl, lmstudioModel);
|
|
||||||
} else {
|
|
||||||
log.info("LlmRestClient initialized - URL: {}/v1/chat/completions, Model: {}", lmstudioBaseUrl,
|
|
||||||
lmstudioModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webClient = builder.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,32 +54,45 @@ public class LlmRestClient {
|
|||||||
* @param userMessage
|
* @param userMessage
|
||||||
* User message/question
|
* User message/question
|
||||||
* @param temperature
|
* @param temperature
|
||||||
* Temperature for response randomness (0.0-1.0)
|
* Ignored - current Claude models do not accept sampling
|
||||||
|
* parameters (kept for signature compatibility)
|
||||||
* @param maxTokens
|
* @param maxTokens
|
||||||
* Maximum tokens in response
|
* Maximum tokens in response
|
||||||
* @return LLM response text, or null on error
|
* @return LLM response text, or null on error
|
||||||
*/
|
*/
|
||||||
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
public String chat(String systemPrompt, String userMessage, double temperature, int maxTokens) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> request = Map.of("model", model, "messages",
|
MessageCreateParams.Builder builder = MessageCreateParams.builder().model(model).maxTokens(maxTokens);
|
||||||
List.of(Map.of("role", "system", "content", systemPrompt != null ? systemPrompt : ""),
|
if (systemPrompt != null && !systemPrompt.isBlank()) {
|
||||||
Map.of("role", "user", "content", userMessage)),
|
builder.system(systemPrompt);
|
||||||
"temperature", temperature, "max_tokens", maxTokens, "stream", false);
|
}
|
||||||
|
builder.addUserMessage(userMessage);
|
||||||
|
|
||||||
log.info("Sending request to LLM (model: {}, prompt length: {} chars)...", model, userMessage.length());
|
log.info("Sending request to Claude (model: {}, prompt length: {} chars)...", model, userMessage.length());
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
String response = webClient.post().contentType(MediaType.APPLICATION_JSON).bodyValue(request).retrieve()
|
Message response = client.messages().create(builder.build());
|
||||||
.bodyToMono(String.class).timeout(Duration.ofSeconds(120)).block();
|
|
||||||
|
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
log.info("LLM response received in {}ms", duration);
|
log.info("Claude response received in {}ms (stop_reason: {})", duration, response.stopReason());
|
||||||
log.debug("LLM response payload received ({} chars)", response != null ? response.length() : 0);
|
|
||||||
|
|
||||||
return extractContent(response);
|
String content = response.content().stream().flatMap(block -> block.text().stream())
|
||||||
|
.map(textBlock -> textBlock.text()).collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
if (content.isBlank()) {
|
||||||
|
log.warn("Claude response content is empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitizedContent = sanitizeAssistantContent(content);
|
||||||
|
if (sanitizedContent.isBlank()) {
|
||||||
|
log.warn("Claude response content is empty after sanitization");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return sanitizedContent;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error calling LLM API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
|
log.error("Error calling Claude API: {} - {}", e.getClass().getSimpleName(), e.getMessage());
|
||||||
if (log.isDebugEnabled()) {
|
if (log.isDebugEnabled()) {
|
||||||
log.debug("Full stack trace:", e);
|
log.debug("Full stack trace:", e);
|
||||||
}
|
}
|
||||||
@@ -122,35 +111,6 @@ public class LlmRestClient {
|
|||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractContent(String response) {
|
|
||||||
if (response == null || response.isBlank()) {
|
|
||||||
log.warn("LLM returned null or blank response");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
JsonNode root = objectMapper.readTree(response);
|
|
||||||
JsonNode choices = root.path("choices");
|
|
||||||
if (choices.isArray() && !choices.isEmpty()) {
|
|
||||||
String content = choices.get(0).path("message").path("content").asText();
|
|
||||||
if (content == null || content.isBlank()) {
|
|
||||||
log.warn("LLM response content is empty");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String sanitizedContent = sanitizeAssistantContent(content);
|
|
||||||
if (sanitizedContent.isBlank()) {
|
|
||||||
log.warn("LLM response content is empty after sanitization");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return sanitizedContent;
|
|
||||||
}
|
|
||||||
log.warn("Unexpected response structure (no choices): {}", response);
|
|
||||||
return null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Error parsing LLM response: {}", e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sanitizeAssistantContent(String content) {
|
private String sanitizeAssistantContent(String content) {
|
||||||
String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" ");
|
String sanitized = THINK_BLOCK_PATTERN.matcher(content).replaceAll(" ");
|
||||||
sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" ");
|
sanitized = THINK_TAG_PATTERN.matcher(sanitized).replaceAll(" ");
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.assecutor.votianlt.config;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmalige Migration der vorhandenen Rechnungen auf das neue Lifecycle-Modell.
|
||||||
|
*
|
||||||
|
* Bestandsdaten haben weder Status noch Typ. Sie werden konservativ auf
|
||||||
|
* <code>type=INVOICE, status=ISSUED</code> gesetzt – sie sind in der Praxis bereits
|
||||||
|
* ausgestellt, denn sie tragen eine Rechnungsnummer und enthalten ein PDF.
|
||||||
|
*
|
||||||
|
* Ein Audit-Eintrag dokumentiert die Migration (R-37).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(50)
|
||||||
|
public class InvoiceLifecycleMigration implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleMigration.class);
|
||||||
|
|
||||||
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
|
|
||||||
|
public InvoiceLifecycleMigration(CustomerInvoiceRepository invoiceRepository) {
|
||||||
|
this.invoiceRepository = invoiceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
List<CustomerInvoice> legacyInvoices = invoiceRepository.findByStatusIsNull();
|
||||||
|
if (legacyInvoices.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (CustomerInvoice invoice : legacyInvoices) {
|
||||||
|
invoice.setType(InvoiceType.INVOICE);
|
||||||
|
invoice.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
if (invoice.getIssuedAt() == null) {
|
||||||
|
invoice.setIssuedAt(now);
|
||||||
|
}
|
||||||
|
InvoiceAuditEntry entry = new InvoiceAuditEntry(InvoiceAuditAction.ISSUED, null, "system",
|
||||||
|
"Migration: Bestandsrechnung auf Status ISSUED gesetzt.");
|
||||||
|
invoice.addAuditEntry(entry);
|
||||||
|
}
|
||||||
|
invoiceRepository.saveAll(legacyInvoices);
|
||||||
|
log.info("Lifecycle-Migration: {} Bestandsrechnungen auf Status ISSUED gesetzt.", legacyInvoices.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,8 +128,10 @@ public class MessageController {
|
|||||||
|
|
||||||
log.info("[JOBS] Retrieving assigned jobs for appUserId: {}", appUserId);
|
log.info("[JOBS] Retrieving assigned jobs for appUserId: {}", appUserId);
|
||||||
|
|
||||||
List<Job> assignedJobs = jobRepository.findByAppUser(appUserId);
|
List<Job> assignedJobs = jobRepository.findByAppUser(appUserId).stream()
|
||||||
log.info("[JOBS] Found {} jobs for appUserId: {}", assignedJobs.size(), appUserId);
|
.filter(job -> job.getStatus() != JobStatus.COMPLETED && job.getStatus() != JobStatus.CANCELLED)
|
||||||
|
.toList();
|
||||||
|
log.info("[JOBS] Found {} open jobs for appUserId: {}", assignedJobs.size(), appUserId);
|
||||||
|
|
||||||
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
|
List<JobWithRelatedDataDTO> jobsWithRelatedData = assignedJobs.stream().map(job -> {
|
||||||
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
|
List<CargoItem> cargoItems = cargoItemRepository.findByJobId(job.getId());
|
||||||
@@ -381,7 +383,8 @@ public class MessageController {
|
|||||||
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
|
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
|
||||||
extraDataSummary);
|
extraDataSummary);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore history logging errors
|
log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
|
||||||
|
e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email notification for task completion
|
// Send email notification for task completion
|
||||||
@@ -393,7 +396,8 @@ public class MessageController {
|
|||||||
// It is now driven by explicit station_completed messages from the app
|
// It is now driven by explicit station_completed messages from the app
|
||||||
// (see handleStationCompleted).
|
// (see handleStationCompleted).
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore email notification errors
|
log.error("[TASK] Could not send completion notification for task {} of job {}: {}", taskIdStr, jobId,
|
||||||
|
e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
jobUpdateBroadcaster.broadcast(jobId);
|
jobUpdateBroadcaster.broadcast(jobId);
|
||||||
@@ -420,11 +424,17 @@ public class MessageController {
|
|||||||
try {
|
try {
|
||||||
emailService.sendJobCompletionNotification(jobId, completedBy);
|
emailService.sendJobCompletionNotification(jobId, completedBy);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore email notification errors
|
log.error("[JOB] Could not send job completion notification for job {}: {}", jobId, e.getMessage(),
|
||||||
|
e);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
List<String> openTaskIds = mandatoryTasks.stream().filter(task -> !task.isCompleted())
|
||||||
|
.map(task -> task.getIdAsString()).toList();
|
||||||
|
log.info("[JOB] Job {} not completed yet, {} of {} mandatory tasks still open: {}", jobId,
|
||||||
|
openTaskIds.size(), mandatoryTasks.size(), openTaskIds);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore job completion check errors
|
log.error("[JOB] Completion check failed for job {}: {}", jobId, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +442,7 @@ public class MessageController {
|
|||||||
try {
|
try {
|
||||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
||||||
if (jobOpt.isEmpty()) {
|
if (jobOpt.isEmpty()) {
|
||||||
|
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,9 +451,10 @@ public class MessageController {
|
|||||||
job.setStatus(JobStatus.COMPLETED);
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
job.setUpdatedAt(LocalDateTime.now());
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
jobRepository.save(job);
|
jobRepository.save(job);
|
||||||
|
log.info("[JOB] Job {} marked as COMPLETED", jobId);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore job status update errors
|
log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,15 +474,19 @@ public class MessageController {
|
|||||||
*/
|
*/
|
||||||
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
|
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
|
||||||
try {
|
try {
|
||||||
|
String jobIdStr = payload.get("jobId") != null ? payload.get("jobId").toString() : null;
|
||||||
String jobNumber = payload.get("jobNumber") != null ? payload.get("jobNumber").toString() : null;
|
String jobNumber = payload.get("jobNumber") != null ? payload.get("jobNumber").toString() : null;
|
||||||
if (jobNumber == null || jobNumber.isBlank()) {
|
|
||||||
log.warn("[STATION] station_completed without jobNumber");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<Job> jobOpt = jobRepository.findByJobNumber(jobNumber);
|
Optional<Job> jobOpt = Optional.empty();
|
||||||
|
if (jobIdStr != null && ObjectId.isValid(jobIdStr)) {
|
||||||
|
jobOpt = jobRepository.findById(new ObjectId(jobIdStr));
|
||||||
|
}
|
||||||
|
if (jobOpt.isEmpty() && jobNumber != null && !jobNumber.isBlank()) {
|
||||||
|
jobOpt = jobRepository.findByJobNumber(jobNumber);
|
||||||
|
}
|
||||||
if (jobOpt.isEmpty()) {
|
if (jobOpt.isEmpty()) {
|
||||||
log.warn("[STATION] Job with jobNumber {} not found", jobNumber);
|
log.warn("[STATION] Job not found for station_completed (jobId={}, jobNumber={})", jobIdStr,
|
||||||
|
jobNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +521,8 @@ public class MessageController {
|
|||||||
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
|
ChatMessageInboundPayload inboundPayload = ChatMessageInboundPayload.fromPayload(payload);
|
||||||
messageService.receiveMessageFromClient(inboundPayload);
|
messageService.receiveMessageFromClient(inboundPayload);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Ignore message handling errors
|
log.error("[MESSAGE] Could not process incoming message from app user {}: {}", appUserId, e.getMessage(),
|
||||||
|
e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ public class PriceTable {
|
|||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
private String monthlyBasePackage;
|
private String monthlyBasePackage;
|
||||||
private String appUsageLicense;
|
|
||||||
private String revenueParticipation;
|
/** Fee billed per app user set up by the customer (unit price per month). */
|
||||||
|
private String appUserFee;
|
||||||
|
|
||||||
private String statisticalEvaluation;
|
private String statisticalEvaluation;
|
||||||
|
|
||||||
public PriceTable() {
|
public PriceTable() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
|
public PriceTable(String monthlyBasePackage, String appUserFee, String statisticalEvaluation) {
|
||||||
String statisticalEvaluation) {
|
|
||||||
this.monthlyBasePackage = monthlyBasePackage;
|
this.monthlyBasePackage = monthlyBasePackage;
|
||||||
this.appUsageLicense = appUsageLicense;
|
this.appUserFee = appUserFee;
|
||||||
this.revenueParticipation = revenueParticipation;
|
|
||||||
this.statisticalEvaluation = statisticalEvaluation;
|
this.statisticalEvaluation = statisticalEvaluation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,20 +41,12 @@ public class PriceTable {
|
|||||||
this.monthlyBasePackage = monthlyBasePackage;
|
this.monthlyBasePackage = monthlyBasePackage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getAppUsageLicense() {
|
public String getAppUserFee() {
|
||||||
return appUsageLicense;
|
return appUserFee;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAppUsageLicense(String appUsageLicense) {
|
public void setAppUserFee(String appUserFee) {
|
||||||
this.appUsageLicense = appUsageLicense;
|
this.appUserFee = appUserFee;
|
||||||
}
|
|
||||||
|
|
||||||
public String getRevenueParticipation() {
|
|
||||||
return revenueParticipation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRevenueParticipation(String revenueParticipation) {
|
|
||||||
this.revenueParticipation = revenueParticipation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getStatisticalEvaluation() {
|
public String getStatisticalEvaluation() {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.assecutor.votianlt.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Field;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable company data of the system operator (admin). Used as issuer data
|
||||||
|
* on system invoices to the users; falls back to the defaults hardcoded in
|
||||||
|
* SystemInvoiceData as long as no profile has been saved.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Document(collection = "system_company_profile")
|
||||||
|
public class SystemCompanyProfile {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Field("company_name")
|
||||||
|
private String companyName;
|
||||||
|
|
||||||
|
@Field("company_subtitle")
|
||||||
|
private String companySubtitle;
|
||||||
|
|
||||||
|
@Field("company_street")
|
||||||
|
private String companyStreet;
|
||||||
|
|
||||||
|
@Field("company_city")
|
||||||
|
private String companyCity;
|
||||||
|
|
||||||
|
@Field("company_phone")
|
||||||
|
private String companyPhone;
|
||||||
|
|
||||||
|
@Field("company_fax")
|
||||||
|
private String companyFax;
|
||||||
|
|
||||||
|
@Field("company_email")
|
||||||
|
private String companyEmail;
|
||||||
|
|
||||||
|
@Field("company_website")
|
||||||
|
private String companyWebsite;
|
||||||
|
|
||||||
|
@Field("sender_line")
|
||||||
|
private String senderLine;
|
||||||
|
|
||||||
|
@Field("payment_terms")
|
||||||
|
private String paymentTerms;
|
||||||
|
|
||||||
|
@Field("footer_text")
|
||||||
|
private String footerText;
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices;
|
|||||||
import org.springframework.data.annotation.Id;
|
import org.springframework.data.annotation.Id;
|
||||||
import org.springframework.data.mongodb.core.mapping.Document;
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Document(collection = "customerInvoices")
|
@Document(collection = "customerInvoices")
|
||||||
@@ -12,6 +14,33 @@ public class CustomerInvoice {
|
|||||||
@Id
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
|
|
||||||
|
// Lebenszyklus und Belegtyp gemäß invoices_rules R-01 bis R-22
|
||||||
|
private InvoiceStatus status = InvoiceStatus.DRAFT;
|
||||||
|
private InvoiceType type = InvoiceType.INVOICE;
|
||||||
|
|
||||||
|
// Verknüpfung auf die Originalrechnung bei Korrektur- oder Stornobelegen (R-10, R-13, R-19, R-22, R-30)
|
||||||
|
private String originalInvoiceId;
|
||||||
|
private String originalInvoiceNumber;
|
||||||
|
private LocalDate originalInvoiceDate;
|
||||||
|
|
||||||
|
// Verkettung: bei stornierten/korrigierten Originalen Verweise auf die erzeugten Folgebelege.
|
||||||
|
private String cancellationInvoiceId;
|
||||||
|
private String correctionInvoiceId;
|
||||||
|
private String replacementInvoiceId;
|
||||||
|
|
||||||
|
// Zeitstempel für Statusübergänge
|
||||||
|
private LocalDateTime issuedAt;
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
private LocalDateTime cancelledAt;
|
||||||
|
|
||||||
|
// Änderungsprotokoll (R-36 bis R-39)
|
||||||
|
private List<InvoiceAuditEntry> auditLog = new ArrayList<>();
|
||||||
|
|
||||||
|
// Zahlungsstatus gemäß R-23 bis R-26
|
||||||
|
private PaymentStatus paymentStatus = PaymentStatus.UNPAID;
|
||||||
|
private BigDecimal paidAmount;
|
||||||
|
private LocalDateTime lastPaymentAt;
|
||||||
|
|
||||||
// Pflichtangaben nach §14 UStG (German VAT law)
|
// Pflichtangaben nach §14 UStG (German VAT law)
|
||||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||||
private LocalDate invoiceDate; // Rechnungsdatum
|
private LocalDate invoiceDate; // Rechnungsdatum
|
||||||
@@ -37,6 +66,7 @@ public class CustomerInvoice {
|
|||||||
private String recipientCity;
|
private String recipientCity;
|
||||||
private String recipientCountry;
|
private String recipientCountry;
|
||||||
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
|
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
|
||||||
|
private String recipientEmail; // E-Mail für den Rechnungsversand
|
||||||
|
|
||||||
// Rechnungsdetails
|
// Rechnungsdetails
|
||||||
private String description; // Beschreibung der Leistung
|
private String description; // Beschreibung der Leistung
|
||||||
@@ -245,6 +275,14 @@ public class CustomerInvoice {
|
|||||||
this.recipientVatId = recipientVatId;
|
this.recipientVatId = recipientVatId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRecipientEmail() {
|
||||||
|
return recipientEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRecipientEmail(String recipientEmail) {
|
||||||
|
this.recipientEmail = recipientEmail;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
@@ -372,4 +410,134 @@ public class CustomerInvoice {
|
|||||||
public void setPdfData(byte[] pdfData) {
|
public void setPdfData(byte[] pdfData) {
|
||||||
this.pdfData = pdfData;
|
this.pdfData = pdfData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public InvoiceStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(InvoiceStatus status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceType getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setType(InvoiceType type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOriginalInvoiceId() {
|
||||||
|
return originalInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalInvoiceId(String originalInvoiceId) {
|
||||||
|
this.originalInvoiceId = originalInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOriginalInvoiceNumber() {
|
||||||
|
return originalInvoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalInvoiceNumber(String originalInvoiceNumber) {
|
||||||
|
this.originalInvoiceNumber = originalInvoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getOriginalInvoiceDate() {
|
||||||
|
return originalInvoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalInvoiceDate(LocalDate originalInvoiceDate) {
|
||||||
|
this.originalInvoiceDate = originalInvoiceDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCancellationInvoiceId() {
|
||||||
|
return cancellationInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCancellationInvoiceId(String cancellationInvoiceId) {
|
||||||
|
this.cancellationInvoiceId = cancellationInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCorrectionInvoiceId() {
|
||||||
|
return correctionInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCorrectionInvoiceId(String correctionInvoiceId) {
|
||||||
|
this.correctionInvoiceId = correctionInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReplacementInvoiceId() {
|
||||||
|
return replacementInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReplacementInvoiceId(String replacementInvoiceId) {
|
||||||
|
this.replacementInvoiceId = replacementInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getIssuedAt() {
|
||||||
|
return issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssuedAt(LocalDateTime issuedAt) {
|
||||||
|
this.issuedAt = issuedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getSentAt() {
|
||||||
|
return sentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSentAt(LocalDateTime sentAt) {
|
||||||
|
this.sentAt = sentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCancelledAt() {
|
||||||
|
return cancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCancelledAt(LocalDateTime cancelledAt) {
|
||||||
|
this.cancelledAt = cancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<InvoiceAuditEntry> getAuditLog() {
|
||||||
|
if (auditLog == null) {
|
||||||
|
auditLog = new ArrayList<>();
|
||||||
|
}
|
||||||
|
return auditLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuditLog(List<InvoiceAuditEntry> auditLog) {
|
||||||
|
this.auditLog = auditLog != null ? auditLog : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addAuditEntry(InvoiceAuditEntry entry) {
|
||||||
|
if (entry == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getAuditLog().add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentStatus getPaymentStatus() {
|
||||||
|
return paymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPaymentStatus(PaymentStatus paymentStatus) {
|
||||||
|
this.paymentStatus = paymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getPaidAmount() {
|
||||||
|
return paidAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPaidAmount(BigDecimal paidAmount) {
|
||||||
|
this.paidAmount = paidAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastPaymentAt() {
|
||||||
|
return lastPaymentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
|
||||||
|
this.lastPaymentAt = lastPaymentAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktionen, die im Rechnungs-Audit-Log gemäß R-36 protokolliert werden.
|
||||||
|
*/
|
||||||
|
public enum InvoiceAuditAction {
|
||||||
|
CREATED_DRAFT, UPDATED_DRAFT, ISSUED, SENT, CANCELLED, CORRECTED, REPLACED, DELETED_DRAFT, PAYMENT_RECORDED
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelner Eintrag im Änderungsprotokoll einer Rechnung gemäß R-36 bis R-39.
|
||||||
|
*
|
||||||
|
* Eingebettet in {@link CustomerInvoice}; wird ausschließlich angehängt, niemals
|
||||||
|
* geändert. Hält Wer/Wann/Was/Warum sowie ggf. den erzeugten Folgebeleg fest.
|
||||||
|
*/
|
||||||
|
public class InvoiceAuditEntry {
|
||||||
|
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
private String userId;
|
||||||
|
private String userDisplayName;
|
||||||
|
private InvoiceAuditAction action;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
/** Optionale Referenz auf einen erzeugten Folgebeleg (Korrektur, Storno, Ersatzrechnung). */
|
||||||
|
private String resultingInvoiceId;
|
||||||
|
private String resultingInvoiceNumber;
|
||||||
|
|
||||||
|
public InvoiceAuditEntry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceAuditEntry(InvoiceAuditAction action, String userId, String userDisplayName, String reason) {
|
||||||
|
this.timestamp = LocalDateTime.now();
|
||||||
|
this.action = action;
|
||||||
|
this.userId = userId;
|
||||||
|
this.userDisplayName = userDisplayName;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(LocalDateTime timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(String userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserDisplayName() {
|
||||||
|
return userDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserDisplayName(String userDisplayName) {
|
||||||
|
this.userDisplayName = userDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceAuditAction getAction() {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAction(InvoiceAuditAction action) {
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResultingInvoiceId() {
|
||||||
|
return resultingInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResultingInvoiceId(String resultingInvoiceId) {
|
||||||
|
this.resultingInvoiceId = resultingInvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResultingInvoiceNumber() {
|
||||||
|
return resultingInvoiceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResultingInvoiceNumber(String resultingInvoiceNumber) {
|
||||||
|
this.resultingInvoiceNumber = resultingInvoiceNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.index.CompoundIndex;
|
||||||
|
import org.springframework.data.mongodb.core.index.CompoundIndexes;
|
||||||
|
import org.springframework.data.mongodb.core.index.Indexed;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Eintrag für jede aus dem Rechnungsnummern-Counter gezogene Nummer.
|
||||||
|
* Erlaubt nachzuweisen, dass jede Lücke im fortlaufenden Nummernkreis erklärt
|
||||||
|
* werden kann (vergeben aber nicht ausgestellt → entweder offen oder begründet
|
||||||
|
* verworfen). Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG i.V.m. GoBD.
|
||||||
|
*
|
||||||
|
* Pro (userId, sequence) existiert genau eine Reservierung — ein Eindeutigkeits-
|
||||||
|
* Index erzwingt das auch bei nebenläufigen Aufrufen.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Document(collection = "invoice_number_reservations")
|
||||||
|
@CompoundIndexes({
|
||||||
|
@CompoundIndex(name = "user_sequence_unique", def = "{'userId': 1, 'sequence': 1}", unique = true),
|
||||||
|
@CompoundIndex(name = "user_status", def = "{'userId': 1, 'status': 1}")
|
||||||
|
})
|
||||||
|
public class InvoiceNumberReservation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private ObjectId id;
|
||||||
|
|
||||||
|
@Indexed
|
||||||
|
private ObjectId userId;
|
||||||
|
|
||||||
|
/** Vollständige formatierte Rechnungsnummer wie sie auf dem Beleg erscheint (Präfix + Sequenz). */
|
||||||
|
private String number;
|
||||||
|
|
||||||
|
/** Roh-Sequenznummer aus dem Counter — Basis für Lücken-Analyse. */
|
||||||
|
private long sequence;
|
||||||
|
|
||||||
|
/** Präfix, mit dem die Nummer formatiert wurde — relevant, falls der Anwender den Präfix später ändert. */
|
||||||
|
private String prefix;
|
||||||
|
|
||||||
|
private Instant reservedAt;
|
||||||
|
|
||||||
|
/** Anzeigename des reservierenden Nutzers (z.B. „Anna Müller") oder „system" für Hintergrundprozesse. */
|
||||||
|
private String reservedBy;
|
||||||
|
|
||||||
|
private InvoiceNumberReservationStatus status;
|
||||||
|
|
||||||
|
/** Bei status=USED: ID der ausgestellten Rechnung. */
|
||||||
|
private String invoiceId;
|
||||||
|
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
/** Bei status=VOIDED: vom Anwender erfasster Grund — Pflichtfeld für betriebsprüfungstaugliche Erklärung. */
|
||||||
|
private String voidReason;
|
||||||
|
|
||||||
|
private Instant voidedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status einer aus dem Nummernkreis gezogenen Rechnungsnummer.
|
||||||
|
*
|
||||||
|
* RESERVED – Nummer wurde aus dem Counter gezogen, aber noch keine Rechnung dazu festgeschrieben.
|
||||||
|
* Bleibt eine Reservierung lange in diesem Zustand, deutet das auf einen
|
||||||
|
* abgebrochenen Erstell-Prozess hin und produziert eine erklärungsbedürftige
|
||||||
|
* Lücke im Nummernkreis (§ 14 Abs. 4 Nr. 4 UStG).
|
||||||
|
* USED – Eine festgeschriebene Rechnung trägt diese Nummer; lücken-unkritisch.
|
||||||
|
* VOIDED – Reservierung wurde bewusst verworfen (Erstellprozess abgebrochen, Anwender
|
||||||
|
* hat erklärt, warum die Nummer nicht ausgestellt wurde) — Lücke ist
|
||||||
|
* dokumentiert und betriebsprüfungstauglich.
|
||||||
|
*/
|
||||||
|
public enum InvoiceNumberReservationStatus {
|
||||||
|
RESERVED, USED, VOIDED
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lebenszyklus einer Rechnung gemäß R-01 bis R-04.
|
||||||
|
*
|
||||||
|
* DRAFT – noch in Bearbeitung, darf editiert oder gelöscht werden.
|
||||||
|
* ISSUED – formal ausgestellt/gebucht, darf nicht mehr direkt überschrieben werden.
|
||||||
|
* SENT – an den Empfänger versendet.
|
||||||
|
* CANCELLED – durch eine Stornorechnung aufgehoben.
|
||||||
|
* CORRECTED – durch eine Berichtigung formal korrigiert (Original bleibt sichtbar).
|
||||||
|
*/
|
||||||
|
public enum InvoiceStatus {
|
||||||
|
DRAFT, ISSUED, SENT, CANCELLED, CORRECTED;
|
||||||
|
|
||||||
|
public boolean isFinalized() {
|
||||||
|
return this != DRAFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMutable() {
|
||||||
|
return this == DRAFT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belegtyp einer Rechnung gemäß R-09, R-12 ff. und R-17 ff.
|
||||||
|
*
|
||||||
|
* INVOICE – reguläre Ausgangsrechnung.
|
||||||
|
* CORRECTION – Rechnungsberichtigung für formale Fehler (R-12 bis R-16).
|
||||||
|
* Verweist eindeutig auf die zu korrigierende Originalrechnung.
|
||||||
|
* CANCELLATION – Stornorechnung für wirtschaftliche Fehler (R-17 bis R-22).
|
||||||
|
* Verweist eindeutig auf die zu stornierende Originalrechnung.
|
||||||
|
*/
|
||||||
|
public enum InvoiceType {
|
||||||
|
INVOICE, CORRECTION, CANCELLATION
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zahlungsstatus einer Rechnung gemäß R-23 bis R-26.
|
||||||
|
*
|
||||||
|
* UNPAID – noch nicht bezahlt.
|
||||||
|
* PARTIALLY_PAID – Teilzahlung erhalten, Restbetrag offen.
|
||||||
|
* PAID – vollständig bezahlt.
|
||||||
|
* OVERPAID – Zahlbetrag übersteigt den Rechnungsbetrag.
|
||||||
|
* REFUND_DUE – Erstattungsbetrag offen (z.B. nach Storno einer bezahlten Rechnung).
|
||||||
|
*/
|
||||||
|
public enum PaymentStatus {
|
||||||
|
UNPAID, PARTIALLY_PAID, PAID, OVERPAID, REFUND_DUE
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.assecutor.votianlt.model.invoices;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.index.Indexed;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Field;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that a system invoice has been sent to a customer by email. Used to
|
||||||
|
* filter the monthly invoice list into open and already sent invoices.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Document(collection = "system_invoice_dispatch")
|
||||||
|
public class SystemInvoiceDispatch {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private ObjectId id;
|
||||||
|
|
||||||
|
/** Invoice number, e.g. SYS-202607-42; one dispatch record per invoice. */
|
||||||
|
@Indexed(unique = true)
|
||||||
|
@Field("invoice_number")
|
||||||
|
private String invoiceNumber;
|
||||||
|
|
||||||
|
@Field("user_id")
|
||||||
|
private ObjectId userId;
|
||||||
|
|
||||||
|
/** Billing month in ISO format, e.g. 2026-07. */
|
||||||
|
@Field("billing_month")
|
||||||
|
private String billingMonth;
|
||||||
|
|
||||||
|
@Field("sent_at")
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
|
||||||
|
@Field("sent_to")
|
||||||
|
private String sentTo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.assecutor.votianlt.pages.base.ui.component;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.Customer;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class CustomerAddressLabelHelper {
|
||||||
|
|
||||||
|
private CustomerAddressLabelHelper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the customer under its full address label ("Name, Street No, Zip City"). */
|
||||||
|
public static void put(Map<String, Customer> target, Customer customer, String fallbackLabel) {
|
||||||
|
target.put(build(customer, fallbackLabel), customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the customer under its plain name label (company or person name). */
|
||||||
|
public static void putName(Map<String, Customer> target, Customer customer, String fallbackLabel) {
|
||||||
|
target.put(buildName(customer, fallbackLabel), customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the selection label as full address ("Name, Street No, Zip City")
|
||||||
|
* so that entries with the same name (e.g. branches of one company) remain
|
||||||
|
* distinguishable in the combo boxes.
|
||||||
|
*/
|
||||||
|
public static String build(Customer customer, String fallbackLabel) {
|
||||||
|
if (customer == null) {
|
||||||
|
return fallbackLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder label = new StringBuilder(buildName(customer, fallbackLabel));
|
||||||
|
String street = trimToNull(join(" ", customer.getStreet(), customer.getHouseNumber()));
|
||||||
|
if (street != null) {
|
||||||
|
label.append(", ").append(street);
|
||||||
|
}
|
||||||
|
String city = trimToNull(join(" ", customer.getZip(), customer.getCity()));
|
||||||
|
if (city != null) {
|
||||||
|
label.append(", ").append(city);
|
||||||
|
}
|
||||||
|
return label.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain name label: company name, otherwise person name, otherwise fallback. */
|
||||||
|
public static String buildName(Customer customer, String fallbackLabel) {
|
||||||
|
if (customer == null) {
|
||||||
|
return fallbackLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = trimToNull(customer.getCompanyName());
|
||||||
|
if (name == null) {
|
||||||
|
name = trimToNull(join(" ", customer.getFirstname(), customer.getLastName()));
|
||||||
|
}
|
||||||
|
return name != null ? name : fallbackLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String resolveCompanyValue(Map<String, Customer> addressOptions, String comboValue) {
|
||||||
|
if (addressOptions.containsKey(comboValue)) {
|
||||||
|
Customer customer = addressOptions.get(comboValue);
|
||||||
|
return customer != null ? customer.getCompanyName() : null;
|
||||||
|
}
|
||||||
|
return comboValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String join(String separator, String first, String second) {
|
||||||
|
String left = first != null ? first.trim() : "";
|
||||||
|
String right = second != null ? second.trim() : "";
|
||||||
|
return (left + separator + right).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -254,9 +254,9 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
formLayout.setSpacing(true);
|
formLayout.setSpacing(true);
|
||||||
formLayout.setWidthFull();
|
formLayout.setWidthFull();
|
||||||
|
|
||||||
// Company with autocomplete
|
// Delivery address with autocomplete
|
||||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
|
||||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
|
||||||
company.setAllowCustomValue(true);
|
company.setAllowCustomValue(true);
|
||||||
company.setWidthFull();
|
company.setWidthFull();
|
||||||
setupCompanyAutocomplete(company, customers);
|
setupCompanyAutocomplete(company, customers);
|
||||||
@@ -390,7 +390,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
addressTabError = createTabErrorIndicator();
|
addressTabError = createTabErrorIndicator();
|
||||||
tasksTabError = createTabErrorIndicator();
|
tasksTabError = createTabErrorIndicator();
|
||||||
|
|
||||||
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout);
|
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.delivery.address"), formLayout);
|
||||||
addressTab.add(addressTabError);
|
addressTab.add(addressTabError);
|
||||||
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
|
Tab tasksTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.tasks"),
|
||||||
createTasksTab(templates, templateSaveCallback));
|
createTasksTab(templates, templateSaveCallback));
|
||||||
@@ -687,17 +687,8 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
||||||
companyAddressOptions.clear();
|
companyAddressOptions.clear();
|
||||||
for (Customer customer : customers) {
|
for (Customer customer : customers) {
|
||||||
String label = buildCompanyAddressLabel(customer);
|
CustomerAddressLabelHelper.put(companyAddressOptions, customer,
|
||||||
if (label == null) {
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String uniqueLabel = label;
|
|
||||||
int counter = 2;
|
|
||||||
while (companyAddressOptions.containsKey(uniqueLabel)) {
|
|
||||||
uniqueLabel = label + " (" + counter++ + ")";
|
|
||||||
}
|
|
||||||
companyAddressOptions.put(uniqueLabel, customer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
||||||
@@ -769,51 +760,8 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
mail.setRequiredIndicatorVisible(false);
|
mail.setRequiredIndicatorVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildCompanyAddressLabel(Customer customer) {
|
|
||||||
if (customer == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> leftParts = new ArrayList<>();
|
|
||||||
if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
|
||||||
leftParts.add(customer.getCompanyName().trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
String fullName = ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
|
|
||||||
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim();
|
|
||||||
if (!fullName.isBlank()) {
|
|
||||||
leftParts.add(fullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> rightParts = new ArrayList<>();
|
|
||||||
String streetLine = ((customer.getStreet() != null ? customer.getStreet() : "") + " "
|
|
||||||
+ (customer.getHouseNumber() != null ? customer.getHouseNumber() : "")).trim();
|
|
||||||
if (!streetLine.isBlank()) {
|
|
||||||
rightParts.add(streetLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
String cityLine = ((customer.getZip() != null ? customer.getZip() : "") + " "
|
|
||||||
+ (customer.getCity() != null ? customer.getCity() : "")).trim();
|
|
||||||
if (!cityLine.isBlank()) {
|
|
||||||
rightParts.add(cityLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
String left = String.join(" | ", leftParts);
|
|
||||||
String right = String.join(", ", rightParts);
|
|
||||||
String label = left;
|
|
||||||
if (!right.isBlank()) {
|
|
||||||
label = label.isBlank() ? right : left + " | " + right;
|
|
||||||
}
|
|
||||||
|
|
||||||
return label.isBlank() ? null : label;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveCompanyValue(String comboValue) {
|
private String resolveCompanyValue(String comboValue) {
|
||||||
Customer customer = companyAddressOptions.get(comboValue);
|
return CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, comboValue);
|
||||||
if (customer != null && customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
|
||||||
return customer.getCompanyName();
|
|
||||||
}
|
|
||||||
return comboValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String findCompanyOptionLabel(DeliveryData data) {
|
private String findCompanyOptionLabel(DeliveryData data) {
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import com.vaadin.flow.component.textfield.TextField;
|
|||||||
import de.assecutor.votianlt.model.Customer;
|
import de.assecutor.votianlt.model.Customer;
|
||||||
import de.assecutor.votianlt.model.DeliveryStation;
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A self-contained tile for one delivery station in the AddJob form. Contains
|
* A self-contained tile for one delivery station in the AddJob form. Contains
|
||||||
@@ -51,6 +53,7 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
private final TextField city;
|
private final TextField city;
|
||||||
private final Checkbox saveAddress;
|
private final Checkbox saveAddress;
|
||||||
private final H3 title;
|
private final H3 title;
|
||||||
|
private final Map<String, Customer> companyAddressOptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
private ChangeListener changeListener;
|
private ChangeListener changeListener;
|
||||||
private DeleteListener deleteListener;
|
private DeleteListener deleteListener;
|
||||||
@@ -100,9 +103,9 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
|
|
||||||
add(titleLayout);
|
add(titleLayout);
|
||||||
|
|
||||||
// Company with autocomplete
|
// Delivery address with autocomplete
|
||||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.delivery.label"));
|
||||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
company.setPlaceholder(translationHelper.getTranslation("addjob.address.delivery.placeholder"));
|
||||||
company.setAllowCustomValue(true);
|
company.setAllowCustomValue(true);
|
||||||
company.setWidthFull();
|
company.setWidthFull();
|
||||||
setupCompanyAutocomplete(company, customers, translationHelper);
|
setupCompanyAutocomplete(company, customers, translationHelper);
|
||||||
@@ -224,22 +227,22 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
|
|
||||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
|
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers,
|
||||||
TranslationHelper translationHelper) {
|
TranslationHelper translationHelper) {
|
||||||
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
|
companyAddressOptions.clear();
|
||||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
for (Customer customer : customers) {
|
||||||
|
CustomerAddressLabelHelper.put(companyAddressOptions, customer,
|
||||||
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
|
}
|
||||||
|
|
||||||
companyField.setItems(companyNames);
|
companyField.setItems(new ArrayList<>(companyAddressOptions.keySet()));
|
||||||
|
|
||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
String selectedCompany = event.getValue();
|
String selectedAddress = event.getValue();
|
||||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<Customer> matchingCustomer = customers.stream()
|
Customer customer = companyAddressOptions.get(selectedAddress);
|
||||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
if (customer != null) {
|
||||||
|
|
||||||
if (matchingCustomer.isPresent()) {
|
|
||||||
Customer customer = matchingCustomer.get();
|
|
||||||
if (customer.getTitle() != null
|
if (customer.getTitle() != null
|
||||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||||
@@ -282,7 +285,7 @@ public class DeliveryStationTile extends VerticalLayout {
|
|||||||
*/
|
*/
|
||||||
public DeliveryStation getDeliveryStation() {
|
public DeliveryStation getDeliveryStation() {
|
||||||
DeliveryStation station = new DeliveryStation();
|
DeliveryStation station = new DeliveryStation();
|
||||||
station.setCompany(company.getValue());
|
station.setCompany(CustomerAddressLabelHelper.resolveCompanyValue(companyAddressOptions, company.getValue()));
|
||||||
station.setSalutation(salutation.getValue());
|
station.setSalutation(salutation.getValue());
|
||||||
station.setFirstName(firstName.getValue());
|
station.setFirstName(firstName.getValue());
|
||||||
station.setLastName(lastName.getValue());
|
station.setLastName(lastName.getValue());
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,7 +302,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
formLayout.setSpacing(true);
|
formLayout.setSpacing(true);
|
||||||
formLayout.setWidthFull();
|
formLayout.setWidthFull();
|
||||||
|
|
||||||
// Customer selection
|
// Principal selection
|
||||||
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
|
customerComboBox = new ComboBox<>(translationHelper.getTranslation("addjob.customer.label"));
|
||||||
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
|
customerComboBox.setPlaceholder(translationHelper.getTranslation("addjob.customer.placeholder"));
|
||||||
customerComboBox.setRequiredIndicatorVisible(true);
|
customerComboBox.setRequiredIndicatorVisible(true);
|
||||||
@@ -311,27 +310,14 @@ public class PickupStationDialog extends Dialog {
|
|||||||
|
|
||||||
customerLabelMap.clear();
|
customerLabelMap.clear();
|
||||||
for (Customer c : customers) {
|
for (Customer c : customers) {
|
||||||
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
|
CustomerAddressLabelHelper.putName(customerLabelMap, c,
|
||||||
? c.getCompanyName() + " | "
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
|
|
||||||
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
|
|
||||||
if (label.isBlank()) {
|
|
||||||
label = translationHelper.getTranslation("addjob.customer.unnamed");
|
|
||||||
}
|
|
||||||
String uniqueLabel = label;
|
|
||||||
int counter = 2;
|
|
||||||
while (customerLabelMap.containsKey(uniqueLabel)) {
|
|
||||||
uniqueLabel = label + " (" + counter++ + ")";
|
|
||||||
}
|
|
||||||
customerLabelMap.put(uniqueLabel, c);
|
|
||||||
}
|
}
|
||||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||||
|
|
||||||
// Company with autocomplete
|
// Pickup address with autocomplete
|
||||||
company = new ComboBox<>(translationHelper.getTranslation("profile.company"));
|
company = new ComboBox<>(translationHelper.getTranslation("addjob.address.pickup.label"));
|
||||||
company.setPlaceholder(translationHelper.getTranslation("addjob.address.company.placeholder"));
|
company.setPlaceholder(translationHelper.getTranslation("addjob.address.pickup.placeholder"));
|
||||||
company.setAllowCustomValue(true);
|
company.setAllowCustomValue(true);
|
||||||
company.setWidthFull();
|
company.setWidthFull();
|
||||||
setupCompanyAutocomplete(company, customers);
|
setupCompanyAutocomplete(company, customers);
|
||||||
@@ -462,10 +448,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedCustomerId = c.getId();
|
selectedCustomerId = c.getId();
|
||||||
if (c.getCompanyName() != null)
|
setCompanySelection(c);
|
||||||
company.setValue(c.getCompanyName());
|
|
||||||
else
|
|
||||||
company.clear();
|
|
||||||
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|
if (c.getTitle() != null && ("Herr".equalsIgnoreCase(c.getTitle()) || "Frau".equalsIgnoreCase(c.getTitle())
|
||||||
|| "Divers".equalsIgnoreCase(c.getTitle())))
|
|| "Divers".equalsIgnoreCase(c.getTitle())))
|
||||||
salutation.setValue(c.getTitle());
|
salutation.setValue(c.getTitle());
|
||||||
@@ -511,7 +494,12 @@ public class PickupStationDialog extends Dialog {
|
|||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
});
|
});
|
||||||
|
|
||||||
formLayout.add(customerComboBox, company, salutation, firstName, lastName, phone, mail, streetLayout,
|
Div addressDivider = new Div();
|
||||||
|
addressDivider.setWidthFull();
|
||||||
|
addressDivider.getStyle().set("border-top", "1px solid var(--lumo-contrast-20pct)");
|
||||||
|
addressDivider.getStyle().set("margin", "var(--lumo-space-m) 0 var(--lumo-space-s)");
|
||||||
|
|
||||||
|
formLayout.add(customerComboBox, addressDivider, company, salutation, firstName, lastName, phone, mail, streetLayout,
|
||||||
addressAddition, zipCityLayout, saveAddress);
|
addressAddition, zipCityLayout, saveAddress);
|
||||||
|
|
||||||
// TabSheet with address, appointments, and cargo tabs
|
// TabSheet with address, appointments, and cargo tabs
|
||||||
@@ -523,7 +511,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
appointmentsTabError = createTabErrorIndicator();
|
appointmentsTabError = createTabErrorIndicator();
|
||||||
cargoTabError = createTabErrorIndicator();
|
cargoTabError = createTabErrorIndicator();
|
||||||
|
|
||||||
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.addresses"), formLayout);
|
Tab addressTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.pickup.address"), formLayout);
|
||||||
addressTab.add(addressTabError);
|
addressTab.add(addressTabError);
|
||||||
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
|
Tab appointmentsTab = tabSheet.add(translationHelper.getTranslation("addjob.tab.appointments"),
|
||||||
createAppointmentsTab(availableAppUsers));
|
createAppointmentsTab(availableAppUsers));
|
||||||
@@ -637,13 +625,23 @@ public class PickupStationDialog extends Dialog {
|
|||||||
public void setData(PickupData data) {
|
public void setData(PickupData data) {
|
||||||
if (data == null)
|
if (data == null)
|
||||||
return;
|
return;
|
||||||
if (data.getCustomerSelection() != null) {
|
String customerSelection = normalizeValue(data.getCustomerSelection());
|
||||||
customerComboBox.setValue(data.getCustomerSelection());
|
if (!customerSelection.isEmpty()) {
|
||||||
|
if (!customerLabelMap.containsKey(customerSelection)) {
|
||||||
|
customerLabelMap.put(customerSelection, null);
|
||||||
|
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||||
|
}
|
||||||
|
customerComboBox.setValue(customerSelection);
|
||||||
} else {
|
} else {
|
||||||
customerComboBox.clear();
|
customerComboBox.clear();
|
||||||
}
|
}
|
||||||
if (data.getCompany() != null)
|
String companyOption = findCompanyOptionLabel(data);
|
||||||
|
boolean customerSelectedFromOptions = companyOption != null;
|
||||||
|
if (companyOption != null) {
|
||||||
|
company.setValue(companyOption);
|
||||||
|
} else if (data.getCompany() != null) {
|
||||||
company.setValue(data.getCompany());
|
company.setValue(data.getCompany());
|
||||||
|
}
|
||||||
if (data.getSalutation() != null)
|
if (data.getSalutation() != null)
|
||||||
salutation.setValue(data.getSalutation());
|
salutation.setValue(data.getSalutation());
|
||||||
if (data.getFirstName() != null)
|
if (data.getFirstName() != null)
|
||||||
@@ -689,19 +687,16 @@ public class PickupStationDialog extends Dialog {
|
|||||||
if (data.getCustomerId() != null) {
|
if (data.getCustomerId() != null) {
|
||||||
selectedCustomerId = data.getCustomerId();
|
selectedCustomerId = data.getCustomerId();
|
||||||
} else {
|
} else {
|
||||||
Customer matched = customerLabelMap.get(data.getCustomerSelection());
|
Customer matched = companyOption != null ? companyCustomerMap.get(companyOption) : null;
|
||||||
if (matched == null) {
|
|
||||||
matched = companyCustomerMap.get(normalizeValue(data.getCompany()));
|
|
||||||
}
|
|
||||||
selectedCustomerId = matched != null ? matched.getId() : null;
|
selectedCustomerId = matched != null ? matched.getId() : null;
|
||||||
}
|
}
|
||||||
saveAddress.setValue(data.isSaveAddress());
|
saveAddress.setValue(customerSelectedFromOptions ? false : data.isSaveAddress());
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private PickupData collectData() {
|
private PickupData collectData() {
|
||||||
PickupData data = new PickupData();
|
PickupData data = new PickupData();
|
||||||
data.setCompany(company.getValue());
|
data.setCompany(resolveCompanyValue(company.getValue()));
|
||||||
data.setSalutation(salutation.getValue());
|
data.setSalutation(salutation.getValue());
|
||||||
data.setFirstName(firstName.getValue());
|
data.setFirstName(firstName.getValue());
|
||||||
data.setLastName(lastName.getValue());
|
data.setLastName(lastName.getValue());
|
||||||
@@ -781,12 +776,9 @@ public class PickupStationDialog extends Dialog {
|
|||||||
private boolean validateMailField() {
|
private boolean validateMailField() {
|
||||||
String value = mail.getValue();
|
String value = mail.getValue();
|
||||||
String normalizedValue = value == null ? "" : value.trim();
|
String normalizedValue = value == null ? "" : value.trim();
|
||||||
boolean empty = normalizedValue.isEmpty();
|
boolean invalid = !normalizedValue.isEmpty() && !normalizedValue.contains("@");
|
||||||
boolean required = Boolean.TRUE.equals(saveAddress.getValue());
|
applyErrorStyling(mail, invalid);
|
||||||
boolean invalid = !empty && !normalizedValue.contains("@");
|
return !invalid;
|
||||||
boolean hasError = invalid || (required && empty);
|
|
||||||
applyErrorStyling(mail, hasError);
|
|
||||||
return !hasError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateCargoItems() {
|
private boolean validateCargoItems() {
|
||||||
@@ -838,56 +830,24 @@ public class PickupStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
private void setupCompanyAutocomplete(ComboBox<String> companyField, List<Customer> customers) {
|
||||||
List<String> companyNames = customers.stream().map(Customer::getCompanyName)
|
|
||||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
|
||||||
|
|
||||||
companyCustomerMap.clear();
|
companyCustomerMap.clear();
|
||||||
for (Customer customer : customers) {
|
for (Customer customer : customers) {
|
||||||
String companyName = normalizeValue(customer.getCompanyName());
|
CustomerAddressLabelHelper.put(companyCustomerMap, customer,
|
||||||
if (companyName.isEmpty() || companyCustomerMap.containsKey(companyName)) {
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
companyCustomerMap.put(companyName, customer);
|
|
||||||
}
|
}
|
||||||
companyField.setItems(companyNames);
|
companyField.setItems(new ArrayList<>(companyCustomerMap.keySet()));
|
||||||
|
|
||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
String selectedCompany = event.getValue();
|
String selectedAddress = event.getValue();
|
||||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||||
selectedCustomerId = null;
|
selectedCustomerId = null;
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<Customer> matchingCustomer = customers.stream()
|
Customer customer = companyCustomerMap.get(selectedAddress);
|
||||||
.filter(c -> sameValue(selectedCompany, c.getCompanyName())).findFirst();
|
if (customer != null) {
|
||||||
|
applyCustomerAddress(customer);
|
||||||
if (matchingCustomer.isPresent()) {
|
|
||||||
Customer customer = matchingCustomer.get();
|
|
||||||
selectedCustomerId = customer.getId();
|
|
||||||
if (customer.getTitle() != null
|
|
||||||
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
|
||||||
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
|
||||||
salutation.setValue(customer.getTitle());
|
|
||||||
}
|
|
||||||
if (customer.getFirstname() != null)
|
|
||||||
firstName.setValue(customer.getFirstname());
|
|
||||||
if (customer.getLastName() != null)
|
|
||||||
lastName.setValue(customer.getLastName());
|
|
||||||
if (customer.getTelephone() != null)
|
|
||||||
phone.setValue(customer.getTelephone());
|
|
||||||
if (customer.getMail() != null)
|
|
||||||
mail.setValue(customer.getMail());
|
|
||||||
if (customer.getStreet() != null)
|
|
||||||
street.setValue(customer.getStreet());
|
|
||||||
if (customer.getHouseNumber() != null)
|
|
||||||
houseNumber.setValue(customer.getHouseNumber());
|
|
||||||
if (customer.getAddressAddition() != null)
|
|
||||||
addressAddition.setValue(customer.getAddressAddition());
|
|
||||||
if (customer.getZip() != null)
|
|
||||||
zip.setValue(customer.getZip());
|
|
||||||
if (customer.getCity() != null)
|
|
||||||
city.setValue(customer.getCity());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSaveAddressState();
|
updateSaveAddressState();
|
||||||
@@ -902,7 +862,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
|
|
||||||
private void updateSaveAddressState() {
|
private void updateSaveAddressState() {
|
||||||
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
|
Customer selectedCustomer = customerLabelMap.get(customerComboBox.getValue());
|
||||||
Customer selectedCompanyCustomer = companyCustomerMap.get(normalizeValue(company.getValue()));
|
Customer selectedCompanyCustomer = companyCustomerMap.get(company.getValue());
|
||||||
boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
|
boolean customerDataMatches = selectedCustomer != null && matchesCustomer(selectedCustomer);
|
||||||
boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
|
boolean companyDataMatches = selectedCompanyCustomer != null && matchesCustomer(selectedCompanyCustomer);
|
||||||
|
|
||||||
@@ -924,11 +884,11 @@ public class PickupStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateMailRequirement() {
|
private void updateMailRequirement() {
|
||||||
mail.setRequiredIndicatorVisible(Boolean.TRUE.equals(saveAddress.getValue()));
|
mail.setRequiredIndicatorVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean matchesCustomer(Customer customer) {
|
private boolean matchesCustomer(Customer customer) {
|
||||||
return sameValue(company.getValue(), customer.getCompanyName())
|
return sameValue(resolveCompanyValue(company.getValue()), customer.getCompanyName())
|
||||||
&& sameValue(salutation.getValue(), customer.getTitle())
|
&& sameValue(salutation.getValue(), customer.getTitle())
|
||||||
&& sameValue(firstName.getValue(), customer.getFirstname())
|
&& sameValue(firstName.getValue(), customer.getFirstname())
|
||||||
&& sameValue(lastName.getValue(), customer.getLastName())
|
&& sameValue(lastName.getValue(), customer.getLastName())
|
||||||
@@ -950,7 +910,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean computeAddressDiffers() {
|
private boolean computeAddressDiffers() {
|
||||||
boolean hasAnyValue = !normalizeValue(company.getValue()).isEmpty()
|
boolean hasAnyValue = !normalizeValue(resolveCompanyValue(company.getValue())).isEmpty()
|
||||||
|| !normalizeValue(firstName.getValue()).isEmpty() || !normalizeValue(lastName.getValue()).isEmpty()
|
|| !normalizeValue(firstName.getValue()).isEmpty() || !normalizeValue(lastName.getValue()).isEmpty()
|
||||||
|| !normalizeValue(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty()
|
|| !normalizeValue(phone.getValue()).isEmpty() || !normalizeValue(mail.getValue()).isEmpty()
|
||||||
|| !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty()
|
|| !normalizeValue(street.getValue()).isEmpty() || !normalizeValue(houseNumber.getValue()).isEmpty()
|
||||||
@@ -983,6 +943,108 @@ public class PickupStationDialog extends Dialog {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyCustomerAddress(Customer customer) {
|
||||||
|
selectedCustomerId = customer.getId();
|
||||||
|
if (customer.getTitle() != null
|
||||||
|
&& ("Herr".equalsIgnoreCase(customer.getTitle()) || "Frau".equalsIgnoreCase(customer.getTitle())
|
||||||
|
|| "Divers".equalsIgnoreCase(customer.getTitle()))) {
|
||||||
|
salutation.setValue(customer.getTitle());
|
||||||
|
} else {
|
||||||
|
salutation.clear();
|
||||||
|
}
|
||||||
|
if (customer.getFirstname() != null)
|
||||||
|
firstName.setValue(customer.getFirstname());
|
||||||
|
else
|
||||||
|
firstName.clear();
|
||||||
|
if (customer.getLastName() != null)
|
||||||
|
lastName.setValue(customer.getLastName());
|
||||||
|
else
|
||||||
|
lastName.clear();
|
||||||
|
if (customer.getTelephone() != null)
|
||||||
|
phone.setValue(customer.getTelephone());
|
||||||
|
else
|
||||||
|
phone.clear();
|
||||||
|
if (customer.getMail() != null)
|
||||||
|
mail.setValue(customer.getMail());
|
||||||
|
else
|
||||||
|
mail.clear();
|
||||||
|
if (customer.getStreet() != null)
|
||||||
|
street.setValue(customer.getStreet());
|
||||||
|
else
|
||||||
|
street.clear();
|
||||||
|
if (customer.getHouseNumber() != null)
|
||||||
|
houseNumber.setValue(customer.getHouseNumber());
|
||||||
|
else
|
||||||
|
houseNumber.clear();
|
||||||
|
if (customer.getAddressAddition() != null)
|
||||||
|
addressAddition.setValue(customer.getAddressAddition());
|
||||||
|
else
|
||||||
|
addressAddition.clear();
|
||||||
|
if (customer.getZip() != null)
|
||||||
|
zip.setValue(customer.getZip());
|
||||||
|
else
|
||||||
|
zip.clear();
|
||||||
|
if (customer.getCity() != null)
|
||||||
|
city.setValue(customer.getCity());
|
||||||
|
else
|
||||||
|
city.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setCompanySelection(Customer customer) {
|
||||||
|
String label = findCompanyOptionLabel(customer);
|
||||||
|
if (label != null) {
|
||||||
|
company.setValue(label);
|
||||||
|
} else if (customer.getCompanyName() != null && !customer.getCompanyName().isBlank()) {
|
||||||
|
company.setValue(customer.getCompanyName());
|
||||||
|
} else {
|
||||||
|
company.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCompanyValue(String comboValue) {
|
||||||
|
return CustomerAddressLabelHelper.resolveCompanyValue(companyCustomerMap, comboValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findCompanyOptionLabel(Customer customer) {
|
||||||
|
if (customer == null || customer.getId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
|
||||||
|
Customer option = entry.getValue();
|
||||||
|
if (option != null && customer.getId().equals(option.getId())) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findCompanyOptionLabel(PickupData data) {
|
||||||
|
for (Map.Entry<String, Customer> entry : companyCustomerMap.entrySet()) {
|
||||||
|
Customer customer = entry.getValue();
|
||||||
|
if (data.getCustomerId() != null && customer.getId() != null && data.getCustomerId().equals(customer.getId())) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
if (matchesCustomer(customer, data)) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesCustomer(Customer customer, PickupData data) {
|
||||||
|
return sameValue(customer.getCompanyName(), data.getCompany())
|
||||||
|
&& sameValue(customer.getTitle(), data.getSalutation())
|
||||||
|
&& sameValue(customer.getFirstname(), data.getFirstName())
|
||||||
|
&& sameValue(customer.getLastName(), data.getLastName())
|
||||||
|
&& sameValue(customer.getTelephone(), data.getPhone())
|
||||||
|
&& sameValue(customer.getMail(), data.getMail())
|
||||||
|
&& sameValue(customer.getStreet(), data.getStreet())
|
||||||
|
&& sameValue(customer.getHouseNumber(), data.getHouseNumber())
|
||||||
|
&& sameValue(customer.getAddressAddition(), data.getAddressAddition())
|
||||||
|
&& sameValue(customer.getZip(), data.getZip())
|
||||||
|
&& sameValue(customer.getCity(), data.getCity());
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Appointments & Processing Tab
|
// Appointments & Processing Tab
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.assecutor.votianlt.pages.base.ui.view;
|
package de.assecutor.votianlt.pages.base.ui.view;
|
||||||
|
|
||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.HasElement;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.component.UI;
|
||||||
import com.vaadin.flow.component.applayout.AppLayout;
|
import com.vaadin.flow.component.applayout.AppLayout;
|
||||||
import com.vaadin.flow.component.avatar.Avatar;
|
import com.vaadin.flow.component.avatar.Avatar;
|
||||||
@@ -18,7 +19,6 @@ import com.vaadin.flow.component.sidenav.SideNavItem;
|
|||||||
import com.vaadin.flow.router.Layout;
|
import com.vaadin.flow.router.Layout;
|
||||||
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.view.EditProfileView;
|
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
|
||||||
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
|
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
|
||||||
@@ -31,6 +31,7 @@ public final class AdminLayout extends AppLayout {
|
|||||||
private Div headerRef;
|
private Div headerRef;
|
||||||
private Scroller navRef;
|
private Scroller navRef;
|
||||||
private Component userMenuRef;
|
private Component userMenuRef;
|
||||||
|
private final Div viewContainer = new Div();
|
||||||
|
|
||||||
public AdminLayout(SecurityService securityService) {
|
public AdminLayout(SecurityService securityService) {
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
@@ -47,12 +48,25 @@ public final class AdminLayout extends AppLayout {
|
|||||||
userMenuRef = createUserMenu();
|
userMenuRef = createUserMenu();
|
||||||
addToDrawer(headerRef, navRef, userMenuRef);
|
addToDrawer(headerRef, navRef, userMenuRef);
|
||||||
|
|
||||||
|
// Same content wrapper as MainLayout so admin pages get the identical
|
||||||
|
// page frame (margin, rounded corners, shell background)
|
||||||
|
viewContainer.addClassName("view-container");
|
||||||
|
getElement().appendChild(viewContainer.getElement());
|
||||||
|
|
||||||
updateDrawerVisibility();
|
updateDrawerVisibility();
|
||||||
|
|
||||||
// Re-check on attach (new UI/session) and on every navigation cycle
|
// Re-check on attach (new UI/session) and on every navigation cycle
|
||||||
addAttachListener(e -> updateDrawerVisibility());
|
addAttachListener(e -> updateDrawerVisibility());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showRouterLayoutContent(HasElement content) {
|
||||||
|
viewContainer.getElement().removeAllChildren();
|
||||||
|
if (content != null) {
|
||||||
|
viewContainer.getElement().appendChild(content.getElement());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateDrawerVisibility() {
|
private void updateDrawerVisibility() {
|
||||||
boolean loggedIn = securityService.isUserLoggedIn();
|
boolean loggedIn = securityService.isUserLoggedIn();
|
||||||
if (headerRef != null)
|
if (headerRef != null)
|
||||||
@@ -90,6 +104,8 @@ public final class AdminLayout extends AppLayout {
|
|||||||
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
|
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
|
||||||
SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator",
|
SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator",
|
||||||
new Icon(VaadinIcon.FILE_PROCESS));
|
new Icon(VaadinIcon.FILE_PROCESS));
|
||||||
|
SideNavItem createInvoices = new SideNavItem("Rechnungen erstellen", "admin-create-invoices",
|
||||||
|
new Icon(VaadinIcon.INVOICE));
|
||||||
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
|
SideNavItem priceTable = new SideNavItem("Preis-Tabelle", "admin-price-table", new Icon(VaadinIcon.COG));
|
||||||
// SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
|
// SideNavItem systemSettings = new SideNavItem("Systemeinstellungen",
|
||||||
// "admin-settings", new Icon(VaadinIcon.COG));
|
// "admin-settings", new Icon(VaadinIcon.COG));
|
||||||
@@ -100,6 +116,7 @@ public final class AdminLayout extends AppLayout {
|
|||||||
|
|
||||||
nav.addItem(dashboard);
|
nav.addItem(dashboard);
|
||||||
nav.addItem(invoiceGenerator);
|
nav.addItem(invoiceGenerator);
|
||||||
|
nav.addItem(createInvoices);
|
||||||
nav.addItem(priceTable);
|
nav.addItem(priceTable);
|
||||||
// nav.addItem(systemSettings);
|
// nav.addItem(systemSettings);
|
||||||
// nav.addItem(userManagement);
|
// nav.addItem(userManagement);
|
||||||
@@ -134,9 +151,8 @@ public final class AdminLayout extends AppLayout {
|
|||||||
var userMenuItem = userMenu.addItem(avatar);
|
var userMenuItem = userMenu.addItem(avatar);
|
||||||
userMenuItem.add(userNameSpan);
|
userMenuItem.add(userNameSpan);
|
||||||
|
|
||||||
// Profile display with navigation
|
// Profile display with navigation to the admin company profile
|
||||||
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
|
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate("admin-profile"));
|
||||||
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
|
|
||||||
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
|
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
|
||||||
|
|
||||||
// Update function for username and avatar
|
// Update function for username and avatar
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.assecutor.votianlt.pages.domain;
|
package de.assecutor.votianlt.pages.domain;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.Customer;
|
import de.assecutor.votianlt.model.Customer;
|
||||||
|
import java.util.List;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
|
||||||
public interface AddCustomerRepository extends MongoRepository<Customer, ObjectId> {
|
public interface AddCustomerRepository extends MongoRepository<Customer, ObjectId> {
|
||||||
|
|
||||||
|
List<Customer> findByOwnerAndInternal(ObjectId owner, boolean internal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.bson.types.ObjectId;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Slice;
|
import org.springframework.data.domain.Slice;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.data.mongodb.repository.Query;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
|
public interface CustomerRepository extends MongoRepository<Customer, ObjectId> {
|
||||||
@@ -14,5 +15,8 @@ public interface CustomerRepository extends MongoRepository<Customer, ObjectId>
|
|||||||
|
|
||||||
List<Customer> findByOwner(ObjectId owner);
|
List<Customer> findByOwner(ObjectId owner);
|
||||||
|
|
||||||
|
// $ne: true matches documents where internal is false, null, or the field is missing
|
||||||
|
// (legacy data without the internal field still shows up in customer dropdowns).
|
||||||
|
@Query("{ 'owner' : ?0, 'internal' : { $ne: true } }")
|
||||||
List<Customer> findByOwnerAndInternalFalse(ObjectId owner);
|
List<Customer> findByOwnerAndInternalFalse(ObjectId owner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,18 +21,29 @@ public class AddCustomerService {
|
|||||||
this.sequenceGeneratorService = sequenceGeneratorService;
|
this.sequenceGeneratorService = sequenceGeneratorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addCustomer(Customer customer) {
|
/**
|
||||||
|
* Creates the customer unless a completely identical entry already exists
|
||||||
|
* for the current owner. Returns false if the duplicate was skipped.
|
||||||
|
*/
|
||||||
|
public boolean addCustomer(Customer customer) {
|
||||||
validateCustomer(customer);
|
validateCustomer(customer);
|
||||||
|
|
||||||
// Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session
|
// Setze den aktuellen Benutzer als Ersteller - jetzt direkt aus der Session
|
||||||
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
|
de.assecutor.votianlt.model.User currentUser = securityService.getCurrentDatabaseUser();
|
||||||
customer.setCreatedBy(currentUser.getId());
|
customer.setCreatedBy(currentUser.getId());
|
||||||
customer.setOwner(currentUser.getId());
|
customer.setOwner(currentUser.getId());
|
||||||
|
|
||||||
|
// Keine komplett identischen Einträge anlegen
|
||||||
|
if (identicalCustomerExists(customer)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (customer.getUsrId() == null) {
|
if (customer.getUsrId() == null) {
|
||||||
customer.setUsrId(sequenceGeneratorService.nextCustomerNumber());
|
customer.setUsrId(sequenceGeneratorService.nextCustomerNumber());
|
||||||
}
|
}
|
||||||
|
|
||||||
addCustomerRepository.save(customer);
|
addCustomerRepository.save(customer);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addInternalCustomer(Customer customer) {
|
public void addInternalCustomer(Customer customer) {
|
||||||
@@ -44,6 +55,28 @@ public class AddCustomerService {
|
|||||||
addCustomer(customer);
|
addCustomer(customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean identicalCustomerExists(Customer customer) {
|
||||||
|
return addCustomerRepository.findByOwnerAndInternal(customer.getOwner(), customer.isInternal()).stream()
|
||||||
|
.anyMatch(existing -> isIdentical(existing, customer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isIdentical(Customer a, Customer b) {
|
||||||
|
return equalsNormalized(a.getCompanyName(), b.getCompanyName()) && equalsNormalized(a.getTitle(), b.getTitle())
|
||||||
|
&& equalsNormalized(a.getFirstname(), b.getFirstname())
|
||||||
|
&& equalsNormalized(a.getLastName(), b.getLastName())
|
||||||
|
&& equalsNormalized(a.getTelephone(), b.getTelephone()) && equalsNormalized(a.getMail(), b.getMail())
|
||||||
|
&& equalsNormalized(a.getStreet(), b.getStreet())
|
||||||
|
&& equalsNormalized(a.getHouseNumber(), b.getHouseNumber())
|
||||||
|
&& equalsNormalized(a.getAddressAddition(), b.getAddressAddition())
|
||||||
|
&& equalsNormalized(a.getZip(), b.getZip()) && equalsNormalized(a.getCity(), b.getCity());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean equalsNormalized(String first, String second) {
|
||||||
|
String left = first != null ? first.trim() : "";
|
||||||
|
String right = second != null ? second.trim() : "";
|
||||||
|
return left.equalsIgnoreCase(right);
|
||||||
|
}
|
||||||
|
|
||||||
public void updateCustomer(Customer customer) {
|
public void updateCustomer(Customer customer) {
|
||||||
if (customer == null || customer.getId() == null) {
|
if (customer == null || customer.getId() == null) {
|
||||||
throw new IllegalArgumentException("Kunden-ID fehlt");
|
throw new IllegalArgumentException("Kunden-ID fehlt");
|
||||||
|
|||||||
@@ -43,4 +43,8 @@ public class CustomerService {
|
|||||||
return todoRepository.findById(id).orElse(null);
|
return todoRepository.findById(id).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void deleteById(ObjectId id) {
|
||||||
|
todoRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package de.assecutor.votianlt.pages.service;
|
package de.assecutor.votianlt.pages.service;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.UserInvoiceData;
|
import de.assecutor.votianlt.model.UserInvoiceData;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
|
||||||
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
import org.springframework.data.mongodb.core.query.Criteria;
|
import org.springframework.data.mongodb.core.query.Criteria;
|
||||||
@@ -10,17 +16,25 @@ import org.springframework.data.mongodb.core.query.Query;
|
|||||||
import org.springframework.data.mongodb.core.query.Update;
|
import org.springframework.data.mongodb.core.query.Update;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class UserInvoiceDataService {
|
public class UserInvoiceDataService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
|
||||||
|
|
||||||
private final UserInvoiceDataRepository userInvoiceDataRepository;
|
private final UserInvoiceDataRepository userInvoiceDataRepository;
|
||||||
private final MongoTemplate mongoTemplate;
|
private final MongoTemplate mongoTemplate;
|
||||||
|
private final InvoiceNumberReservationRepository reservationRepository;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
|
||||||
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate) {
|
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate,
|
||||||
|
InvoiceNumberReservationRepository reservationRepository, SecurityService securityService) {
|
||||||
this.userInvoiceDataRepository = userInvoiceDataRepository;
|
this.userInvoiceDataRepository = userInvoiceDataRepository;
|
||||||
this.mongoTemplate = mongoTemplate;
|
this.mongoTemplate = mongoTemplate;
|
||||||
|
this.reservationRepository = reservationRepository;
|
||||||
|
this.securityService = securityService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
|
public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
|
||||||
@@ -64,6 +78,12 @@ public class UserInvoiceDataService {
|
|||||||
/**
|
/**
|
||||||
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
|
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
|
||||||
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
|
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
|
||||||
|
*
|
||||||
|
* Jede Vergabe wird als {@link InvoiceNumberReservation} mit Status RESERVED
|
||||||
|
* persistiert. Damit ist auch nachvollziehbar, wenn eine Nummer aus dem
|
||||||
|
* Counter gezogen, aber nie zu einer ausgestellten Rechnung wird (abgebrochener
|
||||||
|
* Erstell-Prozess, fehlgeschlagene Validierung). Die Reservierung wird später
|
||||||
|
* vom Lifecycle-Service auf USED bzw. VOIDED gesetzt.
|
||||||
*/
|
*/
|
||||||
public String generateNextInvoiceNumber(ObjectId userId) {
|
public String generateNextInvoiceNumber(ObjectId userId) {
|
||||||
Query query = Query.query(Criteria.where("userId").is(userId));
|
Query query = Query.query(Criteria.where("userId").is(userId));
|
||||||
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
|
|||||||
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
|
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
|
||||||
return findByUserId(userId).map(d -> {
|
return findByUserId(userId).map(d -> {
|
||||||
String prefix = d.getPrefix() != null ? d.getPrefix() : "";
|
String prefix = d.getPrefix() != null ? d.getPrefix() : "";
|
||||||
return prefix + String.format("%06d", d.getNextInvoiceNumber());
|
long sequence = d.getNextInvoiceNumber();
|
||||||
|
String number = prefix + String.format("%06d", sequence);
|
||||||
|
recordReservation(userId, number, sequence, prefix);
|
||||||
|
return number;
|
||||||
}).orElse("000000");
|
}).orElse("000000");
|
||||||
}
|
}
|
||||||
|
|
||||||
String prefix = before.getPrefix() != null ? before.getPrefix() : "";
|
String prefix = before.getPrefix() != null ? before.getPrefix() : "";
|
||||||
return prefix + String.format("%06d", before.getNextInvoiceNumber());
|
long sequence = before.getNextInvoiceNumber();
|
||||||
|
String number = prefix + String.format("%06d", sequence);
|
||||||
|
recordReservation(userId, number, sequence, prefix);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert die Reservierung einer Nummer. Das Schreiben des Audit-Eintrags
|
||||||
|
* ist von der Counter-Vergabe entkoppelt: Sollte das Audit-Repository
|
||||||
|
* vorübergehend ausfallen, geht die Nummer-Vergabe nicht verloren — wir
|
||||||
|
* loggen den Fehler und vertrauen darauf, dass die anschließende Lücken-
|
||||||
|
* Analyse auf Basis der ausgestellten Rechnungen die fehlende Reservierung
|
||||||
|
* sichtbar macht.
|
||||||
|
*/
|
||||||
|
private void recordReservation(ObjectId userId, String number, long sequence, String prefix) {
|
||||||
|
try {
|
||||||
|
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
|
||||||
|
reservation.setUserId(userId);
|
||||||
|
reservation.setNumber(number);
|
||||||
|
reservation.setSequence(sequence);
|
||||||
|
reservation.setPrefix(prefix);
|
||||||
|
reservation.setReservedAt(Instant.now());
|
||||||
|
reservation.setReservedBy(currentUserDisplayName());
|
||||||
|
reservation.setStatus(InvoiceNumberReservationStatus.RESERVED);
|
||||||
|
reservationRepository.save(reservation);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Reservierung der Rechnungsnummer {} (User {}) konnte nicht persistiert werden: {}",
|
||||||
|
number, userId, ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentUserDisplayName() {
|
||||||
|
try {
|
||||||
|
var user = securityService.getCurrentDatabaseUser();
|
||||||
|
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
|
||||||
|
return composed.isBlank() ? safe(user.getEmail()) : composed;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,11 +46,9 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
public AddCustomerView(AddCustomerService todoService, Clock clock) {
|
public AddCustomerView(AddCustomerService todoService, Clock clock) {
|
||||||
this.addCustomerService = todoService;
|
this.addCustomerService = todoService;
|
||||||
|
|
||||||
// Firma (Pflichtfeld)
|
// Firma (optional; auch Privatpersonen können im Adressbuch stehen)
|
||||||
companyName = new TextField(getTranslation("profile.company"));
|
companyName = new TextField(getTranslation("profile.company"));
|
||||||
companyName.setRequiredIndicatorVisible(true);
|
|
||||||
companyName.setWidthFull();
|
companyName.setWidthFull();
|
||||||
companyName.addBlurListener(e -> validateField(companyName));
|
|
||||||
|
|
||||||
// Anrede (Dropdown)
|
// Anrede (Dropdown)
|
||||||
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
title = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
||||||
@@ -162,8 +160,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void configureBinder() {
|
private void configureBinder() {
|
||||||
binder.forField(companyName).asRequired(getTranslation("profile.validation.company.required"))
|
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||||
.bind(Customer::getCompanyName, Customer::setCompanyName);
|
|
||||||
|
|
||||||
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
|
binder.forField(title).bind(Customer::getTitle, Customer::setTitle);
|
||||||
|
|
||||||
@@ -217,7 +214,13 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
Customer customer = new Customer();
|
Customer customer = new Customer();
|
||||||
binder.writeBean(customer);
|
binder.writeBean(customer);
|
||||||
|
|
||||||
addCustomerService.addCustomer(customer);
|
boolean created = addCustomerService.addCustomer(customer);
|
||||||
|
if (!created) {
|
||||||
|
com.vaadin.flow.component.notification.Notification.show(
|
||||||
|
getTranslation("addcustomer.notification.duplicate"), 5000,
|
||||||
|
com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
com.vaadin.flow.component.notification.Notification.show(getTranslation("addcustomer.notification.success"),
|
com.vaadin.flow.component.notification.Notification.show(getTranslation("addcustomer.notification.success"),
|
||||||
3000, com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER);
|
3000, com.vaadin.flow.component.notification.Notification.Position.TOP_CENTER);
|
||||||
@@ -257,7 +260,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateAllFields() {
|
private boolean validateAllFields() {
|
||||||
validateField(companyName);
|
|
||||||
validateField(firstName);
|
validateField(firstName);
|
||||||
validateField(lastName);
|
validateField(lastName);
|
||||||
validateField(telephone);
|
validateField(telephone);
|
||||||
@@ -267,9 +269,8 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
validateField(city);
|
validateField(city);
|
||||||
validateEmail();
|
validateEmail();
|
||||||
|
|
||||||
return !companyName.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid()
|
return !firstName.isInvalid() && !lastName.isInvalid() && !telephone.isInvalid() && !mail.isInvalid()
|
||||||
&& !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid()
|
&& !street.isInvalid() && !houseNumber.isInvalid() && !zip.isInvalid() && !city.isInvalid();
|
||||||
&& !city.isInvalid();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import de.assecutor.votianlt.model.AddressValidationResult;
|
|||||||
import de.assecutor.votianlt.model.RouteCalculationResult;
|
import de.assecutor.votianlt.model.RouteCalculationResult;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
|
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationTile;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
|
import de.assecutor.votianlt.pages.base.ui.component.StationTile;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.CustomerAddressLabelHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
|
import de.assecutor.votianlt.pages.base.ui.component.PickupStationDialog;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
|
import de.assecutor.votianlt.pages.base.ui.component.DeliveryStationDialog;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
@@ -236,32 +237,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
|
customerSelection.setPlaceholder(getTranslation("addjob.customer.placeholder"));
|
||||||
customerSelection.setWidthFull();
|
customerSelection.setWidthFull();
|
||||||
customerSelection.setRequiredIndicatorVisible(true);
|
customerSelection.setRequiredIndicatorVisible(true);
|
||||||
|
customerSelection.setAllowCustomValue(true);
|
||||||
|
customerSelection.addCustomValueSetListener(event -> setCustomerSelectionValue(event.getDetail()));
|
||||||
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
|
// Mit Kunden des angemeldeten Benutzers befüllen und Mapping aufbauen
|
||||||
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
||||||
customerLabelToEntity.clear();
|
customerLabelToEntity.clear();
|
||||||
for (Customer c : ownerCustomers) {
|
for (Customer c : ownerCustomers) {
|
||||||
String label = (c.getCompanyName() != null && !c.getCompanyName().isBlank())
|
CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
||||||
? c.getCompanyName() + " | "
|
|
||||||
+ ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim()
|
|
||||||
: ((c.getFirstname() != null ? c.getFirstname() : "") + " "
|
|
||||||
+ (c.getLastName() != null ? c.getLastName() : "")).trim();
|
|
||||||
if (label.isBlank()) {
|
|
||||||
label = getTranslation("addjob.customer.unnamed");
|
|
||||||
}
|
|
||||||
// Bei Duplikaten Label einzigartig machen
|
|
||||||
String uniqueLabel = label;
|
|
||||||
int counter = 2;
|
|
||||||
while (customerLabelToEntity.containsKey(uniqueLabel)) {
|
|
||||||
uniqueLabel = label + " (" + counter++ + ")";
|
|
||||||
}
|
|
||||||
customerLabelToEntity.put(uniqueLabel, c);
|
|
||||||
}
|
}
|
||||||
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||||
|
|
||||||
// Pickup address
|
// Pickup address
|
||||||
pickupCompany = new ComboBox<>(getTranslation("profile.company"));
|
pickupCompany = new ComboBox<>(getTranslation("addjob.address.pickup.label"));
|
||||||
pickupCompany.setPlaceholder(getTranslation("addjob.address.company.placeholder"));
|
pickupCompany.setPlaceholder(getTranslation("addjob.address.pickup.placeholder"));
|
||||||
pickupCompany.setAllowCustomValue(true);
|
pickupCompany.setAllowCustomValue(true);
|
||||||
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
|
setupCompanyAutocomplete(pickupCompany, true); // true für Pickup
|
||||||
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
pickupSalutation = new ComboBox<>(getTranslation("addjob.address.salutation"));
|
||||||
@@ -857,7 +845,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
translationHelper, data -> {
|
translationHelper, data -> {
|
||||||
// Update customer selection from dialog
|
// Update customer selection from dialog
|
||||||
if (data.getCustomerSelection() != null) {
|
if (data.getCustomerSelection() != null) {
|
||||||
customerSelection.setValue(data.getCustomerSelection());
|
setCustomerSelectionValue(data.getCustomerSelection());
|
||||||
} else {
|
} else {
|
||||||
customerSelection.clear();
|
customerSelection.clear();
|
||||||
}
|
}
|
||||||
@@ -1116,6 +1104,19 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
return trimmed.isEmpty() ? null : trimmed;
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setCustomerSelectionValue(String value) {
|
||||||
|
String normalizedValue = trimToNull(value);
|
||||||
|
if (normalizedValue == null) {
|
||||||
|
customerSelection.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customerLabelToEntity.containsKey(normalizedValue)) {
|
||||||
|
customerLabelToEntity.put(normalizedValue, null);
|
||||||
|
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||||
|
}
|
||||||
|
customerSelection.setValue(normalizedValue);
|
||||||
|
}
|
||||||
|
|
||||||
private void openDeliveryDialog(StationTile tile, int stationIndex) {
|
private void openDeliveryDialog(StationTile tile, int stationIndex) {
|
||||||
// Ensure index is valid (could have changed due to deletions)
|
// Ensure index is valid (could have changed due to deletions)
|
||||||
int actualIndex = deliveryStationTilesList.indexOf(tile);
|
int actualIndex = deliveryStationTilesList.indexOf(tile);
|
||||||
@@ -1412,30 +1413,29 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
// Get all customers for the current owner
|
// Get all customers for the current owner
|
||||||
List<Customer> allCustomers = customerService.findAllForCurrentOwner();
|
List<Customer> allCustomers = customerService.findAllForCurrentOwner();
|
||||||
|
|
||||||
// Extract unique company names (filter out null/empty values)
|
Map<String, Customer> addressOptions = new LinkedHashMap<>();
|
||||||
List<String> companyNames = allCustomers.stream().map(Customer::getCompanyName)
|
for (Customer customer : allCustomers) {
|
||||||
.filter(name -> name != null && !name.trim().isEmpty()).distinct().sorted().toList();
|
CustomerAddressLabelHelper.put(addressOptions, customer, getTranslation("addjob.customer.unnamed"));
|
||||||
|
}
|
||||||
|
|
||||||
// Set items for autocomplete
|
// Set items for autocomplete
|
||||||
companyField.setItems(companyNames);
|
companyField.setItems(new ArrayList<>(addressOptions.keySet()));
|
||||||
|
|
||||||
// Add selection listener to auto-fill pickup address fields when company is
|
// Add selection listener to auto-fill pickup address fields when company is
|
||||||
// selected
|
// selected
|
||||||
companyField.addValueChangeListener(event -> {
|
companyField.addValueChangeListener(event -> {
|
||||||
String selectedCompany = event.getValue();
|
String selectedAddress = event.getValue();
|
||||||
if (selectedCompany == null || selectedCompany.trim().isEmpty()) {
|
if (selectedAddress == null || selectedAddress.trim().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streckeninformationen zurücksetzen, da sich die Adresse ändert
|
// Streckeninformationen zurücksetzen, da sich die Adresse ändert
|
||||||
resetRouteInformation();
|
resetRouteInformation();
|
||||||
|
|
||||||
// Find the first customer with this company name
|
Customer customer = addressOptions.get(selectedAddress);
|
||||||
Optional<Customer> matchingCustomer = allCustomers.stream()
|
|
||||||
.filter(c -> selectedCompany.equals(c.getCompanyName())).findFirst();
|
|
||||||
|
|
||||||
if (matchingCustomer.isPresent()) {
|
if (customer != null) {
|
||||||
Customer customer = matchingCustomer.get();
|
pickupCustomerId = customer.getId();
|
||||||
|
|
||||||
// Fill pickup address fields
|
// Fill pickup address fields
|
||||||
if (customer.getTitle() != null
|
if (customer.getTitle() != null
|
||||||
@@ -1476,6 +1476,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
|
|
||||||
// Reactivate save checkbox for custom values
|
// Reactivate save checkbox for custom values
|
||||||
savePickupAddress.setValue(true);
|
savePickupAddress.setValue(true);
|
||||||
|
pickupCustomerId = null;
|
||||||
pickupMail = null;
|
pickupMail = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1987,7 +1988,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
*/
|
*/
|
||||||
private void loadJobIntoForm(Job job) {
|
private void loadJobIntoForm(Job job) {
|
||||||
if (job.getCustomerSelection() != null) {
|
if (job.getCustomerSelection() != null) {
|
||||||
customerSelection.setValue(job.getCustomerSelection());
|
setCustomerSelectionValue(job.getCustomerSelection());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,994 @@
|
|||||||
|
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.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.IFrame;
|
||||||
|
import com.vaadin.flow.component.html.Main;
|
||||||
|
import com.vaadin.flow.component.html.Paragraph;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.icon.Icon;
|
||||||
|
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
|
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.select.Select;
|
||||||
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.votianlt.model.PriceTable;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||||
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
|
||||||
|
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||||
|
import de.assecutor.votianlt.repository.PriceTableRepository;
|
||||||
|
import de.assecutor.votianlt.repository.SystemInvoiceDispatchRepository;
|
||||||
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.EmailService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
|
import de.assecutor.votianlt.service.PdfToolService;
|
||||||
|
import de.assecutor.votianlt.service.SystemCompanyProfileService;
|
||||||
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin view to create the monthly system invoices for all current customers
|
||||||
|
* (the users of the system). Invoices are always due at the end of the month;
|
||||||
|
* customers starting mid-month are only billed pro rata for the remaining days
|
||||||
|
* until the last day of the month.
|
||||||
|
*/
|
||||||
|
@Route(value = "admin-create-invoices", layout = AdminLayout.class)
|
||||||
|
@RolesAllowed("ADMIN")
|
||||||
|
@Slf4j
|
||||||
|
public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
|
||||||
|
|
||||||
|
private static final BigDecimal SYSTEM_VAT_RATE = new BigDecimal("0.19");
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PriceTableRepository priceTableRepository;
|
||||||
|
private final AppUserRepository appUserRepository;
|
||||||
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
|
private final CustomerInvoiceService customerInvoiceService;
|
||||||
|
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
private final SystemInvoiceDispatchRepository dispatchRepository;
|
||||||
|
private final PdfToolService pdfToolService;
|
||||||
|
|
||||||
|
private final Grid<BillingInfo> grid = new Grid<>();
|
||||||
|
|
||||||
|
/** Billing month, selectable by the user; defaults to the current month. */
|
||||||
|
private YearMonth billingMonth = YearMonth.now();
|
||||||
|
|
||||||
|
/** Filter for the invoice list: all, only open or only sent invoices. */
|
||||||
|
private enum InvoiceFilter {
|
||||||
|
ALL, OPEN, SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceFilter invoiceFilter = InvoiceFilter.ALL;
|
||||||
|
|
||||||
|
/** Discount with reason per customer (user id), entered via the dialog. */
|
||||||
|
private final Map<org.bson.types.ObjectId, DiscountEntry> discounts = new HashMap<>();
|
||||||
|
|
||||||
|
/** A discount entered for one customer: percent value plus reason. */
|
||||||
|
private record DiscountEntry(Double percent, String reason) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository,
|
||||||
|
AppUserRepository appUserRepository, InvoiceTemplateService invoiceTemplateService,
|
||||||
|
CustomerInvoiceService customerInvoiceService, SystemCompanyProfileService systemCompanyProfileService,
|
||||||
|
EmailService emailService, SystemInvoiceDispatchRepository dispatchRepository,
|
||||||
|
PdfToolService pdfToolService) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.priceTableRepository = priceTableRepository;
|
||||||
|
this.appUserRepository = appUserRepository;
|
||||||
|
this.invoiceTemplateService = invoiceTemplateService;
|
||||||
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
|
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||||
|
this.emailService = emailService;
|
||||||
|
this.dispatchRepository = dispatchRepository;
|
||||||
|
this.pdfToolService = pdfToolService;
|
||||||
|
|
||||||
|
setSizeFull();
|
||||||
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
|
LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL);
|
||||||
|
addClassName("data-view");
|
||||||
|
|
||||||
|
Button createSelectedButton = new Button(getTranslation("admincreateinvoices.button.createselected", 0));
|
||||||
|
createSelectedButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
createSelectedButton.setEnabled(false);
|
||||||
|
|
||||||
|
// Billing month selection: current month and up to 12 months back
|
||||||
|
Select<YearMonth> monthSelect = new Select<>();
|
||||||
|
monthSelect.setLabel(getTranslation("admincreateinvoices.field.month"));
|
||||||
|
List<YearMonth> months = new ArrayList<>();
|
||||||
|
for (int offset = 0; offset >= -12; offset--) {
|
||||||
|
months.add(YearMonth.now().plusMonths(offset));
|
||||||
|
}
|
||||||
|
monthSelect.setItems(months);
|
||||||
|
monthSelect.setItemLabelGenerator(
|
||||||
|
month -> month.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale())));
|
||||||
|
monthSelect.setValue(billingMonth);
|
||||||
|
|
||||||
|
// Filter: all invoices of the month, only open or only sent ones
|
||||||
|
Select<InvoiceFilter> filterSelect = new Select<>();
|
||||||
|
filterSelect.setLabel(getTranslation("admincreateinvoices.filter.label"));
|
||||||
|
filterSelect.setItems(InvoiceFilter.values());
|
||||||
|
filterSelect.setItemLabelGenerator(
|
||||||
|
filter -> getTranslation("admincreateinvoices.filter." + filter.name().toLowerCase()));
|
||||||
|
filterSelect.setValue(invoiceFilter);
|
||||||
|
filterSelect.addValueChangeListener(event -> {
|
||||||
|
if (event.getValue() != null) {
|
||||||
|
invoiceFilter = event.getValue();
|
||||||
|
grid.deselectAll();
|
||||||
|
grid.setItems(loadBillingInfos());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
add(new ViewToolbar(getTranslation("admincreateinvoices.title")));
|
||||||
|
|
||||||
|
// Month and filter selection left-aligned below the heading
|
||||||
|
com.vaadin.flow.component.orderedlayout.HorizontalLayout selectionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(
|
||||||
|
monthSelect, filterSelect);
|
||||||
|
selectionLayout.getStyle().set("align-self", "flex-start");
|
||||||
|
add(selectionLayout);
|
||||||
|
|
||||||
|
Paragraph hint = new Paragraph(getTranslation("admincreateinvoices.hint"));
|
||||||
|
hint.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL, LumoUtility.Margin.NONE);
|
||||||
|
add(hint);
|
||||||
|
|
||||||
|
grid.setItems(loadBillingInfos());
|
||||||
|
grid.addColumn(info -> customerDisplayName(info.user()))
|
||||||
|
.setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true);
|
||||||
|
grid.addColumn(info -> info.startDate() != null ? DateTimeFormatUtil.formatDate(info.startDate()) : "")
|
||||||
|
.setHeader(getTranslation("admincreateinvoices.column.start")).setFlexGrow(1).setSortable(true);
|
||||||
|
grid.addColumn(info -> info.billedDays() < info.daysInMonth()
|
||||||
|
? getTranslation("admincreateinvoices.days.partial", info.billedDays(), info.daysInMonth())
|
||||||
|
: getTranslation("admincreateinvoices.days.full"))
|
||||||
|
.setHeader(getTranslation("admincreateinvoices.column.days")).setFlexGrow(1);
|
||||||
|
grid.addColumn(info -> DateTimeFormatUtil.formatDate(info.dueDate()))
|
||||||
|
.setHeader(getTranslation("admincreateinvoices.column.due")).setFlexGrow(1);
|
||||||
|
grid.addColumn(info -> formatAmount(applyDiscount(info).netTotal()) + " €")
|
||||||
|
.setHeader(getTranslation("admincreateinvoices.column.net")).setFlexGrow(1)
|
||||||
|
.setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END);
|
||||||
|
grid.addComponentColumn(info -> {
|
||||||
|
DiscountEntry entry = discounts.get(info.user().getId());
|
||||||
|
Button discountButton = new Button(new Icon(VaadinIcon.TAG));
|
||||||
|
discountButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ICON,
|
||||||
|
entry != null ? ButtonVariant.LUMO_PRIMARY : ButtonVariant.LUMO_TERTIARY);
|
||||||
|
String tooltip = entry != null
|
||||||
|
? getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()))
|
||||||
|
+ (entry.reason().isBlank() ? "" : " – " + entry.reason())
|
||||||
|
: getTranslation("admincreateinvoices.discount.dialog.title");
|
||||||
|
discountButton.setTooltipText(tooltip);
|
||||||
|
discountButton.setAriaLabel(getTranslation("admincreateinvoices.discount.dialog.title"));
|
||||||
|
discountButton.addClickListener(e -> openDiscountDialog(info, grid));
|
||||||
|
return discountButton;
|
||||||
|
}).setHeader(getTranslation("admincreateinvoices.column.discount")).setAutoWidth(true).setFlexGrow(0);
|
||||||
|
grid.addComponentColumn(info -> {
|
||||||
|
Button createButton = new Button(new Icon(VaadinIcon.INVOICE));
|
||||||
|
createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL,
|
||||||
|
ButtonVariant.LUMO_ICON);
|
||||||
|
createButton.setTooltipText(getTranslation("admincreateinvoices.button.create"));
|
||||||
|
createButton.setAriaLabel(getTranslation("admincreateinvoices.button.create"));
|
||||||
|
createButton.addClickListener(e -> createInvoicePdf(info));
|
||||||
|
return createButton;
|
||||||
|
}).setAutoWidth(true).setFlexGrow(0).setFrozenToEnd(true);
|
||||||
|
grid.setSelectionMode(Grid.SelectionMode.MULTI);
|
||||||
|
grid.addSelectionListener(event -> {
|
||||||
|
int count = event.getAllSelectedItems().size();
|
||||||
|
createSelectedButton.setText(getTranslation("admincreateinvoices.button.createselected", count));
|
||||||
|
createSelectedButton.setEnabled(count > 0);
|
||||||
|
});
|
||||||
|
createSelectedButton.addClickListener(e -> openInvoiceReviewDialog(new ArrayList<>(grid.getSelectedItems())));
|
||||||
|
monthSelect.addValueChangeListener(event -> {
|
||||||
|
if (event.getValue() != null) {
|
||||||
|
billingMonth = event.getValue();
|
||||||
|
discounts.clear();
|
||||||
|
grid.deselectAll();
|
||||||
|
grid.setItems(loadBillingInfos());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
grid.setSizeFull();
|
||||||
|
grid.addClassName("data-grid");
|
||||||
|
|
||||||
|
Div gridPanel = new Div(grid);
|
||||||
|
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
||||||
|
gridPanel.setSizeFull();
|
||||||
|
gridPanel.getStyle().set("min-width", "0");
|
||||||
|
add(gridPanel);
|
||||||
|
|
||||||
|
// Action bar below the table
|
||||||
|
com.vaadin.flow.component.orderedlayout.HorizontalLayout actionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(
|
||||||
|
createSelectedButton);
|
||||||
|
actionLayout.setWidthFull();
|
||||||
|
actionLayout.setJustifyContentMode(
|
||||||
|
com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode.START);
|
||||||
|
actionLayout.getStyle().set("flex-shrink", "0");
|
||||||
|
add(actionLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Billing calculation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing data of one customer for the current month.
|
||||||
|
*/
|
||||||
|
public record BillingInfo(User user, LocalDate startDate, int billedDays, int daysInMonth, LocalDate dueDate,
|
||||||
|
List<Map<String, String>> positions, BigDecimal netTotal, BigDecimal vatTotal, BigDecimal grossTotal) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BillingInfo> loadBillingInfos() {
|
||||||
|
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
|
||||||
|
List<BillingInfo> infos = userRepository.findAll().stream().filter(this::isCurrentCustomer)
|
||||||
|
.filter(this::existedInBillingMonth).map(user -> computeBilling(user, priceTable)).toList();
|
||||||
|
if (invoiceFilter == InvoiceFilter.ALL) {
|
||||||
|
return infos;
|
||||||
|
}
|
||||||
|
java.util.Set<String> sentInvoiceNumbers = dispatchRepository.findByBillingMonth(billingMonth.toString())
|
||||||
|
.stream().map(SystemInvoiceDispatch::getInvoiceNumber).collect(java.util.stream.Collectors.toSet());
|
||||||
|
boolean wantSent = invoiceFilter == InvoiceFilter.SENT;
|
||||||
|
return infos.stream()
|
||||||
|
.filter(info -> sentInvoiceNumbers.contains(buildInvoiceNumber(info.user())) == wantSent).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers who only started after the billing month are not billed for it.
|
||||||
|
*/
|
||||||
|
private boolean existedInBillingMonth(User user) {
|
||||||
|
if (user.getCreatedAt() == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !user.getCreatedAt().toLocalDate().isAfter(billingMonth.atEndOfMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current customers are all activated users without the ADMIN role.
|
||||||
|
*/
|
||||||
|
private boolean isCurrentCustomer(User user) {
|
||||||
|
boolean isAdmin = user.getRoles() != null && user.getRoles().contains("ADMIN");
|
||||||
|
return !isAdmin && user.getIsActivated() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BillingInfo computeBilling(User user, PriceTable priceTable) {
|
||||||
|
YearMonth month = billingMonth;
|
||||||
|
int daysInMonth = month.lengthOfMonth();
|
||||||
|
LocalDate dueDate = month.atEndOfMonth();
|
||||||
|
|
||||||
|
LocalDate startDate = user.getCreatedAt() != null ? user.getCreatedAt().toLocalDate() : null;
|
||||||
|
int billedDays = daysInMonth;
|
||||||
|
if (startDate != null && YearMonth.from(startDate).equals(month)) {
|
||||||
|
// Customer started mid-month: bill only the remaining days until
|
||||||
|
// the last day of the month (start day included)
|
||||||
|
billedDays = daysInMonth - startDate.getDayOfMonth() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal factor = new BigDecimal(billedDays).divide(new BigDecimal(daysInMonth), 10, RoundingMode.HALF_UP);
|
||||||
|
boolean proRata = billedDays < daysInMonth;
|
||||||
|
|
||||||
|
// Number of app users belonging to this customer in app_user: the
|
||||||
|
// per-app-user fee is billed once per configured app user
|
||||||
|
long appUserCount = user.getId() != null ? appUserRepository.countByErstelltVon(user.getId()) : 0;
|
||||||
|
|
||||||
|
List<Map<String, String>> positions = new ArrayList<>();
|
||||||
|
BigDecimal netTotal = BigDecimal.ZERO;
|
||||||
|
// Position entries: label, configured value, quantity
|
||||||
|
Object[][] entries = {
|
||||||
|
{ getTranslation("adminpricetable.field.monthly"),
|
||||||
|
priceTable != null ? priceTable.getMonthlyBasePackage() : null, 1L },
|
||||||
|
{ getTranslation("adminpricetable.field.appuserfee"),
|
||||||
|
priceTable != null ? priceTable.getAppUserFee() : null, appUserCount } };
|
||||||
|
|
||||||
|
for (Object[] entry : entries) {
|
||||||
|
String label = (String) entry[0];
|
||||||
|
String rawValue = entry[1] != null ? ((String) entry[1]).trim() : "";
|
||||||
|
long quantity = (Long) entry[2];
|
||||||
|
BigDecimal amount = parseAmount(rawValue);
|
||||||
|
Map<String, String> position = new HashMap<>();
|
||||||
|
if (amount != null) {
|
||||||
|
BigDecimal billed = amount.multiply(new BigDecimal(quantity)).multiply(factor).setScale(2,
|
||||||
|
RoundingMode.HALF_UP);
|
||||||
|
String name = label;
|
||||||
|
if (quantity > 1) {
|
||||||
|
name += " " + getTranslation("admincreateinvoices.appusers.suffix", quantity);
|
||||||
|
}
|
||||||
|
if (proRata) {
|
||||||
|
name += " " + getTranslation("admincreateinvoices.prorata.suffix", billedDays, daysInMonth);
|
||||||
|
}
|
||||||
|
position.put("name", name);
|
||||||
|
position.put("netAmount", formatAmount(billed));
|
||||||
|
netTotal = netTotal.add(billed);
|
||||||
|
} else {
|
||||||
|
// Non-monetary values are shown as-is and are not part of the
|
||||||
|
// totals
|
||||||
|
position.put("name", label);
|
||||||
|
position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
|
||||||
|
}
|
||||||
|
positions.add(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal grossTotal = netTotal.add(vatTotal);
|
||||||
|
|
||||||
|
return new BillingInfo(user, startDate, billedDays, daysInMonth, dueDate, positions, netTotal, vatTotal,
|
||||||
|
grossTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the dialog to enter (or change) the discount and its reason for
|
||||||
|
* one customer. An empty or zero discount removes the entry.
|
||||||
|
*/
|
||||||
|
private void openDiscountDialog(BillingInfo info, Grid<BillingInfo> grid) {
|
||||||
|
Dialog dialog = DialogStylingHelper
|
||||||
|
.createStyledDialog(getTranslation("admincreateinvoices.discount.dialog.title"), "420px");
|
||||||
|
|
||||||
|
DiscountEntry entry = discounts.get(info.user().getId());
|
||||||
|
|
||||||
|
NumberField percentField = new NumberField(getTranslation("admincreateinvoices.discount.dialog.percent"));
|
||||||
|
percentField.setMin(0);
|
||||||
|
percentField.setMax(100);
|
||||||
|
percentField.setSuffixComponent(new Span("%"));
|
||||||
|
percentField.setWidthFull();
|
||||||
|
if (entry != null) {
|
||||||
|
percentField.setValue(entry.percent());
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField reasonField = new TextField(getTranslation("admincreateinvoices.discount.dialog.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
if (entry != null) {
|
||||||
|
reasonField.setValue(entry.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout content = DialogStylingHelper.createContentLayout("380px");
|
||||||
|
content.add(percentField, reasonField);
|
||||||
|
|
||||||
|
Button cancelButton = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
Button applyButton = new Button(getTranslation("admincreateinvoices.discount.dialog.apply"), e -> {
|
||||||
|
Double percent = percentField.getValue();
|
||||||
|
// Clamp manual input to the valid range 0-100
|
||||||
|
if (percent != null && percent > 100) {
|
||||||
|
percent = 100d;
|
||||||
|
}
|
||||||
|
if (percent != null && percent > 0) {
|
||||||
|
discounts.put(info.user().getId(), new DiscountEntry(percent,
|
||||||
|
reasonField.getValue() != null ? reasonField.getValue().trim() : ""));
|
||||||
|
} else {
|
||||||
|
discounts.remove(info.user().getId());
|
||||||
|
}
|
||||||
|
dialog.close();
|
||||||
|
grid.getDataProvider().refreshItem(info);
|
||||||
|
});
|
||||||
|
applyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
dialog.getFooter().add(cancelButton, applyButton);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the discount entered for this customer: adds a negative
|
||||||
|
* discount position (including the reason) and recalculates the totals.
|
||||||
|
* Returns the info unchanged if no discount is set.
|
||||||
|
*/
|
||||||
|
private BillingInfo applyDiscount(BillingInfo info) {
|
||||||
|
DiscountEntry entry = discounts.get(info.user().getId());
|
||||||
|
if (entry == null || entry.percent() == null || entry.percent() <= 0) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
BigDecimal discountAmount = info.netTotal().multiply(BigDecimal.valueOf(entry.percent()))
|
||||||
|
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
|
||||||
|
List<Map<String, String>> positions = new ArrayList<>(info.positions());
|
||||||
|
Map<String, String> position = new HashMap<>();
|
||||||
|
String name = getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()));
|
||||||
|
if (!entry.reason().isBlank()) {
|
||||||
|
name += " – " + entry.reason();
|
||||||
|
}
|
||||||
|
position.put("name", name);
|
||||||
|
position.put("netAmount", "-" + formatAmount(discountAmount));
|
||||||
|
positions.add(position);
|
||||||
|
BigDecimal netTotal = info.netTotal().subtract(discountAmount);
|
||||||
|
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
return new BillingInfo(info.user(), info.startDate(), info.billedDays(), info.daysInMonth(), info.dueDate(),
|
||||||
|
positions, netTotal, vatTotal, netTotal.add(vatTotal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PDF generation
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
private void createInvoicePdf(BillingInfo info) {
|
||||||
|
String templateData = loadSystemTemplateData();
|
||||||
|
if (templateData == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info = applyDiscount(info);
|
||||||
|
try {
|
||||||
|
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
|
||||||
|
buildInvoiceVariables(info), null);
|
||||||
|
showPdfInDialog(new InvoicePreview(info, buildInvoiceNumber(info.user()), pdfBytes));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error creating system invoice for user {}: {}", info.user().getEmail(), ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One generated invoice held in the review dialog. */
|
||||||
|
private record InvoicePreview(BillingInfo info, String invoiceNumber, byte[] pdf) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the review dialog for the selected customers: one invoice per
|
||||||
|
* page, navigable via previous/next, removable from the list and sendable
|
||||||
|
* to the customers by email.
|
||||||
|
*/
|
||||||
|
private void openInvoiceReviewDialog(List<BillingInfo> infos) {
|
||||||
|
if (infos.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String templateData = loadSystemTemplateData();
|
||||||
|
if (templateData == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<InvoicePreview> previews = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
for (BillingInfo info : infos) {
|
||||||
|
BillingInfo billed = applyDiscount(info);
|
||||||
|
previews.add(new InvoicePreview(billed, buildInvoiceNumber(billed.user()), customerInvoiceService
|
||||||
|
.generatePdfFromCanvasTemplateWithData(templateData, buildInvoiceVariables(billed), null)));
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error creating system invoices for {} users: {}", infos.size(), ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("admincreateinvoices.dialog.title"),
|
||||||
|
"90vw");
|
||||||
|
dialog.setHeight("90vh");
|
||||||
|
|
||||||
|
Paragraph positionLabel = new Paragraph();
|
||||||
|
positionLabel.addClassNames(LumoUtility.Margin.NONE, LumoUtility.FontWeight.SEMIBOLD,
|
||||||
|
LumoUtility.Padding.SMALL);
|
||||||
|
positionLabel.getStyle().set("flex-shrink", "0");
|
||||||
|
|
||||||
|
IFrame pdfFrame = new IFrame();
|
||||||
|
pdfFrame.setWidth("100%");
|
||||||
|
pdfFrame.getStyle().set("flex", "1").set("min-height", "0").set("border", "none");
|
||||||
|
|
||||||
|
Div content = new Div(positionLabel, pdfFrame);
|
||||||
|
content.getStyle().set("display", "flex").set("flex-direction", "column");
|
||||||
|
content.setWidth("100%");
|
||||||
|
content.setHeight("100%");
|
||||||
|
|
||||||
|
Button previousButton = new Button(getTranslation("admincreateinvoices.dialog.button.previous"));
|
||||||
|
previousButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
Button nextButton = new Button(getTranslation("admincreateinvoices.dialog.button.next"));
|
||||||
|
nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
Button deleteButton = new Button(getTranslation("admincreateinvoices.dialog.button.delete"));
|
||||||
|
deleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
|
||||||
|
Button zugferdButton = new Button(getTranslation("admincreateinvoices.button.zugferd"));
|
||||||
|
zugferdButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
Button sendButton = new Button(getTranslation("admincreateinvoices.dialog.button.send"));
|
||||||
|
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
Button closeButton = new Button(getTranslation("button.close"), e -> dialog.close());
|
||||||
|
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
int[] index = { 0 };
|
||||||
|
Runnable updateView = () -> {
|
||||||
|
InvoicePreview current = previews.get(index[0]);
|
||||||
|
positionLabel.setText(getTranslation("admincreateinvoices.dialog.position", index[0] + 1, previews.size())
|
||||||
|
+ " – " + customerDisplayName(current.info().user()) + " (" + current.invoiceNumber() + ")");
|
||||||
|
pdfFrame.getElement().setAttribute("src",
|
||||||
|
"data:application/pdf;base64," + Base64.getEncoder().encodeToString(current.pdf()));
|
||||||
|
previousButton.setEnabled(index[0] > 0);
|
||||||
|
nextButton.setEnabled(index[0] < previews.size() - 1);
|
||||||
|
};
|
||||||
|
previousButton.addClickListener(e -> {
|
||||||
|
if (index[0] > 0) {
|
||||||
|
index[0]--;
|
||||||
|
updateView.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nextButton.addClickListener(e -> {
|
||||||
|
if (index[0] < previews.size() - 1) {
|
||||||
|
index[0]++;
|
||||||
|
updateView.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
deleteButton.addClickListener(e -> {
|
||||||
|
previews.remove(index[0]);
|
||||||
|
Notification.show(getTranslation("admincreateinvoices.dialog.notification.removed"), 3000,
|
||||||
|
Notification.Position.BOTTOM_CENTER);
|
||||||
|
if (previews.isEmpty()) {
|
||||||
|
dialog.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
index[0] = Math.min(index[0], previews.size() - 1);
|
||||||
|
updateView.run();
|
||||||
|
});
|
||||||
|
zugferdButton.addClickListener(e -> downloadZugferdZip(previews.get(index[0])));
|
||||||
|
sendButton.addClickListener(e -> {
|
||||||
|
sendButton.setEnabled(false);
|
||||||
|
sendInvoices(previews);
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
updateView.run();
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content, true));
|
||||||
|
dialog.getFooter().add(previousButton, nextButton, deleteButton, zugferdButton, sendButton, closeButton);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the invoices as signed ZUGFeRD e-invoice (ZIP) to the respective
|
||||||
|
* customers. First all signed ZIPs are created and cached; only when every
|
||||||
|
* ZIP could be created and passed the validation the mails are sent.
|
||||||
|
* Afterwards a combined ZIP containing all individual invoice ZIPs is
|
||||||
|
* offered as browser download.
|
||||||
|
*/
|
||||||
|
private void sendInvoices(List<InvoicePreview> previews) {
|
||||||
|
// Phase 1: create and cache the signed ZIP of every invoice; abort
|
||||||
|
// without sending anything if a single one fails
|
||||||
|
Map<InvoicePreview, PdfToolService.SignedInvoice> signedZips = new LinkedHashMap<>();
|
||||||
|
for (InvoicePreview preview : previews) {
|
||||||
|
try {
|
||||||
|
signedZips.put(preview, pdfToolService.signInvoice(preview.pdf(), buildZugferdMetadata(preview)));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error creating ZUGFeRD e-invoice {} for batch send: {}", preview.invoiceNumber(),
|
||||||
|
ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.zugferd.batcherror",
|
||||||
|
preview.invoiceNumber(), ex.getMessage()), 7000, Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invoices failing the Mustang validation stop the whole batch: no
|
||||||
|
// mail is sent as long as a single invoice is invalid
|
||||||
|
String invalidNumbers = signedZips.entrySet().stream().filter(entry -> !entry.getValue().valid())
|
||||||
|
.map(entry -> entry.getKey().invoiceNumber()).collect(java.util.stream.Collectors.joining(", "));
|
||||||
|
if (!invalidNumbers.isEmpty()) {
|
||||||
|
log.warn("Invoice batch send aborted, validation failed for: {}", invalidNumbers);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.zugferd.invalidsend", invalidNumbers),
|
||||||
|
7000, Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String issuerName = "";
|
||||||
|
try {
|
||||||
|
issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Could not load system company profile for invoice mail: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()));
|
||||||
|
|
||||||
|
// Phase 2: all ZIPs exist, send one mail per invoice with its ZIP
|
||||||
|
int sent = 0;
|
||||||
|
int failed = 0;
|
||||||
|
for (Map.Entry<InvoicePreview, PdfToolService.SignedInvoice> entry : signedZips.entrySet()) {
|
||||||
|
InvoicePreview preview = entry.getKey();
|
||||||
|
PdfToolService.SignedInvoice signed = entry.getValue();
|
||||||
|
String email = preview.info().user().getEmail();
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
log.warn("Customer {} has no email address, invoice {} not sent",
|
||||||
|
customerDisplayName(preview.info().user()), preview.invoiceNumber());
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
emailService.sendEmailWithAttachment(email,
|
||||||
|
getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()),
|
||||||
|
getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel,
|
||||||
|
issuerName),
|
||||||
|
signed.zip(), signed.fileName());
|
||||||
|
recordDispatch(preview, email);
|
||||||
|
sent++;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error sending invoice {} to {}: {}", preview.invoiceNumber(), email, ex.getMessage(), ex);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: bundle all individual invoice ZIPs and offer the download
|
||||||
|
if (signedZips.size() > 1) {
|
||||||
|
try {
|
||||||
|
String archiveName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))
|
||||||
|
+ "-zugferd.zip";
|
||||||
|
triggerZipDownload(buildCombinedZip(signedZips.values()), archiveName);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error creating combined invoice ZIP: {}", ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.zip.combined.error", ex.getMessage()),
|
||||||
|
7000, Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent > 0) {
|
||||||
|
grid.deselectAll();
|
||||||
|
grid.setItems(loadBillingInfos());
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.sent", sent), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.sendfailed", failed), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists (or refreshes) the dispatch record of a sent invoice so the
|
||||||
|
* list can be filtered into open and sent invoices.
|
||||||
|
*/
|
||||||
|
private void recordDispatch(InvoicePreview preview, String email) {
|
||||||
|
try {
|
||||||
|
SystemInvoiceDispatch dispatch = dispatchRepository.findByInvoiceNumber(preview.invoiceNumber())
|
||||||
|
.orElseGet(SystemInvoiceDispatch::new);
|
||||||
|
dispatch.setInvoiceNumber(preview.invoiceNumber());
|
||||||
|
dispatch.setUserId(preview.info().user().getId());
|
||||||
|
dispatch.setBillingMonth(billingMonth.toString());
|
||||||
|
dispatch.setSentAt(LocalDateTime.now());
|
||||||
|
dispatch.setSentTo(email);
|
||||||
|
dispatchRepository.save(dispatch);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error recording dispatch of invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored system template data, or null (with a notification)
|
||||||
|
* if no template has been saved yet.
|
||||||
|
*/
|
||||||
|
private String loadSystemTemplateData() {
|
||||||
|
Optional<de.assecutor.votianlt.model.InvoiceTemplate> template = invoiceTemplateService
|
||||||
|
.getTemplateByUserId(InvoiceGeneratorView.SYSTEM_TEMPLATE_USER_ID);
|
||||||
|
if (template.isEmpty() || template.get().getTemplateData() == null
|
||||||
|
|| template.get().getTemplateData().isEmpty()) {
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.notemplate"), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return template.get().getTemplateData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> buildInvoiceVariables(BillingInfo info) throws Exception {
|
||||||
|
SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
|
||||||
|
User user = info.user();
|
||||||
|
Map<String, String> variables = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
String companyFull = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim();
|
||||||
|
variables.put("masterdata.company_name", companyFull);
|
||||||
|
variables.put("masterdata.street", safe(issuer.getCompanyStreet()));
|
||||||
|
variables.put("masterdata.city", safe(issuer.getCompanyCity()));
|
||||||
|
variables.put("masterdata.phone", safe(issuer.getCompanyPhone()));
|
||||||
|
variables.put("masterdata.email", safe(issuer.getCompanyEmail()));
|
||||||
|
variables.put("masterdata.website", safe(issuer.getCompanyWebsite()));
|
||||||
|
variables.put("masterdata.sender_line", safe(issuer.getSenderLine()));
|
||||||
|
variables.put("masterdata.payment_terms", safe(issuer.getPaymentTerms()));
|
||||||
|
variables.put("masterdata.footer", safe(issuer.getFooterText()).replace("<br>", "\n"));
|
||||||
|
variables.put("masterdata.invoice_number", buildInvoiceNumber(user));
|
||||||
|
variables.put("masterdata.invoice_date",
|
||||||
|
LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
|
||||||
|
|
||||||
|
// Recipient: the user's invoice address if a different one is set
|
||||||
|
if (user.isDiffInvoiceAddress()) {
|
||||||
|
variables.put("customer.company_name", firstNonBlank(user.getInvCompany(), user.getCompany()));
|
||||||
|
variables.put("customer.contact_name",
|
||||||
|
(safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim());
|
||||||
|
variables.put("customer.street",
|
||||||
|
(safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim());
|
||||||
|
variables.put("customer.city", (safe(user.getInvZip()) + " " + safe(user.getInvCity())).trim());
|
||||||
|
} else {
|
||||||
|
variables.put("customer.company_name", safe(user.getCompany()));
|
||||||
|
variables.put("customer.contact_name", (safe(user.getFirstname()) + " " + safe(user.getName())).trim());
|
||||||
|
variables.put("customer.street", (safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
|
||||||
|
variables.put("customer.city", (safe(user.getZip()) + " " + safe(user.getCity())).trim());
|
||||||
|
}
|
||||||
|
variables.put("customer.email", safe(user.getEmail()));
|
||||||
|
variables.put("customer.phone", safe(user.getPhone()));
|
||||||
|
|
||||||
|
// Positions and totals
|
||||||
|
variables.put("services.json",
|
||||||
|
new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(info.positions()));
|
||||||
|
variables.put("services.count", String.valueOf(info.positions().size()));
|
||||||
|
variables.put("invoice.net_total", formatAmount(info.netTotal()) + " €");
|
||||||
|
variables.put("invoice.vat_total", formatAmount(info.vatTotal()) + " €");
|
||||||
|
variables.put("invoice.gross_total", formatAmount(info.grossTotal()) + " €");
|
||||||
|
variables.put("invoice.vat_rate",
|
||||||
|
SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%");
|
||||||
|
variables.put("invoice.due_date", DateTimeFormatUtil.formatDate(info.dueDate()));
|
||||||
|
variables.put("services.net_total", formatAmount(info.netTotal()) + " €");
|
||||||
|
variables.put("services.gross_total", formatAmount(info.grossTotal()) + " €");
|
||||||
|
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildInvoiceNumber(User user) {
|
||||||
|
String monthPart = billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||||
|
String userPart = user.getUsrId() > 0 ? String.valueOf(user.getUsrId())
|
||||||
|
: (user.getId() != null ? user.getId().toHexString().substring(20) : "0");
|
||||||
|
return "SYS-" + monthPart + "-" + userPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPdfInDialog(InvoicePreview preview) {
|
||||||
|
Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"),
|
||||||
|
"90vw");
|
||||||
|
pdfDialog.setHeight("90vh");
|
||||||
|
|
||||||
|
Div pdfContainer = new Div();
|
||||||
|
pdfContainer.setWidth("100%");
|
||||||
|
pdfContainer.setHeight("100%");
|
||||||
|
|
||||||
|
String base64Pdf = Base64.getEncoder().encodeToString(preview.pdf());
|
||||||
|
|
||||||
|
IFrame pdfFrame = new IFrame();
|
||||||
|
pdfFrame.setWidth("100%");
|
||||||
|
pdfFrame.setHeight("100%");
|
||||||
|
pdfFrame.getElement().setAttribute("src", "data:application/pdf;base64," + base64Pdf);
|
||||||
|
pdfContainer.add(pdfFrame);
|
||||||
|
|
||||||
|
Button closeButton = new Button(getTranslation("button.close"), e -> pdfDialog.close());
|
||||||
|
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button zugferdButton = new Button(getTranslation("admincreateinvoices.button.zugferd"),
|
||||||
|
e -> downloadZugferdZip(preview));
|
||||||
|
zugferdButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button sendButton = new Button(getTranslation("admincreateinvoices.pdf.button.send"), e -> {
|
||||||
|
e.getSource().setEnabled(false);
|
||||||
|
sendInvoices(List.of(preview));
|
||||||
|
pdfDialog.close();
|
||||||
|
});
|
||||||
|
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
|
||||||
|
pdfDialog.getFooter().add(zugferdButton, sendButton, closeButton);
|
||||||
|
pdfDialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ZUGFeRD e-invoice (external PDF Tool)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the invoice via the external PDF Tool into a ZUGFeRD e-invoice
|
||||||
|
* and offers the resulting ZIP (signed PDF/A-3, factur-x.xml, validation
|
||||||
|
* report) as browser download.
|
||||||
|
*/
|
||||||
|
private void downloadZugferdZip(InvoicePreview preview) {
|
||||||
|
try {
|
||||||
|
PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(),
|
||||||
|
buildZugferdMetadata(preview));
|
||||||
|
triggerZipDownload(signed.zip(), signed.fileName());
|
||||||
|
if (signed.valid()) {
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.zugferd.created",
|
||||||
|
preview.invoiceNumber()), 5000, Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
} else {
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.zugferd.invalid"), 7000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_WARNING);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error creating ZUGFeRD e-invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("admincreateinvoices.notification.zugferd.error", ex.getMessage()), 7000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packs the individual invoice ZIPs into one combined ZIP so all sent
|
||||||
|
* e-invoices can be archived with a single download.
|
||||||
|
*/
|
||||||
|
private byte[] buildCombinedZip(java.util.Collection<PdfToolService.SignedInvoice> signedInvoices)
|
||||||
|
throws java.io.IOException {
|
||||||
|
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
|
||||||
|
try (java.util.zip.ZipOutputStream zipStream = new java.util.zip.ZipOutputStream(buffer)) {
|
||||||
|
for (PdfToolService.SignedInvoice signed : signedInvoices) {
|
||||||
|
zipStream.putNextEntry(new java.util.zip.ZipEntry(signed.fileName()));
|
||||||
|
zipStream.write(signed.zip());
|
||||||
|
zipStream.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offers the given ZIP bytes as browser download. */
|
||||||
|
private void triggerZipDownload(byte[] zip, String fileName) {
|
||||||
|
String base64Zip = Base64.getEncoder().encodeToString(zip);
|
||||||
|
getElement().executeJs("const link = document.createElement('a');"
|
||||||
|
+ "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '"
|
||||||
|
+ fileName.replace("'", "") + "';"
|
||||||
|
+ "document.body.appendChild(link); link.click(); document.body.removeChild(link);");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the EN16931 metadata for the PDF Tool from the billing data: the
|
||||||
|
* system operator as sender, the customer as recipient and the billed
|
||||||
|
* positions (including a possible negative discount position) as lines.
|
||||||
|
*/
|
||||||
|
private PdfToolService.InvoiceMetadata buildZugferdMetadata(InvoicePreview preview) throws Exception {
|
||||||
|
SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
|
||||||
|
BillingInfo info = preview.info();
|
||||||
|
User user = info.user();
|
||||||
|
|
||||||
|
String[] senderZipCity = splitZipCity(issuer.getCompanyCity());
|
||||||
|
String senderName = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim();
|
||||||
|
String footer = safe(issuer.getFooterText());
|
||||||
|
PdfToolService.Party sender = new PdfToolService.Party(senderName, safe(issuer.getCompanyStreet()),
|
||||||
|
senderZipCity[0], senderZipCity[1], "DE", extractByPattern(footer, "USt-IdNr\\.?:?\\s*([A-Z]{2}[0-9A-Za-z]{2,12})"),
|
||||||
|
safe(issuer.getCompanyEmail()), safe(issuer.getCompanyPhone()));
|
||||||
|
|
||||||
|
PdfToolService.Party recipient;
|
||||||
|
if (user.isDiffInvoiceAddress()) {
|
||||||
|
recipient = new PdfToolService.Party(
|
||||||
|
firstNonBlank(user.getInvCompany(), user.getCompany(),
|
||||||
|
(safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim()),
|
||||||
|
(safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim(), safe(user.getInvZip()),
|
||||||
|
safe(user.getInvCity()), "DE", null, safe(user.getEmail()), safe(user.getPhone()));
|
||||||
|
} else {
|
||||||
|
recipient = new PdfToolService.Party(
|
||||||
|
firstNonBlank(user.getCompany(), (safe(user.getFirstname()) + " " + safe(user.getName())).trim()),
|
||||||
|
(safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim(), safe(user.getZip()),
|
||||||
|
safe(user.getCity()), "DE", null, safe(user.getEmail()), safe(user.getPhone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal vatPercent = SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP);
|
||||||
|
List<PdfToolService.LineItem> items = new ArrayList<>();
|
||||||
|
for (Map<String, String> position : info.positions()) {
|
||||||
|
BigDecimal amount = parseSignedAmount(position.get("netAmount"));
|
||||||
|
if (amount == null) {
|
||||||
|
// Non-monetary positions cannot be part of an EN16931 invoice
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.add(new PdfToolService.LineItem(position.get("name"), BigDecimal.ONE, amount, vatPercent));
|
||||||
|
}
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
throw new IllegalStateException(getTranslation("admincreateinvoices.notification.zugferd.nopositions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PdfToolService.InvoiceMetadata(preview.invoiceNumber(), LocalDate.now(),
|
||||||
|
billingMonth.atEndOfMonth(), info.dueDate(), "EUR", safe(issuer.getPaymentTerms()), null,
|
||||||
|
extractByPattern(footer, "IBAN\\s*:?\\s*([A-Z]{2}[0-9A-Za-z]{13,32})"),
|
||||||
|
extractByPattern(footer, "BIC\\s*:?\\s*([A-Za-z0-9]{8,11})"), sender, recipient, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a formatted position amount like "99,00" or "-49,50" including
|
||||||
|
* its sign. Returns null for non-monetary values such as "-" or "15 %".
|
||||||
|
*/
|
||||||
|
private BigDecimal parseSignedAmount(String value) {
|
||||||
|
if (value == null || value.isBlank() || value.contains("%")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean negative = value.trim().startsWith("-");
|
||||||
|
BigDecimal amount = parseAmount(value.trim().replaceFirst("^-", ""));
|
||||||
|
if (amount == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return negative ? amount.negate() : amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a combined "zip city" value like "21502 Geesthacht". */
|
||||||
|
private String[] splitZipCity(String zipCity) {
|
||||||
|
String trimmed = safe(zipCity).trim();
|
||||||
|
int space = trimmed.indexOf(' ');
|
||||||
|
if (space < 0) {
|
||||||
|
return new String[] { trimmed, trimmed };
|
||||||
|
}
|
||||||
|
return new String[] { trimmed.substring(0, space), trimmed.substring(space + 1).trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the first regex group found in the text, or null. */
|
||||||
|
private String extractByPattern(String text, String pattern) {
|
||||||
|
Matcher matcher = Pattern.compile(pattern).matcher(text);
|
||||||
|
return matcher.find() ? matcher.group(1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
private String customerDisplayName(User user) {
|
||||||
|
String company = safe(user.getCompany()).trim();
|
||||||
|
if (!company.isEmpty()) {
|
||||||
|
return company;
|
||||||
|
}
|
||||||
|
return (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a price table value like "99,00 €" or "1.234,56" into an amount.
|
||||||
|
* Returns null for non-monetary values such as "15 %".
|
||||||
|
*/
|
||||||
|
private BigDecimal parseAmount(String value) {
|
||||||
|
if (value == null || value.isBlank() || value.contains("%")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Matcher matcher = Pattern.compile("([0-9]{1,3}(?:\\.[0-9]{3})*(?:,[0-9]+)?|[0-9]+(?:[.,][0-9]+)?)")
|
||||||
|
.matcher(value);
|
||||||
|
if (!matcher.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String number = matcher.group(1);
|
||||||
|
if (number.contains(",")) {
|
||||||
|
number = number.replace(".", "").replace(',', '.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new BigDecimal(number);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAmount(BigDecimal amount) {
|
||||||
|
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a percent value without trailing zeros, e.g. 10 or 12,5. */
|
||||||
|
private String formatPercent(Double percent) {
|
||||||
|
return new BigDecimal(percent.toString()).stripTrailingZeros().toPlainString().replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPageTitle() {
|
||||||
|
return getTranslation("page.title.admin.createinvoices");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
import com.vaadin.flow.component.html.H1;
|
import com.vaadin.flow.component.html.H1;
|
||||||
import com.vaadin.flow.component.html.H3;
|
import com.vaadin.flow.component.html.H3;
|
||||||
@@ -14,15 +15,23 @@ import com.vaadin.flow.router.HasDynamicTitle;
|
|||||||
import com.vaadin.flow.router.Menu;
|
import com.vaadin.flow.router.Menu;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.votianlt.model.Customer;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.pages.domain.CustomerRepository;
|
||||||
import de.assecutor.votianlt.repository.*;
|
import de.assecutor.votianlt.repository.*;
|
||||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
@Route(value = "admin-dashboard", layout = de.assecutor.votianlt.pages.base.ui.view.AdminLayout.class)
|
||||||
@RolesAllowed("ADMIN")
|
@RolesAllowed("ADMIN")
|
||||||
@@ -39,6 +48,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
private final BarcodeRepository barcodeRepository;
|
private final BarcodeRepository barcodeRepository;
|
||||||
private final SignatureRepository signatureRepository;
|
private final SignatureRepository signatureRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
|
private final CustomerRepository customerRepository;
|
||||||
|
|
||||||
private final Div statisticsContainer;
|
private final Div statisticsContainer;
|
||||||
|
|
||||||
@@ -46,7 +56,8 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
public AdminDashboardView(JobRepository jobRepository, TaskRepository taskRepository, UserRepository userRepository,
|
||||||
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
AppUserRepository appUserRepository, CargoItemRepository cargoItemRepository,
|
||||||
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||||
SignatureRepository signatureRepository, CommentRepository commentRepository) {
|
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||||
|
CustomerRepository customerRepository) {
|
||||||
|
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
@@ -57,6 +68,7 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
this.barcodeRepository = barcodeRepository;
|
this.barcodeRepository = barcodeRepository;
|
||||||
this.signatureRepository = signatureRepository;
|
this.signatureRepository = signatureRepository;
|
||||||
this.commentRepository = commentRepository;
|
this.commentRepository = commentRepository;
|
||||||
|
this.customerRepository = customerRepository;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
@@ -124,6 +136,9 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
// System overview section
|
// System overview section
|
||||||
mainLayout.add(createSystemOverviewSection());
|
mainLayout.add(createSystemOverviewSection());
|
||||||
|
|
||||||
|
// Customers overview section
|
||||||
|
mainLayout.add(createCustomersSection());
|
||||||
|
|
||||||
// Job statistics section
|
// Job statistics section
|
||||||
mainLayout.add(createJobStatisticsSection());
|
mainLayout.add(createJobStatisticsSection());
|
||||||
|
|
||||||
@@ -174,6 +189,62 @@ public class AdminDashboardView extends Main implements HasDynamicTitle {
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createCustomersSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.addClassNames("surface-panel", "dashboard-section");
|
||||||
|
|
||||||
|
List<Customer> customers = customerRepository.findAll().stream().filter(customer -> !customer.isInternal())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("admindashboard.section.customers", customers.size()));
|
||||||
|
title.addClassNames(LumoUtility.Margin.Bottom.MEDIUM, "dashboard-section-title");
|
||||||
|
|
||||||
|
if (customers.isEmpty()) {
|
||||||
|
Span emptyHint = new Span(getTranslation("admindashboard.customers.empty"));
|
||||||
|
section.add(title, emptyHint);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<ObjectId, User> usersById = userRepository.findAll().stream().filter(user -> user.getId() != null)
|
||||||
|
.collect(Collectors.toMap(User::getId, Function.identity(), (first, second) -> first));
|
||||||
|
|
||||||
|
Grid<Customer> grid = new Grid<>();
|
||||||
|
grid.setItems(customers);
|
||||||
|
grid.addColumn(Customer::getUsrId).setHeader(getTranslation("admindashboard.customers.column.number"))
|
||||||
|
.setAutoWidth(true).setFlexGrow(0).setSortable(true);
|
||||||
|
grid.addColumn(Customer::getCompanyName).setHeader(getTranslation("customers.column.company"))
|
||||||
|
.setAutoWidth(true).setSortable(true);
|
||||||
|
grid.addColumn(customer -> ((customer.getFirstname() != null ? customer.getFirstname() : "") + " "
|
||||||
|
+ (customer.getLastName() != null ? customer.getLastName() : "")).trim())
|
||||||
|
.setHeader(getTranslation("customers.column.name")).setAutoWidth(true).setSortable(true);
|
||||||
|
grid.addColumn(Customer::getMail).setHeader(getTranslation("customers.column.email")).setAutoWidth(true);
|
||||||
|
grid.addColumn(Customer::getTelephone).setHeader(getTranslation("customers.column.phone")).setAutoWidth(true);
|
||||||
|
grid.addColumn(customer -> ((customer.getZip() != null ? customer.getZip() : "") + " "
|
||||||
|
+ (customer.getCity() != null ? customer.getCity() : "")).trim())
|
||||||
|
.setHeader(getTranslation("customers.column.city")).setAutoWidth(true).setSortable(true);
|
||||||
|
grid.addColumn(customer -> formatOwner(usersById.get(customer.getOwner())))
|
||||||
|
.setHeader(getTranslation("admindashboard.customers.column.owner")).setAutoWidth(true)
|
||||||
|
.setSortable(true);
|
||||||
|
grid.addClassName("data-grid");
|
||||||
|
grid.setWidthFull();
|
||||||
|
grid.setHeight("400px");
|
||||||
|
|
||||||
|
section.add(title, grid);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatOwner(User owner) {
|
||||||
|
if (owner == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (owner.getCompany() != null && !owner.getCompany().isBlank()) {
|
||||||
|
return owner.getCompany();
|
||||||
|
}
|
||||||
|
return ((owner.getFirstname() != null ? owner.getFirstname() : "") + " "
|
||||||
|
+ (owner.getName() != null ? owner.getName() : "")).trim();
|
||||||
|
}
|
||||||
|
|
||||||
private VerticalLayout createJobStatisticsSection() {
|
private VerticalLayout createJobStatisticsSection() {
|
||||||
VerticalLayout section = new VerticalLayout();
|
VerticalLayout section = new VerticalLayout();
|
||||||
section.setPadding(false);
|
section.setPadding(false);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.html.Div;
|
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
@@ -19,16 +18,14 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
|
|||||||
|
|
||||||
private final PriceTableRepository priceTableRepository;
|
private final PriceTableRepository priceTableRepository;
|
||||||
private final TextField monthlyBasePackage;
|
private final TextField monthlyBasePackage;
|
||||||
private final TextField appUsageLicense;
|
private final TextField appUserFee;
|
||||||
private final TextField revenueParticipation;
|
|
||||||
|
|
||||||
public AdminPricetableView(PriceTableRepository priceTableRepository) {
|
public AdminPricetableView(PriceTableRepository priceTableRepository) {
|
||||||
this.priceTableRepository = priceTableRepository;
|
this.priceTableRepository = priceTableRepository;
|
||||||
|
|
||||||
setSpacing(false);
|
setSpacing(false);
|
||||||
setPadding(false);
|
setPadding(false);
|
||||||
getStyle().set("margin", "14px");
|
setWidthFull();
|
||||||
setWidth("90%");
|
|
||||||
addClassName("admin-form-view");
|
addClassName("admin-form-view");
|
||||||
add(new ViewToolbar(getTranslation("adminpricetable.title")));
|
add(new ViewToolbar(getTranslation("adminpricetable.title")));
|
||||||
|
|
||||||
@@ -42,29 +39,20 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
|
|||||||
monthlyBasePackage.setWidth("40%");
|
monthlyBasePackage.setWidth("40%");
|
||||||
monthlyBasePackage.setMaxWidth("40%");
|
monthlyBasePackage.setMaxWidth("40%");
|
||||||
|
|
||||||
appUsageLicense = new TextField();
|
appUserFee = new TextField();
|
||||||
appUsageLicense.setLabel(getTranslation("adminpricetable.field.applicense"));
|
appUserFee.setLabel(getTranslation("adminpricetable.field.appuserfee"));
|
||||||
appUsageLicense.setWidth("40%");
|
appUserFee.setHelperText(getTranslation("adminpricetable.field.appuserfee.helper"));
|
||||||
appUsageLicense.setMaxWidth("40%");
|
appUserFee.setWidth("40%");
|
||||||
|
appUserFee.setMaxWidth("40%");
|
||||||
revenueParticipation = new TextField();
|
|
||||||
revenueParticipation.setLabel(getTranslation("adminpricetable.field.revenue"));
|
|
||||||
revenueParticipation.setWidth("40%");
|
|
||||||
revenueParticipation.setMaxWidth("40%");
|
|
||||||
|
|
||||||
fieldsLayout.add(monthlyBasePackage, appUsageLicense, revenueParticipation);
|
|
||||||
|
|
||||||
add(fieldsLayout);
|
|
||||||
|
|
||||||
Button saveButton = new Button(getTranslation("button.savechanges"));
|
Button saveButton = new Button(getTranslation("button.savechanges"));
|
||||||
saveButton.getStyle().set("margin-top", "20px");
|
saveButton.getStyle().set("margin-top", "20px");
|
||||||
saveButton.addClickListener(e -> savePriceTable());
|
saveButton.addClickListener(e -> savePriceTable());
|
||||||
saveButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
|
saveButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
Div actions = new Div(saveButton);
|
fieldsLayout.add(monthlyBasePackage, appUserFee, saveButton);
|
||||||
actions.addClassNames("form-shell", "simple-card");
|
|
||||||
|
|
||||||
add(fieldsLayout, actions);
|
add(fieldsLayout);
|
||||||
|
|
||||||
// Load existing data
|
// Load existing data
|
||||||
loadPriceTable();
|
loadPriceTable();
|
||||||
@@ -76,8 +64,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
|
|||||||
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
|
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
|
||||||
|
|
||||||
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
|
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
|
||||||
priceTable.setAppUsageLicense(appUsageLicense.getValue());
|
priceTable.setAppUserFee(appUserFee.getValue());
|
||||||
priceTable.setRevenueParticipation(revenueParticipation.getValue());
|
|
||||||
|
|
||||||
priceTableRepository.save(priceTable);
|
priceTableRepository.save(priceTable);
|
||||||
Notification.show(getTranslation("adminpricetable.notification.saved"), 3000,
|
Notification.show(getTranslation("adminpricetable.notification.saved"), 3000,
|
||||||
@@ -95,10 +82,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
|
|||||||
if (priceTable != null) {
|
if (priceTable != null) {
|
||||||
monthlyBasePackage
|
monthlyBasePackage
|
||||||
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
|
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
|
||||||
appUsageLicense
|
appUserFee.setValue(priceTable.getAppUserFee() != null ? priceTable.getAppUserFee() : "");
|
||||||
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
|
|
||||||
revenueParticipation.setValue(
|
|
||||||
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
|
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show(getTranslation("adminpricetable.notification.load.error", ex.getMessage()), 5000,
|
Notification.show(getTranslation("adminpricetable.notification.load.error", ex.getMessage()), 5000,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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.formlayout.FormLayout;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
|
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
|
import com.vaadin.flow.router.Route;
|
||||||
|
import de.assecutor.votianlt.model.SystemCompanyProfile;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
|
||||||
|
import de.assecutor.votianlt.service.SystemCompanyProfileService;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin profile page for editing the company data of the system operator.
|
||||||
|
* These values are used as issuer data on system invoices to the users.
|
||||||
|
*/
|
||||||
|
@Route(value = "admin-profile", layout = AdminLayout.class)
|
||||||
|
@RolesAllowed("ADMIN")
|
||||||
|
@Slf4j
|
||||||
|
public class AdminProfileView extends VerticalLayout implements HasDynamicTitle {
|
||||||
|
|
||||||
|
private final SystemCompanyProfileService profileService;
|
||||||
|
|
||||||
|
private final TextField companyName = new TextField();
|
||||||
|
private final TextField companySubtitle = new TextField();
|
||||||
|
private final TextField companyStreet = new TextField();
|
||||||
|
private final TextField companyCity = new TextField();
|
||||||
|
private final TextField companyPhone = new TextField();
|
||||||
|
private final TextField companyFax = new TextField();
|
||||||
|
private final TextField companyEmail = new TextField();
|
||||||
|
private final TextField companyWebsite = new TextField();
|
||||||
|
private final TextField senderLine = new TextField();
|
||||||
|
private final TextArea paymentTerms = new TextArea();
|
||||||
|
private final TextArea footerText = new TextArea();
|
||||||
|
|
||||||
|
private SystemCompanyProfile profile;
|
||||||
|
|
||||||
|
public AdminProfileView(SystemCompanyProfileService profileService) {
|
||||||
|
this.profileService = profileService;
|
||||||
|
|
||||||
|
setSpacing(false);
|
||||||
|
setPadding(false);
|
||||||
|
setWidthFull();
|
||||||
|
addClassName("admin-form-view");
|
||||||
|
add(new ViewToolbar(getTranslation("adminprofile.title")));
|
||||||
|
|
||||||
|
companyName.setLabel(getTranslation("adminprofile.field.companyname"));
|
||||||
|
companySubtitle.setLabel(getTranslation("adminprofile.field.companysubtitle"));
|
||||||
|
companyStreet.setLabel(getTranslation("adminprofile.field.street"));
|
||||||
|
companyCity.setLabel(getTranslation("adminprofile.field.city"));
|
||||||
|
companyPhone.setLabel(getTranslation("adminprofile.field.phone"));
|
||||||
|
companyFax.setLabel(getTranslation("adminprofile.field.fax"));
|
||||||
|
companyEmail.setLabel(getTranslation("adminprofile.field.email"));
|
||||||
|
companyWebsite.setLabel(getTranslation("adminprofile.field.website"));
|
||||||
|
senderLine.setLabel(getTranslation("adminprofile.field.senderline"));
|
||||||
|
senderLine.setHelperText(getTranslation("adminprofile.field.senderline.helper"));
|
||||||
|
paymentTerms.setLabel(getTranslation("adminprofile.field.paymentterms"));
|
||||||
|
paymentTerms.setMinHeight("80px");
|
||||||
|
footerText.setLabel(getTranslation("adminprofile.field.footer"));
|
||||||
|
footerText.setHelperText(getTranslation("adminprofile.field.footer.helper"));
|
||||||
|
footerText.setMinHeight("120px");
|
||||||
|
|
||||||
|
FormLayout form = new FormLayout();
|
||||||
|
form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 2));
|
||||||
|
form.add(companyName, companySubtitle, companyStreet, companyCity, companyPhone, companyFax, companyEmail,
|
||||||
|
companyWebsite);
|
||||||
|
form.add(senderLine, 2);
|
||||||
|
form.add(paymentTerms, 2);
|
||||||
|
form.add(footerText, 2);
|
||||||
|
|
||||||
|
VerticalLayout fieldsLayout = new VerticalLayout(form);
|
||||||
|
fieldsLayout.setSpacing(true);
|
||||||
|
fieldsLayout.setPadding(false);
|
||||||
|
fieldsLayout.addClassNames("form-shell", "form-card");
|
||||||
|
add(fieldsLayout);
|
||||||
|
|
||||||
|
Button saveButton = new Button(getTranslation("button.savechanges"), e -> saveProfile());
|
||||||
|
saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
Div actions = new Div(saveButton);
|
||||||
|
actions.addClassNames("form-shell", "simple-card");
|
||||||
|
add(actions);
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadProfile() {
|
||||||
|
try {
|
||||||
|
profile = profileService.getOrCreateProfile();
|
||||||
|
companyName.setValue(nvl(profile.getCompanyName()));
|
||||||
|
companySubtitle.setValue(nvl(profile.getCompanySubtitle()));
|
||||||
|
companyStreet.setValue(nvl(profile.getCompanyStreet()));
|
||||||
|
companyCity.setValue(nvl(profile.getCompanyCity()));
|
||||||
|
companyPhone.setValue(nvl(profile.getCompanyPhone()));
|
||||||
|
companyFax.setValue(nvl(profile.getCompanyFax()));
|
||||||
|
companyEmail.setValue(nvl(profile.getCompanyEmail()));
|
||||||
|
companyWebsite.setValue(nvl(profile.getCompanyWebsite()));
|
||||||
|
senderLine.setValue(nvl(profile.getSenderLine()));
|
||||||
|
paymentTerms.setValue(nvl(profile.getPaymentTerms()));
|
||||||
|
// Footer is stored with <br> line breaks; edit it as multi-line text
|
||||||
|
footerText.setValue(nvl(profile.getFooterText()).replace("<br>", "\n"));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error loading system company profile: {}", ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("adminprofile.notification.load.error", ex.getMessage()), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveProfile() {
|
||||||
|
try {
|
||||||
|
profile.setCompanyName(companyName.getValue().trim());
|
||||||
|
profile.setCompanySubtitle(companySubtitle.getValue().trim());
|
||||||
|
profile.setCompanyStreet(companyStreet.getValue().trim());
|
||||||
|
profile.setCompanyCity(companyCity.getValue().trim());
|
||||||
|
profile.setCompanyPhone(companyPhone.getValue().trim());
|
||||||
|
profile.setCompanyFax(companyFax.getValue().trim());
|
||||||
|
profile.setCompanyEmail(companyEmail.getValue().trim());
|
||||||
|
profile.setCompanyWebsite(companyWebsite.getValue().trim());
|
||||||
|
profile.setSenderLine(senderLine.getValue().trim());
|
||||||
|
profile.setPaymentTerms(paymentTerms.getValue().trim());
|
||||||
|
profile.setFooterText(footerText.getValue().trim().replace("\n", "<br>"));
|
||||||
|
|
||||||
|
profile = profileService.save(profile);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("adminprofile.notification.saved"), 3000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Error saving system company profile: {}", ex.getMessage(), ex);
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("adminprofile.notification.save.error", ex.getMessage()), 5000,
|
||||||
|
Notification.Position.BOTTOM_CENTER)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nvl(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPageTitle() {
|
||||||
|
return getTranslation("page.title.admin.profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,17 @@ import de.assecutor.votianlt.model.Service;
|
|||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.service.CustomerService;
|
import de.assecutor.votianlt.pages.service.CustomerService;
|
||||||
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
import de.assecutor.votianlt.repository.ServiceRepository;
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -62,8 +64,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
private final UserInvoiceDataService userInvoiceDataService;
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
|
||||||
private final CustomerService customerService;
|
private final CustomerService customerService;
|
||||||
|
private final InvoiceLifecycleService invoiceLifecycleService;
|
||||||
private User currentUser;
|
private User currentUser;
|
||||||
private Job currentJob;
|
private Job currentJob;
|
||||||
private List<ServiceRow> gridRows = new ArrayList<>();
|
private List<ServiceRow> gridRows = new ArrayList<>();
|
||||||
@@ -117,8 +119,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
|
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
|
||||||
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
||||||
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
||||||
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository,
|
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
|
||||||
CustomerService customerService) {
|
InvoiceLifecycleService invoiceLifecycleService) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.serviceRepository = serviceRepository;
|
this.serviceRepository = serviceRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -126,8 +128,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
this.invoiceTemplateService = invoiceTemplateService;
|
this.invoiceTemplateService = invoiceTemplateService;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
this.userInvoiceDataService = userInvoiceDataService;
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
|
||||||
this.customerService = customerService;
|
this.customerService = customerService;
|
||||||
|
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
setSpacing(true);
|
setSpacing(true);
|
||||||
@@ -584,8 +586,20 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
invoice.setVatRate(vatRate);
|
invoice.setVatRate(vatRate);
|
||||||
invoice.setVatAmount(vatAmount);
|
invoice.setVatAmount(vatAmount);
|
||||||
invoice.setTotalAmount(totalAmount);
|
invoice.setTotalAmount(totalAmount);
|
||||||
|
|
||||||
|
// Pflichtangaben nach §14 UStG: Leistungsdatum, Absender, Empfänger und
|
||||||
|
// Positionen — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung.
|
||||||
|
invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate()
|
||||||
|
: java.time.LocalDate.now());
|
||||||
|
applySenderData(invoice, user);
|
||||||
|
applyRecipientData(invoice);
|
||||||
|
invoice.setItems(buildInvoiceItems(vatRate));
|
||||||
|
|
||||||
invoice.setPdfData(pdfBytes);
|
invoice.setPdfData(pdfBytes);
|
||||||
CustomerInvoice savedInvoice = customerInvoiceRepository.save(invoice);
|
|
||||||
|
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
|
||||||
|
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
|
||||||
|
"Rechnung erstellt aus Auftrag " + currentJob.getJobNumber());
|
||||||
|
|
||||||
currentJob.setInvoiceId(savedInvoice.getId());
|
currentJob.setInvoiceId(savedInvoice.getId());
|
||||||
jobRepository.save(currentJob);
|
jobRepository.save(currentJob);
|
||||||
@@ -594,6 +608,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
|
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
|
||||||
Notification.Position.BOTTOM_END);
|
Notification.Position.BOTTOM_END);
|
||||||
|
|
||||||
|
} catch (InvoiceLifecycleException lifecycleEx) {
|
||||||
|
log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage());
|
||||||
|
Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("Fehler beim Speichern der Rechnung", ex);
|
log.error("Fehler beim Speichern der Rechnung", ex);
|
||||||
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
|
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,
|
||||||
@@ -601,6 +618,105 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absenderdaten aus dem Nutzerprofil und den Rechnungs-Stammdaten
|
||||||
|
* (USt-IdNr./Steuernummer, Bankverbindung, Zahlungsbedingungen) übernehmen.
|
||||||
|
*/
|
||||||
|
private void applySenderData(CustomerInvoice invoice, User user) {
|
||||||
|
invoice.setSenderName(firstNonBlank(user.getCompany(),
|
||||||
|
(safe(user.getFirstname()) + " " + safe(user.getName())).trim()));
|
||||||
|
invoice.setSenderAddress((safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
|
||||||
|
invoice.setSenderPostcode(safe(user.getZip()));
|
||||||
|
invoice.setSenderCity(safe(user.getCity()));
|
||||||
|
invoice.setSenderCountry("DE");
|
||||||
|
invoice.setSenderPhone(safe(user.getPhone()));
|
||||||
|
invoice.setSenderEmail(safe(user.getEmail()));
|
||||||
|
|
||||||
|
userInvoiceDataService.findByUserId(user.getId()).ifPresent(invoiceData -> {
|
||||||
|
invoice.setSenderVatId(safe(invoiceData.getUstId()));
|
||||||
|
invoice.setSenderTaxNumber(safe(invoiceData.getTaxNumber()));
|
||||||
|
invoice.setIban(safe(invoiceData.getIban()));
|
||||||
|
invoice.setBankAccount(safe(invoiceData.getBankName()));
|
||||||
|
invoice.setPaymentTerms(safe(invoiceData.getPaymentTerms()));
|
||||||
|
});
|
||||||
|
if (invoice.getPaymentDueDate() == null) {
|
||||||
|
invoice.setPaymentDueDate(invoice.getInvoiceDate().plusDays(14));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfängerdaten aus der Kundenauswahl des Auftrags übernehmen; die volle
|
||||||
|
* Anschrift und E-Mail kommen aus dem passenden Adressbuch-Eintrag.
|
||||||
|
*/
|
||||||
|
private void applyRecipientData(CustomerInvoice invoice) {
|
||||||
|
String customerSelection = currentJob.getCustomerSelection();
|
||||||
|
if (customerSelection == null || customerSelection.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Format: "Firmenname | Vorname Nachname"
|
||||||
|
String[] parts = customerSelection.split("\\|");
|
||||||
|
String companyName = parts.length > 0 ? parts[0].trim() : "";
|
||||||
|
String contactName = parts.length > 1 ? parts[1].trim() : "";
|
||||||
|
|
||||||
|
invoice.setRecipientCompany(companyName);
|
||||||
|
invoice.setRecipientName(firstNonBlank(contactName, companyName));
|
||||||
|
|
||||||
|
Customer matchedCustomer = customerService.findAllForCurrentOwner().stream()
|
||||||
|
.filter(c -> companyName.equalsIgnoreCase(c.getCompanyName())).findFirst().orElse(null);
|
||||||
|
if (matchedCustomer != null) {
|
||||||
|
invoice.setRecipientAddress(
|
||||||
|
(safe(matchedCustomer.getStreet()) + " " + safe(matchedCustomer.getHouseNumber())).trim());
|
||||||
|
invoice.setRecipientPostcode(safe(matchedCustomer.getZip()));
|
||||||
|
invoice.setRecipientCity(safe(matchedCustomer.getCity()));
|
||||||
|
invoice.setRecipientCountry("DE");
|
||||||
|
invoice.setRecipientEmail(safe(matchedCustomer.getMail()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnungspositionen aus den ausgewählten Leistungen aufbauen — mit
|
||||||
|
* denselben Mengen-/Preisregeln wie {@link #calculateNetAmount()}, damit
|
||||||
|
* die Positionssumme exakt zum Rechnungs-Netto passt.
|
||||||
|
*/
|
||||||
|
private List<CustomerInvoiceItem> buildInvoiceItems(BigDecimal vatRate) {
|
||||||
|
List<CustomerInvoiceItem> items = new ArrayList<>();
|
||||||
|
for (ServiceRow row : getSelectedServices()) {
|
||||||
|
Service service = row.getService();
|
||||||
|
BigDecimal quantity = null;
|
||||||
|
BigDecimal unitPrice = null;
|
||||||
|
String unit = null;
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) {
|
||||||
|
quantity = BigDecimal.ONE;
|
||||||
|
unitPrice = service.getPrice();
|
||||||
|
unit = "pauschal";
|
||||||
|
} else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE
|
||||||
|
&& service.getPricePerKilometer() != null && getRouteDistanceKm(row.getSelection()) != null) {
|
||||||
|
quantity = BigDecimal.valueOf(getRouteDistanceKm(row.getSelection()));
|
||||||
|
unitPrice = service.getPricePerKilometer();
|
||||||
|
unit = "km";
|
||||||
|
} else if (service.getCalculationBasis() == Service.CalculationBasis.TIME
|
||||||
|
&& service.getPricePer15Minutes() != null && getTimeIn15MinUnits(row.getSelection()) != null) {
|
||||||
|
quantity = new BigDecimal(getTimeIn15MinUnits(row.getSelection()));
|
||||||
|
unitPrice = service.getPricePer15Minutes();
|
||||||
|
unit = "x 15 Min.";
|
||||||
|
}
|
||||||
|
if (quantity == null || quantity.signum() <= 0 || unitPrice == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.add(new CustomerInvoiceItem(quantity, unit, service.getName(), unitPrice, vatRate));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an invoice PDF from the template with actual job data.
|
* Generates an invoice PDF from the template with actual job data.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
|||||||
|
|
||||||
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
|
Button confirmDeleteButton = new Button(getTranslation("editcustomer.dialog.delete.confirm"), e -> {
|
||||||
if (customer != null && customer.getId() != null) {
|
if (customer != null && customer.getId() != null) {
|
||||||
|
customerService.deleteById(customer.getId());
|
||||||
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
|
Notification.show(getTranslation("editcustomer.notification.deleted"), 3000,
|
||||||
Notification.Position.MIDDLE);
|
Notification.Position.MIDDLE);
|
||||||
confirmDialog.close();
|
confirmDialog.close();
|
||||||
|
|||||||
@@ -372,6 +372,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
|
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
|
||||||
billingTab.add(billingHeaderLayout);
|
billingTab.add(billingHeaderLayout);
|
||||||
|
|
||||||
|
// Steuer- und Bankdaten: Pflichtangaben nach §14 UStG bzw. für die
|
||||||
|
// ZUGFeRD-E-Rechnung (Absender-USt-IdNr./Steuernummer, IBAN)
|
||||||
|
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
|
||||||
|
ustIdField.setPlaceholder("DE123456789");
|
||||||
|
ustIdField.addBlurListener(e -> saveInvoiceData());
|
||||||
|
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
|
||||||
|
taxNumberField.addBlurListener(e -> saveInvoiceData());
|
||||||
|
bankNameField.setLabel(getTranslation("profile.billing.bankname"));
|
||||||
|
bankNameField.addBlurListener(e -> saveInvoiceData());
|
||||||
|
ibanField.setLabel(getTranslation("profile.billing.iban"));
|
||||||
|
ibanField.setPlaceholder("DE00 0000 0000 0000 0000 00");
|
||||||
|
ibanField.addBlurListener(e -> saveInvoiceData());
|
||||||
|
|
||||||
|
HorizontalLayout billingTaxLayout = new HorizontalLayout(ustIdField, taxNumberField, bankNameField, ibanField);
|
||||||
|
billingTaxLayout.setSpacing(true);
|
||||||
|
billingTaxLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
|
||||||
|
billingTab.add(billingTaxLayout);
|
||||||
|
|
||||||
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||||
final HorizontalLayout mainLayout = new HorizontalLayout();
|
final HorizontalLayout mainLayout = new HorizontalLayout();
|
||||||
mainLayout.setWidthFull();
|
mainLayout.setWidthFull();
|
||||||
@@ -429,6 +447,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
actionLayout.setSpacing(true);
|
actionLayout.setSpacing(true);
|
||||||
actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)");
|
actionLayout.getStyle().set("margin-top", "var(--lumo-space-s)");
|
||||||
|
|
||||||
|
Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||||
|
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
undoButton.addClickListener(
|
||||||
|
e -> getElement().executeJs("if (window.undoProfileCanvas) { window.undoProfileCanvas(); }"));
|
||||||
|
|
||||||
Button previewPdfButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
Button previewPdfButton = new Button(getTranslation("button.preview"), new Icon(VaadinIcon.EYE));
|
||||||
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
previewPdfButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile());
|
previewPdfButton.addClickListener(e -> generatePreviewPdfFromProfile());
|
||||||
@@ -462,7 +485,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
actionLayout.add(previewPdfButton, saveTemplateButton);
|
actionLayout.add(undoButton, previewPdfButton, saveTemplateButton);
|
||||||
billingTab.add(actionLayout);
|
billingTab.add(actionLayout);
|
||||||
|
|
||||||
// Initialen Zustand setzen (sichtbar da checkbox standardmäßig true)
|
// Initialen Zustand setzen (sichtbar da checkbox standardmäßig true)
|
||||||
@@ -486,7 +509,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
// Also register this view instance for JavaScript callbacks
|
// Also register this view instance for JavaScript callbacks
|
||||||
tabSheet.addSelectedChangeListener(e -> {
|
tabSheet.addSelectedChangeListener(e -> {
|
||||||
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
||||||
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" + "setTimeout(function() { "
|
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
||||||
|
+ "window.profileInvoiceServiceData = null;" + "setTimeout(function() { "
|
||||||
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
|
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
|
||||||
+ " console.log('Canvas initialized, now loading template...'); "
|
+ " console.log('Canvas initialized, now loading template...'); "
|
||||||
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
|
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
|
||||||
@@ -1466,6 +1490,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
getElement().executeJs("setTimeout(function() { "
|
getElement().executeJs("setTimeout(function() { "
|
||||||
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
||||||
+ " console.log('Loading template into canvas...'); "
|
+ " console.log('Loading template into canvas...'); "
|
||||||
|
+ " window.profileInvoiceServiceData = null; "
|
||||||
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
|
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
|
||||||
+ " window.masterdataValues = "
|
+ " window.masterdataValues = "
|
||||||
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,92 @@
|
|||||||
package de.assecutor.votianlt.pages.view;
|
package de.assecutor.votianlt.pages.view;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.UI;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H3;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import com.vaadin.flow.component.html.Span;
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.server.StreamRegistration;
|
||||||
|
import com.vaadin.flow.server.StreamResource;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
|
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||||
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.EmailService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceExportService;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
|
import de.assecutor.votianlt.service.InvoicePermissionService;
|
||||||
|
import de.assecutor.votianlt.service.PdfToolService;
|
||||||
|
import de.assecutor.votianlt.service.UserInvoiceZugferdService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import com.vaadin.flow.server.StreamResource;
|
|
||||||
import com.vaadin.flow.server.StreamRegistration;
|
|
||||||
|
|
||||||
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@RolesAllowed({ "USER", "ADMIN" })
|
@RolesAllowed({ "USER", "ADMIN" })
|
||||||
|
@Slf4j
|
||||||
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
|
||||||
|
Locale.GERMANY);
|
||||||
|
|
||||||
private final Grid<CustomerInvoice> invoiceGrid;
|
private final Grid<CustomerInvoice> invoiceGrid;
|
||||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
private final InvoiceLifecycleService invoiceLifecycleService;
|
||||||
|
private final CustomerInvoiceService customerInvoiceService;
|
||||||
|
private final InvoiceExportService invoiceExportService;
|
||||||
|
private final InvoicePermissionService invoicePermissionService;
|
||||||
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserInvoiceZugferdService userInvoiceZugferdService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
|
||||||
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService) {
|
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
|
||||||
|
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
|
||||||
|
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
|
||||||
|
UserInvoiceDataService userInvoiceDataService,
|
||||||
|
UserRepository userRepository, UserInvoiceZugferdService userInvoiceZugferdService,
|
||||||
|
EmailService emailService) {
|
||||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
|
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||||
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
|
this.invoiceExportService = invoiceExportService;
|
||||||
|
this.invoicePermissionService = invoicePermissionService;
|
||||||
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.userInvoiceZugferdService = userInvoiceZugferdService;
|
||||||
|
this.emailService = emailService;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
@@ -43,60 +96,603 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
addClassName("data-view");
|
addClassName("data-view");
|
||||||
|
|
||||||
add(new ViewToolbar(getTranslation("invoices.title")));
|
add(new ViewToolbar(getTranslation("invoices.title")));
|
||||||
|
add(buildLegalDisclaimer());
|
||||||
|
|
||||||
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
|
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
|
||||||
invoiceGrid.setWidthFull();
|
invoiceGrid.setWidthFull();
|
||||||
invoiceGrid.addClassName("data-grid");
|
invoiceGrid.addClassName("data-grid");
|
||||||
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
|
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
|
||||||
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
|
||||||
|
invoiceGrid.addComponentColumn(this::renderTypeBadge)
|
||||||
|
.setHeader(getTranslation("invoices.column.type")).setAutoWidth(true);
|
||||||
|
invoiceGrid.addComponentColumn(this::renderStatusBadge)
|
||||||
|
.setHeader(getTranslation("invoices.column.status")).setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
|
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
|
||||||
.setAutoWidth(true);
|
.setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
|
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
|
||||||
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
||||||
.setAutoWidth(true);
|
.setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), ""))
|
invoiceGrid.addComponentColumn(this::renderActions)
|
||||||
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
|
||||||
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
|
|
||||||
invoiceGrid.getStyle().set("cursor", "pointer");
|
|
||||||
|
|
||||||
invoiceGrid.addItemClickListener(event -> {
|
invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
|
||||||
CustomerInvoice invoice = event.getItem();
|
|
||||||
if (invoice != null) {
|
|
||||||
downloadInvoicePdf(invoice);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadInvoices();
|
|
||||||
Div gridPanel = new Div(invoiceGrid);
|
Div gridPanel = new Div(invoiceGrid);
|
||||||
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
||||||
gridPanel.setWidthFull();
|
gridPanel.setWidthFull();
|
||||||
|
|
||||||
add(gridPanel);
|
add(gridPanel);
|
||||||
|
|
||||||
|
loadInvoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component buildLegalDisclaimer() {
|
||||||
|
Div banner = new Div();
|
||||||
|
banner.addClassName("surface-panel");
|
||||||
|
banner.getStyle().set("padding", "12px 16px").set("border-left", "4px solid var(--lumo-primary-color)")
|
||||||
|
.set("background", "var(--lumo-contrast-5pct)");
|
||||||
|
Span text = new Span(getTranslation("invoices.disclaimer"));
|
||||||
|
text.getStyle().set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
banner.add(text);
|
||||||
|
return banner;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadInvoices() {
|
private void loadInvoices() {
|
||||||
String currentUserId = securityService.getCurrentUserId().toHexString();
|
String currentUserId = securityService.getCurrentUserId().toHexString();
|
||||||
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
|
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
|
||||||
.filter(this::hasPdfData).sorted((left, right) -> {
|
.sorted(Comparator
|
||||||
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) {
|
.comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
|
||||||
return 0;
|
: i.getInvoiceDate())
|
||||||
}
|
.reversed())
|
||||||
if (left.getInvoiceDate() == null) {
|
.toList();
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (right.getInvoiceDate() == null) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
|
|
||||||
}).toList();
|
|
||||||
invoiceGrid.setItems(invoices);
|
invoiceGrid.setItems(invoices);
|
||||||
|
}
|
||||||
|
|
||||||
if (invoices.isEmpty()) {
|
private Component renderStatusBadge(CustomerInvoice invoice) {
|
||||||
Span emptyState = new Span(getTranslation("invoices.empty"));
|
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
|
||||||
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
|
||||||
add(emptyState);
|
badge.getElement().getThemeList().add("badge");
|
||||||
|
switch (status) {
|
||||||
|
case DRAFT -> badge.getElement().getThemeList().add("contrast");
|
||||||
|
case SENT -> badge.getElement().getThemeList().add("success");
|
||||||
|
case CANCELLED -> badge.getElement().getThemeList().add("error");
|
||||||
|
case CORRECTED -> badge.getElement().getThemeList().add("warning");
|
||||||
|
default -> {
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component renderTypeBadge(CustomerInvoice invoice) {
|
||||||
|
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
|
||||||
|
HorizontalLayout layout = new HorizontalLayout();
|
||||||
|
layout.setSpacing(true);
|
||||||
|
layout.setPadding(false);
|
||||||
|
|
||||||
|
Span badge = new Span(getTranslation("invoices.type." + type.name().toLowerCase(Locale.ROOT)));
|
||||||
|
badge.getElement().getThemeList().add("badge");
|
||||||
|
if (type == InvoiceType.CANCELLATION) {
|
||||||
|
badge.getElement().getThemeList().add("error");
|
||||||
|
} else if (type == InvoiceType.CORRECTION) {
|
||||||
|
badge.getElement().getThemeList().add("warning");
|
||||||
|
}
|
||||||
|
layout.add(badge);
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component renderActions(CustomerInvoice invoice) {
|
||||||
|
HorizontalLayout actions = new HorizontalLayout();
|
||||||
|
actions.setSpacing(true);
|
||||||
|
actions.setPadding(false);
|
||||||
|
|
||||||
|
Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice));
|
||||||
|
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0);
|
||||||
|
actions.add(viewBtn);
|
||||||
|
|
||||||
|
Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice));
|
||||||
|
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(historyBtn);
|
||||||
|
|
||||||
|
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
|
||||||
|
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
|
||||||
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
|
|
||||||
|
// Aktionen nur für reguläre, noch aktive Rechnungen anbieten
|
||||||
|
boolean isLiveInvoice = type == InvoiceType.INVOICE
|
||||||
|
&& (status == InvoiceStatus.ISSUED || status == InvoiceStatus.SENT);
|
||||||
|
|
||||||
|
if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED
|
||||||
|
&& invoicePermissionService.canMarkAsSent(currentUser)) {
|
||||||
|
Button sentBtn = new Button(getTranslation("invoices.action.marksent"),
|
||||||
|
e -> markAsSent(invoice));
|
||||||
|
sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(sentBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Rechnung (ZUGFeRD) über das externe PDF Tool — analog zu den
|
||||||
|
// System-Rechnungen der Admin-Seite
|
||||||
|
if (type == InvoiceType.INVOICE && invoice.getPdfData() != null && invoice.getPdfData().length > 0) {
|
||||||
|
Button zugferdBtn = new Button(getTranslation("invoices.action.zugferd"),
|
||||||
|
e -> downloadZugferdZip(invoice));
|
||||||
|
zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(zugferdBtn);
|
||||||
|
|
||||||
|
if (isLiveInvoice && invoicePermissionService.canMarkAsSent(currentUser)) {
|
||||||
|
Button sendBtn = new Button(getTranslation("invoices.action.sendinvoice"),
|
||||||
|
e -> sendAsEInvoice(invoice));
|
||||||
|
sendBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
|
||||||
|
actions.add(sendBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLiveInvoice) {
|
||||||
|
if (invoicePermissionService.canCorrect(currentUser)) {
|
||||||
|
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
|
||||||
|
e -> openCorrectionDialog(invoice));
|
||||||
|
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(correctBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoicePermissionService.canCancel(currentUser)) {
|
||||||
|
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
|
||||||
|
e -> openCancellationDialog(invoice));
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
|
||||||
|
actions.add(cancelBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zahlung erfassen: nur für reguläre Rechnungen (R-25)
|
||||||
|
if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT
|
||||||
|
&& invoicePermissionService.canRecordPayment(currentUser)) {
|
||||||
|
Button payBtn = new Button(getTranslation("invoices.action.payment"),
|
||||||
|
e -> openPaymentDialog(invoice));
|
||||||
|
payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(payBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belegpaket exportieren (R-33/R-34)
|
||||||
|
Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice));
|
||||||
|
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
|
||||||
|
actions.add(exportBtn);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openPaymentDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
|
||||||
|
Span hint = new Span(getTranslation("invoices.payment.hint",
|
||||||
|
java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding)));
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
content.add(hint);
|
||||||
|
|
||||||
|
NumberField amountField = new NumberField(getTranslation("invoices.payment.amount"));
|
||||||
|
amountField.setStep(0.01);
|
||||||
|
amountField.setValue(outstanding.doubleValue());
|
||||||
|
amountField.setRequiredIndicatorVisible(true);
|
||||||
|
amountField.setWidthFull();
|
||||||
|
content.add(amountField);
|
||||||
|
|
||||||
|
TextField referenceField = new TextField(getTranslation("invoices.payment.reference"));
|
||||||
|
referenceField.setWidthFull();
|
||||||
|
content.add(referenceField);
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("invoices.payment.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("80px");
|
||||||
|
content.add(reasonField);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
|
||||||
|
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmBtn = new Button(getTranslation("invoices.payment.confirm"), e -> {
|
||||||
|
Double amount = amountField.getValue();
|
||||||
|
if (amount == null || amount == 0d) {
|
||||||
|
amountField.setInvalid(true);
|
||||||
|
amountField.setErrorMessage(getTranslation("invoices.payment.amount.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performPayment(invoice, java.math.BigDecimal.valueOf(amount), referenceField.getValue(),
|
||||||
|
reasonField.getValue(), dialog);
|
||||||
|
});
|
||||||
|
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.getFooter().add(cancelBtn, confirmBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performPayment(CustomerInvoice invoice, java.math.BigDecimal amount, String reference, String reason,
|
||||||
|
Dialog dialog) {
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requirePayment(invoicePermissionService.currentUser());
|
||||||
|
invoiceLifecycleService.registerPayment(invoice.getId(), amount, reference, reason);
|
||||||
|
dialog.close();
|
||||||
|
Notification.show(getTranslation("invoices.notification.payment"), 3000, Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void exportPackage(CustomerInvoice invoice) {
|
||||||
|
try {
|
||||||
|
byte[] zipBytes = invoiceExportService.exportInvoicePackage(invoice);
|
||||||
|
String fileName = invoiceExportService.suggestFilename(invoice);
|
||||||
|
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zipBytes));
|
||||||
|
resource.setContentType("application/zip");
|
||||||
|
resource.setCacheTime(0);
|
||||||
|
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
|
||||||
|
.registerResource(resource);
|
||||||
|
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt die Rechnung über das externe PDF Tool in eine signierte
|
||||||
|
* ZUGFeRD-E-Rechnung um und bietet das ZIP (PDF/A-3, factur-x.xml,
|
||||||
|
* Prüfbericht) als Download an.
|
||||||
|
*/
|
||||||
|
private void downloadZugferdZip(CustomerInvoice invoice) {
|
||||||
|
try {
|
||||||
|
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
|
||||||
|
triggerZipDownload(signed.zip(), signed.fileName());
|
||||||
|
if (signed.valid()) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.zugferd.created", invoice.getInvoiceNumber()),
|
||||||
|
5000, Notification.Position.BOTTOM_END);
|
||||||
|
} else {
|
||||||
|
Notification.show(getTranslation("invoices.notification.zugferd.invalid"), 7000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Fehler beim Erstellen der ZUGFeRD-E-Rechnung {}: {}", invoice.getInvoiceNumber(),
|
||||||
|
ex.getMessage(), ex);
|
||||||
|
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versendet die Rechnung als signierte ZUGFeRD-E-Rechnung (ZIP) per E-Mail
|
||||||
|
* an den Kunden. Besteht die Rechnung die Validierung nicht (HTTP 422 des
|
||||||
|
* PDF Tools), wird der Versand abgebrochen — wie beim Sammelversand der
|
||||||
|
* System-Rechnungen. Nach erfolgreichem Versand wird der Status auf
|
||||||
|
* "Versendet" gesetzt.
|
||||||
|
*/
|
||||||
|
private void sendAsEInvoice(CustomerInvoice invoice) {
|
||||||
|
String recipientEmail = invoice.getRecipientEmail();
|
||||||
|
if (recipientEmail == null || recipientEmail.isBlank()) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.email.missing"), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
|
||||||
|
|
||||||
|
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
|
||||||
|
if (!signed.valid()) {
|
||||||
|
log.warn("Versand der Rechnung {} abgebrochen, ZUGFeRD-Validierung fehlgeschlagen",
|
||||||
|
invoice.getInvoiceNumber());
|
||||||
|
Notification.show(
|
||||||
|
getTranslation("invoices.notification.zugferd.invalidsend", invoice.getInvoiceNumber()), 7000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emailService.sendEmailWithAttachment(recipientEmail,
|
||||||
|
getTranslation("invoices.mail.subject", invoice.getInvoiceNumber()),
|
||||||
|
getTranslation("invoices.mail.body", invoice.getInvoiceNumber(),
|
||||||
|
firstNonBlank(invoice.getSenderName(), "")),
|
||||||
|
signed.zip(), signed.fileName());
|
||||||
|
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.ISSUED) {
|
||||||
|
invoiceLifecycleService.markAsSent(invoice.getId(),
|
||||||
|
"Als E-Rechnung per E-Mail versendet an " + recipientEmail);
|
||||||
|
}
|
||||||
|
Notification.show(
|
||||||
|
getTranslation("invoices.notification.emailsent", invoice.getInvoiceNumber(), recipientEmail),
|
||||||
|
5000, Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Fehler beim Versand der Rechnung {} an {}: {}", invoice.getInvoiceNumber(), recipientEmail,
|
||||||
|
ex.getMessage(), ex);
|
||||||
|
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bietet die ZIP-Bytes als Browser-Download an. */
|
||||||
|
private void triggerZipDownload(byte[] zip, String fileName) {
|
||||||
|
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zip));
|
||||||
|
resource.setContentType("application/zip");
|
||||||
|
resource.setCacheTime(0);
|
||||||
|
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
|
||||||
|
.registerResource(resource);
|
||||||
|
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markAsSent(CustomerInvoice invoice) {
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
|
||||||
|
invoiceLifecycleService.markAsSent(invoice.getId(), "Manuell als versendet markiert");
|
||||||
|
Notification.show(getTranslation("invoices.notification.sent"), 3000, Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 5000, Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCancellationDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.cancel.title", invoice.getInvoiceNumber()), "560px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
Span hint = new Span(getTranslation("invoices.cancel.hint"));
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
content.add(hint);
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("invoices.cancel.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("100px");
|
||||||
|
reasonField.setRequired(true);
|
||||||
|
content.add(reasonField);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
|
||||||
|
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmBtn = new Button(getTranslation("invoices.cancel.confirm"), e -> {
|
||||||
|
String reason = reasonField.getValue();
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
reasonField.setInvalid(true);
|
||||||
|
reasonField.setErrorMessage(getTranslation("invoices.cancel.reason.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performCancellation(invoice, reason, dialog);
|
||||||
|
});
|
||||||
|
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
|
||||||
|
|
||||||
|
dialog.getFooter().add(cancelBtn, confirmBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performCancellation(CustomerInvoice invoice, String reason, Dialog dialog) {
|
||||||
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireCancel(currentUser);
|
||||||
|
User issuer = resolveIssuer(invoice);
|
||||||
|
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
byte[] pdf = customerInvoiceService.generateCancellationPdf(invoice, number, today, reason);
|
||||||
|
invoiceLifecycleService.cancel(invoice.getId(), number, today, pdf, reason);
|
||||||
|
dialog.close();
|
||||||
|
Notification.show(getTranslation("invoices.notification.cancelled", number), 4000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCorrectionDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.correct.title", invoice.getInvoiceNumber()), "560px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
Span hint = new Span(getTranslation("invoices.correct.hint"));
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
content.add(hint);
|
||||||
|
|
||||||
|
TextArea fieldsField = new TextArea(getTranslation("invoices.correct.fields"));
|
||||||
|
fieldsField.setWidthFull();
|
||||||
|
fieldsField.setMinHeight("100px");
|
||||||
|
fieldsField.setHelperText(getTranslation("invoices.correct.fields.helper"));
|
||||||
|
fieldsField.setRequired(true);
|
||||||
|
content.add(fieldsField);
|
||||||
|
|
||||||
|
TextArea reasonField = new TextArea(getTranslation("invoices.correct.reason"));
|
||||||
|
reasonField.setWidthFull();
|
||||||
|
reasonField.setMinHeight("80px");
|
||||||
|
content.add(reasonField);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content));
|
||||||
|
|
||||||
|
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button confirmBtn = new Button(getTranslation("invoices.correct.confirm"), e -> {
|
||||||
|
String fields = fieldsField.getValue();
|
||||||
|
if (fields == null || fields.isBlank()) {
|
||||||
|
fieldsField.setInvalid(true);
|
||||||
|
fieldsField.setErrorMessage(getTranslation("invoices.correct.fields.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performCorrection(invoice, fields, reasonField.getValue(), dialog);
|
||||||
|
});
|
||||||
|
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.getFooter().add(cancelBtn, confirmBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void performCorrection(CustomerInvoice invoice, String correctedFields, String reason, Dialog dialog) {
|
||||||
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
|
try {
|
||||||
|
invoicePermissionService.requireCorrect(currentUser);
|
||||||
|
User issuer = resolveIssuer(invoice);
|
||||||
|
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
byte[] pdf = customerInvoiceService.generateCorrectionPdf(invoice, number, today, reason, correctedFields);
|
||||||
|
invoiceLifecycleService.correct(invoice.getId(), number, today, pdf, correctedFields, reason);
|
||||||
|
dialog.close();
|
||||||
|
Notification.show(getTranslation("invoices.notification.corrected", number), 4000,
|
||||||
|
Notification.Position.BOTTOM_END);
|
||||||
|
loadInvoices();
|
||||||
|
} catch (InvoiceLifecycleException ex) {
|
||||||
|
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
|
||||||
|
Notification.Position.MIDDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private User resolveIssuer(CustomerInvoice invoice) {
|
||||||
|
if (invoice.getUserId() != null && !invoice.getUserId().isBlank()) {
|
||||||
|
try {
|
||||||
|
return userRepository.findById(new org.bson.types.ObjectId(invoice.getUserId()))
|
||||||
|
.orElseGet(securityService::getCurrentDatabaseUser);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
// userId ist kein gültiger ObjectId – Fallback auf eingeloggten Nutzer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return securityService.getCurrentDatabaseUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openHistoryDialog(CustomerInvoice invoice) {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(
|
||||||
|
getTranslation("invoices.history.title", invoice.getInvoiceNumber()), "640px");
|
||||||
|
|
||||||
|
VerticalLayout content = new VerticalLayout();
|
||||||
|
content.setSpacing(true);
|
||||||
|
content.setPadding(false);
|
||||||
|
|
||||||
|
// Verkettung anzeigen, falls vorhanden
|
||||||
|
Div linksBlock = renderRelatedInvoiceLinks(invoice);
|
||||||
|
if (linksBlock != null) {
|
||||||
|
content.add(linksBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
H3 logTitle = new H3(getTranslation("invoices.history.log"));
|
||||||
|
content.add(logTitle);
|
||||||
|
|
||||||
|
List<InvoiceAuditEntry> log = invoice.getAuditLog();
|
||||||
|
if (log == null || log.isEmpty()) {
|
||||||
|
content.add(new Span(getTranslation("invoices.history.empty")));
|
||||||
|
} else {
|
||||||
|
log.stream()
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((InvoiceAuditEntry e) -> e.getTimestamp() == null
|
||||||
|
? java.time.LocalDateTime.MIN
|
||||||
|
: e.getTimestamp())
|
||||||
|
.reversed())
|
||||||
|
.forEach(entry -> content.add(renderAuditEntry(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(content, true));
|
||||||
|
|
||||||
|
Button closeBtn = new Button(getTranslation("button.close"), e -> dialog.close());
|
||||||
|
closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
dialog.getFooter().add(closeBtn);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div renderRelatedInvoiceLinks(CustomerInvoice invoice) {
|
||||||
|
Div block = new Div();
|
||||||
|
boolean hasContent = false;
|
||||||
|
if (invoice.getOriginalInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.original"),
|
||||||
|
invoice.getOriginalInvoiceNumber(), invoice.getOriginalInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
if (invoice.getCancellationInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.cancellation"),
|
||||||
|
null, invoice.getCancellationInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
if (invoice.getCorrectionInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.correction"),
|
||||||
|
null, invoice.getCorrectionInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
if (invoice.getReplacementInvoiceId() != null) {
|
||||||
|
block.add(buildLinkRow(getTranslation("invoices.history.replacement"),
|
||||||
|
null, invoice.getReplacementInvoiceId()));
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
return hasContent ? block : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HorizontalLayout buildLinkRow(String label, String fallbackNumber, String invoiceId) {
|
||||||
|
HorizontalLayout row = new HorizontalLayout();
|
||||||
|
row.setSpacing(true);
|
||||||
|
row.setPadding(false);
|
||||||
|
Span lbl = new Span(label);
|
||||||
|
lbl.getStyle().set("min-width", "180px").set("color", "var(--lumo-secondary-text-color)");
|
||||||
|
row.add(lbl);
|
||||||
|
|
||||||
|
CustomerInvoice related = invoiceLifecycleService.findById(invoiceId).orElse(null);
|
||||||
|
String number = related != null && related.getInvoiceNumber() != null ? related.getInvoiceNumber()
|
||||||
|
: fallbackNumber != null ? fallbackNumber : invoiceId;
|
||||||
|
if (related != null && related.getPdfData() != null && related.getPdfData().length > 0) {
|
||||||
|
Anchor link = new Anchor("javascript:void(0)", number);
|
||||||
|
link.getElement().addEventListener("click", e -> downloadInvoicePdf(related));
|
||||||
|
row.add(link);
|
||||||
|
} else {
|
||||||
|
row.add(new Span(number));
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Div renderAuditEntry(InvoiceAuditEntry entry) {
|
||||||
|
Div container = new Div();
|
||||||
|
container.getStyle().set("padding", "8px 12px").set("margin-bottom", "6px")
|
||||||
|
.set("border-left", "3px solid var(--lumo-contrast-30pct)")
|
||||||
|
.set("background", "var(--lumo-contrast-5pct)");
|
||||||
|
|
||||||
|
String timestamp = entry.getTimestamp() != null ? entry.getTimestamp().format(DATE_TIME_FMT) : "—";
|
||||||
|
String actionLabel = entry.getAction() != null
|
||||||
|
? getTranslation("invoices.audit.action." + entry.getAction().name().toLowerCase(Locale.ROOT))
|
||||||
|
: "?";
|
||||||
|
String userLabel = entry.getUserDisplayName() != null ? entry.getUserDisplayName() : "system";
|
||||||
|
|
||||||
|
Span header = new Span(timestamp + " · " + actionLabel + " · " + userLabel);
|
||||||
|
header.getStyle().set("font-weight", "600");
|
||||||
|
container.add(header);
|
||||||
|
|
||||||
|
if (entry.getReason() != null && !entry.getReason().isBlank()) {
|
||||||
|
Div reason = new Div();
|
||||||
|
reason.setText(entry.getReason());
|
||||||
|
reason.getStyle().set("margin-top", "4px");
|
||||||
|
container.add(reason);
|
||||||
|
}
|
||||||
|
if (entry.getResultingInvoiceNumber() != null) {
|
||||||
|
Div link = new Div();
|
||||||
|
link.setText(getTranslation("invoices.audit.resulting", entry.getResultingInvoiceNumber()));
|
||||||
|
link.getStyle().set("margin-top", "4px").set("color", "var(--lumo-secondary-text-color)")
|
||||||
|
.set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
container.add(link);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void downloadInvoicePdf(CustomerInvoice invoice) {
|
private void downloadInvoicePdf(CustomerInvoice invoice) {
|
||||||
@@ -123,10 +719,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasPdfData(CustomerInvoice invoice) {
|
|
||||||
return invoice != null && invoice.getPdfData() != null && invoice.getPdfData().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getRecipientLabel(CustomerInvoice invoice) {
|
private String getRecipientLabel(CustomerInvoice invoice) {
|
||||||
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
|
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,46 +2,97 @@ package de.assecutor.votianlt.pages.view;
|
|||||||
|
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.H3;
|
||||||
import com.vaadin.flow.component.html.Main;
|
import com.vaadin.flow.component.html.Main;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.icon.Icon;
|
||||||
|
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.notification.NotificationVariant;
|
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||||
|
import com.vaadin.flow.component.orderedlayout.FlexComponent;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
|
import com.vaadin.flow.component.textfield.IntegerField;
|
||||||
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
import com.vaadin.flow.component.textfield.TextArea;
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
import com.vaadin.flow.router.BeforeEvent;
|
import com.vaadin.flow.router.BeforeEvent;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
import com.vaadin.flow.router.HasUrlParameter;
|
import com.vaadin.flow.router.HasUrlParameter;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
import com.vaadin.flow.theme.lumo.LumoUtility;
|
import com.vaadin.flow.theme.lumo.LumoUtility;
|
||||||
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.JobHistoryType;
|
import de.assecutor.votianlt.model.JobHistoryType;
|
||||||
|
import de.assecutor.votianlt.model.JobServiceSelection;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
|
import de.assecutor.votianlt.model.Service;
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
||||||
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import de.assecutor.votianlt.service.ClientConnectionService;
|
||||||
import de.assecutor.votianlt.service.JobHistoryService;
|
import de.assecutor.votianlt.service.JobHistoryService;
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
@Route(value = "job_manual_complete", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
@RolesAllowed("USER")
|
@RolesAllowed("USER")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class JobManualCompleteView extends Main implements HasUrlParameter<String>, HasDynamicTitle {
|
public class JobManualCompleteView extends Main implements HasUrlParameter<String>, HasDynamicTitle {
|
||||||
|
|
||||||
|
private static final class ServiceRow {
|
||||||
|
private final Service service;
|
||||||
|
private final JobServiceSelection selection;
|
||||||
|
|
||||||
|
private ServiceRow(Service service, JobServiceSelection selection) {
|
||||||
|
this.service = service;
|
||||||
|
this.selection = selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
private final JobHistoryService jobHistoryService;
|
private final JobHistoryService jobHistoryService;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
|
private final ServiceRepository serviceRepository;
|
||||||
|
private final ClientConnectionService clientConnectionService;
|
||||||
|
private final MessagingPublisher messagingPublisher;
|
||||||
private final VerticalLayout content;
|
private final VerticalLayout content;
|
||||||
|
|
||||||
|
private Job job;
|
||||||
|
private final List<ServiceRow> serviceRows = new ArrayList<>();
|
||||||
|
private Grid<ServiceRow> servicesGrid;
|
||||||
|
private Span netTotalLabel;
|
||||||
|
private Span grossTotalLabel;
|
||||||
|
private TextArea remarkArea;
|
||||||
|
private BigDecimal vatRate = Service.FIXED_VAT_RATE;
|
||||||
|
private Double manualDistanceKm;
|
||||||
|
private Integer manualDurationSeconds;
|
||||||
|
|
||||||
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
|
public JobManualCompleteView(JobRepository jobRepository, JobHistoryService jobHistoryService,
|
||||||
SecurityService securityService) {
|
SecurityService securityService, ServiceRepository serviceRepository,
|
||||||
|
ClientConnectionService clientConnectionService, MessagingPublisher messagingPublisher) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.jobHistoryService = jobHistoryService;
|
this.jobHistoryService = jobHistoryService;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
|
this.serviceRepository = serviceRepository;
|
||||||
|
this.clientConnectionService = clientConnectionService;
|
||||||
|
this.messagingPublisher = messagingPublisher;
|
||||||
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
|
||||||
@@ -66,6 +117,8 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
|||||||
@Override
|
@Override
|
||||||
public void setParameter(BeforeEvent event, String parameter) {
|
public void setParameter(BeforeEvent event, String parameter) {
|
||||||
content.removeAll();
|
content.removeAll();
|
||||||
|
serviceRows.clear();
|
||||||
|
job = null;
|
||||||
|
|
||||||
if (parameter == null || parameter.isBlank()) {
|
if (parameter == null || parameter.isBlank()) {
|
||||||
content.add(new Span(getTranslation("jobhistory.error.no.id")));
|
content.add(new Span(getTranslation("jobhistory.error.no.id")));
|
||||||
@@ -80,16 +133,32 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Job job = jobRepository.findById(jobId).orElse(null);
|
Job loaded = jobRepository.findById(jobId).orElse(null);
|
||||||
if (job == null) {
|
if (loaded == null) {
|
||||||
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
|
content.add(new Span(getTranslation("jobhistory.error.not.found", parameter)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(job);
|
job = loaded;
|
||||||
|
loadVatRate();
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void render(Job job) {
|
private void loadVatRate() {
|
||||||
|
try {
|
||||||
|
User user = securityService.getCurrentDatabaseUser();
|
||||||
|
if (user != null && user.getVatRate() != null) {
|
||||||
|
vatRate = user.getVatRate();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not load user VAT rate, falling back to default: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void render() {
|
||||||
|
manualDistanceKm = job.getRouteDistanceKm();
|
||||||
|
manualDurationSeconds = job.getRouteDurationSeconds();
|
||||||
|
|
||||||
Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber()));
|
Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber()));
|
||||||
warningText.getStyle().set("color", "var(--lumo-error-text-color)");
|
warningText.getStyle().set("color", "var(--lumo-error-text-color)");
|
||||||
|
|
||||||
@@ -100,9 +169,16 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
|||||||
|
|
||||||
content.add(warningText, reasonField);
|
content.add(warningText, reasonField);
|
||||||
|
|
||||||
|
boolean hasRouteData = manualDistanceKm != null && manualDistanceKm > 0;
|
||||||
|
content.add(hasRouteData ? createRouteSection() : createManualRouteSection());
|
||||||
|
|
||||||
|
content.add(createServicesSection());
|
||||||
|
content.add(createSummarySection());
|
||||||
|
content.add(createRemarkSection());
|
||||||
|
|
||||||
HorizontalLayout buttonBar = new HorizontalLayout();
|
HorizontalLayout buttonBar = new HorizontalLayout();
|
||||||
buttonBar.setWidthFull();
|
buttonBar.setWidthFull();
|
||||||
buttonBar.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END);
|
buttonBar.setJustifyContentMode(FlexComponent.JustifyContentMode.END);
|
||||||
buttonBar.setSpacing(true);
|
buttonBar.setSpacing(true);
|
||||||
|
|
||||||
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
|
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
|
||||||
@@ -110,43 +186,545 @@ public class JobManualCompleteView extends Main implements HasUrlParameter<Strin
|
|||||||
|
|
||||||
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
|
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
|
||||||
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
|
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
|
||||||
confirmButton.addClickListener(e -> {
|
confirmButton.addClickListener(e -> confirm(reasonField));
|
||||||
String reason = reasonField.getValue();
|
|
||||||
if (reason == null || reason.trim().isEmpty()) {
|
|
||||||
reasonField.setInvalid(true);
|
|
||||||
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
JobStatus oldStatus = job.getStatus();
|
|
||||||
job.setStatus(JobStatus.COMPLETED);
|
|
||||||
job.setUpdatedAt(LocalDateTime.now());
|
|
||||||
jobRepository.save(job);
|
|
||||||
|
|
||||||
String currentUser = securityService.getCurrentUsername();
|
|
||||||
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
|
|
||||||
|
|
||||||
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s",
|
|
||||||
currentUser, reason.trim());
|
|
||||||
jobHistoryService.logCustomEvent(job.getId(),
|
|
||||||
getTranslation("jobsummary.history.manualcomplete.reason"),
|
|
||||||
description, currentUser, JobHistoryType.STATUS_CHANGE);
|
|
||||||
|
|
||||||
Notification
|
|
||||||
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
|
|
||||||
Notification.Position.BOTTOM_END)
|
|
||||||
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
|
||||||
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Notification
|
|
||||||
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
|
|
||||||
Notification.Position.BOTTOM_END)
|
|
||||||
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
buttonBar.add(cancelButton, confirmButton);
|
buttonBar.add(cancelButton, confirmButton);
|
||||||
content.add(buttonBar);
|
content.add(buttonBar);
|
||||||
|
|
||||||
|
loadSelectedServicesFromJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createRouteSection() {
|
||||||
|
VerticalLayout routeBox = new VerticalLayout();
|
||||||
|
routeBox.setPadding(true);
|
||||||
|
routeBox.setSpacing(true);
|
||||||
|
routeBox.setWidthFull();
|
||||||
|
routeBox.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
|
||||||
|
routeBox.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
routeBox.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
|
||||||
|
routeBox.addClassName("route-card");
|
||||||
|
|
||||||
|
H3 routeTitle = new H3(getTranslation("addjob.route.title"));
|
||||||
|
routeTitle.getStyle().set("margin", "0");
|
||||||
|
routeTitle.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
|
||||||
|
HorizontalLayout distanceRow = new HorizontalLayout();
|
||||||
|
distanceRow.setWidthFull();
|
||||||
|
distanceRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
distanceRow.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
Span distanceLabel = new Span(getTranslation("addjob.route.distance") + ":");
|
||||||
|
Span distanceValue = new Span(formatDistance(job.getRouteDistanceKm()));
|
||||||
|
distanceValue.getStyle().set("font-weight", "bold");
|
||||||
|
distanceValue.getStyle().set("font-size", "var(--lumo-font-size-l)");
|
||||||
|
distanceValue.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
distanceRow.add(distanceLabel, distanceValue);
|
||||||
|
|
||||||
|
HorizontalLayout durationRow = new HorizontalLayout();
|
||||||
|
durationRow.setWidthFull();
|
||||||
|
durationRow.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||||
|
durationRow.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
Span durationLabel = new Span(getTranslation("addjob.route.duration") + ":");
|
||||||
|
Span durationValue = new Span(formatDuration(job.getRouteDurationSeconds()));
|
||||||
|
durationValue.getStyle().set("font-weight", "bold");
|
||||||
|
durationValue.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
||||||
|
durationRow.add(durationLabel, durationValue);
|
||||||
|
|
||||||
|
routeBox.add(routeTitle, distanceRow, durationRow);
|
||||||
|
return routeBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createManualRouteSection() {
|
||||||
|
VerticalLayout box = new VerticalLayout();
|
||||||
|
box.setPadding(true);
|
||||||
|
box.setSpacing(true);
|
||||||
|
box.setWidthFull();
|
||||||
|
box.getStyle().set("border", "1px solid var(--lumo-primary-color-50pct)");
|
||||||
|
box.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
box.getStyle().set("background-color", "var(--lumo-primary-color-10pct)");
|
||||||
|
box.addClassName("route-card");
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.route.title"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
title.getStyle().set("color", "var(--lumo-primary-text-color)");
|
||||||
|
|
||||||
|
NumberField distanceField = new NumberField(getTranslation("addjob.route.distance.km"));
|
||||||
|
distanceField.setMin(0);
|
||||||
|
distanceField.setStep(0.1);
|
||||||
|
distanceField.setPlaceholder(getTranslation("addjob.route.distance.placeholder"));
|
||||||
|
distanceField.setWidthFull();
|
||||||
|
|
||||||
|
IntegerField hoursField = new IntegerField(getTranslation("jobmanualcomplete.route.hours"));
|
||||||
|
hoursField.setMin(0);
|
||||||
|
hoursField.setStepButtonsVisible(true);
|
||||||
|
hoursField.setWidthFull();
|
||||||
|
|
||||||
|
IntegerField minutesField = new IntegerField(getTranslation("jobmanualcomplete.route.minutes"));
|
||||||
|
minutesField.setMin(0);
|
||||||
|
minutesField.setMax(59);
|
||||||
|
minutesField.setStepButtonsVisible(true);
|
||||||
|
minutesField.setWidthFull();
|
||||||
|
|
||||||
|
if (manualDurationSeconds != null && manualDurationSeconds > 0) {
|
||||||
|
hoursField.setValue(manualDurationSeconds / 3600);
|
||||||
|
minutesField.setValue((manualDurationSeconds % 3600) / 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
distanceField.addValueChangeListener(e -> {
|
||||||
|
manualDistanceKm = e.getValue();
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
});
|
||||||
|
|
||||||
|
Runnable recalcDuration = () -> {
|
||||||
|
Integer h = hoursField.getValue();
|
||||||
|
Integer m = minutesField.getValue();
|
||||||
|
int hours = h != null ? Math.max(0, h) : 0;
|
||||||
|
int minutes = m != null ? Math.max(0, Math.min(59, m)) : 0;
|
||||||
|
int total = hours * 3600 + minutes * 60;
|
||||||
|
manualDurationSeconds = total > 0 ? total : null;
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
};
|
||||||
|
hoursField.addValueChangeListener(e -> recalcDuration.run());
|
||||||
|
minutesField.addValueChangeListener(e -> recalcDuration.run());
|
||||||
|
|
||||||
|
HorizontalLayout durationRow = new HorizontalLayout(hoursField, minutesField);
|
||||||
|
durationRow.setWidthFull();
|
||||||
|
durationRow.setSpacing(true);
|
||||||
|
hoursField.getStyle().set("flex", "1");
|
||||||
|
minutesField.getStyle().set("flex", "1");
|
||||||
|
|
||||||
|
Span hint = new Span(getTranslation("jobmanualcomplete.route.manual.hint"));
|
||||||
|
hint.getStyle().set("font-size", "var(--lumo-font-size-s)");
|
||||||
|
hint.getStyle().set("color", "var(--lumo-secondary-text-color)");
|
||||||
|
hint.getStyle().set("font-style", "italic");
|
||||||
|
|
||||||
|
box.add(title, distanceField, durationRow, hint);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createServicesSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(true);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.services.title"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
|
||||||
|
servicesGrid = new Grid<>();
|
||||||
|
servicesGrid.setWidthFull();
|
||||||
|
servicesGrid.setHeight("250px");
|
||||||
|
servicesGrid.setItems(serviceRows);
|
||||||
|
servicesGrid.addClassName("data-grid");
|
||||||
|
|
||||||
|
servicesGrid.addColumn(row -> row.service != null ? row.service.getName() : "")
|
||||||
|
.setHeader(getTranslation("common.service")).setSortable(true);
|
||||||
|
servicesGrid.addColumn(row -> formatDeliveryStationLabel(
|
||||||
|
row.selection != null ? row.selection.getDeliveryStationOrder() : null))
|
||||||
|
.setHeader(getTranslation("addjob.services.deliverystation")).setSortable(false);
|
||||||
|
servicesGrid.addColumn(row -> formatCalculationBasis(row.service))
|
||||||
|
.setHeader(getTranslation("addjob.services.calculation")).setSortable(true);
|
||||||
|
servicesGrid.addColumn(this::formatPrice).setHeader(getTranslation("common.price")).setSortable(false);
|
||||||
|
servicesGrid.addComponentColumn(row -> {
|
||||||
|
if (row.service != null && row.service.isMandatory()) {
|
||||||
|
return new Span("");
|
||||||
|
}
|
||||||
|
Button removeButton = new Button(new Icon(VaadinIcon.TRASH));
|
||||||
|
removeButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY,
|
||||||
|
ButtonVariant.LUMO_SMALL);
|
||||||
|
removeButton.addClickListener(e -> {
|
||||||
|
serviceRows.remove(row);
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
});
|
||||||
|
return removeButton;
|
||||||
|
}).setHeader(getTranslation("common.actions")).setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
|
Div gridPanel = new Div(servicesGrid);
|
||||||
|
gridPanel.addClassNames("surface-panel", "data-grid-panel");
|
||||||
|
gridPanel.setWidthFull();
|
||||||
|
|
||||||
|
Button addButton = new Button(getTranslation("addjob.services.add"), new Icon(VaadinIcon.PLUS));
|
||||||
|
addButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
addButton.addClickListener(e -> openAddServiceDialog());
|
||||||
|
|
||||||
|
section.add(title, gridPanel, addButton);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createSummarySection() {
|
||||||
|
VerticalLayout summary = new VerticalLayout();
|
||||||
|
summary.setPadding(true);
|
||||||
|
summary.setSpacing(true);
|
||||||
|
summary.setWidthFull();
|
||||||
|
summary.getStyle().set("border", "1px solid var(--lumo-contrast-20pct)");
|
||||||
|
summary.getStyle().set("border-radius", "var(--lumo-border-radius-m)");
|
||||||
|
summary.getStyle().set("background-color", "var(--lumo-contrast-5pct)");
|
||||||
|
summary.setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.STRETCH);
|
||||||
|
summary.addClassName("summary-card");
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.summary.title"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
summary.add(title);
|
||||||
|
|
||||||
|
Div priceTable = new Div();
|
||||||
|
priceTable.getStyle().set("width", "100%");
|
||||||
|
|
||||||
|
Div netRow = new Div();
|
||||||
|
netRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
|
||||||
|
Span netLabel = new Span(getTranslation("addjob.summary.net") + ":");
|
||||||
|
netLabel.getStyle().set("padding-right", "8px");
|
||||||
|
netTotalLabel = new Span("0,00 €");
|
||||||
|
netTotalLabel.getStyle().set("font-weight", "bold").set("white-space", "nowrap");
|
||||||
|
netRow.add(netLabel, netTotalLabel);
|
||||||
|
|
||||||
|
Div grossRow = new Div();
|
||||||
|
grossRow.getStyle().set("display", "flex").set("justify-content", "space-between").set("padding", "4px 0");
|
||||||
|
Span grossLabel = new Span(getTranslation("addjob.summary.gross") + ":");
|
||||||
|
grossLabel.getStyle().set("padding-right", "8px").set("font-weight", "bold");
|
||||||
|
grossTotalLabel = new Span("0,00 €");
|
||||||
|
grossTotalLabel.getStyle().set("font-size", "var(--lumo-font-size-l)");
|
||||||
|
grossTotalLabel.getStyle().set("font-weight", "bold");
|
||||||
|
grossTotalLabel.getStyle().set("color", "var(--lumo-primary-text-color)").set("white-space", "nowrap");
|
||||||
|
grossRow.add(grossLabel, grossTotalLabel);
|
||||||
|
|
||||||
|
priceTable.add(netRow, grossRow);
|
||||||
|
summary.add(priceTable);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VerticalLayout createRemarkSection() {
|
||||||
|
VerticalLayout section = new VerticalLayout();
|
||||||
|
section.setPadding(false);
|
||||||
|
section.setSpacing(true);
|
||||||
|
section.setWidthFull();
|
||||||
|
|
||||||
|
H3 title = new H3(getTranslation("addjob.tasks.remark"));
|
||||||
|
title.getStyle().set("margin", "0");
|
||||||
|
|
||||||
|
remarkArea = new TextArea();
|
||||||
|
remarkArea.setPlaceholder(getTranslation("addjob.tasks.remark.placeholder"));
|
||||||
|
remarkArea.setWidthFull();
|
||||||
|
remarkArea.setMinHeight("120px");
|
||||||
|
if (job.getRemark() != null) {
|
||||||
|
remarkArea.setValue(job.getRemark());
|
||||||
|
}
|
||||||
|
|
||||||
|
section.add(title, remarkArea);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadSelectedServicesFromJob() {
|
||||||
|
serviceRows.clear();
|
||||||
|
|
||||||
|
if (job.getSelectedServices() != null && !job.getSelectedServices().isEmpty()) {
|
||||||
|
for (JobServiceSelection selection : job.getSelectedServices()) {
|
||||||
|
if (selection.getServiceId() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serviceRepository.findById(selection.getServiceId())
|
||||||
|
.ifPresent(service -> serviceRows.add(new ServiceRow(service, selection)));
|
||||||
|
}
|
||||||
|
} else if (job.getServiceIds() != null && !job.getServiceIds().isEmpty()) {
|
||||||
|
for (String serviceId : job.getServiceIds()) {
|
||||||
|
serviceRepository.findById(serviceId)
|
||||||
|
.ifPresent(service -> serviceRows.add(new ServiceRow(service, null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAddServiceDialog() {
|
||||||
|
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("addjob.services.dialog.title"), "720px");
|
||||||
|
dialog.setCloseOnOutsideClick(false);
|
||||||
|
|
||||||
|
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("620px");
|
||||||
|
|
||||||
|
User currentUser = securityService.getCurrentDatabaseUser();
|
||||||
|
List<Service> availableServices = currentUser != null
|
||||||
|
? serviceRepository.findByUserId(currentUser.getId().toString())
|
||||||
|
: List.of();
|
||||||
|
|
||||||
|
ComboBox<Service> serviceCombo = new ComboBox<>(getTranslation("common.service"));
|
||||||
|
serviceCombo.setWidthFull();
|
||||||
|
serviceCombo.setItems(availableServices);
|
||||||
|
serviceCombo.setItemLabelGenerator(service -> {
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE
|
||||||
|
&& service.getEffectivePrice() != null) {
|
||||||
|
return service.getName() + " (" + service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €)";
|
||||||
|
}
|
||||||
|
return service.getName();
|
||||||
|
});
|
||||||
|
serviceCombo.setPlaceholder(getTranslation("addjob.services.dialog.placeholder"));
|
||||||
|
serviceCombo.setRequired(true);
|
||||||
|
|
||||||
|
List<Integer> stationOrders = availableDeliveryStationOrders();
|
||||||
|
ComboBox<Integer> stationCombo = new ComboBox<>(getTranslation("addjob.services.deliverystation"));
|
||||||
|
stationCombo.setWidthFull();
|
||||||
|
stationCombo.setRequired(true);
|
||||||
|
stationCombo.setRequiredIndicatorVisible(true);
|
||||||
|
stationCombo.setItems(stationOrders);
|
||||||
|
stationCombo.setItemLabelGenerator(this::buildDeliveryStationSelectionLabel);
|
||||||
|
stationCombo.setPlaceholder(getTranslation("addjob.services.dialog.station.placeholder"));
|
||||||
|
if (!stationOrders.isEmpty()) {
|
||||||
|
stationCombo.setValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogContent.add(serviceCombo, stationCombo);
|
||||||
|
|
||||||
|
Button cancel = new Button(getTranslation("button.cancel"), e -> dialog.close());
|
||||||
|
cancel.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
|
||||||
|
Button add = new Button(getTranslation("addjob.services.dialog.add"), e -> {
|
||||||
|
Service service = serviceCombo.getValue();
|
||||||
|
Integer stationOrder = stationCombo.getValue();
|
||||||
|
if (service == null || stationOrder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JobServiceSelection selection = new JobServiceSelection();
|
||||||
|
selection.setServiceId(service.getId());
|
||||||
|
selection.setDeliveryStationOrder(stationOrder);
|
||||||
|
selection.setRouteDistanceKm(manualDistanceKm);
|
||||||
|
selection.setRouteDurationSeconds(manualDurationSeconds);
|
||||||
|
serviceRows.add(new ServiceRow(service, selection));
|
||||||
|
servicesGrid.getDataProvider().refreshAll();
|
||||||
|
updatePriceSummary();
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
|
||||||
|
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
|
||||||
|
dialog.getFooter().add(cancel, add);
|
||||||
|
dialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> availableDeliveryStationOrders() {
|
||||||
|
List<Integer> orders = new ArrayList<>();
|
||||||
|
if (job.getDeliveryStations() != null) {
|
||||||
|
for (int i = 0; i < job.getDeliveryStations().size(); i++) {
|
||||||
|
orders.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeliveryStationSelectionLabel(Integer order) {
|
||||||
|
if (order == null || job.getDeliveryStations() == null || order < 0
|
||||||
|
|| order >= job.getDeliveryStations().size()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
DeliveryStation station = job.getDeliveryStations().get(order);
|
||||||
|
StringBuilder label = new StringBuilder(getTranslation("addjob.station.delivery", order + 1));
|
||||||
|
if (station.getCity() != null && !station.getCity().isBlank()) {
|
||||||
|
label.append(" - ").append(station.getCity());
|
||||||
|
} else if (station.getCompany() != null && !station.getCompany().isBlank()) {
|
||||||
|
label.append(" - ").append(station.getCompany());
|
||||||
|
}
|
||||||
|
return label.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDeliveryStationLabel(Integer order) {
|
||||||
|
if (order == null || order < 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return getTranslation("addjob.station.delivery", order + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatCalculationBasis(Service service) {
|
||||||
|
if (service == null || service.getCalculationBasis() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return switch (service.getCalculationBasis()) {
|
||||||
|
case DISTANCE -> getTranslation("addjob.services.basis.distance");
|
||||||
|
case TIME -> getTranslation("addjob.services.basis.time");
|
||||||
|
case FLAT_RATE -> getTranslation("addjob.services.basis.flatrate");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatPrice(ServiceRow row) {
|
||||||
|
Service service = row.service;
|
||||||
|
if (service == null || service.getCalculationBasis() == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
BigDecimal price = calculateServicePrice(row);
|
||||||
|
if (price != null && price.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
return price.setScale(2, RoundingMode.HALF_UP) + " €";
|
||||||
|
}
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE && service.getPricePerKilometer() != null
|
||||||
|
&& routeDistanceFor(row.selection) == null) {
|
||||||
|
return service.getPricePerKilometer().setScale(2, RoundingMode.HALF_UP) + " €/km ("
|
||||||
|
+ getTranslation("addjob.services.route.missing") + ")";
|
||||||
|
}
|
||||||
|
if (service.getCalculationBasis() == Service.CalculationBasis.TIME && service.getPricePer15Minutes() != null
|
||||||
|
&& routeDurationFor(row.selection) == null) {
|
||||||
|
return service.getPricePer15Minutes().setScale(2, RoundingMode.HALF_UP) + " €/15 Min. ("
|
||||||
|
+ getTranslation("addjob.services.route.missing") + ")";
|
||||||
|
}
|
||||||
|
return service.getEffectivePrice() != null
|
||||||
|
? service.getEffectivePrice().setScale(2, RoundingMode.HALF_UP) + " €"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateServicePrice(ServiceRow row) {
|
||||||
|
Service service = row.service;
|
||||||
|
if (service == null || service.getCalculationBasis() == null) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
switch (service.getCalculationBasis()) {
|
||||||
|
case FLAT_RATE:
|
||||||
|
return service.getPrice() != null ? service.getPrice() : BigDecimal.ZERO;
|
||||||
|
case DISTANCE: {
|
||||||
|
Double km = routeDistanceFor(row.selection);
|
||||||
|
if (service.getPricePerKilometer() != null && km != null && km > 0) {
|
||||||
|
return service.getPricePerKilometer().multiply(BigDecimal.valueOf(km));
|
||||||
|
}
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
case TIME: {
|
||||||
|
Integer seconds = routeDurationFor(row.selection);
|
||||||
|
if (service.getPricePer15Minutes() != null && seconds != null && seconds > 0) {
|
||||||
|
int units = seconds / 900;
|
||||||
|
if (seconds % 900 > 0) {
|
||||||
|
units++;
|
||||||
|
}
|
||||||
|
return service.getPricePer15Minutes().multiply(BigDecimal.valueOf(units));
|
||||||
|
}
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double routeDistanceFor(JobServiceSelection selection) {
|
||||||
|
if (selection != null && selection.getRouteDistanceKm() != null) {
|
||||||
|
return selection.getRouteDistanceKm();
|
||||||
|
}
|
||||||
|
return manualDistanceKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer routeDurationFor(JobServiceSelection selection) {
|
||||||
|
if (selection != null && selection.getRouteDurationSeconds() != null) {
|
||||||
|
return selection.getRouteDurationSeconds();
|
||||||
|
}
|
||||||
|
return manualDurationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePriceSummary() {
|
||||||
|
BigDecimal net = BigDecimal.ZERO;
|
||||||
|
for (ServiceRow row : serviceRows) {
|
||||||
|
net = net.add(calculateServicePrice(row));
|
||||||
|
}
|
||||||
|
BigDecimal gross = net.add(net.multiply(vatRate));
|
||||||
|
netTotalLabel.setText(formatAmount(net));
|
||||||
|
grossTotalLabel.setText(formatAmount(gross));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAmount(BigDecimal amount) {
|
||||||
|
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",") + " €";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDistance(Double km) {
|
||||||
|
if (km == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return String.format(Locale.GERMANY, "%.1f km", km);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDuration(Integer seconds) {
|
||||||
|
if (seconds == null || seconds <= 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
int hours = seconds / 3600;
|
||||||
|
int minutes = (seconds % 3600) / 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format("%d Std. %d Min.", hours, minutes);
|
||||||
|
}
|
||||||
|
return String.format("%d Min.", minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirm(TextArea reasonField) {
|
||||||
|
String reason = reasonField.getValue();
|
||||||
|
if (reason == null || reason.trim().isEmpty()) {
|
||||||
|
reasonField.setInvalid(true);
|
||||||
|
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JobStatus oldStatus = job.getStatus();
|
||||||
|
|
||||||
|
List<JobServiceSelection> selections = new ArrayList<>();
|
||||||
|
for (ServiceRow row : serviceRows) {
|
||||||
|
if (row.service == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JobServiceSelection selection = row.selection != null ? row.selection : new JobServiceSelection();
|
||||||
|
selection.setServiceId(row.service.getId());
|
||||||
|
if (selection.getDeliveryStationOrder() == null && row.selection != null) {
|
||||||
|
selection.setDeliveryStationOrder(row.selection.getDeliveryStationOrder());
|
||||||
|
}
|
||||||
|
selections.add(selection);
|
||||||
|
}
|
||||||
|
job.setSelectedServices(selections);
|
||||||
|
|
||||||
|
String remark = remarkArea.getValue();
|
||||||
|
job.setRemark(remark != null && !remark.isBlank() ? remark.trim() : null);
|
||||||
|
|
||||||
|
if (job.getRouteDistanceKm() == null || job.getRouteDistanceKm() <= 0) {
|
||||||
|
job.setRouteDistanceKm(manualDistanceKm);
|
||||||
|
job.setRouteDurationSeconds(manualDurationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.setStatus(JobStatus.COMPLETED);
|
||||||
|
job.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobRepository.save(job);
|
||||||
|
|
||||||
|
String currentUser = securityService.getCurrentUsername();
|
||||||
|
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
|
||||||
|
|
||||||
|
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s", currentUser,
|
||||||
|
reason.trim());
|
||||||
|
jobHistoryService.logCustomEvent(job.getId(), getTranslation("jobsummary.history.manualcomplete.reason"),
|
||||||
|
description, currentUser, JobHistoryType.STATUS_CHANGE);
|
||||||
|
|
||||||
|
notifyClientJobDeleted(job);
|
||||||
|
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
|
||||||
|
Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
|
||||||
|
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Notification
|
||||||
|
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
|
||||||
|
Notification.Position.BOTTOM_END)
|
||||||
|
.addThemeVariants(NotificationVariant.LUMO_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyClientJobDeleted(Job job) {
|
||||||
|
if (!job.isDigitalProcessing()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String appUserId = job.getAppUser();
|
||||||
|
if (appUserId == null || appUserId.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientConnectionService.isClientConnected(appUserId)) {
|
||||||
|
log.info("[JOB] Client {} not online, skipping job_deleted notification", appUserId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> payload = Map.of("type", "job_deleted", "jobId", job.getId().toHexString(), "jobNumber",
|
||||||
|
job.getJobNumber() != null ? job.getJobNumber() : "", "deletedAt", LocalDateTime.now().toString());
|
||||||
|
|
||||||
|
log.info("[JOB] Sending job_deleted to {}: {}", appUserId, payload);
|
||||||
|
messagingPublisher.publishAsJson(appUserId, "job_deleted", payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import jakarta.annotation.security.RolesAllowed;
|
|||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.component.UI;
|
||||||
import com.vaadin.flow.server.StreamResource;
|
import com.vaadin.flow.server.StreamResource;
|
||||||
import com.vaadin.flow.server.StreamRegistration;
|
import com.vaadin.flow.server.StreamRegistration;
|
||||||
|
import de.assecutor.votianlt.service.SystemCompanyProfileService;
|
||||||
import de.assecutor.votianlt.service.SystemInvoiceService;
|
import de.assecutor.votianlt.service.SystemInvoiceService;
|
||||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||||
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||||
@@ -49,11 +50,14 @@ public class MyInvoicesView extends Main implements HasDynamicTitle {
|
|||||||
private final List<MyInvoiceRow> allRows = new ArrayList<>(); // zunächst leer
|
private final List<MyInvoiceRow> allRows = new ArrayList<>(); // zunächst leer
|
||||||
private final Div emptyState = new Div();
|
private final Div emptyState = new Div();
|
||||||
private final SystemInvoiceService systemInvoiceService;
|
private final SystemInvoiceService systemInvoiceService;
|
||||||
|
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||||
|
|
||||||
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||||
|
|
||||||
public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
|
public MyInvoicesView(SystemInvoiceService systemInvoiceService,
|
||||||
|
SystemCompanyProfileService systemCompanyProfileService) {
|
||||||
this.systemInvoiceService = systemInvoiceService;
|
this.systemInvoiceService = systemInvoiceService;
|
||||||
|
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||||
addClassName("data-view");
|
addClassName("data-view");
|
||||||
getStyle().set("max-width", "1100px");
|
getStyle().set("max-width", "1100px");
|
||||||
getStyle().set("margin-left", "auto");
|
getStyle().set("margin-left", "auto");
|
||||||
@@ -272,7 +276,7 @@ public class MyInvoicesView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
|
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
|
||||||
SystemInvoiceData data = new SystemInvoiceData();
|
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
|
||||||
data.setInvoiceNumber(row.invoiceNumber());
|
data.setInvoiceNumber(row.invoiceNumber());
|
||||||
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
|
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
|
||||||
data.setInvoiceText("Rechnung " + row.invoiceNumber());
|
data.setInvoiceText("Rechnung " + row.invoiceNumber());
|
||||||
|
|||||||
@@ -23,4 +23,8 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
|
|||||||
|
|
||||||
// Find AppUser by bezeichnung
|
// Find AppUser by bezeichnung
|
||||||
AppUser findByBezeichnung(String bezeichnung);
|
AppUser findByBezeichnung(String bezeichnung);
|
||||||
|
|
||||||
|
// Number of app users belonging to a customer (for per-app-user billing);
|
||||||
|
// same association as the customer's own app user list (findByErstelltVon)
|
||||||
|
long countByErstelltVon(ObjectId erstelltVon);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.assecutor.votianlt.repository;
|
package de.assecutor.votianlt.repository;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
|
|||||||
Optional<CustomerInvoice> findByJobId(String jobId);
|
Optional<CustomerInvoice> findByJobId(String jobId);
|
||||||
|
|
||||||
List<CustomerInvoice> findByUserId(String userId);
|
List<CustomerInvoice> findByUserId(String userId);
|
||||||
|
|
||||||
|
/** Liefert die – höchstens eine – aktive (nicht stornierte) Rechnung mit dieser Nummer (R-11). */
|
||||||
|
Optional<CustomerInvoice> findByInvoiceNumberAndStatusNot(String invoiceNumber, InvoiceStatus status);
|
||||||
|
|
||||||
|
/** Alle Folgebelege (Storno, Korrektur, Ersatzrechnung), die auf diese Originalrechnung verweisen. */
|
||||||
|
List<CustomerInvoice> findByOriginalInvoiceId(String originalInvoiceId);
|
||||||
|
|
||||||
|
/** Findet alle Rechnungen ohne expliziten Status — wird für die Bestandsdatenmigration genutzt. */
|
||||||
|
List<CustomerInvoice> findByStatusIsNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.assecutor.votianlt.repository;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface InvoiceNumberReservationRepository extends MongoRepository<InvoiceNumberReservation, ObjectId> {
|
||||||
|
|
||||||
|
Optional<InvoiceNumberReservation> findByUserIdAndNumber(ObjectId userId, String number);
|
||||||
|
|
||||||
|
Optional<InvoiceNumberReservation> findByUserIdAndSequence(ObjectId userId, long sequence);
|
||||||
|
|
||||||
|
List<InvoiceNumberReservation> findByUserIdOrderBySequenceAsc(ObjectId userId);
|
||||||
|
|
||||||
|
List<InvoiceNumberReservation> findByUserIdAndStatusOrderBySequenceAsc(ObjectId userId,
|
||||||
|
InvoiceNumberReservationStatus status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.assecutor.votianlt.repository;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.SystemCompanyProfile;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SystemCompanyProfileRepository extends MongoRepository<SystemCompanyProfile, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.assecutor.votianlt.repository;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SystemInvoiceDispatchRepository extends MongoRepository<SystemInvoiceDispatch, ObjectId> {
|
||||||
|
|
||||||
|
List<SystemInvoiceDispatch> findByBillingMonth(String billingMonth);
|
||||||
|
|
||||||
|
Optional<SystemInvoiceDispatch> findByInvoiceNumber(String invoiceNumber);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.assecutor.votianlt.security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollen für die Bearbeitung von Rechnungen gemäß R-40 bis R-42.
|
||||||
|
*
|
||||||
|
* Die Rollen sind als Konstanten definiert und werden in der bestehenden
|
||||||
|
* {@link de.assecutor.votianlt.model.User#getRoles()}-Sammlung als String hinterlegt.
|
||||||
|
*
|
||||||
|
* Backwards-compat: Bestehende Nutzer haben keine dieser Rollen — die
|
||||||
|
* {@code USER}-Rolle bleibt vollumfänglich berechtigt, sofern keine speziellen
|
||||||
|
* Rechnungsrollen explizit zugewiesen sind.
|
||||||
|
*/
|
||||||
|
public final class InvoiceRoles {
|
||||||
|
|
||||||
|
/** Erstellt Entwürfe und stellt Rechnungen aus. */
|
||||||
|
public static final String CREATOR = "INVOICE_CREATOR";
|
||||||
|
/** Prüft Entwürfe und Folgebelege vor Freigabe. */
|
||||||
|
public static final String REVIEWER = "INVOICE_REVIEWER";
|
||||||
|
/** Gibt Storno- und Berichtigungsbelege frei (R-42). */
|
||||||
|
public static final String APPROVER = "INVOICE_APPROVER";
|
||||||
|
/** Erfasst Zahlungen und buchhalterische Vorgänge (R-25). */
|
||||||
|
public static final String ACCOUNTANT = "INVOICE_ACCOUNTANT";
|
||||||
|
|
||||||
|
private InvoiceRoles() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symmetrische AES-256-GCM-Verschlüsselung mit Master-Key-Ableitung über SHA-256.
|
||||||
|
*
|
||||||
|
* Format der erzeugten Bytes: {@code IV (12 Byte) || Ciphertext+Tag (16 Byte Tag)}.
|
||||||
|
* Der IV wird pro Verschlüsselung neu zufällig erzeugt; ein Master-Key liefert
|
||||||
|
* deterministisch denselben AES-Schlüssel.
|
||||||
|
*
|
||||||
|
* Nicht für hochfrequente Krypto-Operationen optimiert — bewusst minimal gehalten.
|
||||||
|
*/
|
||||||
|
final class AesGcmCipher {
|
||||||
|
|
||||||
|
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||||
|
private static final int IV_LENGTH = 12;
|
||||||
|
private static final int TAG_LENGTH_BITS = 128;
|
||||||
|
|
||||||
|
private final SecretKeySpec key;
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
AesGcmCipher(String masterKey) {
|
||||||
|
if (masterKey == null || masterKey.length() < 16) {
|
||||||
|
throw new IllegalArgumentException("Master-Key muss mindestens 16 Zeichen lang sein.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes("UTF-8"));
|
||||||
|
this.key = new SecretKeySpec(derived, "AES");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Master-Key konnte nicht abgeleitet werden.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encrypt(byte[] plaintext) {
|
||||||
|
try {
|
||||||
|
byte[] iv = new byte[IV_LENGTH];
|
||||||
|
random.nextBytes(iv);
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||||
|
byte[] ciphertext = cipher.doFinal(plaintext);
|
||||||
|
return ByteBuffer.allocate(IV_LENGTH + ciphertext.length).put(iv).put(ciphertext).array();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Verschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] decrypt(byte[] ivAndCiphertext) {
|
||||||
|
if (ivAndCiphertext == null || ivAndCiphertext.length < IV_LENGTH + 16) {
|
||||||
|
throw new IllegalArgumentException("Ciphertext zu kurz.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(ivAndCiphertext);
|
||||||
|
byte[] iv = new byte[IV_LENGTH];
|
||||||
|
buffer.get(iv);
|
||||||
|
byte[] ciphertext = new byte[buffer.remaining()];
|
||||||
|
buffer.get(ciphertext);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||||
|
return cipher.doFinal(ciphertext);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Entschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,7 +248,7 @@ public class CustomerInvoiceService {
|
|||||||
* representation of the canvas elements and converts it to PDF.
|
* representation of the canvas elements and converts it to PDF.
|
||||||
*/
|
*/
|
||||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData) throws Exception {
|
||||||
return generatePdfFromCanvasTemplate(jsonTemplateData, null, null);
|
return generatePdfFromCanvasTemplate(jsonTemplateData, (de.assecutor.votianlt.model.User) null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user)
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user)
|
||||||
@@ -265,27 +265,6 @@ public class CustomerInvoiceService {
|
|||||||
String invoicePrefix, BigDecimal vatRate) throws Exception {
|
String invoicePrefix, BigDecimal vatRate) throws Exception {
|
||||||
BigDecimal effectiveVatRate = vatRate != null ? vatRate
|
BigDecimal effectiveVatRate = vatRate != null ? vatRate
|
||||||
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
|
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
|
||||||
// Parse the JSON template data
|
|
||||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
||||||
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
|
||||||
com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements");
|
|
||||||
|
|
||||||
// Build HTML content from canvas elements
|
|
||||||
StringBuilder htmlBuilder = new StringBuilder();
|
|
||||||
htmlBuilder.append("<!DOCTYPE html>");
|
|
||||||
htmlBuilder.append("<html><head>");
|
|
||||||
htmlBuilder.append("<meta charset='UTF-8'>");
|
|
||||||
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; }");
|
|
||||||
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; }");
|
|
||||||
htmlBuilder.append(".image { overflow: hidden; }");
|
|
||||||
htmlBuilder.append(".image img { width: 100%; height: 100%; object-fit: contain; display: block; }");
|
|
||||||
htmlBuilder.append("</style>");
|
|
||||||
htmlBuilder.append("</head><body>");
|
|
||||||
|
|
||||||
// Prepare variable substitution map
|
// Prepare variable substitution map
|
||||||
java.util.Map<String, String> variables = new java.util.HashMap<>();
|
java.util.Map<String, String> variables = new java.util.HashMap<>();
|
||||||
@@ -321,6 +300,39 @@ public class CustomerInvoiceService {
|
|||||||
variables.put("customer.email", "kunde@beispiel.de");
|
variables.put("customer.email", "kunde@beispiel.de");
|
||||||
variables.put("customer.phone", "0987 654321");
|
variables.put("customer.phone", "0987 654321");
|
||||||
|
|
||||||
|
return generatePdfFromCanvasTemplate(jsonTemplateData, variables, effectiveVatRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a canvas template PDF using a pre-built variable substitution map.
|
||||||
|
* Used e.g. for the system invoice template where the issuer data comes from
|
||||||
|
* SystemInvoiceData instead of a User.
|
||||||
|
*/
|
||||||
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, java.util.Map<String, String> variables,
|
||||||
|
BigDecimal vatRate) throws Exception {
|
||||||
|
BigDecimal effectiveVatRate = vatRate != null ? vatRate : new BigDecimal("0.19");
|
||||||
|
// Parse the JSON template data
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(jsonTemplateData);
|
||||||
|
com.fasterxml.jackson.databind.JsonNode elements = rootNode.get("elements");
|
||||||
|
|
||||||
|
// Build HTML content from canvas elements
|
||||||
|
StringBuilder htmlBuilder = new StringBuilder();
|
||||||
|
htmlBuilder.append("<!DOCTYPE html>");
|
||||||
|
htmlBuilder.append("<html><head>");
|
||||||
|
htmlBuilder.append("<meta charset='UTF-8'>");
|
||||||
|
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; }");
|
||||||
|
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; }");
|
||||||
|
htmlBuilder.append(".image { overflow: hidden; }");
|
||||||
|
htmlBuilder.append(".image img { width: 100%; height: 100%; object-fit: contain; display: block; }");
|
||||||
|
htmlBuilder.append("</style>");
|
||||||
|
htmlBuilder.append("</head><body>");
|
||||||
|
|
||||||
if (elements != null && elements.isArray()) {
|
if (elements != null && elements.isArray()) {
|
||||||
for (com.fasterxml.jackson.databind.JsonNode element : elements) {
|
for (com.fasterxml.jackson.databind.JsonNode element : elements) {
|
||||||
String type = element.has("type") ? element.get("type").asText("text") : "text";
|
String type = element.has("type") ? element.get("type").asText("text") : "text";
|
||||||
@@ -464,8 +476,13 @@ public class CustomerInvoiceService {
|
|||||||
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
"<div style='width:100%;height:100%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:10pt;color:#666;'>[Bild]</div>");
|
||||||
}
|
}
|
||||||
} else if ("services.list".equals(variable)) {
|
} else if ("services.list".equals(variable)) {
|
||||||
// Render services list as a table
|
// Render positions from services.json if provided (e.g. system
|
||||||
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
|
// invoice: price table positions), otherwise sample data
|
||||||
|
if (variables.containsKey("services.json")) {
|
||||||
|
htmlBuilder.append(generateServicesTableHtmlWithData(mmWidth, variables));
|
||||||
|
} else {
|
||||||
|
htmlBuilder.append(generateServicesTableHtml(mmWidth, effectiveVatRate));
|
||||||
|
}
|
||||||
} else if (text.contains("<br>")) {
|
} else if (text.contains("<br>")) {
|
||||||
// Multi-line text: render without nowrap so <br> tags work
|
// Multi-line text: render without nowrap so <br> tags work
|
||||||
htmlBuilder.append("<span>").append(text).append("</span>");
|
htmlBuilder.append("<span>").append(text).append("</span>");
|
||||||
@@ -865,8 +882,11 @@ public class CustomerInvoiceService {
|
|||||||
.append(escapeHtml(name)).append("</td>");
|
.append(escapeHtml(name)).append("</td>");
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:20%;'>")
|
||||||
.append(escapeHtml(vatRateLabel)).append("</td>");
|
.append(escapeHtml(vatRateLabel)).append("</td>");
|
||||||
|
// Append € only for plain numeric amounts; values like "15 %"
|
||||||
|
// (revenue participation from the price table) are shown as-is
|
||||||
|
String amountDisplay = netAmount.matches("[0-9.,]+") ? netAmount + " €" : escapeHtml(netAmount);
|
||||||
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
html.append("<td style='text-align:right;padding:4px 8px;white-space:nowrap;width:25%;'>")
|
||||||
.append(netAmount).append(" €</td>");
|
.append(amountDisplay).append("</td>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -921,4 +941,181 @@ public class CustomerInvoiceService {
|
|||||||
return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||||
.replace("'", "'");
|
.replace("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein einfaches PDF für einen Stornobeleg gemäß R-19.
|
||||||
|
* Verweist eindeutig auf die Originalrechnung (Nummer + Datum) und stellt die
|
||||||
|
* Beträge als negative Werte dar.
|
||||||
|
*/
|
||||||
|
public byte[] generateCancellationPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
|
||||||
|
String cancellationNumber, java.time.LocalDate cancellationDate, String reason) throws Exception {
|
||||||
|
return generateCorrectionDocumentPdf("STORNORECHNUNG", original, cancellationNumber, cancellationDate, reason,
|
||||||
|
null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein einfaches PDF für einen Berichtigungsbeleg gemäß R-13/R-14.
|
||||||
|
* Verweist eindeutig auf die Originalrechnung und beschreibt die berichtigten Angaben.
|
||||||
|
*/
|
||||||
|
public byte[] generateCorrectionPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
|
||||||
|
String correctionNumber, java.time.LocalDate correctionDate, String reason, String correctedFields)
|
||||||
|
throws Exception {
|
||||||
|
return generateCorrectionDocumentPdf("RECHNUNGSBERICHTIGUNG", original, correctionNumber, correctionDate,
|
||||||
|
reason, correctedFields, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] generateCorrectionDocumentPdf(String documentLabel,
|
||||||
|
de.assecutor.votianlt.model.invoices.CustomerInvoice original, String number,
|
||||||
|
java.time.LocalDate documentDate, String reason, String correctedFields, boolean negateAmounts)
|
||||||
|
throws Exception {
|
||||||
|
java.time.LocalDate effectiveDate = documentDate != null ? documentDate : java.time.LocalDate.now();
|
||||||
|
java.time.format.DateTimeFormatter dateFmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy",
|
||||||
|
Locale.GERMANY);
|
||||||
|
|
||||||
|
BigDecimal net = negateAmounts ? negateOrZero(original.getNetAmount()) : safeAmount(original.getNetAmount());
|
||||||
|
BigDecimal vat = negateAmounts ? negateOrZero(original.getVatAmount()) : safeAmount(original.getVatAmount());
|
||||||
|
BigDecimal total = negateAmounts ? negateOrZero(original.getTotalAmount())
|
||||||
|
: safeAmount(original.getTotalAmount());
|
||||||
|
|
||||||
|
StringBuilder html = new StringBuilder();
|
||||||
|
html.append("<!DOCTYPE html><html><head><meta charset='UTF-8'><style>");
|
||||||
|
html.append("@page { size: A4; margin: 20mm 18mm 20mm 18mm; }");
|
||||||
|
html.append("body { font-family: Arial, sans-serif; font-size: 11pt; color: #222; }");
|
||||||
|
html.append("h1 { font-size: 18pt; letter-spacing: 0.05em; margin: 0 0 8pt 0; }");
|
||||||
|
html.append("h2 { font-size: 13pt; margin: 18pt 0 6pt 0; }");
|
||||||
|
html.append(".doc-number { font-size: 11pt; color: #555; }");
|
||||||
|
html.append(".section { margin-top: 14pt; }");
|
||||||
|
html.append(".reference { background: #f6f6f6; border-left: 3px solid #888; padding: 8pt 12pt; }");
|
||||||
|
html.append(".reason { background: #fff8e6; border-left: 3px solid #d9a300; padding: 8pt 12pt; }");
|
||||||
|
html.append("table.amounts { width: 60%; margin-left: 40%; border-collapse: collapse; margin-top: 10pt; }");
|
||||||
|
html.append("table.amounts td { padding: 3pt 6pt; }");
|
||||||
|
html.append("table.amounts td.value { text-align: right; }");
|
||||||
|
html.append("table.amounts tr.total td { font-weight: bold; border-top: 1px solid #333; }");
|
||||||
|
html.append(".addresses { width: 100%; margin-top: 14pt; }");
|
||||||
|
html.append(".addresses td { width: 50%; vertical-align: top; }");
|
||||||
|
html.append(".muted { color: #666; font-size: 9pt; }");
|
||||||
|
html.append("</style></head><body>");
|
||||||
|
|
||||||
|
html.append("<h1>").append(escapeHtml(documentLabel)).append("</h1>");
|
||||||
|
html.append("<div class='doc-number'>Beleg-Nr.: ")
|
||||||
|
.append(escapeHtml(safe(number)))
|
||||||
|
.append(" · Datum: ").append(escapeHtml(effectiveDate.format(dateFmt)))
|
||||||
|
.append("</div>");
|
||||||
|
|
||||||
|
// Sender / Empfänger
|
||||||
|
html.append("<table class='addresses'><tr><td>");
|
||||||
|
html.append("<strong>Aussteller</strong><br/>");
|
||||||
|
html.append(formatAddressBlock(original.getSenderName(), original.getSenderAddress(),
|
||||||
|
original.getSenderPostcode(), original.getSenderCity(), original.getSenderCountry()));
|
||||||
|
if (original.getSenderTaxNumber() != null && !original.getSenderTaxNumber().isBlank()) {
|
||||||
|
html.append("<div class='muted'>Steuernr.: ").append(escapeHtml(original.getSenderTaxNumber()))
|
||||||
|
.append("</div>");
|
||||||
|
}
|
||||||
|
if (original.getSenderVatId() != null && !original.getSenderVatId().isBlank()) {
|
||||||
|
html.append("<div class='muted'>USt-IdNr.: ").append(escapeHtml(original.getSenderVatId()))
|
||||||
|
.append("</div>");
|
||||||
|
}
|
||||||
|
html.append("</td><td>");
|
||||||
|
html.append("<strong>Empfänger</strong><br/>");
|
||||||
|
html.append(formatAddressBlock(
|
||||||
|
firstNonBlank(original.getRecipientCompany(), original.getRecipientName()),
|
||||||
|
original.getRecipientAddress(), original.getRecipientPostcode(), original.getRecipientCity(),
|
||||||
|
original.getRecipientCountry()));
|
||||||
|
html.append("</td></tr></table>");
|
||||||
|
|
||||||
|
// Eindeutige Referenz auf Originalrechnung (R-13/R-19/R-28)
|
||||||
|
html.append("<div class='section reference'>");
|
||||||
|
html.append("<strong>Bezug:</strong> ");
|
||||||
|
html.append("Diese ").append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))).append(" bezieht sich ");
|
||||||
|
html.append("eindeutig auf die Rechnung <strong>")
|
||||||
|
.append(escapeHtml(safe(original.getInvoiceNumber()))).append("</strong>");
|
||||||
|
if (original.getInvoiceDate() != null) {
|
||||||
|
html.append(" vom ").append(escapeHtml(original.getInvoiceDate().format(dateFmt)));
|
||||||
|
}
|
||||||
|
html.append(".");
|
||||||
|
html.append("</div>");
|
||||||
|
|
||||||
|
if (correctedFields != null && !correctedFields.isBlank()) {
|
||||||
|
html.append("<div class='section'><h2>Berichtigte Angaben</h2>");
|
||||||
|
html.append("<div>").append(escapeHtml(correctedFields).replace("\n", "<br/>")).append("</div></div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason != null && !reason.isBlank()) {
|
||||||
|
html.append("<div class='section reason'><strong>Grund:</strong> ")
|
||||||
|
.append(escapeHtml(reason).replace("\n", "<br/>")).append("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.append("<h2>Beträge</h2>");
|
||||||
|
html.append("<table class='amounts'>");
|
||||||
|
html.append("<tr><td>Nettobetrag</td><td class='value'>").append(formatCurrency(net)).append("</td></tr>");
|
||||||
|
if (original.getVatRate() != null) {
|
||||||
|
BigDecimal vatPct = original.getVatRate().multiply(new BigDecimal("100"))
|
||||||
|
.setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros();
|
||||||
|
if (vatPct.scale() < 0) {
|
||||||
|
vatPct = vatPct.setScale(0);
|
||||||
|
}
|
||||||
|
html.append("<tr><td>zzgl. ").append(vatPct.toPlainString().replace('.', ','))
|
||||||
|
.append("% USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
|
||||||
|
} else {
|
||||||
|
html.append("<tr><td>zzgl. USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
|
||||||
|
}
|
||||||
|
html.append("<tr class='total'><td>Gesamtbetrag</td><td class='value'>").append(formatCurrency(total))
|
||||||
|
.append("</td></tr>");
|
||||||
|
html.append("</table>");
|
||||||
|
|
||||||
|
html.append("<div class='section muted'>");
|
||||||
|
html.append("Hinweis: Dieser Beleg ersetzt die Originalrechnung nicht. Original und ");
|
||||||
|
html.append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY)));
|
||||||
|
html.append(" sind gemeinsam aufzubewahren.");
|
||||||
|
html.append("</div>");
|
||||||
|
|
||||||
|
html.append("</body></html>");
|
||||||
|
|
||||||
|
return generatePdfFromHtmlString(html.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal safeAmount(BigDecimal value) {
|
||||||
|
return value != null ? value : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal negateOrZero(BigDecimal value) {
|
||||||
|
return value != null ? value.negate() : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAddressBlock(String name, String street, String postcode, String city, String country) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
sb.append(escapeHtml(name)).append("<br/>");
|
||||||
|
}
|
||||||
|
if (street != null && !street.isBlank()) {
|
||||||
|
sb.append(escapeHtml(street)).append("<br/>");
|
||||||
|
}
|
||||||
|
String line = String.join(" ", filterBlanks(postcode, city)).trim();
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
sb.append(escapeHtml(line)).append("<br/>");
|
||||||
|
}
|
||||||
|
if (country != null && !country.isBlank()) {
|
||||||
|
sb.append(escapeHtml(country));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.util.List<String> filterBlanks(String... values) {
|
||||||
|
java.util.List<String> out = new java.util.ArrayList<>();
|
||||||
|
for (String v : values) {
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
out.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
for (String v : values) {
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import de.assecutor.votianlt.model.User;
|
|||||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||||
import de.assecutor.votianlt.repository.JobRepository;
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.mail.SimpleMailMessage;
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -378,6 +382,23 @@ public class EmailService {
|
|||||||
mailSender.send(message);
|
mailSender.send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text email with a single attachment. Synchronous on purpose so
|
||||||
|
* callers can report per-recipient success or failure to the user.
|
||||||
|
*/
|
||||||
|
public void sendEmailWithAttachment(String to, String subject, String body, byte[] attachment,
|
||||||
|
String attachmentName) throws MessagingException {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||||
|
helper.setFrom(smtpUsername);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(body);
|
||||||
|
helper.addAttachment(attachmentName, new ByteArrayResource(attachment));
|
||||||
|
mailSender.send(message);
|
||||||
|
log.info("Email with attachment {} sent to {}", attachmentName, to);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a simple text email
|
* Send a simple text email
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn eine Rechnung beim Festschreiben gegen Pflichtangaben
|
||||||
|
* nach § 14 UStG bzw. interne Konsistenzregeln verstößt. Die Verstöße werden
|
||||||
|
* gesammelt geliefert, damit der Anwender alle Korrekturen in einem Schritt
|
||||||
|
* durchführen kann statt jeden Fehler einzeln zu beheben.
|
||||||
|
*/
|
||||||
|
public class InvoiceComplianceException extends InvoiceLifecycleException {
|
||||||
|
|
||||||
|
private final List<String> violations;
|
||||||
|
|
||||||
|
public InvoiceComplianceException(List<String> violations) {
|
||||||
|
super(buildMessage(violations));
|
||||||
|
this.violations = List.copyOf(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getViolations() {
|
||||||
|
return Collections.unmodifiableList(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildMessage(List<String> violations) {
|
||||||
|
if (violations == null || violations.isEmpty()) {
|
||||||
|
return "Die Rechnung erfüllt die gesetzlichen Pflichtangaben nicht.";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder("Die Rechnung kann nicht festgeschrieben werden — folgende Pflichtangaben fehlen oder sind inkonsistent:");
|
||||||
|
for (String violation : violations) {
|
||||||
|
sb.append("\n • ").append(violation);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft eine {@link CustomerInvoice} auf die Pflichtangaben nach § 14 UStG
|
||||||
|
* sowie auf interne Konsistenz (Beträge, Items). Wird vor jeder Festschreibung
|
||||||
|
* (Übergang DRAFT → ISSUED) aufgerufen; eine festgeschriebene Rechnung darf
|
||||||
|
* keine Pflichtfeld-Lücken mehr haben, da sie nach R-08 nicht mehr direkt
|
||||||
|
* änderbar ist.
|
||||||
|
*
|
||||||
|
* Nicht abgedeckt (bewusst):
|
||||||
|
* <ul>
|
||||||
|
* <li>Lückenlose Rechnungsnummer-Vergabe (separater Block).</li>
|
||||||
|
* <li>Online-Validierung der USt-IdNr beim Bzst (separater Block).</li>
|
||||||
|
* <li>Storno-/Korrekturbelege — diese haben eigene Beleg-Regeln (negierte Beträge,
|
||||||
|
* Pflicht-Verweis auf Originalrechnung), die hier nicht greifen.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Toleranz für Beträge: 1 Cent. Damit fängt der Validator typische Rundungs-
|
||||||
|
* differenzen aus dezimaler Arithmetik ab, ohne echte Inkonsistenzen zu schlucken.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceComplianceValidator {
|
||||||
|
|
||||||
|
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wirft {@link InvoiceComplianceException} mit allen gefundenen Verstößen,
|
||||||
|
* wenn die Rechnung nicht festschreibungsreif ist. Andernfalls passiert nichts.
|
||||||
|
*/
|
||||||
|
public void validateForIssuance(CustomerInvoice invoice) {
|
||||||
|
if (invoice == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
|
||||||
|
}
|
||||||
|
if (invoice.getType() != null && invoice.getType() != InvoiceType.INVOICE) {
|
||||||
|
// Storno/Korrektur folgen anderen Regeln und werden hier nicht geprüft.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> violations = collectViolations(invoice);
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
throw new InvoiceComplianceException(violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> collectViolations(CustomerInvoice invoice) {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
checkInvoiceNumber(invoice, violations);
|
||||||
|
checkDates(invoice, violations);
|
||||||
|
checkSender(invoice, violations);
|
||||||
|
checkRecipient(invoice, violations);
|
||||||
|
checkItems(invoice, violations);
|
||||||
|
checkAmounts(invoice, violations);
|
||||||
|
checkVatNotices(invoice, violations);
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkInvoiceNumber(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (isBlank(invoice.getInvoiceNumber())) {
|
||||||
|
violations.add("Rechnungsnummer fehlt (§ 14 Abs. 4 Nr. 4 UStG).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkDates(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (invoice.getInvoiceDate() == null) {
|
||||||
|
violations.add("Rechnungsdatum (Ausstellungsdatum) fehlt (§ 14 Abs. 4 Nr. 3 UStG).");
|
||||||
|
}
|
||||||
|
if (invoice.getDeliveryDate() == null) {
|
||||||
|
violations.add("Leistungsdatum fehlt (§ 14 Abs. 4 Nr. 6 UStG). "
|
||||||
|
+ "Bei zeitgleicher Leistung kann es dem Rechnungsdatum entsprechen — muss aber gesetzt sein.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkSender(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (isBlank(invoice.getSenderName())) {
|
||||||
|
violations.add("Name des Leistenden (Absender) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
if (isBlank(invoice.getSenderAddress()) || isBlank(invoice.getSenderPostcode())
|
||||||
|
|| isBlank(invoice.getSenderCity())) {
|
||||||
|
violations.add("Vollständige Anschrift des Leistenden (Straße, PLZ, Ort) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
if (isBlank(invoice.getSenderTaxNumber()) && isBlank(invoice.getSenderVatId())) {
|
||||||
|
violations.add("Steuernummer oder USt-IdNr des Leistenden fehlt (§ 14 Abs. 4 Nr. 2 UStG).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkRecipient(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
if (isBlank(invoice.getRecipientName())) {
|
||||||
|
violations.add("Name des Leistungsempfängers fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
if (isBlank(invoice.getRecipientAddress()) || isBlank(invoice.getRecipientPostcode())
|
||||||
|
|| isBlank(invoice.getRecipientCity())) {
|
||||||
|
violations.add("Vollständige Anschrift des Leistungsempfängers (Straße, PLZ, Ort) fehlt "
|
||||||
|
+ "(§ 14 Abs. 4 Nr. 1 UStG).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkItems(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
List<CustomerInvoiceItem> items = invoice.getItems();
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
violations.add("Keine Positionen erfasst — Menge und Art der Leistung sind erforderlich "
|
||||||
|
+ "(§ 14 Abs. 4 Nr. 5 UStG).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
CustomerInvoiceItem item = items.get(i);
|
||||||
|
int rowNumber = i + 1;
|
||||||
|
if (item == null) {
|
||||||
|
violations.add("Position " + rowNumber + ": leere Position.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isBlank(item.getDescription())) {
|
||||||
|
violations.add("Position " + rowNumber + ": Bezeichnung der Leistung fehlt.");
|
||||||
|
}
|
||||||
|
if (item.getQuantity() == null || item.getQuantity().signum() <= 0) {
|
||||||
|
violations.add("Position " + rowNumber + ": Menge muss größer 0 sein.");
|
||||||
|
}
|
||||||
|
if (item.getUnitPrice() == null || item.getUnitPrice().signum() < 0) {
|
||||||
|
violations.add("Position " + rowNumber + ": Einzelpreis fehlt oder ist negativ.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkAmounts(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
BigDecimal net = invoice.getNetAmount();
|
||||||
|
BigDecimal vat = invoice.getVatAmount();
|
||||||
|
BigDecimal total = invoice.getTotalAmount();
|
||||||
|
if (net == null || vat == null || total == null) {
|
||||||
|
violations.add("Beträge unvollständig: Netto, Steuerbetrag und Bruttobetrag müssen ausgewiesen sein "
|
||||||
|
+ "(§ 14 Abs. 4 Nr. 7 + 8 UStG).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (net.add(vat).subtract(total).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
|
||||||
|
violations.add("Bruttobetrag passt nicht zu Netto + Steuerbetrag (Differenz > 1 Cent).");
|
||||||
|
}
|
||||||
|
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
|
||||||
|
BigDecimal sumItems = invoice.getItems().stream()
|
||||||
|
.map(CustomerInvoiceItem::getNetTotal)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
if (sumItems.subtract(net).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
|
||||||
|
violations.add("Summe der Positionen (netto) " + sumItems + " weicht vom Rechnungs-Netto " + net
|
||||||
|
+ " ab (Differenz > 1 Cent).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkVatNotices(CustomerInvoice invoice, List<String> violations) {
|
||||||
|
BigDecimal rate = invoice.getVatRate();
|
||||||
|
if (rate == null) {
|
||||||
|
violations.add("Steuersatz fehlt (§ 14 Abs. 4 Nr. 8 UStG).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rate.signum() == 0) {
|
||||||
|
// Bei 0 % USt verlangt das UStG einen erklärenden Hinweis — entweder
|
||||||
|
// Reverse-Charge (§ 13b), Kleinunternehmerregelung (§ 19), eine
|
||||||
|
// innergemeinschaftliche Lieferung (§ 6a) oder eine andere Steuerbefreiung.
|
||||||
|
// Ohne Hinweis ist eine 0 %-Rechnung formal mangelhaft.
|
||||||
|
boolean hasNotice = !isBlank(invoice.getReverseChargeNote()) || !isBlank(invoice.getLegalNotes());
|
||||||
|
if (!hasNotice) {
|
||||||
|
violations.add("Bei 0 % USt ist ein rechtlicher Hinweis erforderlich "
|
||||||
|
+ "(z.B. \"Steuerschuldnerschaft des Leistungsempfängers\" nach § 13b UStG, "
|
||||||
|
+ "Kleinunternehmerregelung § 19 UStG oder Steuerbefreiung). "
|
||||||
|
+ "Bitte im Feld \"Reverse-Charge-Hinweis\" oder \"Rechtliche Hinweise\" ergänzen.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BigDecimal expectedVat = invoice.getNetAmount() != null
|
||||||
|
? invoice.getNetAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP)
|
||||||
|
: null;
|
||||||
|
if (expectedVat != null && invoice.getVatAmount() != null
|
||||||
|
&& expectedVat.subtract(invoice.getVatAmount()).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
|
||||||
|
violations.add("Ausgewiesener Steuerbetrag " + invoice.getVatAmount()
|
||||||
|
+ " passt nicht zu Netto × Steuersatz (erwartet " + expectedVat + ").");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export-Funktion für Rechnungen gemäß R-33 und R-34.
|
||||||
|
*
|
||||||
|
* Bündelt eine Originalrechnung mit allen erzeugten Folgebelegen
|
||||||
|
* (Storno, Berichtigung, Ersatzrechnung) sowie einer Manifest-Datei
|
||||||
|
* mit Audit-Log und Verkettungsangaben in einer ZIP-Datei.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceExportService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
|
||||||
|
Locale.GERMANY);
|
||||||
|
|
||||||
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
|
|
||||||
|
public InvoiceExportService(CustomerInvoiceRepository invoiceRepository) {
|
||||||
|
this.invoiceRepository = invoiceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt ein ZIP-Archiv mit dem Originalbeleg, allen verlinkten Folgebelegen
|
||||||
|
* sowie einer Manifest-Datei. Ist die übergebene Rechnung selbst ein Folgebeleg,
|
||||||
|
* wird automatisch das Bündel um die zugehörige Originalrechnung erweitert.
|
||||||
|
*/
|
||||||
|
public byte[] exportInvoicePackage(CustomerInvoice anchor) {
|
||||||
|
if (anchor == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnung erforderlich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerInvoice root = resolveRoot(anchor);
|
||||||
|
List<CustomerInvoice> bundle = collectBundle(root);
|
||||||
|
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ZipOutputStream zip = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
|
||||||
|
|
||||||
|
for (CustomerInvoice invoice : bundle) {
|
||||||
|
writePdfEntry(zip, invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
String manifest = buildManifest(root, bundle);
|
||||||
|
ZipEntry manifestEntry = new ZipEntry("MANIFEST.txt");
|
||||||
|
zip.putNextEntry(manifestEntry);
|
||||||
|
zip.write(manifest.getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.closeEntry();
|
||||||
|
|
||||||
|
zip.finish();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Export fehlgeschlagen: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlägt einen Dateinamen für das ZIP-Archiv auf Basis des Originalbelegs vor.
|
||||||
|
*/
|
||||||
|
public String suggestFilename(CustomerInvoice anchor) {
|
||||||
|
CustomerInvoice root = resolveRoot(anchor);
|
||||||
|
String number = root.getInvoiceNumber() != null ? root.getInvoiceNumber() : root.getId();
|
||||||
|
return "Rechnung_" + sanitize(number) + ".zip";
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice resolveRoot(CustomerInvoice anchor) {
|
||||||
|
if (anchor.getType() != InvoiceType.INVOICE && anchor.getOriginalInvoiceId() != null) {
|
||||||
|
return invoiceRepository.findById(anchor.getOriginalInvoiceId()).orElse(anchor);
|
||||||
|
}
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CustomerInvoice> collectBundle(CustomerInvoice root) {
|
||||||
|
List<CustomerInvoice> result = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
result.add(root);
|
||||||
|
seen.add(root.getId());
|
||||||
|
|
||||||
|
for (CustomerInvoice related : invoiceRepository.findByOriginalInvoiceId(root.getId())) {
|
||||||
|
if (related.getId() != null && seen.add(related.getId())) {
|
||||||
|
result.add(related);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePdfEntry(ZipOutputStream zip, CustomerInvoice invoice) throws java.io.IOException {
|
||||||
|
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String label = switch (invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE) {
|
||||||
|
case INVOICE -> "Rechnung";
|
||||||
|
case CANCELLATION -> "Storno";
|
||||||
|
case CORRECTION -> "Berichtigung";
|
||||||
|
};
|
||||||
|
String number = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : invoice.getId();
|
||||||
|
String name = sanitize(label + "_" + number) + ".pdf";
|
||||||
|
ZipEntry entry = new ZipEntry(name);
|
||||||
|
zip.putNextEntry(entry);
|
||||||
|
zip.write(invoice.getPdfData());
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildManifest(CustomerInvoice root, List<CustomerInvoice> bundle) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Rechnungspaket\n");
|
||||||
|
sb.append("===============\n\n");
|
||||||
|
sb.append("Originalrechnung: ").append(safe(root.getInvoiceNumber()));
|
||||||
|
if (root.getInvoiceDate() != null) {
|
||||||
|
sb.append(" vom ").append(root.getInvoiceDate());
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append("Status: ").append(root.getStatus()).append("\n");
|
||||||
|
sb.append("Zahlungsstatus: ").append(root.getPaymentStatus()).append("\n");
|
||||||
|
if (root.getTotalAmount() != null) {
|
||||||
|
sb.append("Gesamtbetrag: ").append(root.getTotalAmount()).append("\n");
|
||||||
|
}
|
||||||
|
if (root.getPaidAmount() != null) {
|
||||||
|
sb.append("Bezahlt: ").append(root.getPaidAmount()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\nEnthaltene Belege:\n");
|
||||||
|
for (CustomerInvoice invoice : bundle) {
|
||||||
|
sb.append("- [").append(invoice.getType()).append("] ")
|
||||||
|
.append(safe(invoice.getInvoiceNumber()));
|
||||||
|
if (invoice.getInvoiceDate() != null) {
|
||||||
|
sb.append(" vom ").append(invoice.getInvoiceDate());
|
||||||
|
}
|
||||||
|
sb.append(" — Status ").append(invoice.getStatus()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\nÄnderungsprotokoll der Originalrechnung:\n");
|
||||||
|
List<InvoiceAuditEntry> log = root.getAuditLog();
|
||||||
|
if (log == null || log.isEmpty()) {
|
||||||
|
sb.append("(keine Einträge)\n");
|
||||||
|
} else {
|
||||||
|
for (InvoiceAuditEntry entry : log) {
|
||||||
|
sb.append("- ");
|
||||||
|
sb.append(entry.getTimestamp() != null ? entry.getTimestamp().format(TS_FMT) : "-");
|
||||||
|
sb.append(" · ").append(entry.getAction());
|
||||||
|
sb.append(" · ").append(safe(entry.getUserDisplayName()));
|
||||||
|
if (entry.getReason() != null && !entry.getReason().isBlank()) {
|
||||||
|
sb.append(" — ").append(entry.getReason());
|
||||||
|
}
|
||||||
|
if (entry.getResultingInvoiceNumber() != null) {
|
||||||
|
sb.append(" → ").append(entry.getResultingInvoiceNumber());
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append(
|
||||||
|
"\nHinweis: Dieses Paket dient der gemeinsamen Aufbewahrung von Original und Folgebelegen.\n");
|
||||||
|
sb.append("Die rechtliche Aufbewahrungspflicht liegt beim Aussteller (R-31/R-32).\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitize(String input) {
|
||||||
|
if (input == null) {
|
||||||
|
return "Beleg";
|
||||||
|
}
|
||||||
|
return input.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird geworfen, wenn ein Statusübergang oder eine Änderung an einer Rechnung
|
||||||
|
* gegen die Regeln aus <code>invoices_rules.md</code> verstößt (z.B. R-03, R-08, R-11, R-35).
|
||||||
|
*
|
||||||
|
* Die Nachricht ist als Anwendertext formuliert und kann direkt in der UI angezeigt werden.
|
||||||
|
*/
|
||||||
|
public class InvoiceLifecycleException extends RuntimeException {
|
||||||
|
|
||||||
|
public InvoiceLifecycleException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvoiceLifecycleException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import de.assecutor.votianlt.model.invoices.PaymentStatus;
|
||||||
|
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet den Lebenszyklus einer {@link CustomerInvoice} gemäß den Regeln aus
|
||||||
|
* <code>invoices_rules.md</code>.
|
||||||
|
*
|
||||||
|
* Phase 1 stellt die Status- und Audit-Mechanik bereit:
|
||||||
|
* <ul>
|
||||||
|
* <li>R-02/R-03: Entwürfe sind editier-/löschbar, finalisierte Belege nicht.</li>
|
||||||
|
* <li>R-07: Finalisierung markiert eine Rechnung als verbindlich.</li>
|
||||||
|
* <li>R-08/R-11: Verhindert Doppelvergabe der Rechnungsnummer und unsichtbare Änderungen.</li>
|
||||||
|
* <li>R-35: Direktes Löschen finalisierter Belege wird abgelehnt.</li>
|
||||||
|
* <li>R-36 bis R-39: Jede Statusänderung wird im Audit-Log protokolliert.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Korrektur-/Storno-Workflows folgen in Phase 2; entsprechende Hooks werden hier vorbereitet.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceLifecycleService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleService.class);
|
||||||
|
|
||||||
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
|
private final SecurityService securityService;
|
||||||
|
private final InvoiceComplianceValidator complianceValidator;
|
||||||
|
private final InvoiceNumberAuditService numberAuditService;
|
||||||
|
|
||||||
|
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
|
||||||
|
InvoiceComplianceValidator complianceValidator,
|
||||||
|
InvoiceNumberAuditService numberAuditService) {
|
||||||
|
this.invoiceRepository = invoiceRepository;
|
||||||
|
this.securityService = securityService;
|
||||||
|
this.complianceValidator = complianceValidator;
|
||||||
|
this.numberAuditService = numberAuditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistiert einen neu erzeugten Rechnungsentwurf (Status DRAFT).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice createDraft(CustomerInvoice draft, String reason) {
|
||||||
|
if (draft == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnungsentwurf darf nicht null sein.");
|
||||||
|
}
|
||||||
|
draft.setStatus(InvoiceStatus.DRAFT);
|
||||||
|
if (draft.getType() == null) {
|
||||||
|
draft.setType(InvoiceType.INVOICE);
|
||||||
|
}
|
||||||
|
draft.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
return invoiceRepository.save(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine Rechnung und finalisiert sie unmittelbar (Status ISSUED).
|
||||||
|
* Wird vom bestehenden Erstell-Flow verwendet, der Vorschau und Speichern in
|
||||||
|
* einem Schritt vereint (R-06/R-07).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice createAndIssue(CustomerInvoice invoice, String reason) {
|
||||||
|
if (invoice == null) {
|
||||||
|
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
|
||||||
|
}
|
||||||
|
if (invoice.getType() == null) {
|
||||||
|
invoice.setType(InvoiceType.INVOICE);
|
||||||
|
}
|
||||||
|
complianceValidator.validateForIssuance(invoice);
|
||||||
|
ensureInvoiceNumberUnique(invoice);
|
||||||
|
invoice.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
invoice.setIssuedAt(LocalDateTime.now());
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
|
||||||
|
CustomerInvoice saved = invoiceRepository.save(invoice);
|
||||||
|
numberAuditService.markUsed(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert Änderungen an einem bestehenden Entwurf (R-02/R-05).
|
||||||
|
* Lehnt Änderungen an finalisierten Rechnungen ab (R-03/R-08).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice updateDraft(CustomerInvoice draft, String reason) {
|
||||||
|
if (draft == null || draft.getId() == null) {
|
||||||
|
throw new IllegalArgumentException("Bestehender Entwurf erwartet.");
|
||||||
|
}
|
||||||
|
CustomerInvoice persisted = invoiceRepository.findById(draft.getId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + draft.getId()));
|
||||||
|
if (!persisted.getStatus().isMutable()) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Diese Rechnung ist bereits ausgestellt und kann nicht mehr direkt bearbeitet werden. "
|
||||||
|
+ "Bitte erstellen Sie eine Berichtigung oder ein Storno.");
|
||||||
|
}
|
||||||
|
draft.setStatus(InvoiceStatus.DRAFT);
|
||||||
|
draft.setAuditLog(persisted.getAuditLog());
|
||||||
|
draft.addAuditEntry(audit(InvoiceAuditAction.UPDATED_DRAFT, reason));
|
||||||
|
return invoiceRepository.save(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalisiert einen Entwurf (Status ISSUED). Stellt sicher, dass die Rechnungsnummer
|
||||||
|
* eindeutig ist (R-11) und protokolliert den Wechsel.
|
||||||
|
*/
|
||||||
|
public CustomerInvoice issue(String invoiceId, String reason) {
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.ISSUED || invoice.getStatus() == InvoiceStatus.SENT) {
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
if (invoice.getStatus() != InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nur Entwürfe können ausgestellt werden. Aktueller Status: " + invoice.getStatus());
|
||||||
|
}
|
||||||
|
complianceValidator.validateForIssuance(invoice);
|
||||||
|
ensureInvoiceNumberUnique(invoice);
|
||||||
|
invoice.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
invoice.setIssuedAt(LocalDateTime.now());
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
|
||||||
|
CustomerInvoice saved = invoiceRepository.save(invoice);
|
||||||
|
numberAuditService.markUsed(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert eine ausgestellte Rechnung als versendet (R-08).
|
||||||
|
*/
|
||||||
|
public CustomerInvoice markAsSent(String invoiceId, String reason) {
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Eine Rechnung muss vor dem Versand zunächst ausgestellt werden.");
|
||||||
|
}
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException("Eine stornierte Rechnung kann nicht mehr versendet werden.");
|
||||||
|
}
|
||||||
|
invoice.setStatus(InvoiceStatus.SENT);
|
||||||
|
invoice.setSentAt(LocalDateTime.now());
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.SENT, reason));
|
||||||
|
return invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Stornobeleg zu einer bereits ausgestellten Rechnung (R-17 bis R-22).
|
||||||
|
*
|
||||||
|
* Der Stornobeleg ist ein eigenständiger Beleg vom Typ {@link InvoiceType#CANCELLATION}
|
||||||
|
* mit eigener (neuer) Rechnungsnummer. Die Originalrechnung wird auf
|
||||||
|
* {@link InvoiceStatus#CANCELLED} gesetzt; Original und Storno sind über
|
||||||
|
* {@code originalInvoiceId} bzw. {@code cancellationInvoiceId} verlinkt.
|
||||||
|
*
|
||||||
|
* @param originalId ID der zu stornierenden Rechnung
|
||||||
|
* @param cancellationNumber neue, fortlaufende Rechnungsnummer für den Stornobeleg
|
||||||
|
* @param cancellationDate Belegdatum des Stornos
|
||||||
|
* @param pdfData generiertes PDF des Stornobelegs
|
||||||
|
* @param reason nachvollziehbarer Grund (R-36)
|
||||||
|
*/
|
||||||
|
public CustomerInvoice cancel(String originalId, String cancellationNumber, LocalDate cancellationDate,
|
||||||
|
byte[] pdfData, String reason) {
|
||||||
|
CustomerInvoice original = requireInvoice(originalId);
|
||||||
|
if (original.getType() != InvoiceType.INVOICE) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nur reguläre Rechnungen können storniert werden. Belegtyp: " + original.getType());
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException("Diese Rechnung ist bereits storniert.");
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Ein Entwurf wird nicht storniert, sondern gelöscht oder bearbeitet.");
|
||||||
|
}
|
||||||
|
if (cancellationNumber == null || cancellationNumber.isBlank()) {
|
||||||
|
throw new InvoiceLifecycleException("Stornobeleg benötigt eine fortlaufende Belegnummer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerInvoice cancellation = new CustomerInvoice();
|
||||||
|
cancellation.setType(InvoiceType.CANCELLATION);
|
||||||
|
cancellation.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
cancellation.setInvoiceNumber(cancellationNumber);
|
||||||
|
cancellation.setInvoiceDate(cancellationDate != null ? cancellationDate : LocalDate.now());
|
||||||
|
cancellation.setIssuedAt(LocalDateTime.now());
|
||||||
|
cancellation.setUserId(original.getUserId());
|
||||||
|
cancellation.setJobId(original.getJobId());
|
||||||
|
cancellation.setOriginalInvoiceId(original.getId());
|
||||||
|
cancellation.setOriginalInvoiceNumber(original.getInvoiceNumber());
|
||||||
|
cancellation.setOriginalInvoiceDate(original.getInvoiceDate());
|
||||||
|
|
||||||
|
// Empfänger-/Sender-Daten übernehmen für vollständige Pflichtangaben
|
||||||
|
copyParties(original, cancellation);
|
||||||
|
cancellation.setItems(original.getItems());
|
||||||
|
|
||||||
|
// Beträge negieren – Storno bucht den Originalbetrag aus
|
||||||
|
cancellation.setNetAmount(negate(original.getNetAmount()));
|
||||||
|
cancellation.setVatRate(original.getVatRate());
|
||||||
|
cancellation.setVatAmount(negate(original.getVatAmount()));
|
||||||
|
cancellation.setTotalAmount(negate(original.getTotalAmount()));
|
||||||
|
|
||||||
|
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
|
||||||
|
cancellation.setPdfData(pdfData);
|
||||||
|
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
||||||
|
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
|
||||||
|
cancellation.addAuditEntry(issuedEntry);
|
||||||
|
|
||||||
|
ensureInvoiceNumberUnique(cancellation);
|
||||||
|
CustomerInvoice savedCancellation = invoiceRepository.save(cancellation);
|
||||||
|
numberAuditService.markUsed(savedCancellation);
|
||||||
|
|
||||||
|
// Original markieren und verlinken
|
||||||
|
original.setStatus(InvoiceStatus.CANCELLED);
|
||||||
|
original.setCancelledAt(LocalDateTime.now());
|
||||||
|
original.setCancellationInvoiceId(savedCancellation.getId());
|
||||||
|
// Wenn die Originalrechnung bereits (teil-)bezahlt war, entsteht ein Erstattungsanspruch (R-26)
|
||||||
|
BigDecimal paid = original.getPaidAmount() != null ? original.getPaidAmount() : BigDecimal.ZERO;
|
||||||
|
original.setPaymentStatus(computePaymentStatus(original, paid));
|
||||||
|
InvoiceAuditEntry cancelEntry = audit(InvoiceAuditAction.CANCELLED, reason);
|
||||||
|
cancelEntry.setResultingInvoiceId(savedCancellation.getId());
|
||||||
|
cancelEntry.setResultingInvoiceNumber(savedCancellation.getInvoiceNumber());
|
||||||
|
original.addAuditEntry(cancelEntry);
|
||||||
|
invoiceRepository.save(original);
|
||||||
|
|
||||||
|
log.info("Rechnung {} storniert durch Beleg {}.", original.getInvoiceNumber(),
|
||||||
|
savedCancellation.getInvoiceNumber());
|
||||||
|
return savedCancellation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Berichtigungsbeleg zu einer bereits ausgestellten Rechnung (R-12 bis R-16).
|
||||||
|
*
|
||||||
|
* Eine Berichtigung adressiert formale Fehler (Adresse, Leistungsdatum, Pflichtangabe).
|
||||||
|
* Sie ersetzt die Originalrechnung nicht, sondern verweist auf sie. Originalrechnung
|
||||||
|
* wechselt in den Status {@link InvoiceStatus#CORRECTED} und hält eine Referenz auf den
|
||||||
|
* Berichtigungsbeleg.
|
||||||
|
*
|
||||||
|
* @param originalId ID der zu berichtigenden Rechnung
|
||||||
|
* @param correctionNumber fortlaufende Belegnummer für den Berichtigungsbeleg
|
||||||
|
* @param correctionDate Belegdatum
|
||||||
|
* @param pdfData generiertes PDF des Berichtigungsbelegs
|
||||||
|
* @param correctedFields Beschreibung der ergänzten/korrigierten Angaben (R-14)
|
||||||
|
* @param reason Grund der Berichtigung (R-36)
|
||||||
|
*/
|
||||||
|
public CustomerInvoice correct(String originalId, String correctionNumber, LocalDate correctionDate,
|
||||||
|
byte[] pdfData, String correctedFields, String reason) {
|
||||||
|
CustomerInvoice original = requireInvoice(originalId);
|
||||||
|
if (original.getType() != InvoiceType.INVOICE) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nur reguläre Rechnungen können berichtigt werden. Belegtyp: " + original.getType());
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Ein Entwurf wird nicht berichtigt, sondern direkt bearbeitet.");
|
||||||
|
}
|
||||||
|
if (original.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Eine bereits stornierte Rechnung kann nicht berichtigt werden. Erstellen Sie eine neue Rechnung.");
|
||||||
|
}
|
||||||
|
if (correctionNumber == null || correctionNumber.isBlank()) {
|
||||||
|
throw new InvoiceLifecycleException("Berichtigungsbeleg benötigt eine fortlaufende Belegnummer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomerInvoice correction = new CustomerInvoice();
|
||||||
|
correction.setType(InvoiceType.CORRECTION);
|
||||||
|
correction.setStatus(InvoiceStatus.ISSUED);
|
||||||
|
correction.setInvoiceNumber(correctionNumber);
|
||||||
|
correction.setInvoiceDate(correctionDate != null ? correctionDate : LocalDate.now());
|
||||||
|
correction.setIssuedAt(LocalDateTime.now());
|
||||||
|
correction.setUserId(original.getUserId());
|
||||||
|
correction.setJobId(original.getJobId());
|
||||||
|
correction.setOriginalInvoiceId(original.getId());
|
||||||
|
correction.setOriginalInvoiceNumber(original.getInvoiceNumber());
|
||||||
|
correction.setOriginalInvoiceDate(original.getInvoiceDate());
|
||||||
|
|
||||||
|
copyParties(original, correction);
|
||||||
|
correction.setItems(original.getItems());
|
||||||
|
correction.setNetAmount(original.getNetAmount());
|
||||||
|
correction.setVatRate(original.getVatRate());
|
||||||
|
correction.setVatAmount(original.getVatAmount());
|
||||||
|
correction.setTotalAmount(original.getTotalAmount());
|
||||||
|
|
||||||
|
String descriptionPrefix = "Berichtigung zu Rechnung " + original.getInvoiceNumber();
|
||||||
|
correction.setDescription(
|
||||||
|
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
|
||||||
|
: descriptionPrefix + " — " + correctedFields);
|
||||||
|
correction.setPdfData(pdfData);
|
||||||
|
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
|
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
||||||
|
issuedEntry.setResultingInvoiceNumber(correctionNumber);
|
||||||
|
correction.addAuditEntry(issuedEntry);
|
||||||
|
|
||||||
|
ensureInvoiceNumberUnique(correction);
|
||||||
|
CustomerInvoice savedCorrection = invoiceRepository.save(correction);
|
||||||
|
numberAuditService.markUsed(savedCorrection);
|
||||||
|
|
||||||
|
original.setStatus(InvoiceStatus.CORRECTED);
|
||||||
|
original.setCorrectionInvoiceId(savedCorrection.getId());
|
||||||
|
InvoiceAuditEntry correctEntry = audit(InvoiceAuditAction.CORRECTED, reason);
|
||||||
|
correctEntry.setResultingInvoiceId(savedCorrection.getId());
|
||||||
|
correctEntry.setResultingInvoiceNumber(savedCorrection.getInvoiceNumber());
|
||||||
|
original.addAuditEntry(correctEntry);
|
||||||
|
invoiceRepository.save(original);
|
||||||
|
|
||||||
|
log.info("Rechnung {} berichtigt durch Beleg {}.", original.getInvoiceNumber(),
|
||||||
|
savedCorrection.getInvoiceNumber());
|
||||||
|
return savedCorrection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert eine vollständig neue Ersatzrechnung nach einem Storno (R-20 bis R-22).
|
||||||
|
* Die neue Rechnung erhält eine eigene Rechnungsnummer und referenziert die stornierte
|
||||||
|
* Originalrechnung, sodass die Verkettung Original → Storno → Ersatzrechnung erhalten bleibt.
|
||||||
|
*/
|
||||||
|
public CustomerInvoice createReplacementInvoice(String cancelledOriginalId, CustomerInvoice replacement,
|
||||||
|
String reason) {
|
||||||
|
CustomerInvoice cancelledOriginal = requireInvoice(cancelledOriginalId);
|
||||||
|
if (cancelledOriginal.getStatus() != InvoiceStatus.CANCELLED) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Eine Ersatzrechnung kann nur zu einer stornierten Rechnung erstellt werden.");
|
||||||
|
}
|
||||||
|
if (replacement == null) {
|
||||||
|
throw new IllegalArgumentException("Ersatzrechnung darf nicht null sein.");
|
||||||
|
}
|
||||||
|
replacement.setType(InvoiceType.INVOICE);
|
||||||
|
replacement.setOriginalInvoiceId(cancelledOriginal.getId());
|
||||||
|
replacement.setOriginalInvoiceNumber(cancelledOriginal.getInvoiceNumber());
|
||||||
|
replacement.setOriginalInvoiceDate(cancelledOriginal.getInvoiceDate());
|
||||||
|
|
||||||
|
CustomerInvoice savedReplacement = createAndIssue(replacement,
|
||||||
|
reason != null ? reason : "Ersatzrechnung nach Storno");
|
||||||
|
|
||||||
|
cancelledOriginal.setReplacementInvoiceId(savedReplacement.getId());
|
||||||
|
InvoiceAuditEntry replaceEntry = audit(InvoiceAuditAction.REPLACED, reason);
|
||||||
|
replaceEntry.setResultingInvoiceId(savedReplacement.getId());
|
||||||
|
replaceEntry.setResultingInvoiceNumber(savedReplacement.getInvoiceNumber());
|
||||||
|
cancelledOriginal.addAuditEntry(replaceEntry);
|
||||||
|
invoiceRepository.save(cancelledOriginal);
|
||||||
|
|
||||||
|
return savedReplacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Folgebelege (Storno, Berichtigung, Ersatzrechnung) zu einer Rechnung.
|
||||||
|
*/
|
||||||
|
public List<CustomerInvoice> findRelatedDocuments(String originalInvoiceId) {
|
||||||
|
if (originalInvoiceId == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return invoiceRepository.findByOriginalInvoiceId(originalInvoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfasst eine Zahlung zur Rechnung (R-23 bis R-26).
|
||||||
|
*
|
||||||
|
* Eine Zahlung wird ausschließlich erfasst – die Rechnungsdaten selbst werden nicht
|
||||||
|
* verändert (R-25). Der Zahlungsstatus ergibt sich aus dem Verhältnis Zahlung zu
|
||||||
|
* Bruttobetrag der Rechnung.
|
||||||
|
*
|
||||||
|
* @param invoiceId Rechnung, auf die gebucht wird
|
||||||
|
* @param amount erfasster Zahlbetrag (kann negativ sein für Korrekturen)
|
||||||
|
* @param paymentReference frei wählbarer Referenztext (z.B. Kontoauszug, Beleg)
|
||||||
|
* @param reason erläuternder Grund für das Audit-Log
|
||||||
|
*/
|
||||||
|
public CustomerInvoice registerPayment(String invoiceId, BigDecimal amount, String paymentReference,
|
||||||
|
String reason) {
|
||||||
|
if (amount == null) {
|
||||||
|
throw new IllegalArgumentException("Zahlbetrag erforderlich.");
|
||||||
|
}
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Auf Entwürfen können keine Zahlungen erfasst werden. Bitte zuerst ausstellen.");
|
||||||
|
}
|
||||||
|
if (invoice.getType() == InvoiceType.CORRECTION) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Auf Berichtigungsbelegen werden keine Zahlungen erfasst – buchen Sie auf der Originalrechnung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal previous = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
|
||||||
|
BigDecimal newPaid = previous.add(amount);
|
||||||
|
invoice.setPaidAmount(newPaid);
|
||||||
|
invoice.setLastPaymentAt(LocalDateTime.now());
|
||||||
|
invoice.setPaymentStatus(computePaymentStatus(invoice, newPaid));
|
||||||
|
|
||||||
|
StringBuilder logReason = new StringBuilder();
|
||||||
|
logReason.append("Zahlung erfasst: ").append(amount.toPlainString());
|
||||||
|
if (paymentReference != null && !paymentReference.isBlank()) {
|
||||||
|
logReason.append(" (Referenz: ").append(paymentReference).append(")");
|
||||||
|
}
|
||||||
|
if (reason != null && !reason.isBlank()) {
|
||||||
|
logReason.append(" – ").append(reason);
|
||||||
|
}
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.PAYMENT_RECORDED, logReason.toString()));
|
||||||
|
|
||||||
|
return invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den noch offenen Betrag einer Rechnung. Negative Werte zeigen eine
|
||||||
|
* Überzahlung bzw. einen Erstattungsanspruch (R-26).
|
||||||
|
*/
|
||||||
|
public BigDecimal computeOutstandingAmount(CustomerInvoice invoice) {
|
||||||
|
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
|
||||||
|
BigDecimal paid = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
|
||||||
|
return total.subtract(paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PaymentStatus computePaymentStatus(CustomerInvoice invoice, BigDecimal paid) {
|
||||||
|
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
|
||||||
|
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
|
||||||
|
return paid.signum() == 0 ? PaymentStatus.UNPAID : PaymentStatus.REFUND_DUE;
|
||||||
|
}
|
||||||
|
int cmp = paid.compareTo(total);
|
||||||
|
if (paid.signum() == 0) {
|
||||||
|
return PaymentStatus.UNPAID;
|
||||||
|
}
|
||||||
|
if (cmp == 0) {
|
||||||
|
return PaymentStatus.PAID;
|
||||||
|
}
|
||||||
|
if (cmp < 0) {
|
||||||
|
return PaymentStatus.PARTIALLY_PAID;
|
||||||
|
}
|
||||||
|
return PaymentStatus.OVERPAID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Entwurf. Finalisierte Rechnungen dürfen nicht gelöscht werden (R-35).
|
||||||
|
*/
|
||||||
|
public void deleteDraft(String invoiceId, String reason) {
|
||||||
|
CustomerInvoice invoice = requireInvoice(invoiceId);
|
||||||
|
if (!invoice.getStatus().isMutable()) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Finalisierte Rechnungen können nicht gelöscht werden. "
|
||||||
|
+ "Bitte führen Sie stattdessen ein Storno oder eine Berichtigung durch.");
|
||||||
|
}
|
||||||
|
invoice.addAuditEntry(audit(InvoiceAuditAction.DELETED_DRAFT, reason));
|
||||||
|
log.info("Rechnungsentwurf {} wird gelöscht: {}", invoiceId, reason);
|
||||||
|
invoiceRepository.delete(invoice);
|
||||||
|
// Wenn dem Entwurf bereits eine Nummer reserviert wurde, dokumentiert das
|
||||||
|
// Verwerfen jetzt die Lücke im Nummernkreis — sonst bliebe die Reservierung
|
||||||
|
// als unerklärter „RESERVED"-Eintrag im Audit hängen.
|
||||||
|
if (invoice.getInvoiceNumber() != null && !invoice.getInvoiceNumber().isBlank()
|
||||||
|
&& invoice.getUserId() != null) {
|
||||||
|
try {
|
||||||
|
ObjectId userId = new ObjectId(invoice.getUserId());
|
||||||
|
String voidReason = (reason != null && !reason.isBlank())
|
||||||
|
? reason
|
||||||
|
: "Rechnungsentwurf gelöscht";
|
||||||
|
numberAuditService.markVoided(userId, invoice.getInvoiceNumber(), voidReason);
|
||||||
|
} catch (IllegalArgumentException | InvoiceLifecycleException ex) {
|
||||||
|
// Keine Reservierung vorhanden oder UserId nicht parsebar — Lücken-Detektor
|
||||||
|
// erfasst das später; das Löschen selbst soll nicht blockiert werden.
|
||||||
|
log.debug("VOIDED-Markierung beim Löschen des Entwurfs übersprungen: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<CustomerInvoice> findById(String invoiceId) {
|
||||||
|
return invoiceRepository.findById(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice requireInvoice(String invoiceId) {
|
||||||
|
if (invoiceId == null || invoiceId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Rechnungs-ID erforderlich.");
|
||||||
|
}
|
||||||
|
return invoiceRepository.findById(invoiceId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureInvoiceNumberUnique(CustomerInvoice invoice) {
|
||||||
|
String number = invoice.getInvoiceNumber();
|
||||||
|
if (number == null || number.isBlank()) {
|
||||||
|
throw new InvoiceLifecycleException("Eine ausgestellte Rechnung benötigt eine Rechnungsnummer.");
|
||||||
|
}
|
||||||
|
invoiceRepository.findByInvoiceNumberAndStatusNot(number, InvoiceStatus.CANCELLED).ifPresent(existing -> {
|
||||||
|
if (!existing.getId().equals(invoice.getId())) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Rechnungsnummer " + number + " wird bereits von einer aktiven Rechnung verwendet.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceAuditEntry audit(InvoiceAuditAction action, String reason) {
|
||||||
|
String userId = null;
|
||||||
|
String displayName = "system";
|
||||||
|
try {
|
||||||
|
User user = securityService.getCurrentDatabaseUser();
|
||||||
|
userId = user.getId() != null ? user.getId().toHexString() : null;
|
||||||
|
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
|
||||||
|
displayName = composed.isBlank() ? safe(user.getEmail()) : composed;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Audit funktioniert auch außerhalb einer Vaadin-Session (z.B. Migration).
|
||||||
|
}
|
||||||
|
return new InvoiceAuditEntry(action, userId, displayName, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyParties(CustomerInvoice source, CustomerInvoice target) {
|
||||||
|
target.setSenderName(source.getSenderName());
|
||||||
|
target.setSenderAddress(source.getSenderAddress());
|
||||||
|
target.setSenderPostcode(source.getSenderPostcode());
|
||||||
|
target.setSenderCity(source.getSenderCity());
|
||||||
|
target.setSenderCountry(source.getSenderCountry());
|
||||||
|
target.setSenderTaxNumber(source.getSenderTaxNumber());
|
||||||
|
target.setSenderVatId(source.getSenderVatId());
|
||||||
|
target.setSenderPhone(source.getSenderPhone());
|
||||||
|
target.setSenderEmail(source.getSenderEmail());
|
||||||
|
target.setSenderWebsite(source.getSenderWebsite());
|
||||||
|
|
||||||
|
target.setRecipientName(source.getRecipientName());
|
||||||
|
target.setRecipientCompany(source.getRecipientCompany());
|
||||||
|
target.setRecipientAddress(source.getRecipientAddress());
|
||||||
|
target.setRecipientPostcode(source.getRecipientPostcode());
|
||||||
|
target.setRecipientCity(source.getRecipientCity());
|
||||||
|
target.setRecipientCountry(source.getRecipientCountry());
|
||||||
|
target.setRecipientVatId(source.getRecipientVatId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal negate(BigDecimal value) {
|
||||||
|
return value != null ? value.negate() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet das Audit der vergebenen Rechnungsnummern. Jede Vergabe aus dem
|
||||||
|
* Counter wird als RESERVED protokolliert; dieser Service vollzieht die
|
||||||
|
* Status-Übergänge nach (USED beim Festschreiben, VOIDED beim Verwerfen)
|
||||||
|
* und liefert Lücken-Reports für die Betriebsprüfung.
|
||||||
|
*
|
||||||
|
* Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende
|
||||||
|
* Rechnungsnummer; lückenhafte Nummernkreise sind nur zulässig, wenn jede
|
||||||
|
* fehlende Nummer dokumentiert erklärt werden kann (GoBD).
|
||||||
|
*
|
||||||
|
* Fehler beim Audit-Schreiben werden bewusst nicht propagiert: Die
|
||||||
|
* fachliche Operation (Rechnung festschreiben) hat Vorrang. Verlorene
|
||||||
|
* USED-Markierungen sind über den Lücken-Report nachträglich rekonstruierbar.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoiceNumberAuditService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InvoiceNumberAuditService.class);
|
||||||
|
|
||||||
|
private final InvoiceNumberReservationRepository repository;
|
||||||
|
|
||||||
|
public InvoiceNumberAuditService(InvoiceNumberReservationRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert die Reservierung der übergebenen Rechnung als USED.
|
||||||
|
* Wird vom Lifecycle nach erfolgreichem Festschreiben aufgerufen.
|
||||||
|
*/
|
||||||
|
public void markUsed(CustomerInvoice invoice) {
|
||||||
|
if (invoice == null || invoice.getInvoiceNumber() == null || invoice.getUserId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectId userId = parseUserId(invoice.getUserId());
|
||||||
|
if (userId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Optional<InvoiceNumberReservation> existing = repository.findByUserIdAndNumber(userId,
|
||||||
|
invoice.getInvoiceNumber());
|
||||||
|
InvoiceNumberReservation reservation = existing.orElseGet(() -> bootstrapReservation(userId, invoice));
|
||||||
|
reservation.setStatus(InvoiceNumberReservationStatus.USED);
|
||||||
|
reservation.setInvoiceId(invoice.getId());
|
||||||
|
reservation.setUsedAt(Instant.now());
|
||||||
|
repository.save(reservation);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("USED-Markierung für Nummer {} (Rechnung {}) fehlgeschlagen: {}",
|
||||||
|
invoice.getInvoiceNumber(), invoice.getId(), ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert die zur übergebenen Nummer gehörende Reservierung als VOIDED.
|
||||||
|
* Pflichtfeld {@code reason} dokumentiert die Erklärung der Lücke.
|
||||||
|
*/
|
||||||
|
public void markVoided(ObjectId userId, String number, String reason) {
|
||||||
|
if (userId == null || number == null || number.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("userId und number sind Pflichtparameter.");
|
||||||
|
}
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Grund (reason) ist Pflicht beim Verwerfen einer Reservierung.");
|
||||||
|
}
|
||||||
|
InvoiceNumberReservation reservation = repository.findByUserIdAndNumber(userId, number)
|
||||||
|
.orElseThrow(() -> new InvoiceLifecycleException(
|
||||||
|
"Keine Reservierung für Nummer " + number + " gefunden."));
|
||||||
|
if (reservation.getStatus() == InvoiceNumberReservationStatus.USED) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Nummer " + number + " ist bereits einer ausgestellten Rechnung zugeordnet "
|
||||||
|
+ "und kann nicht verworfen werden.");
|
||||||
|
}
|
||||||
|
reservation.setStatus(InvoiceNumberReservationStatus.VOIDED);
|
||||||
|
reservation.setVoidReason(reason);
|
||||||
|
reservation.setVoidedAt(Instant.now());
|
||||||
|
repository.save(reservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle Reservierungen eines Nutzers in Sequenzreihenfolge.
|
||||||
|
* Basis für vollständige Audit-Reports.
|
||||||
|
*/
|
||||||
|
public List<InvoiceNumberReservation> findAll(ObjectId userId) {
|
||||||
|
return repository.findByUserIdOrderBySequenceAsc(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert nur die noch nicht verwendeten Reservierungen eines Nutzers
|
||||||
|
* (Status RESERVED oder VOIDED). Im Idealfall ist diese Liste leer oder
|
||||||
|
* enthält ausschließlich VOIDED-Einträge mit dokumentiertem Grund.
|
||||||
|
* Verbleibende RESERVED-Einträge nach abgeschlossenen Vorgängen sind
|
||||||
|
* unerklärte Lücken und sollten in der UI hervorgehoben werden.
|
||||||
|
*/
|
||||||
|
public List<InvoiceNumberReservation> findUnused(ObjectId userId) {
|
||||||
|
List<InvoiceNumberReservation> all = repository.findByUserIdOrderBySequenceAsc(userId);
|
||||||
|
return all.stream()
|
||||||
|
.filter(r -> r.getStatus() != InvoiceNumberReservationStatus.USED)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Bootstrap-Eintrag für Rechnungen, die ohne vorausgegangene
|
||||||
|
* Reservierung festgeschrieben wurden — z.B. Bestandsdaten aus der Zeit
|
||||||
|
* vor Einführung des Reservierungs-Audits oder Storno-/Korrekturbelege,
|
||||||
|
* deren Nummer extern übergeben wurde. Status wird direkt auf USED gesetzt.
|
||||||
|
*/
|
||||||
|
private InvoiceNumberReservation bootstrapReservation(ObjectId userId, CustomerInvoice invoice) {
|
||||||
|
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
|
||||||
|
reservation.setUserId(userId);
|
||||||
|
reservation.setNumber(invoice.getInvoiceNumber());
|
||||||
|
reservation.setSequence(extractSequence(invoice.getInvoiceNumber()));
|
||||||
|
reservation.setReservedAt(Instant.now());
|
||||||
|
reservation.setReservedBy("system (bootstrap)");
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort-Extraktion der numerischen Sequenz aus einer formatierten
|
||||||
|
* Rechnungsnummer (z.B. „RE-2026-000123" → 123). Liefert -1, wenn keine
|
||||||
|
* trailing-Ziffern vorhanden sind — dann ist die Nummer für die
|
||||||
|
* Lücken-Sortierung ungeeignet, aber der Audit-Eintrag bleibt erhalten.
|
||||||
|
*/
|
||||||
|
private long extractSequence(String number) {
|
||||||
|
if (number == null) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
int end = number.length();
|
||||||
|
int start = end;
|
||||||
|
while (start > 0 && Character.isDigit(number.charAt(start - 1))) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
if (start == end) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(number.substring(start, end));
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectId parseUserId(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new ObjectId(value);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
log.warn("UserId '{}' ist keine gültige ObjectId — Audit übersprungen.", value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.security.InvoiceRoles;
|
||||||
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungs-Checks für Rechnungsaktionen gemäß R-40 bis R-42.
|
||||||
|
*
|
||||||
|
* Backwards-compat: ein Nutzer mit der bestehenden {@code USER}- oder {@code ADMIN}-Rolle,
|
||||||
|
* der keine der spezialisierten Invoice-Rollen besitzt, hat weiterhin volle Berechtigung
|
||||||
|
* — andernfalls würden alle bestehenden Installationen sofort handlungsunfähig.
|
||||||
|
*
|
||||||
|
* Sobald ein Nutzer mindestens eine {@code INVOICE_*}-Rolle hat, gelten die feingranularen
|
||||||
|
* Regeln und nur die explizit zugewiesenen Aktionen sind erlaubt.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InvoicePermissionService {
|
||||||
|
|
||||||
|
private static final String ROLE_ADMIN = "ADMIN";
|
||||||
|
|
||||||
|
private final SecurityService securityService;
|
||||||
|
|
||||||
|
public InvoicePermissionService(SecurityService securityService) {
|
||||||
|
this.securityService = securityService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCreateOrIssue(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canMarkAsSent(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR, InvoiceRoles.REVIEWER, InvoiceRoles.APPROVER)
|
||||||
|
|| isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCancel(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canCorrect(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER, InvoiceRoles.REVIEWER) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canRecordPayment(User user) {
|
||||||
|
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCreate(User user) {
|
||||||
|
if (!canCreateOrIssue(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Rechnungen zu erstellen oder auszustellen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireSend(User user) {
|
||||||
|
if (!canMarkAsSent(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Rechnungen als versendet zu markieren.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCancel(User user) {
|
||||||
|
if (!canCancel(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Rechnungen zu stornieren.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCorrect(User user) {
|
||||||
|
if (!canCorrect(user)) {
|
||||||
|
throw new InvoiceLifecycleException(
|
||||||
|
"Sie haben keine Berechtigung, Berichtigungsbelege zu erstellen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requirePayment(User user) {
|
||||||
|
if (!canRecordPayment(user)) {
|
||||||
|
throw new InvoiceLifecycleException("Sie haben keine Berechtigung, Zahlungen zu erfassen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
|
||||||
|
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
|
||||||
|
*/
|
||||||
|
public boolean isOwnerOrAdmin(User user, CustomerInvoice invoice) {
|
||||||
|
if (user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isAdmin(user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (invoice == null || invoice.getUserId() == null || user.getId() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return invoice.getUserId().equals(user.getId().toHexString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasAnyInvoiceRole(User user, String... roles) {
|
||||||
|
if (user == null || user.getRoles() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Set<String> userRoles = user.getRoles();
|
||||||
|
if (userRoles.contains(ROLE_ADMIN)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String role : roles) {
|
||||||
|
if (userRoles.contains(role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code true}, wenn dem Nutzer keine spezialisierte Invoice-Rolle zugewiesen ist —
|
||||||
|
* dann gilt das alte Pauschalrecht der USER-Rolle (Backwards-Compat).
|
||||||
|
*/
|
||||||
|
private boolean isUnscoped(User user) {
|
||||||
|
if (user == null || user.getRoles() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Set<String> roles = user.getRoles();
|
||||||
|
return roles.stream().noneMatch(r -> r.startsWith("INVOICE_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAdmin(User user) {
|
||||||
|
return user != null && user.getRoles() != null && user.getRoles().contains(ROLE_ADMIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemer Lookup für die UI. */
|
||||||
|
public User currentUser() {
|
||||||
|
return securityService.getCurrentDatabaseUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.assecutor.votianlt.service;
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.User;
|
||||||
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
@@ -7,6 +9,9 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats
|
* Service für monatliche Scheduler-Aufgaben, die am letzten Tag des Monats
|
||||||
@@ -17,6 +22,61 @@ public class MonthlySchedulerService {
|
|||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class);
|
private static final Logger logger = LoggerFactory.getLogger(MonthlySchedulerService.class);
|
||||||
|
|
||||||
|
private final EmailService emailService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public MonthlySchedulerService(EmailService emailService, UserRepository userRepository) {
|
||||||
|
this.emailService = emailService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Läuft täglich um 08:00 Uhr und erinnert die Administratoren drei Tage vor
|
||||||
|
* Ultimo per E-Mail daran, die Monatsrechnungen fertigzustellen.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0 8 * * *")
|
||||||
|
public void checkAndSendInvoiceReminder() {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate lastDayOfMonth = YearMonth.from(today).atEndOfMonth();
|
||||||
|
|
||||||
|
if (!today.equals(lastDayOfMonth.minusDays(3))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Drei Tage vor Ultimo ({}): Sende Rechnungs-Erinnerung an die Administratoren.", lastDayOfMonth);
|
||||||
|
sendInvoiceReminderToAdmins(lastDayOfMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendInvoiceReminderToAdmins(LocalDate lastDayOfMonth) {
|
||||||
|
List<User> admins = userRepository.findAll().stream()
|
||||||
|
.filter(user -> user.getRoles() != null && user.getRoles().contains("ADMIN"))
|
||||||
|
.filter(user -> user.getEmail() != null && !user.getEmail().isBlank()).toList();
|
||||||
|
|
||||||
|
if (admins.isEmpty()) {
|
||||||
|
logger.warn("Keine Administratoren mit E-Mail-Adresse gefunden, Rechnungs-Erinnerung entfällt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String monthLabel = YearMonth.from(lastDayOfMonth)
|
||||||
|
.format(DateTimeFormatter.ofPattern("LLLL yyyy", Locale.GERMAN));
|
||||||
|
String dueDateLabel = lastDayOfMonth.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"));
|
||||||
|
String subject = "Erinnerung: Rechnungen für " + monthLabel + " erstellen";
|
||||||
|
String body = "Hallo,\n\n" //
|
||||||
|
+ "in drei Tagen ist Ultimo (" + dueDateLabel + ").\n"
|
||||||
|
+ "Bitte die Monatsrechnungen für " + monthLabel
|
||||||
|
+ " fertigstellen: Seite \"Rechnungen erstellen\" im Admin-Bereich (/admin-create-invoices).\n\n"
|
||||||
|
+ "Diese Nachricht wurde automatisch von VotianLT versendet.";
|
||||||
|
|
||||||
|
for (User admin : admins) {
|
||||||
|
try {
|
||||||
|
emailService.sendSimpleEmail(admin.getEmail(), subject, body);
|
||||||
|
logger.info("Rechnungs-Erinnerung an {} gesendet.", admin.getEmail());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Rechnungs-Erinnerung an {} fehlgeschlagen: {}", admin.getEmail(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag
|
* Scheduler, der täglich um 23:00 Uhr läuft und prüft, ob heute der letzte Tag
|
||||||
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the external PDF Tool (ZUGFeRD converter). Sends an invoice PDF
|
||||||
|
* together with the structured invoice data to {@code POST /api/invoices/sign}
|
||||||
|
* and receives a ZIP file containing the signed e-invoice (PDF/A-3 with
|
||||||
|
* embedded EN16931 XML), the factur-x.xml and the Mustang validation report.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class PdfToolService {
|
||||||
|
|
||||||
|
/** Response header of the PDF Tool with the Mustang validation result. */
|
||||||
|
private static final String VALIDATION_HEADER = "X-Zugferd-Valid";
|
||||||
|
|
||||||
|
private final RestClient restClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public PdfToolService(@Value("${app.pdftool.base-url:http://localhost:8083}") String baseUrl,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||||
|
requestFactory.setConnectTimeout(5000);
|
||||||
|
// PDF/A conversion and veraPDF validation can take a while
|
||||||
|
requestFactory.setReadTimeout(120000);
|
||||||
|
this.restClient = RestClient.builder().baseUrl(baseUrl).requestFactory(requestFactory).build();
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Party (sender or recipient) of the invoice metadata. */
|
||||||
|
public record Party(String name, String street, String zip, String city, String countryCode, String vatId,
|
||||||
|
String email, String phone) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One invoice line of the invoice metadata. */
|
||||||
|
public record LineItem(String description, BigDecimal quantity, BigDecimal unitPriceNet, BigDecimal vatPercent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoice metadata as expected by the PDF Tool (EN16931 fields). */
|
||||||
|
public record InvoiceMetadata(String invoiceNumber, LocalDate issueDate, LocalDate deliveryDate, LocalDate dueDate,
|
||||||
|
String currency, String paymentTerms, String buyerReference, String iban, String bic, Party sender,
|
||||||
|
Party recipient, List<LineItem> items) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a sign call: the ZIP, its file name and the validation flag. */
|
||||||
|
public record SignedInvoice(byte[] zip, String fileName, boolean valid) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given invoice PDF into a ZUGFeRD e-invoice. Returns the ZIP
|
||||||
|
* provided by the PDF Tool; throws with the tool's error message if the
|
||||||
|
* conversion is rejected (e.g. incomplete metadata). A failed Mustang
|
||||||
|
* validation is reported by the tool as HTTP 422 but still carries the ZIP
|
||||||
|
* (including the validation report) in the body; it is returned as a
|
||||||
|
* {@link SignedInvoice} with {@code valid=false} instead of throwing.
|
||||||
|
*/
|
||||||
|
public SignedInvoice signInvoice(byte[] pdfBytes, InvoiceMetadata metadata) throws Exception {
|
||||||
|
String metadataJson = objectMapper.writeValueAsString(metadata);
|
||||||
|
|
||||||
|
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||||
|
body.part("file", new ByteArrayResource(pdfBytes) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return metadata.invoiceNumber() + ".pdf";
|
||||||
|
}
|
||||||
|
}, MediaType.APPLICATION_PDF);
|
||||||
|
body.part("metadata", metadataJson, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ResponseEntity<byte[]> response = restClient.post().uri("/api/invoices/sign")
|
||||||
|
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
return toSignedInvoice(response.getBody(), response.getHeaders(), metadata);
|
||||||
|
} catch (RestClientResponseException ex) {
|
||||||
|
if (ex.getStatusCode().value() == 422 && ex.getResponseBodyAsByteArray().length > 0) {
|
||||||
|
return toSignedInvoice(ex.getResponseBodyAsByteArray(), ex.getResponseHeaders(), metadata);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(extractErrorMessage(ex), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the result from the response ZIP, file name and validation header. */
|
||||||
|
private SignedInvoice toSignedInvoice(byte[] zip, org.springframework.http.HttpHeaders headers,
|
||||||
|
InvoiceMetadata metadata) {
|
||||||
|
if (zip == null || zip.length == 0) {
|
||||||
|
throw new IllegalStateException("PDF Tool returned an empty response");
|
||||||
|
}
|
||||||
|
String fileName = headers != null ? headers.getContentDisposition().getFilename() : null;
|
||||||
|
if (fileName == null || fileName.isBlank()) {
|
||||||
|
fileName = metadata.invoiceNumber() + "-zugferd.zip";
|
||||||
|
}
|
||||||
|
boolean valid = headers != null && Boolean.parseBoolean(headers.getFirst(VALIDATION_HEADER));
|
||||||
|
return new SignedInvoice(zip, fileName, valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PDF Tool reports errors as {@code {"error": "..."}} with HTTP 400. */
|
||||||
|
private String extractErrorMessage(RestClientResponseException ex) {
|
||||||
|
try {
|
||||||
|
JsonNode node = objectMapper.readTree(ex.getResponseBodyAsString());
|
||||||
|
if (node.hasNonNull("error")) {
|
||||||
|
return node.get("error").asText();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// fall through to the generic message
|
||||||
|
}
|
||||||
|
return ex.getStatusText() + " (" + ex.getStatusCode().value() + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.SystemCompanyProfile;
|
||||||
|
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
|
||||||
|
import de.assecutor.votianlt.repository.SystemCompanyProfileRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the company data of the system operator. As long as no profile has
|
||||||
|
* been saved, the defaults from SystemInvoiceData are used.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SystemCompanyProfileService {
|
||||||
|
|
||||||
|
private final SystemCompanyProfileRepository repository;
|
||||||
|
|
||||||
|
public SystemCompanyProfileService(SystemCompanyProfileRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored profile, or a new unsaved instance prefilled with the
|
||||||
|
* SystemInvoiceData defaults for first-time editing.
|
||||||
|
*/
|
||||||
|
public SystemCompanyProfile getOrCreateProfile() {
|
||||||
|
Optional<SystemCompanyProfile> stored = repository.findAll().stream().findFirst();
|
||||||
|
if (stored.isPresent()) {
|
||||||
|
return stored.get();
|
||||||
|
}
|
||||||
|
SystemInvoiceData defaults = new SystemInvoiceData();
|
||||||
|
SystemCompanyProfile profile = new SystemCompanyProfile();
|
||||||
|
profile.setCompanyName(defaults.getCompanyName());
|
||||||
|
profile.setCompanySubtitle(defaults.getCompanySubtitle());
|
||||||
|
profile.setCompanyStreet(defaults.getCompanyStreet());
|
||||||
|
profile.setCompanyCity(defaults.getCompanyCity());
|
||||||
|
profile.setCompanyPhone(defaults.getCompanyPhone());
|
||||||
|
profile.setCompanyFax(defaults.getCompanyFax());
|
||||||
|
profile.setCompanyEmail(defaults.getCompanyEmail());
|
||||||
|
profile.setCompanyWebsite(defaults.getCompanyWebsite());
|
||||||
|
profile.setSenderLine(defaults.getSenderLine());
|
||||||
|
profile.setPaymentTerms(defaults.getPaymentTerms());
|
||||||
|
profile.setFooterText(defaults.getFooterText());
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SystemCompanyProfile save(SystemCompanyProfile profile) {
|
||||||
|
return repository.save(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a SystemInvoiceData whose issuer fields come from the stored
|
||||||
|
* profile. Once a profile exists it is the source of truth for all issuer
|
||||||
|
* fields; without one the hardcoded defaults remain.
|
||||||
|
*/
|
||||||
|
public SystemInvoiceData createSystemInvoiceData() {
|
||||||
|
SystemInvoiceData data = new SystemInvoiceData();
|
||||||
|
repository.findAll().stream().findFirst().ifPresent(profile -> {
|
||||||
|
data.setCompanyName(nvl(profile.getCompanyName()));
|
||||||
|
data.setCompanySubtitle(nvl(profile.getCompanySubtitle()));
|
||||||
|
data.setCompanyStreet(nvl(profile.getCompanyStreet()));
|
||||||
|
data.setCompanyCity(nvl(profile.getCompanyCity()));
|
||||||
|
data.setCompanyPhone(nvl(profile.getCompanyPhone()));
|
||||||
|
data.setCompanyFax(nvl(profile.getCompanyFax()));
|
||||||
|
data.setCompanyEmail(nvl(profile.getCompanyEmail()));
|
||||||
|
data.setCompanyWebsite(nvl(profile.getCompanyWebsite()));
|
||||||
|
data.setSenderLine(nvl(profile.getSenderLine()));
|
||||||
|
data.setPaymentTerms(nvl(profile.getPaymentTerms()));
|
||||||
|
data.setFooterText(nvl(profile.getFooterText()));
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nvl(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ import java.util.List;
|
|||||||
@Service
|
@Service
|
||||||
public class SystemInvoiceService {
|
public class SystemInvoiceService {
|
||||||
|
|
||||||
|
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||||
|
|
||||||
|
public SystemInvoiceService(SystemCompanyProfileService systemCompanyProfileService) {
|
||||||
|
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||||
|
}
|
||||||
|
|
||||||
public byte[] generateInvoicePdfFromHtml() throws Exception {
|
public byte[] generateInvoicePdfFromHtml() throws Exception {
|
||||||
// Read the HTML template
|
// Read the HTML template
|
||||||
String htmlContent = readHtmlTemplate();
|
String htmlContent = readHtmlTemplate();
|
||||||
@@ -35,7 +41,7 @@ public class SystemInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SystemInvoiceData createSampleInvoiceData() {
|
public SystemInvoiceData createSampleInvoiceData() {
|
||||||
SystemInvoiceData data = new SystemInvoiceData();
|
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
|
||||||
|
|
||||||
// Set sample data
|
// Set sample data
|
||||||
data.setInvoiceNumber("HHA-2021-007");
|
data.setInvoiceNumber("HHA-2021-007");
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt Nutzer-Rechnungen (Rechnungen der Systemnutzer an ihre eigenen
|
||||||
|
* Kunden) über das externe PDF Tool in signierte ZUGFeRD-E-Rechnungen um —
|
||||||
|
* dasselbe Verfahren, das {@code AdminCreateInvoicesView} für die
|
||||||
|
* System-Rechnungen verwendet. Die EN16931-Metadaten werden vollständig aus
|
||||||
|
* der persistierten {@link CustomerInvoice} aufgebaut (Absender, Empfänger,
|
||||||
|
* Positionen), die beim Erstellen der Rechnung in {@code CreateInvoiceView}
|
||||||
|
* befüllt werden.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UserInvoiceZugferdService {
|
||||||
|
|
||||||
|
private final PdfToolService pdfToolService;
|
||||||
|
|
||||||
|
public UserInvoiceZugferdService(PdfToolService pdfToolService) {
|
||||||
|
this.pdfToolService = pdfToolService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signiert die Rechnung über das PDF Tool. Wirft eine
|
||||||
|
* {@link IllegalStateException} mit verständlicher Meldung, wenn der
|
||||||
|
* Rechnung Daten fehlen, die für eine EN16931-E-Rechnung zwingend sind.
|
||||||
|
*/
|
||||||
|
public PdfToolService.SignedInvoice sign(CustomerInvoice invoice) throws Exception {
|
||||||
|
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
|
||||||
|
throw new IllegalStateException("Für diese Rechnung ist kein PDF gespeichert.");
|
||||||
|
}
|
||||||
|
return pdfToolService.signInvoice(invoice.getPdfData(), buildMetadata(invoice));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PdfToolService.InvoiceMetadata buildMetadata(CustomerInvoice invoice) {
|
||||||
|
if (isBlank(invoice.getSenderName()) || isBlank(invoice.getRecipientName())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Absender- oder Empfängerdaten fehlen — diese Rechnung wurde vor der E-Rechnungs-Umstellung "
|
||||||
|
+ "erstellt und kann nicht als E-Rechnung aufbereitet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfToolService.Party sender = new PdfToolService.Party(invoice.getSenderName(),
|
||||||
|
safe(invoice.getSenderAddress()), safe(invoice.getSenderPostcode()), safe(invoice.getSenderCity()),
|
||||||
|
firstNonBlank(invoice.getSenderCountry(), "DE"), blankToNull(invoice.getSenderVatId()),
|
||||||
|
safe(invoice.getSenderEmail()), safe(invoice.getSenderPhone()));
|
||||||
|
|
||||||
|
PdfToolService.Party recipient = new PdfToolService.Party(
|
||||||
|
firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName()),
|
||||||
|
safe(invoice.getRecipientAddress()), safe(invoice.getRecipientPostcode()),
|
||||||
|
safe(invoice.getRecipientCity()), firstNonBlank(invoice.getRecipientCountry(), "DE"),
|
||||||
|
blankToNull(invoice.getRecipientVatId()), safe(invoice.getRecipientEmail()), null);
|
||||||
|
|
||||||
|
List<PdfToolService.LineItem> items = buildLineItems(invoice);
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Die Rechnung enthält keine Positionen mit Betrag.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDate issueDate = invoice.getInvoiceDate() != null ? invoice.getInvoiceDate() : LocalDate.now();
|
||||||
|
LocalDate deliveryDate = invoice.getDeliveryDate() != null ? invoice.getDeliveryDate() : issueDate;
|
||||||
|
LocalDate dueDate = invoice.getPaymentDueDate() != null ? invoice.getPaymentDueDate() : issueDate.plusDays(14);
|
||||||
|
|
||||||
|
return new PdfToolService.InvoiceMetadata(invoice.getInvoiceNumber(), issueDate, deliveryDate, dueDate, "EUR",
|
||||||
|
safe(invoice.getPaymentTerms()), null, blankToNull(invoice.getIban()), blankToNull(invoice.getBic()),
|
||||||
|
sender, recipient, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PdfToolService.LineItem> buildLineItems(CustomerInvoice invoice) {
|
||||||
|
BigDecimal vatPercent = invoice.getVatRate() != null
|
||||||
|
? invoice.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
|
||||||
|
: new BigDecimal("19");
|
||||||
|
|
||||||
|
List<PdfToolService.LineItem> items = new ArrayList<>();
|
||||||
|
if (invoice.getItems() != null) {
|
||||||
|
for (CustomerInvoiceItem item : invoice.getItems()) {
|
||||||
|
if (item == null || item.getQuantity() == null || item.getUnitPrice() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BigDecimal itemVatPercent = item.getVatRate() != null
|
||||||
|
? item.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
|
||||||
|
: vatPercent;
|
||||||
|
items.add(new PdfToolService.LineItem(safe(item.getDescription()), item.getQuantity(),
|
||||||
|
item.getUnitPrice(), itemVatPercent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ältere Rechnungen ohne Positionsliste: eine Sammelposition aus dem
|
||||||
|
// Rechnungs-Netto, damit sie trotzdem als E-Rechnung versendbar bleiben.
|
||||||
|
if (items.isEmpty() && invoice.getNetAmount() != null && invoice.getNetAmount().signum() > 0) {
|
||||||
|
items.add(new PdfToolService.LineItem(firstNonBlank(invoice.getDescription(), "Leistung gemäß Rechnung"),
|
||||||
|
BigDecimal.ONE, invoice.getNetAmount(), vatPercent));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safe(String value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
|
|||||||
# Enable browser launch in development
|
# Enable browser launch in development
|
||||||
vaadin.launch-browser=true
|
vaadin.launch-browser=true
|
||||||
|
|
||||||
|
# Sessions über Neustarts persistieren (eingeloggt bleiben nach DevTools-Restart).
|
||||||
|
# Hinweis: Nach inkompatiblen Code-Änderungen kann beim Start eine harmlose
|
||||||
|
# SESSIONS.ser-Deserialisierungs-Warnung im Log erscheinen.
|
||||||
|
server.servlet.session.persistent=true
|
||||||
|
|
||||||
# Development logging levels
|
# Development logging levels
|
||||||
logging.level.de.assecutor.votianlt=DEBUG
|
logging.level.de.assecutor.votianlt=DEBUG
|
||||||
logging.level.root=INFO
|
logging.level.root=INFO
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
|
|||||||
# Disable browser launch in production
|
# Disable browser launch in production
|
||||||
vaadin.launch-browser=false
|
vaadin.launch-browser=false
|
||||||
|
|
||||||
|
# Sessions über Neustarts persistieren: Nutzer bleiben nach einem
|
||||||
|
# Deployment/Neustart eingeloggt. Nach inkompatiblen Code-Änderungen verwirft
|
||||||
|
# Tomcat die alte SESSIONS.ser mit einer harmlosen Log-Warnung.
|
||||||
|
server.servlet.session.persistent=true
|
||||||
|
|
||||||
# 2FA Configuration - Enabled for production
|
# 2FA Configuration - Enabled for production
|
||||||
app.security.two-factor.enabled=true
|
app.security.two-factor.enabled=true
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
server.port=${PORT:8082}
|
server.port=${PORT:8082}
|
||||||
server.address=0.0.0.0
|
server.address=0.0.0.0
|
||||||
|
# Sessions standardmäßig nicht über Neustarts persistieren; nur im dev-Profil aktiviert
|
||||||
|
# (siehe application-dev.properties)
|
||||||
|
server.servlet.session.persistent=false
|
||||||
|
|
||||||
# Default active profile
|
# Default active profile
|
||||||
spring.profiles.active=dev
|
spring.profiles.active=dev
|
||||||
@@ -72,22 +75,15 @@ app.version=@project.version@
|
|||||||
# Google Maps API Key
|
# Google Maps API Key
|
||||||
app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
|
app.google.maps.api-key=AIzaSyDnbitL06iLp3elmj-WtPudCykX9xvXcVE
|
||||||
|
|
||||||
# ===========================================
|
# PDF Tool (ZUGFeRD-E-Rechnungs-Konverter), Basis-URL des externen Service
|
||||||
# LLM Configuration (LM Studio)
|
app.pdftool.base-url=${PDFTOOL_BASE_URL:http://localhost:8083}
|
||||||
# ===========================================
|
|
||||||
app.ai.lmstudio.base-url=${LMSTUDIO_URL}
|
|
||||||
app.ai.lmstudio.model=${LMSTUDIO_MODEL}
|
|
||||||
app.ai.lmstudio.htaccess-username=${LMSTUDIO_HTACCESS_USERNAME}
|
|
||||||
app.ai.lmstudio.htaccess-password=${LMSTUDIO_HTACCESS_PASSWORD}
|
|
||||||
|
|
||||||
# Spring AI OpenAI properties (Pflicht für Auto-Configuration, werden vom LlmRestClient überschrieben)
|
# ===========================================
|
||||||
spring.ai.openai.base-url=${LMSTUDIO_URL}
|
# LLM Configuration (Anthropic Claude)
|
||||||
spring.ai.openai.api-key=not-used
|
# ===========================================
|
||||||
spring.ai.openai.chat.options.model=${LMSTUDIO_MODEL}
|
# API-Key kann per Umgebungsvariable ANTHROPIC_API_KEY ueberschrieben werden
|
||||||
spring.ai.openai.chat.options.temperature=0.7
|
app.ai.anthropic.api-key=${ANTHROPIC_API_KEY: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}
|
||||||
spring.ai.openai.chat.options.stream=false
|
app.ai.anthropic.model=${ANTHROPIC_MODEL:claude-opus-4-8}
|
||||||
spring.ai.openai.connect-timeout=10s
|
|
||||||
spring.ai.openai.read-timeout=120s
|
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# MCP Server Configuration
|
# MCP Server Configuration
|
||||||
|
|||||||
@@ -241,15 +241,15 @@ page.title.dashboard=VotianLT - Dashboard
|
|||||||
page.title.appuser.create=Neuen App-Nutzer anlegen
|
page.title.appuser.create=Neuen App-Nutzer anlegen
|
||||||
page.title.messages=Nachrichten
|
page.title.messages=Nachrichten
|
||||||
page.title.register=Bei VotianLT registrieren
|
page.title.register=Bei VotianLT registrieren
|
||||||
page.title.customers=Kunden
|
page.title.customers=Adressbuch
|
||||||
page.title.customer.edit=Kunde bearbeiten
|
page.title.customer.edit=Adresse bearbeiten
|
||||||
page.title.verwaltung=Verwaltung
|
page.title.verwaltung=Verwaltung
|
||||||
page.title.company.create=Neue Firma anlegen
|
page.title.company.create=Neue Firma anlegen
|
||||||
page.title.imprint=Impressum
|
page.title.imprint=Impressum
|
||||||
page.title.profile.edit=Profil bearbeiten
|
page.title.profile.edit=Profil bearbeiten
|
||||||
page.title.admin.dashboard=Admin Dashboard
|
page.title.admin.dashboard=Admin Dashboard
|
||||||
page.title.invoice.create=Rechnung erstellen
|
page.title.invoice.create=Rechnung erstellen
|
||||||
page.title.customer.create=Neuen Kunden anlegen
|
page.title.customer.create=Neue Adresse anlegen
|
||||||
page.title.login=Bei VotianLT anmelden
|
page.title.login=Bei VotianLT anmelden
|
||||||
page.title.jobs=Aufträge
|
page.title.jobs=Aufträge
|
||||||
page.title.appuser.edit=App-Nutzer bearbeiten
|
page.title.appuser.edit=App-Nutzer bearbeiten
|
||||||
@@ -328,9 +328,9 @@ editappuser.dialog.delete.text=Möchten Sie diesen App-Nutzer wirklich löschen?
|
|||||||
editappuser.dialog.delete.confirm=Löschen
|
editappuser.dialog.delete.confirm=Löschen
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Kunden
|
customers.title=Adressbuch
|
||||||
customers.button.add=Neuen Kunden hinzufügen
|
customers.button.add=Neue Adresse anlegen
|
||||||
customers.hint.click=Klicken Sie auf einen Kunden, um Details zu sehen
|
customers.hint.click=Klicken Sie auf einen Adresseintrag, um Details zu sehen
|
||||||
customers.column.company=Firma
|
customers.column.company=Firma
|
||||||
customers.column.name=Name
|
customers.column.name=Name
|
||||||
customers.column.email=E-Mail
|
customers.column.email=E-Mail
|
||||||
@@ -339,20 +339,21 @@ customers.column.street=Straße
|
|||||||
customers.column.city=Ort
|
customers.column.city=Ort
|
||||||
|
|
||||||
# Edit Customer
|
# Edit Customer
|
||||||
editcustomer.title=Kunde bearbeiten
|
editcustomer.title=Adresse bearbeiten
|
||||||
editcustomer.notification.notfound=Kunde nicht gefunden
|
editcustomer.notification.notfound=Adresse nicht gefunden
|
||||||
editcustomer.notification.invalid.id=Ungültige Kunden-ID
|
editcustomer.notification.invalid.id=Ungültige Adress-ID
|
||||||
editcustomer.notification.saved=Kunde erfolgreich gespeichert
|
editcustomer.notification.saved=Adresse erfolgreich gespeichert
|
||||||
editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
|
editcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
|
||||||
editcustomer.notification.deleted=Kunde erfolgreich gelöscht
|
editcustomer.notification.deleted=Adresse erfolgreich gelöscht
|
||||||
editcustomer.dialog.delete.text=Möchten Sie diesen Kunden wirklich löschen?
|
editcustomer.dialog.delete.text=Möchten Sie diese Adresse wirklich löschen?
|
||||||
editcustomer.dialog.delete.confirm=Löschen
|
editcustomer.dialog.delete.confirm=Löschen
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Neuen Kunden anlegen
|
addcustomer.title=Neue Adresse anlegen
|
||||||
addcustomer.button.submit=Kunden anlegen
|
addcustomer.button.submit=Adresse anlegen
|
||||||
addcustomer.notification.validation=Bitte füllen Sie alle Pflichtfelder aus
|
addcustomer.notification.validation=Bitte füllen Sie alle Pflichtfelder aus
|
||||||
addcustomer.notification.success=Kunde erfolgreich angelegt
|
addcustomer.notification.success=Kunde erfolgreich angelegt
|
||||||
|
addcustomer.notification.duplicate=Ein identischer Adressbucheintrag existiert bereits
|
||||||
addcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
|
addcustomer.notification.check=Bitte überprüfen Sie Ihre Eingaben
|
||||||
addcustomer.notification.error=Fehler: {0}
|
addcustomer.notification.error=Fehler: {0}
|
||||||
addcustomer.validation.required=Dieses Feld ist erforderlich
|
addcustomer.validation.required=Dieses Feld ist erforderlich
|
||||||
@@ -429,9 +430,9 @@ messages.sender.unknown=Unbekannter Absender
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Neuen Auftrag anlegen
|
addjob.title=Neuen Auftrag anlegen
|
||||||
addjob.customer.label=Kunde
|
addjob.customer.label=Auftraggeber
|
||||||
addjob.customer.placeholder=Kunde auswählen
|
addjob.customer.placeholder=Auftraggeber auswählen
|
||||||
addjob.customer.unnamed=Unbenannter Kunde
|
addjob.customer.unnamed=Unbenannter Auftraggeber
|
||||||
addjob.button.clearfields=Felder leeren
|
addjob.button.clearfields=Felder leeren
|
||||||
addjob.button.submit=Auftrag anlegen
|
addjob.button.submit=Auftrag anlegen
|
||||||
addjob.address.salutation=Anrede
|
addjob.address.salutation=Anrede
|
||||||
@@ -440,6 +441,10 @@ addjob.salutation.mr=Herr
|
|||||||
addjob.salutation.ms=Frau
|
addjob.salutation.ms=Frau
|
||||||
addjob.salutation.other=Divers
|
addjob.salutation.other=Divers
|
||||||
addjob.address.company.placeholder=Firma eingeben
|
addjob.address.company.placeholder=Firma eingeben
|
||||||
|
addjob.address.pickup.label=Abholadresse
|
||||||
|
addjob.address.pickup.placeholder=Abholadresse auswählen oder eingeben
|
||||||
|
addjob.address.delivery.label=Lieferadresse
|
||||||
|
addjob.address.delivery.placeholder=Lieferadresse auswählen oder eingeben
|
||||||
addjob.address.street.placeholder=Straße eingeben
|
addjob.address.street.placeholder=Straße eingeben
|
||||||
addjob.address.housenumber=Hausnummer
|
addjob.address.housenumber=Hausnummer
|
||||||
addjob.address.addition.placeholder=Adresszusatz
|
addjob.address.addition.placeholder=Adresszusatz
|
||||||
@@ -460,6 +465,8 @@ addjob.station.max.reached=Maximale Anzahl von 25 Lieferstationen erreicht
|
|||||||
addjob.station.unused=Nicht genutzt
|
addjob.station.unused=Nicht genutzt
|
||||||
addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt.
|
addjob.appointment.delivery.info=Liefertermine werden direkt in den Lieferstationen festgelegt.
|
||||||
addjob.tab.addresses=Auftraggeber & Adressen
|
addjob.tab.addresses=Auftraggeber & Adressen
|
||||||
|
addjob.tab.pickup.address=Auftraggeber & Abholadresse
|
||||||
|
addjob.tab.delivery.address=Lieferadresse
|
||||||
addjob.tab.appointments=Termine & Verarbeitung
|
addjob.tab.appointments=Termine & Verarbeitung
|
||||||
addjob.tab.cargo=Fracht
|
addjob.tab.cargo=Fracht
|
||||||
addjob.tab.tasks=Aufgaben
|
addjob.tab.tasks=Aufgaben
|
||||||
@@ -621,6 +628,9 @@ jobsummary.dialog.manualcomplete.reason.required=Bitte geben Sie eine Begründun
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Abbrechen
|
jobsummary.dialog.manualcomplete.cancel=Abbrechen
|
||||||
jobsummary.dialog.manualcomplete.confirm=Akzeptiert
|
jobsummary.dialog.manualcomplete.confirm=Akzeptiert
|
||||||
jobsummary.history.manualcomplete.reason=Manuell beendet
|
jobsummary.history.manualcomplete.reason=Manuell beendet
|
||||||
|
jobmanualcomplete.route.hours=Stunden
|
||||||
|
jobmanualcomplete.route.minutes=Minuten
|
||||||
|
jobmanualcomplete.route.manual.hint=Keine Routendaten vorhanden – bitte Entfernung und Dauer manuell erfassen.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Aufträge
|
jobs.title=Aufträge
|
||||||
@@ -697,6 +707,72 @@ invoices.column.amount=Betrag
|
|||||||
invoices.column.description=Beschreibung
|
invoices.column.description=Beschreibung
|
||||||
invoices.empty=Es wurden noch keine Rechnungen erstellt.
|
invoices.empty=Es wurden noch keine Rechnungen erstellt.
|
||||||
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert.
|
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert.
|
||||||
|
invoices.notification.pdf.error=Die PDF-Anzeige ist fehlgeschlagen: {0}
|
||||||
|
invoices.column.status=Status
|
||||||
|
invoices.column.type=Typ
|
||||||
|
invoices.column.actions=Aktionen
|
||||||
|
invoices.disclaimer=Hinweis: Die rechtliche Aufbewahrungspflicht liegt beim Aussteller. Eine bereits ausgestellte Rechnung wird nicht überschrieben — Korrekturen erfolgen über Berichtigung oder Stornorechnung mit eindeutigem Bezug.
|
||||||
|
invoices.status.draft=Entwurf
|
||||||
|
invoices.status.issued=Ausgestellt
|
||||||
|
invoices.status.sent=Versendet
|
||||||
|
invoices.status.cancelled=Storniert
|
||||||
|
invoices.status.corrected=Berichtigt
|
||||||
|
invoices.type.invoice=Rechnung
|
||||||
|
invoices.type.cancellation=Stornorechnung
|
||||||
|
invoices.type.correction=Berichtigung
|
||||||
|
invoices.action.view=PDF anzeigen
|
||||||
|
invoices.action.history=Historie
|
||||||
|
invoices.action.marksent=Versendet markieren
|
||||||
|
invoices.action.correct=Berichtigen
|
||||||
|
invoices.action.cancel=Stornieren
|
||||||
|
invoices.notification.sent=Rechnung als versendet markiert.
|
||||||
|
invoices.notification.cancelled=Stornobeleg {0} erstellt.
|
||||||
|
invoices.notification.corrected=Berichtigungsbeleg {0} erstellt.
|
||||||
|
invoices.notification.error=Aktion fehlgeschlagen: {0}
|
||||||
|
invoices.cancel.title=Rechnung {0} stornieren
|
||||||
|
invoices.cancel.hint=Die Originalrechnung bleibt unverändert sichtbar. Es wird ein eigenständiger Stornobeleg mit eigener Belegnummer erstellt, der die Originalrechnung eindeutig referenziert.
|
||||||
|
invoices.cancel.reason=Grund der Stornierung
|
||||||
|
invoices.cancel.reason.required=Bitte einen Grund angeben.
|
||||||
|
invoices.cancel.confirm=Stornobeleg erstellen
|
||||||
|
invoices.correct.title=Rechnung {0} berichtigen
|
||||||
|
invoices.correct.hint=Eine Berichtigung dient ausschließlich der Korrektur formaler Fehler (z.B. Adresse, Leistungsdatum). Die Originalrechnung bleibt sichtbar; der Berichtigungsbeleg verweist eindeutig auf sie.
|
||||||
|
invoices.correct.fields=Berichtigte Angaben
|
||||||
|
invoices.correct.fields.helper=Beschreiben Sie, welche Angaben ergänzt oder ersetzt werden.
|
||||||
|
invoices.correct.fields.required=Bitte die berichtigten Angaben beschreiben.
|
||||||
|
invoices.correct.reason=Grund der Berichtigung
|
||||||
|
invoices.correct.confirm=Berichtigung erstellen
|
||||||
|
invoices.history.title=Historie zu Rechnung {0}
|
||||||
|
invoices.history.log=Änderungsprotokoll
|
||||||
|
invoices.history.empty=Keine Einträge vorhanden.
|
||||||
|
invoices.history.original=Originalrechnung
|
||||||
|
invoices.history.cancellation=Stornobeleg
|
||||||
|
invoices.history.correction=Berichtigungsbeleg
|
||||||
|
invoices.history.replacement=Ersatzrechnung
|
||||||
|
invoices.audit.action.created_draft=Entwurf erstellt
|
||||||
|
invoices.audit.action.updated_draft=Entwurf geändert
|
||||||
|
invoices.audit.action.issued=Ausgestellt
|
||||||
|
invoices.audit.action.sent=Versendet
|
||||||
|
invoices.audit.action.cancelled=Storniert
|
||||||
|
invoices.audit.action.corrected=Berichtigt
|
||||||
|
invoices.audit.action.replaced=Ersetzt durch neue Rechnung
|
||||||
|
invoices.audit.action.deleted_draft=Entwurf gelöscht
|
||||||
|
invoices.audit.action.payment_recorded=Zahlung erfasst
|
||||||
|
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
|
||||||
|
invoices.payment.unpaid=Offen
|
||||||
|
invoices.payment.partially_paid=Teilzahlung
|
||||||
|
invoices.payment.paid=Bezahlt
|
||||||
|
invoices.payment.overpaid=Überzahlung
|
||||||
|
invoices.payment.refund_due=Erstattung offen
|
||||||
|
invoices.action.payment=Zahlung erfassen
|
||||||
|
invoices.action.export=Exportieren
|
||||||
|
invoices.payment.title=Zahlung zu Rechnung {0}
|
||||||
|
invoices.payment.hint=Offener Restbetrag: {0}. Negative Beträge können zur Korrektur erfasst werden.
|
||||||
|
invoices.payment.amount=Zahlbetrag
|
||||||
|
invoices.payment.amount.required=Bitte einen Betrag (ungleich 0) angeben.
|
||||||
|
invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
|
||||||
|
invoices.payment.reason=Anmerkung
|
||||||
|
invoices.payment.confirm=Zahlung erfassen
|
||||||
|
invoices.notification.payment=Zahlung erfasst.
|
||||||
|
|
||||||
# My Invoices
|
# My Invoices
|
||||||
myinvoices.title=Rechnungen
|
myinvoices.title=Rechnungen
|
||||||
@@ -976,8 +1052,123 @@ misc.retry=Erneut versuchen
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Preistabelle
|
adminpricetable.title=Preistabelle
|
||||||
adminpricetable.field.monthly=Monatliches Basispaket
|
adminpricetable.field.monthly=Monatliches Basispaket
|
||||||
adminpricetable.field.applicense=App-Nutzungslizenz
|
adminpricetable.field.appuserfee=Gebühr pro App-Nutzer
|
||||||
adminpricetable.field.revenue=Umsatzbeteiligung
|
adminpricetable.field.appuserfee.helper=Wird je eingerichtetem App-Nutzer monatlich berechnet
|
||||||
adminpricetable.notification.saved=Preistabelle wurde gespeichert
|
adminpricetable.notification.saved=Preistabelle wurde gespeichert
|
||||||
adminpricetable.notification.save.error=Fehler beim Speichern: {0}
|
adminpricetable.notification.save.error=Fehler beim Speichern: {0}
|
||||||
adminpricetable.notification.load.error=Fehler beim Laden: {0}
|
adminpricetable.notification.load.error=Fehler beim Laden: {0}
|
||||||
|
|
||||||
|
# Rechnungsgenerator - System-Template (Rechnungen an Nutzer)
|
||||||
|
invoicegenerator.issuer.header=Rechnungssteller
|
||||||
|
invoicegenerator.issuer.website=Webseite
|
||||||
|
invoicegenerator.issuer.senderline=Absenderzeile
|
||||||
|
invoicegenerator.issuer.paymentterms=Zahlungsbedingungen
|
||||||
|
invoicegenerator.issuer.footer=Fußzeile
|
||||||
|
invoicegenerator.issuer.invoicedate=Rechnungsdatum
|
||||||
|
invoicegenerator.recipient.header=Empfänger (Nutzer)
|
||||||
|
invoicegenerator.recipient.company=Nutzer-Firma
|
||||||
|
invoicegenerator.recipient.name=Nutzer-Name
|
||||||
|
invoicegenerator.recipient.street=Nutzer-Straße
|
||||||
|
invoicegenerator.recipient.city=Nutzer-PLZ/Ort
|
||||||
|
invoicegenerator.recipient.email=Nutzer-E-Mail
|
||||||
|
invoicegenerator.template.saved=Template gespeichert
|
||||||
|
invoicegenerator.template.save.error=Fehler beim Speichern des Templates: {0}
|
||||||
|
invoicegenerator.button.clear=Leeren
|
||||||
|
invoicegenerator.notification.cleared=Canvas geleert
|
||||||
|
invoicegenerator.notification.canvas.error=Canvas-Daten konnten nicht gelesen werden
|
||||||
|
invoicegenerator.notification.preview.error=Fehler bei der Vorschau: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=PDF-Vorschau
|
||||||
|
|
||||||
|
# Admin Dashboard - Kundenübersicht
|
||||||
|
admindashboard.section.customers=Kunden ({0})
|
||||||
|
admindashboard.customers.column.number=Kundennummer
|
||||||
|
admindashboard.customers.column.owner=Nutzer
|
||||||
|
admindashboard.customers.empty=Keine Kunden vorhanden
|
||||||
|
invoicegenerator.positions.header=Positionen (Preistabelle)
|
||||||
|
invoicegenerator.positions.list=Positionen auflisten
|
||||||
|
button.undo=Rückgängig
|
||||||
|
|
||||||
|
# Admin-Profil (Firmendaten)
|
||||||
|
page.title.admin.profile=Firmendaten bearbeiten
|
||||||
|
adminprofile.title=Firmendaten
|
||||||
|
adminprofile.field.companyname=Firmenname
|
||||||
|
adminprofile.field.companysubtitle=Firmenzusatz
|
||||||
|
adminprofile.field.street=Straße und Hausnummer
|
||||||
|
adminprofile.field.city=PLZ und Ort
|
||||||
|
adminprofile.field.phone=Telefon
|
||||||
|
adminprofile.field.fax=Fax
|
||||||
|
adminprofile.field.email=E-Mail
|
||||||
|
adminprofile.field.website=Webseite
|
||||||
|
adminprofile.field.senderline=Absenderzeile
|
||||||
|
adminprofile.field.senderline.helper=Einzeilige Absenderangabe über der Empfängeradresse auf Rechnungen
|
||||||
|
adminprofile.field.paymentterms=Zahlungsbedingungen
|
||||||
|
adminprofile.field.footer=Fußzeile
|
||||||
|
adminprofile.field.footer.helper=Geschäftsführer, Steuernummer, Bankverbindung – jede Zeile erscheint als eigene Zeile auf der Rechnung
|
||||||
|
adminprofile.notification.saved=Firmendaten gespeichert
|
||||||
|
adminprofile.notification.save.error=Fehler beim Speichern: {0}
|
||||||
|
adminprofile.notification.load.error=Fehler beim Laden: {0}
|
||||||
|
|
||||||
|
# Rechnungen erstellen (Admin)
|
||||||
|
page.title.admin.createinvoices=Rechnungen erstellen
|
||||||
|
admincreateinvoices.title=Rechnungen erstellen
|
||||||
|
admincreateinvoices.hint=Rechnungen werden zum Monatsende (Ultimo) fällig. Beginnt ein Kunde im laufenden Monat, werden nur die verbleibenden Tage bis Ultimo berechnet.
|
||||||
|
admincreateinvoices.column.customer=Kunde
|
||||||
|
admincreateinvoices.column.discount=Rabatt
|
||||||
|
admincreateinvoices.discount.position=Rabatt ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Rabatt erfassen
|
||||||
|
admincreateinvoices.discount.dialog.percent=Rabatt in %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Grund für den Rabatt
|
||||||
|
admincreateinvoices.discount.dialog.apply=Übernehmen
|
||||||
|
admincreateinvoices.column.start=Kunde seit
|
||||||
|
admincreateinvoices.column.days=Abgerechnete Tage
|
||||||
|
admincreateinvoices.column.due=Fällig am
|
||||||
|
admincreateinvoices.column.net=Nettobetrag
|
||||||
|
admincreateinvoices.days.full=Voller Monat
|
||||||
|
admincreateinvoices.days.partial={0} von {1} Tagen
|
||||||
|
admincreateinvoices.prorata.suffix=(anteilig {0}/{1} Tage)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} App-Nutzer)
|
||||||
|
admincreateinvoices.button.create=Rechnung erstellen
|
||||||
|
admincreateinvoices.notification.notemplate=Kein Rechnungstemplate vorhanden. Bitte zuerst im Rechnungsgenerator ein Template speichern.
|
||||||
|
admincreateinvoices.notification.error=Fehler beim Erstellen der Rechnung: {0}
|
||||||
|
admincreateinvoices.button.createselected=Rechnungen erstellen ({0})
|
||||||
|
admincreateinvoices.field.month=Abrechnungsmonat
|
||||||
|
admincreateinvoices.filter.label=Anzeigen
|
||||||
|
admincreateinvoices.filter.all=Alle Rechnungen
|
||||||
|
admincreateinvoices.filter.open=Nur offene
|
||||||
|
admincreateinvoices.filter.sent=Nur gesendete
|
||||||
|
admincreateinvoices.dialog.title=Rechnungen prüfen
|
||||||
|
admincreateinvoices.dialog.position=Rechnung {0} von {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Zurück
|
||||||
|
admincreateinvoices.dialog.button.next=Weiter
|
||||||
|
admincreateinvoices.dialog.button.delete=Aus Liste entfernen
|
||||||
|
admincreateinvoices.dialog.button.send=Rechnungen senden
|
||||||
|
admincreateinvoices.pdf.button.send=Senden
|
||||||
|
admincreateinvoices.dialog.notification.removed=Rechnung aus der Liste entfernt
|
||||||
|
admincreateinvoices.mail.subject=Ihre Rechnung {0}
|
||||||
|
admincreateinvoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} für den Abrechnungszeitraum {1}.\n\nMit freundlichen Grüßen\n{2}
|
||||||
|
admincreateinvoices.notification.sent={0} Rechnung(en) per E-Mail versendet
|
||||||
|
admincreateinvoices.notification.sendfailed={0} Rechnung(en) konnten nicht versendet werden
|
||||||
|
admincreateinvoices.button.zugferd=E-Rechnung (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=E-Rechnung {0} erstellt
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Fehler beim Erstellen der E-Rechnung: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=Keine abrechenbaren Positionen für die E-Rechnung vorhanden
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=E-Rechnung {0} konnte nicht erstellt werden, es wurden keine Rechnungen versendet: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Validierung fehlgeschlagen für: {0}. Es wurden keine Rechnungen versendet.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Fehler beim Erstellen der Sammel-ZIP-Datei: {0}
|
||||||
|
|
||||||
|
# E-Rechnung (ZUGFeRD) für Nutzer-Rechnungen an ihre Kunden
|
||||||
|
invoices.action.zugferd=E-Rechnung (ZIP)
|
||||||
|
invoices.action.sendinvoice=Per E-Mail senden
|
||||||
|
invoices.notification.zugferd.created=E-Rechnung {0} erstellt
|
||||||
|
invoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
|
||||||
|
invoices.notification.zugferd.invalidsend=Versand abgebrochen: Die Validierung der E-Rechnung {0} ist fehlgeschlagen.
|
||||||
|
invoices.notification.zugferd.error=Fehler bei der E-Rechnung: {0}
|
||||||
|
invoices.notification.email.missing=Für den Rechnungsempfänger ist keine E-Mail-Adresse hinterlegt.
|
||||||
|
invoices.notification.emailsent=Rechnung {0} wurde als E-Rechnung an {1} versendet.
|
||||||
|
invoices.mail.subject=Ihre Rechnung {0}
|
||||||
|
invoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} als E-Rechnung (ZIP mit PDF und Rechnungsdaten).\n\nMit freundlichen Grüßen\n{1}
|
||||||
|
profile.billing.ustid=USt-IdNr.
|
||||||
|
profile.billing.taxnumber=Steuernummer
|
||||||
|
profile.billing.bankname=Bank
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ page.title.dashboard=VotianLT - T\u00f6\u00f6laud
|
|||||||
page.title.appuser.create=Uue \u00e4pikasutaja loomine
|
page.title.appuser.create=Uue \u00e4pikasutaja loomine
|
||||||
page.title.messages=S\u00f5numid
|
page.title.messages=S\u00f5numid
|
||||||
page.title.register=VotianLT-sse registreerimine
|
page.title.register=VotianLT-sse registreerimine
|
||||||
page.title.customers=Kliendid
|
page.title.customers=Aadressiraamat
|
||||||
page.title.customer.edit=Kliendi muutmine
|
page.title.customer.edit=Kliendi muutmine
|
||||||
page.title.verwaltung=Haldus
|
page.title.verwaltung=Haldus
|
||||||
page.title.company.create=Uue ettev\u00f5tte loomine
|
page.title.company.create=Uue ettev\u00f5tte loomine
|
||||||
@@ -221,7 +221,7 @@ page.title.imprint=Impressum
|
|||||||
page.title.profile.edit=Profiili muutmine
|
page.title.profile.edit=Profiili muutmine
|
||||||
page.title.admin.dashboard=Administraatori t\u00f6\u00f6laud
|
page.title.admin.dashboard=Administraatori t\u00f6\u00f6laud
|
||||||
page.title.invoice.create=Arve koostamine
|
page.title.invoice.create=Arve koostamine
|
||||||
page.title.customer.create=Uue kliendi loomine
|
page.title.customer.create=Uue aadressi loomine
|
||||||
page.title.login=VotianLT-sse sisselogimine
|
page.title.login=VotianLT-sse sisselogimine
|
||||||
page.title.jobs=Tellimused
|
page.title.jobs=Tellimused
|
||||||
page.title.appuser.edit=\u00c4pikasutaja muutmine
|
page.title.appuser.edit=\u00c4pikasutaja muutmine
|
||||||
@@ -292,9 +292,9 @@ editappuser.notification.password.enter=Palun sisestage uus parool
|
|||||||
editappuser.notification.deleted=\u00c4pikasutaja edukalt kustutatud
|
editappuser.notification.deleted=\u00c4pikasutaja edukalt kustutatud
|
||||||
editappuser.dialog.delete.text=Kas soovite selle \u00e4pikasutaja t\u00f5esti kustutada?
|
editappuser.dialog.delete.text=Kas soovite selle \u00e4pikasutaja t\u00f5esti kustutada?
|
||||||
editappuser.dialog.delete.confirm=Kustuta
|
editappuser.dialog.delete.confirm=Kustuta
|
||||||
customers.title=Kliendid
|
customers.title=Aadressiraamat
|
||||||
customers.button.add=Lisa uus klient
|
customers.button.add=Lisa uus aadress
|
||||||
customers.hint.click=Kl\u00f5psake kliendil, et n\u00e4ha \u00fcksikasju
|
customers.hint.click=Kl\u00f5psake aadressikirjel, et n\u00e4ha \u00fcksikasju
|
||||||
customers.column.company=Ettev\u00f5te
|
customers.column.company=Ettev\u00f5te
|
||||||
customers.column.name=Nimi
|
customers.column.name=Nimi
|
||||||
customers.column.email=E-post
|
customers.column.email=E-post
|
||||||
@@ -309,10 +309,11 @@ editcustomer.notification.check=Palun kontrollige oma sisestusi
|
|||||||
editcustomer.notification.deleted=Klient edukalt kustutatud
|
editcustomer.notification.deleted=Klient edukalt kustutatud
|
||||||
editcustomer.dialog.delete.text=Kas soovite selle kliendi t\u00f5esti kustutada?
|
editcustomer.dialog.delete.text=Kas soovite selle kliendi t\u00f5esti kustutada?
|
||||||
editcustomer.dialog.delete.confirm=Kustuta
|
editcustomer.dialog.delete.confirm=Kustuta
|
||||||
addcustomer.title=Uue kliendi loomine
|
addcustomer.title=Uue aadressi loomine
|
||||||
addcustomer.button.submit=Loo klient
|
addcustomer.button.submit=Loo aadress
|
||||||
addcustomer.notification.validation=Palun t\u00e4itke k\u00f5ik kohustuslikud v\u00e4ljad
|
addcustomer.notification.validation=Palun t\u00e4itke k\u00f5ik kohustuslikud v\u00e4ljad
|
||||||
addcustomer.notification.success=Klient edukalt loodud
|
addcustomer.notification.success=Klient edukalt loodud
|
||||||
|
addcustomer.notification.duplicate=Identne aadressiraamatu kirje on juba olemas
|
||||||
addcustomer.notification.check=Palun kontrollige oma sisestusi
|
addcustomer.notification.check=Palun kontrollige oma sisestusi
|
||||||
addcustomer.notification.error=Viga: {0}
|
addcustomer.notification.error=Viga: {0}
|
||||||
addcustomer.validation.required=See v\u00e4li on kohustuslik
|
addcustomer.validation.required=See v\u00e4li on kohustuslik
|
||||||
@@ -377,9 +378,9 @@ messages.preview.image=Pilt
|
|||||||
messages.preview.empty=Eelvaade puudub
|
messages.preview.empty=Eelvaade puudub
|
||||||
messages.sender.unknown=Tundmatu saatja
|
messages.sender.unknown=Tundmatu saatja
|
||||||
addjob.title=Uue tellimuse loomine
|
addjob.title=Uue tellimuse loomine
|
||||||
addjob.customer.label=Klient
|
addjob.customer.label=Tellija
|
||||||
addjob.customer.placeholder=Valige klient
|
addjob.customer.placeholder=Vali tellija
|
||||||
addjob.customer.unnamed=Nimetu klient
|
addjob.customer.unnamed=Nimetu tellija
|
||||||
addjob.button.clearfields=T\u00fchjenda v\u00e4ljad
|
addjob.button.clearfields=T\u00fchjenda v\u00e4ljad
|
||||||
addjob.button.submit=Loo tellimus
|
addjob.button.submit=Loo tellimus
|
||||||
addjob.address.salutation=P\u00f6\u00f6rdumine
|
addjob.address.salutation=P\u00f6\u00f6rdumine
|
||||||
@@ -388,6 +389,10 @@ addjob.salutation.mr=Hr
|
|||||||
addjob.salutation.ms=Pr
|
addjob.salutation.ms=Pr
|
||||||
addjob.salutation.other=Muu
|
addjob.salutation.other=Muu
|
||||||
addjob.address.company.placeholder=Sisestage ettev\u00f5te
|
addjob.address.company.placeholder=Sisestage ettev\u00f5te
|
||||||
|
addjob.address.pickup.label=Pealekorje aadress
|
||||||
|
addjob.address.pickup.placeholder=Vali v\u00f5i sisesta pealekorje aadress
|
||||||
|
addjob.address.delivery.label=Kohaletoimetamise aadress
|
||||||
|
addjob.address.delivery.placeholder=Vali v\u00f5i sisesta kohaletoimetamise aadress
|
||||||
addjob.address.street.placeholder=Sisestage t\u00e4nav
|
addjob.address.street.placeholder=Sisestage t\u00e4nav
|
||||||
addjob.address.housenumber=Majanumber
|
addjob.address.housenumber=Majanumber
|
||||||
addjob.address.addition.placeholder=Aadressi t\u00e4iend
|
addjob.address.addition.placeholder=Aadressi t\u00e4iend
|
||||||
@@ -408,6 +413,8 @@ addjob.station.max.reached=Maksimaalne arv 25 kohaletoimetamise jaama on saavuta
|
|||||||
addjob.station.unused=Kasutamata
|
addjob.station.unused=Kasutamata
|
||||||
addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades.
|
addjob.appointment.delivery.info=Kohaletoimetamise ajad m\u00e4\u00e4ratakse otse kohaletoimetamise jaamades.
|
||||||
addjob.tab.addresses=Tellija ja aadressid
|
addjob.tab.addresses=Tellija ja aadressid
|
||||||
|
addjob.tab.pickup.address=Tellija ja pealekorje aadress
|
||||||
|
addjob.tab.delivery.address=Kohaletoimetamise aadress
|
||||||
addjob.tab.appointments=Ajad ja t\u00f6\u00f6tlemine
|
addjob.tab.appointments=Ajad ja t\u00f6\u00f6tlemine
|
||||||
addjob.tab.cargo=Veosed
|
addjob.tab.cargo=Veosed
|
||||||
addjob.tab.tasks=\u00dclesanded
|
addjob.tab.tasks=\u00dclesanded
|
||||||
@@ -567,6 +574,9 @@ jobsummary.dialog.manualcomplete.reason.required=Palun sisestage põhjendus
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Tühista
|
jobsummary.dialog.manualcomplete.cancel=Tühista
|
||||||
jobsummary.dialog.manualcomplete.confirm=Nõustu
|
jobsummary.dialog.manualcomplete.confirm=Nõustu
|
||||||
jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud
|
jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud
|
||||||
|
jobmanualcomplete.route.hours=Tunnid
|
||||||
|
jobmanualcomplete.route.minutes=Minutid
|
||||||
|
jobmanualcomplete.route.manual.hint=Marsruudiandmed puuduvad – palun sisestage vahemaa ja kestus käsitsi.
|
||||||
jobs.title=Tellimused
|
jobs.title=Tellimused
|
||||||
jobs.filter.search=Otsi
|
jobs.filter.search=Otsi
|
||||||
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...
|
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...
|
||||||
@@ -872,8 +882,123 @@ misc.error=Ilmnes viga
|
|||||||
misc.retry=Proovi uuesti
|
misc.retry=Proovi uuesti
|
||||||
adminpricetable.title=Hinnatabel
|
adminpricetable.title=Hinnatabel
|
||||||
adminpricetable.field.monthly=Igakuine p\u00f5hipakett
|
adminpricetable.field.monthly=Igakuine p\u00f5hipakett
|
||||||
adminpricetable.field.applicense=\u00c4pi kasutuslitsents
|
adminpricetable.field.appuserfee=Tasu äpi kasutaja kohta
|
||||||
adminpricetable.field.revenue=K\u00e4ibest osalus
|
adminpricetable.field.appuserfee.helper=Arvestatakse igakuiselt iga seadistatud äpi kasutaja kohta
|
||||||
adminpricetable.notification.saved=Hinnatabel on salvestatud
|
adminpricetable.notification.saved=Hinnatabel on salvestatud
|
||||||
adminpricetable.notification.save.error=Salvestamisel ilmnes viga: {0}
|
adminpricetable.notification.save.error=Salvestamisel ilmnes viga: {0}
|
||||||
adminpricetable.notification.load.error=Laadimisel ilmnes viga: {0}
|
adminpricetable.notification.load.error=Laadimisel ilmnes viga: {0}
|
||||||
|
|
||||||
|
# Arvegeneraator - süsteemi mall (arved kasutajatele)
|
||||||
|
invoicegenerator.issuer.header=Arve väljastaja
|
||||||
|
invoicegenerator.issuer.website=Veebileht
|
||||||
|
invoicegenerator.issuer.senderline=Saatja rida
|
||||||
|
invoicegenerator.issuer.paymentterms=Maksetingimused
|
||||||
|
invoicegenerator.issuer.footer=Jalus
|
||||||
|
invoicegenerator.issuer.invoicedate=Arve kuupäev
|
||||||
|
invoicegenerator.recipient.header=Saaja (kasutaja)
|
||||||
|
invoicegenerator.recipient.company=Kasutaja firma
|
||||||
|
invoicegenerator.recipient.name=Kasutaja nimi
|
||||||
|
invoicegenerator.recipient.street=Kasutaja tänav
|
||||||
|
invoicegenerator.recipient.city=Kasutaja linn
|
||||||
|
invoicegenerator.recipient.email=Kasutaja e-post
|
||||||
|
invoicegenerator.template.saved=Mall salvestatud
|
||||||
|
invoicegenerator.template.save.error=Viga malli salvestamisel: {0}
|
||||||
|
invoicegenerator.button.clear=Tühjenda
|
||||||
|
invoicegenerator.notification.cleared=Lõuend tühjendatud
|
||||||
|
invoicegenerator.notification.canvas.error=Lõuendi andmeid ei õnnestunud lugeda
|
||||||
|
invoicegenerator.notification.preview.error=Eelvaate viga: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=PDF-i eelvaade
|
||||||
|
|
||||||
|
# Admin Dashboard - Klientide ülevaade
|
||||||
|
admindashboard.section.customers=Kliendid ({0})
|
||||||
|
admindashboard.customers.column.number=Kliendinumber
|
||||||
|
admindashboard.customers.column.owner=Kasutaja
|
||||||
|
admindashboard.customers.empty=Kliente pole
|
||||||
|
invoicegenerator.positions.header=Positsioonid (hinnatabel)
|
||||||
|
invoicegenerator.positions.list=Positsioonide loend
|
||||||
|
button.undo=Võta tagasi
|
||||||
|
|
||||||
|
# Admini profiil (ettevõtte andmed)
|
||||||
|
page.title.admin.profile=Ettevõtte andmete muutmine
|
||||||
|
adminprofile.title=Ettevõtte andmed
|
||||||
|
adminprofile.field.companyname=Ettevõtte nimi
|
||||||
|
adminprofile.field.companysubtitle=Ettevõtte lisand
|
||||||
|
adminprofile.field.street=Tänav ja majanumber
|
||||||
|
adminprofile.field.city=Sihtnumber ja linn
|
||||||
|
adminprofile.field.phone=Telefon
|
||||||
|
adminprofile.field.fax=Faks
|
||||||
|
adminprofile.field.email=E-post
|
||||||
|
adminprofile.field.website=Veebileht
|
||||||
|
adminprofile.field.senderline=Saatja rida
|
||||||
|
adminprofile.field.senderline.helper=Üherealine saatja teave arve saaja aadressi kohal
|
||||||
|
adminprofile.field.paymentterms=Maksetingimused
|
||||||
|
adminprofile.field.footer=Jalus
|
||||||
|
adminprofile.field.footer.helper=Tegevjuhid, maksunumber, pangaandmed – iga rida kuvatakse arvel eraldi reana
|
||||||
|
adminprofile.notification.saved=Ettevõtte andmed salvestatud
|
||||||
|
adminprofile.notification.save.error=Viga salvestamisel: {0}
|
||||||
|
adminprofile.notification.load.error=Viga laadimisel: {0}
|
||||||
|
|
||||||
|
# Arvete koostamine (admin)
|
||||||
|
page.title.admin.createinvoices=Arvete koostamine
|
||||||
|
admincreateinvoices.title=Arvete koostamine
|
||||||
|
admincreateinvoices.hint=Arved kuuluvad tasumisele kuu lõpus. Kui klient alustab kuu keskel, arvestatakse ainult kuu lõpuni jäänud päevad.
|
||||||
|
admincreateinvoices.column.customer=Klient
|
||||||
|
admincreateinvoices.column.discount=Allahindlus
|
||||||
|
admincreateinvoices.discount.position=Allahindlus ({0}%)
|
||||||
|
admincreateinvoices.discount.dialog.title=Sisesta allahindlus
|
||||||
|
admincreateinvoices.discount.dialog.percent=Allahindlus (%)
|
||||||
|
admincreateinvoices.discount.dialog.reason=Allahindluse põhjus
|
||||||
|
admincreateinvoices.discount.dialog.apply=Rakenda
|
||||||
|
admincreateinvoices.column.start=Klient alates
|
||||||
|
admincreateinvoices.column.days=Arvestatud päevad
|
||||||
|
admincreateinvoices.column.due=Tähtaeg
|
||||||
|
admincreateinvoices.column.net=Netosumma
|
||||||
|
admincreateinvoices.days.full=Terve kuu
|
||||||
|
admincreateinvoices.days.partial={0} / {1} päeva
|
||||||
|
admincreateinvoices.prorata.suffix=(proportsionaalselt {0}/{1} päeva)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} äpi kasutajat)
|
||||||
|
admincreateinvoices.button.create=Koosta arve
|
||||||
|
admincreateinvoices.notification.notemplate=Arve mall puudub. Palun salvestage kõigepealt mall arvegeneraatoris.
|
||||||
|
admincreateinvoices.notification.error=Viga arve koostamisel: {0}
|
||||||
|
admincreateinvoices.button.createselected=Koosta arved ({0})
|
||||||
|
admincreateinvoices.field.month=Arveldusperiood
|
||||||
|
admincreateinvoices.filter.label=Kuva
|
||||||
|
admincreateinvoices.filter.all=Kõik arved
|
||||||
|
admincreateinvoices.filter.open=Ainult avatud
|
||||||
|
admincreateinvoices.filter.sent=Ainult saadetud
|
||||||
|
admincreateinvoices.dialog.title=Arvete ülevaatus
|
||||||
|
admincreateinvoices.dialog.position=Arve {0} / {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Tagasi
|
||||||
|
admincreateinvoices.dialog.button.next=Edasi
|
||||||
|
admincreateinvoices.dialog.button.delete=Eemalda loendist
|
||||||
|
admincreateinvoices.dialog.button.send=Saada arved
|
||||||
|
admincreateinvoices.pdf.button.send=Saada
|
||||||
|
admincreateinvoices.dialog.notification.removed=Arve eemaldati loendist
|
||||||
|
admincreateinvoices.mail.subject=Teie arve {0}
|
||||||
|
admincreateinvoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} arveldusperioodi {1} eest.\n\nLugupidamisega\n{2}
|
||||||
|
admincreateinvoices.notification.sent={0} arve(t) saadeti e-postiga
|
||||||
|
admincreateinvoices.notification.sendfailed={0} arve(t) ei õnnestunud saata
|
||||||
|
admincreateinvoices.button.zugferd=E-arve (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=E-arve {0} loodud
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E-arve on loodud, kuid valideerimine ebaõnnestus. Üksikasjad on ZIP-failis olevas valideerimisaruandes.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Viga e-arve loomisel: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=E-arve jaoks puuduvad arveldatavad positsioonid
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=E-arvet {0} ei õnnestunud luua, ühtegi arvet ei saadetud: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Valideerimine ebaõnnestus: {0}. Ühtegi arvet ei saadetud.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Viga koond-ZIP-faili loomisel: {0}
|
||||||
|
|
||||||
|
# E-arve (ZUGFeRD) kasutajate arvetele nende klientidele
|
||||||
|
invoices.action.zugferd=E-arve (ZIP)
|
||||||
|
invoices.action.sendinvoice=Saada e-postiga
|
||||||
|
invoices.notification.zugferd.created=E-arve {0} loodud
|
||||||
|
invoices.notification.zugferd.invalid=E-arve loodi, kuid valideerimine ebaõnnestus. Vaadake kontrolliaruannet ZIP-failis.
|
||||||
|
invoices.notification.zugferd.invalidsend=Saatmine katkestati: e-arve {0} valideerimine ebaõnnestus.
|
||||||
|
invoices.notification.zugferd.error=E-arve viga: {0}
|
||||||
|
invoices.notification.email.missing=Arve saajale pole e-posti aadressi salvestatud.
|
||||||
|
invoices.notification.emailsent=Arve {0} saadeti e-arvena aadressile {1}.
|
||||||
|
invoices.mail.subject=Teie arve {0}
|
||||||
|
invoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} e-arvena (ZIP koos PDF-i ja arveandmetega).\n\nLugupidamisega\n{1}
|
||||||
|
profile.billing.ustid=KMKR nr
|
||||||
|
profile.billing.taxnumber=Maksunumber
|
||||||
|
profile.billing.bankname=Pank
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ page.title.dashboard=VotianLT - Dashboard
|
|||||||
page.title.appuser.create=Create New App User
|
page.title.appuser.create=Create New App User
|
||||||
page.title.messages=Messages
|
page.title.messages=Messages
|
||||||
page.title.register=Register at VotianLT
|
page.title.register=Register at VotianLT
|
||||||
page.title.customers=Customers
|
page.title.customers=Address Book
|
||||||
page.title.customer.edit=Edit Customer
|
page.title.customer.edit=Edit Customer
|
||||||
page.title.verwaltung=Management
|
page.title.verwaltung=Management
|
||||||
page.title.company.create=Create New Company
|
page.title.company.create=Create New Company
|
||||||
@@ -249,7 +249,7 @@ page.title.imprint=Imprint
|
|||||||
page.title.profile.edit=Edit Profile
|
page.title.profile.edit=Edit Profile
|
||||||
page.title.admin.dashboard=Admin Dashboard
|
page.title.admin.dashboard=Admin Dashboard
|
||||||
page.title.invoice.create=Create Invoice
|
page.title.invoice.create=Create Invoice
|
||||||
page.title.customer.create=Create New Customer
|
page.title.customer.create=Create New Address
|
||||||
page.title.login=Log In to VotianLT
|
page.title.login=Log In to VotianLT
|
||||||
page.title.jobs=Jobs
|
page.title.jobs=Jobs
|
||||||
page.title.appuser.edit=Edit App User
|
page.title.appuser.edit=Edit App User
|
||||||
@@ -328,9 +328,9 @@ editappuser.dialog.delete.text=Are you sure you want to delete this app user?
|
|||||||
editappuser.dialog.delete.confirm=Delete
|
editappuser.dialog.delete.confirm=Delete
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Customers
|
customers.title=Address Book
|
||||||
customers.button.add=Add New Customer
|
customers.button.add=Add New Address
|
||||||
customers.hint.click=Click on a customer to see details
|
customers.hint.click=Click on an address entry to see details
|
||||||
customers.column.company=Company
|
customers.column.company=Company
|
||||||
customers.column.name=Name
|
customers.column.name=Name
|
||||||
customers.column.email=Email
|
customers.column.email=Email
|
||||||
@@ -349,10 +349,11 @@ editcustomer.dialog.delete.text=Are you sure you want to delete this customer?
|
|||||||
editcustomer.dialog.delete.confirm=Delete
|
editcustomer.dialog.delete.confirm=Delete
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Create New Customer
|
addcustomer.title=Create New Address
|
||||||
addcustomer.button.submit=Create Customer
|
addcustomer.button.submit=Create Address
|
||||||
addcustomer.notification.validation=Please fill in all required fields
|
addcustomer.notification.validation=Please fill in all required fields
|
||||||
addcustomer.notification.success=Customer created successfully
|
addcustomer.notification.success=Customer created successfully
|
||||||
|
addcustomer.notification.duplicate=An identical address book entry already exists
|
||||||
addcustomer.notification.check=Please check your input
|
addcustomer.notification.check=Please check your input
|
||||||
addcustomer.notification.error=Error: {0}
|
addcustomer.notification.error=Error: {0}
|
||||||
addcustomer.validation.required=This field is required
|
addcustomer.validation.required=This field is required
|
||||||
@@ -429,9 +430,9 @@ messages.sender.unknown=Unknown Sender
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Create New Job
|
addjob.title=Create New Job
|
||||||
addjob.customer.label=Customer
|
addjob.customer.label=Principal
|
||||||
addjob.customer.placeholder=Select Customer
|
addjob.customer.placeholder=Select principal
|
||||||
addjob.customer.unnamed=Unnamed Customer
|
addjob.customer.unnamed=Unnamed principal
|
||||||
addjob.button.clearfields=Clear Fields
|
addjob.button.clearfields=Clear Fields
|
||||||
addjob.button.submit=Create Job
|
addjob.button.submit=Create Job
|
||||||
addjob.address.salutation=Salutation
|
addjob.address.salutation=Salutation
|
||||||
@@ -440,6 +441,10 @@ addjob.salutation.mr=Mr
|
|||||||
addjob.salutation.ms=Ms
|
addjob.salutation.ms=Ms
|
||||||
addjob.salutation.other=Other
|
addjob.salutation.other=Other
|
||||||
addjob.address.company.placeholder=Enter company
|
addjob.address.company.placeholder=Enter company
|
||||||
|
addjob.address.pickup.label=Pickup address
|
||||||
|
addjob.address.pickup.placeholder=Select or enter pickup address
|
||||||
|
addjob.address.delivery.label=Delivery address
|
||||||
|
addjob.address.delivery.placeholder=Select or enter delivery address
|
||||||
addjob.address.street.placeholder=Enter street
|
addjob.address.street.placeholder=Enter street
|
||||||
addjob.address.housenumber=House Number
|
addjob.address.housenumber=House Number
|
||||||
addjob.address.addition.placeholder=Address suffix
|
addjob.address.addition.placeholder=Address suffix
|
||||||
@@ -460,6 +465,8 @@ addjob.station.max.reached=Maximum number of 25 delivery stations reached
|
|||||||
addjob.station.unused=Not used
|
addjob.station.unused=Not used
|
||||||
addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations.
|
addjob.appointment.delivery.info=Delivery dates are set directly in the delivery stations.
|
||||||
addjob.tab.addresses=Client & Addresses
|
addjob.tab.addresses=Client & Addresses
|
||||||
|
addjob.tab.pickup.address=Principal & Pickup Address
|
||||||
|
addjob.tab.delivery.address=Delivery Address
|
||||||
addjob.tab.appointments=Appointments & Processing
|
addjob.tab.appointments=Appointments & Processing
|
||||||
addjob.tab.cargo=Cargo
|
addjob.tab.cargo=Cargo
|
||||||
addjob.tab.tasks=Tasks
|
addjob.tab.tasks=Tasks
|
||||||
@@ -621,6 +628,9 @@ jobsummary.dialog.manualcomplete.reason.required=Please enter a reason
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Cancel
|
jobsummary.dialog.manualcomplete.cancel=Cancel
|
||||||
jobsummary.dialog.manualcomplete.confirm=Accept
|
jobsummary.dialog.manualcomplete.confirm=Accept
|
||||||
jobsummary.history.manualcomplete.reason=Manually completed
|
jobsummary.history.manualcomplete.reason=Manually completed
|
||||||
|
jobmanualcomplete.route.hours=Hours
|
||||||
|
jobmanualcomplete.route.minutes=Minutes
|
||||||
|
jobmanualcomplete.route.manual.hint=No route data available – please enter distance and duration manually.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Jobs
|
jobs.title=Jobs
|
||||||
@@ -697,6 +707,72 @@ invoices.column.amount=Amount
|
|||||||
invoices.column.description=Description
|
invoices.column.description=Description
|
||||||
invoices.empty=No invoices have been created yet.
|
invoices.empty=No invoices have been created yet.
|
||||||
invoices.notification.pdf.missing=No PDF is stored for this invoice.
|
invoices.notification.pdf.missing=No PDF is stored for this invoice.
|
||||||
|
invoices.notification.pdf.error=Failed to display PDF: {0}
|
||||||
|
invoices.column.status=Status
|
||||||
|
invoices.column.type=Type
|
||||||
|
invoices.column.actions=Actions
|
||||||
|
invoices.disclaimer=Note: Legal retention obligations remain with the issuer. An already issued invoice is never overwritten — corrections are made through a correction document or cancellation invoice that explicitly references the original.
|
||||||
|
invoices.status.draft=Draft
|
||||||
|
invoices.status.issued=Issued
|
||||||
|
invoices.status.sent=Sent
|
||||||
|
invoices.status.cancelled=Cancelled
|
||||||
|
invoices.status.corrected=Corrected
|
||||||
|
invoices.type.invoice=Invoice
|
||||||
|
invoices.type.cancellation=Cancellation invoice
|
||||||
|
invoices.type.correction=Correction document
|
||||||
|
invoices.action.view=View PDF
|
||||||
|
invoices.action.history=History
|
||||||
|
invoices.action.marksent=Mark as sent
|
||||||
|
invoices.action.correct=Correct
|
||||||
|
invoices.action.cancel=Cancel
|
||||||
|
invoices.notification.sent=Invoice marked as sent.
|
||||||
|
invoices.notification.cancelled=Cancellation document {0} created.
|
||||||
|
invoices.notification.corrected=Correction document {0} created.
|
||||||
|
invoices.notification.error=Action failed: {0}
|
||||||
|
invoices.cancel.title=Cancel invoice {0}
|
||||||
|
invoices.cancel.hint=The original invoice remains visible. A separate cancellation document with its own number will be created, explicitly referencing the original invoice.
|
||||||
|
invoices.cancel.reason=Reason for cancellation
|
||||||
|
invoices.cancel.reason.required=Please provide a reason.
|
||||||
|
invoices.cancel.confirm=Create cancellation document
|
||||||
|
invoices.correct.title=Correct invoice {0}
|
||||||
|
invoices.correct.hint=A correction document is intended for formal errors only (e.g. address, delivery date). The original invoice remains visible and the correction document refers to it explicitly.
|
||||||
|
invoices.correct.fields=Corrected information
|
||||||
|
invoices.correct.fields.helper=Describe which fields are added or replaced.
|
||||||
|
invoices.correct.fields.required=Please describe the corrected information.
|
||||||
|
invoices.correct.reason=Reason for correction
|
||||||
|
invoices.correct.confirm=Create correction document
|
||||||
|
invoices.history.title=History for invoice {0}
|
||||||
|
invoices.history.log=Audit log
|
||||||
|
invoices.history.empty=No entries available.
|
||||||
|
invoices.history.original=Original invoice
|
||||||
|
invoices.history.cancellation=Cancellation document
|
||||||
|
invoices.history.correction=Correction document
|
||||||
|
invoices.history.replacement=Replacement invoice
|
||||||
|
invoices.audit.action.created_draft=Draft created
|
||||||
|
invoices.audit.action.updated_draft=Draft updated
|
||||||
|
invoices.audit.action.issued=Issued
|
||||||
|
invoices.audit.action.sent=Sent
|
||||||
|
invoices.audit.action.cancelled=Cancelled
|
||||||
|
invoices.audit.action.corrected=Corrected
|
||||||
|
invoices.audit.action.replaced=Replaced by new invoice
|
||||||
|
invoices.audit.action.deleted_draft=Draft deleted
|
||||||
|
invoices.audit.action.payment_recorded=Payment recorded
|
||||||
|
invoices.audit.resulting=Resulting document: {0}
|
||||||
|
invoices.payment.unpaid=Open
|
||||||
|
invoices.payment.partially_paid=Partially paid
|
||||||
|
invoices.payment.paid=Paid
|
||||||
|
invoices.payment.overpaid=Overpaid
|
||||||
|
invoices.payment.refund_due=Refund due
|
||||||
|
invoices.action.payment=Record payment
|
||||||
|
invoices.action.export=Export
|
||||||
|
invoices.payment.title=Payment for invoice {0}
|
||||||
|
invoices.payment.hint=Outstanding balance: {0}. Negative amounts can be recorded for corrections.
|
||||||
|
invoices.payment.amount=Amount
|
||||||
|
invoices.payment.amount.required=Please enter a non-zero amount.
|
||||||
|
invoices.payment.reference=Payment reference (e.g. statement, booking ID)
|
||||||
|
invoices.payment.reason=Note
|
||||||
|
invoices.payment.confirm=Record payment
|
||||||
|
invoices.notification.payment=Payment recorded.
|
||||||
|
|
||||||
# My Invoices
|
# My Invoices
|
||||||
myinvoices.title=Invoices
|
myinvoices.title=Invoices
|
||||||
@@ -976,8 +1052,123 @@ misc.retry=Retry
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Price Table
|
adminpricetable.title=Price Table
|
||||||
adminpricetable.field.monthly=Monthly Base Package
|
adminpricetable.field.monthly=Monthly Base Package
|
||||||
adminpricetable.field.applicense=App Usage License
|
adminpricetable.field.appuserfee=Fee per App User
|
||||||
adminpricetable.field.revenue=Revenue Share
|
adminpricetable.field.appuserfee.helper=Billed monthly per app user set up
|
||||||
adminpricetable.notification.saved=Price table has been saved
|
adminpricetable.notification.saved=Price table has been saved
|
||||||
adminpricetable.notification.save.error=Error saving: {0}
|
adminpricetable.notification.save.error=Error saving: {0}
|
||||||
adminpricetable.notification.load.error=Error loading: {0}
|
adminpricetable.notification.load.error=Error loading: {0}
|
||||||
|
|
||||||
|
# Invoice generator - system template (invoices to users)
|
||||||
|
invoicegenerator.issuer.header=Invoice issuer
|
||||||
|
invoicegenerator.issuer.website=Website
|
||||||
|
invoicegenerator.issuer.senderline=Sender line
|
||||||
|
invoicegenerator.issuer.paymentterms=Payment terms
|
||||||
|
invoicegenerator.issuer.footer=Footer
|
||||||
|
invoicegenerator.issuer.invoicedate=Invoice date
|
||||||
|
invoicegenerator.recipient.header=Recipient (user)
|
||||||
|
invoicegenerator.recipient.company=User company
|
||||||
|
invoicegenerator.recipient.name=User name
|
||||||
|
invoicegenerator.recipient.street=User street
|
||||||
|
invoicegenerator.recipient.city=User city
|
||||||
|
invoicegenerator.recipient.email=User email
|
||||||
|
invoicegenerator.template.saved=Template saved
|
||||||
|
invoicegenerator.template.save.error=Error saving template: {0}
|
||||||
|
invoicegenerator.button.clear=Clear
|
||||||
|
invoicegenerator.notification.cleared=Canvas cleared
|
||||||
|
invoicegenerator.notification.canvas.error=Could not read canvas data
|
||||||
|
invoicegenerator.notification.preview.error=Preview error: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=PDF preview
|
||||||
|
|
||||||
|
# Admin Dashboard - Customer overview
|
||||||
|
admindashboard.section.customers=Customers ({0})
|
||||||
|
admindashboard.customers.column.number=Customer number
|
||||||
|
admindashboard.customers.column.owner=User
|
||||||
|
admindashboard.customers.empty=No customers available
|
||||||
|
invoicegenerator.positions.header=Positions (price table)
|
||||||
|
invoicegenerator.positions.list=List positions
|
||||||
|
button.undo=Undo
|
||||||
|
|
||||||
|
# Admin profile (company data)
|
||||||
|
page.title.admin.profile=Edit company data
|
||||||
|
adminprofile.title=Company data
|
||||||
|
adminprofile.field.companyname=Company name
|
||||||
|
adminprofile.field.companysubtitle=Company addition
|
||||||
|
adminprofile.field.street=Street and number
|
||||||
|
adminprofile.field.city=ZIP and city
|
||||||
|
adminprofile.field.phone=Phone
|
||||||
|
adminprofile.field.fax=Fax
|
||||||
|
adminprofile.field.email=Email
|
||||||
|
adminprofile.field.website=Website
|
||||||
|
adminprofile.field.senderline=Sender line
|
||||||
|
adminprofile.field.senderline.helper=Single-line sender information above the recipient address on invoices
|
||||||
|
adminprofile.field.paymentterms=Payment terms
|
||||||
|
adminprofile.field.footer=Footer
|
||||||
|
adminprofile.field.footer.helper=Managing directors, tax number, bank details – each line appears as its own line on the invoice
|
||||||
|
adminprofile.notification.saved=Company data saved
|
||||||
|
adminprofile.notification.save.error=Error while saving: {0}
|
||||||
|
adminprofile.notification.load.error=Error while loading: {0}
|
||||||
|
|
||||||
|
# Create invoices (admin)
|
||||||
|
page.title.admin.createinvoices=Create invoices
|
||||||
|
admincreateinvoices.title=Create invoices
|
||||||
|
admincreateinvoices.hint=Invoices are due at the end of the month. If a customer starts mid-month, only the remaining days until the end of the month are billed.
|
||||||
|
admincreateinvoices.column.customer=Customer
|
||||||
|
admincreateinvoices.column.discount=Discount
|
||||||
|
admincreateinvoices.discount.position=Discount ({0}%)
|
||||||
|
admincreateinvoices.discount.dialog.title=Enter discount
|
||||||
|
admincreateinvoices.discount.dialog.percent=Discount in %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Reason for the discount
|
||||||
|
admincreateinvoices.discount.dialog.apply=Apply
|
||||||
|
admincreateinvoices.column.start=Customer since
|
||||||
|
admincreateinvoices.column.days=Billed days
|
||||||
|
admincreateinvoices.column.due=Due on
|
||||||
|
admincreateinvoices.column.net=Net amount
|
||||||
|
admincreateinvoices.days.full=Full month
|
||||||
|
admincreateinvoices.days.partial={0} of {1} days
|
||||||
|
admincreateinvoices.prorata.suffix=(pro rata {0}/{1} days)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} app users)
|
||||||
|
admincreateinvoices.button.create=Create invoice
|
||||||
|
admincreateinvoices.notification.notemplate=No invoice template available. Please save a template in the invoice generator first.
|
||||||
|
admincreateinvoices.notification.error=Error creating the invoice: {0}
|
||||||
|
admincreateinvoices.button.createselected=Create invoices ({0})
|
||||||
|
admincreateinvoices.field.month=Billing month
|
||||||
|
admincreateinvoices.filter.label=Show
|
||||||
|
admincreateinvoices.filter.all=All invoices
|
||||||
|
admincreateinvoices.filter.open=Only open
|
||||||
|
admincreateinvoices.filter.sent=Only sent
|
||||||
|
admincreateinvoices.dialog.title=Review invoices
|
||||||
|
admincreateinvoices.dialog.position=Invoice {0} of {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Back
|
||||||
|
admincreateinvoices.dialog.button.next=Next
|
||||||
|
admincreateinvoices.dialog.button.delete=Remove from list
|
||||||
|
admincreateinvoices.dialog.button.send=Send invoices
|
||||||
|
admincreateinvoices.pdf.button.send=Send
|
||||||
|
admincreateinvoices.dialog.notification.removed=Invoice removed from the list
|
||||||
|
admincreateinvoices.mail.subject=Your invoice {0}
|
||||||
|
admincreateinvoices.mail.body=Dear Sir or Madam,\n\nplease find attached your invoice {0} for the billing period {1}.\n\nKind regards\n{2}
|
||||||
|
admincreateinvoices.notification.sent={0} invoice(s) sent by email
|
||||||
|
admincreateinvoices.notification.sendfailed={0} invoice(s) could not be sent
|
||||||
|
admincreateinvoices.button.zugferd=E-invoice (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=E-invoice {0} created
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Error creating the e-invoice: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=No billable positions available for the e-invoice
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=E-invoice {0} could not be created, no invoices were sent: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Validation failed for: {0}. No invoices were sent.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Error creating the combined ZIP file: {0}
|
||||||
|
|
||||||
|
# E-invoice (ZUGFeRD) for user invoices to their customers
|
||||||
|
invoices.action.zugferd=E-invoice (ZIP)
|
||||||
|
invoices.action.sendinvoice=Send by email
|
||||||
|
invoices.notification.zugferd.created=E-invoice {0} created
|
||||||
|
invoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
|
||||||
|
invoices.notification.zugferd.invalidsend=Sending aborted: validation of e-invoice {0} failed.
|
||||||
|
invoices.notification.zugferd.error=E-invoice error: {0}
|
||||||
|
invoices.notification.email.missing=No email address is stored for the invoice recipient.
|
||||||
|
invoices.notification.emailsent=Invoice {0} was sent as an e-invoice to {1}.
|
||||||
|
invoices.mail.subject=Your invoice {0}
|
||||||
|
invoices.mail.body=Dear Sir or Madam,\n\nPlease find attached your invoice {0} as an e-invoice (ZIP with PDF and invoice data).\n\nKind regards\n{1}
|
||||||
|
profile.billing.ustid=VAT ID
|
||||||
|
profile.billing.taxnumber=Tax number
|
||||||
|
profile.billing.bankname=Bank
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Panel de control
|
|||||||
page.title.appuser.create=Crear nuevo usuario de la app
|
page.title.appuser.create=Crear nuevo usuario de la app
|
||||||
page.title.messages=Mensajes
|
page.title.messages=Mensajes
|
||||||
page.title.register=Registrarse en VotianLT
|
page.title.register=Registrarse en VotianLT
|
||||||
page.title.customers=Clientes
|
page.title.customers=Libreta de direcciones
|
||||||
page.title.customer.edit=Editar cliente
|
page.title.customer.edit=Editar cliente
|
||||||
page.title.verwaltung=Administraci\u00f3n
|
page.title.verwaltung=Administraci\u00f3n
|
||||||
page.title.company.create=Crear nueva empresa
|
page.title.company.create=Crear nueva empresa
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=Aviso legal
|
|||||||
page.title.profile.edit=Editar perfil
|
page.title.profile.edit=Editar perfil
|
||||||
page.title.admin.dashboard=Panel de administraci\u00f3n
|
page.title.admin.dashboard=Panel de administraci\u00f3n
|
||||||
page.title.invoice.create=Crear factura
|
page.title.invoice.create=Crear factura
|
||||||
page.title.customer.create=Crear nuevo cliente
|
page.title.customer.create=Crear nueva dirección
|
||||||
page.title.login=Iniciar sesi\u00f3n en VotianLT
|
page.title.login=Iniciar sesi\u00f3n en VotianLT
|
||||||
page.title.jobs=Pedidos
|
page.title.jobs=Pedidos
|
||||||
page.title.appuser.edit=Editar usuario de la app
|
page.title.appuser.edit=Editar usuario de la app
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=\u00bfDesea realmente eliminar este usuario de la
|
|||||||
editappuser.dialog.delete.confirm=Eliminar
|
editappuser.dialog.delete.confirm=Eliminar
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Clientes
|
customers.title=Libreta de direcciones
|
||||||
customers.button.add=A\u00f1adir nuevo cliente
|
customers.button.add=A\u00f1adir nueva direcci\u00f3n
|
||||||
customers.hint.click=Haga clic en un cliente para ver los detalles
|
customers.hint.click=Haga clic en una entrada de direcci\u00f3n para ver los detalles
|
||||||
customers.column.company=Empresa
|
customers.column.company=Empresa
|
||||||
customers.column.name=Nombre
|
customers.column.name=Nombre
|
||||||
customers.column.email=Correo electr\u00f3nico
|
customers.column.email=Correo electr\u00f3nico
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=\u00bfDesea realmente eliminar este cliente?
|
|||||||
editcustomer.dialog.delete.confirm=Eliminar
|
editcustomer.dialog.delete.confirm=Eliminar
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Crear nuevo cliente
|
addcustomer.title=Crear nueva dirección
|
||||||
addcustomer.button.submit=Crear cliente
|
addcustomer.button.submit=Crear dirección
|
||||||
addcustomer.notification.validation=Por favor, complete todos los campos obligatorios
|
addcustomer.notification.validation=Por favor, complete todos los campos obligatorios
|
||||||
addcustomer.notification.success=Cliente creado correctamente
|
addcustomer.notification.success=Cliente creado correctamente
|
||||||
|
addcustomer.notification.duplicate=Ya existe una entrada idéntica en la libreta de direcciones
|
||||||
addcustomer.notification.check=Por favor, revise sus datos
|
addcustomer.notification.check=Por favor, revise sus datos
|
||||||
addcustomer.notification.error=Error: {0}
|
addcustomer.notification.error=Error: {0}
|
||||||
addcustomer.validation.required=Este campo es obligatorio
|
addcustomer.validation.required=Este campo es obligatorio
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Remitente desconocido
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Crear nuevo pedido
|
addjob.title=Crear nuevo pedido
|
||||||
addjob.customer.label=Cliente
|
addjob.customer.label=Ordenante
|
||||||
addjob.customer.placeholder=Seleccionar cliente
|
addjob.customer.placeholder=Seleccionar ordenante
|
||||||
addjob.customer.unnamed=Cliente sin nombre
|
addjob.customer.unnamed=Ordenante sin nombre
|
||||||
addjob.button.clearfields=Vaciar campos
|
addjob.button.clearfields=Vaciar campos
|
||||||
addjob.button.submit=Crear pedido
|
addjob.button.submit=Crear pedido
|
||||||
addjob.address.salutation=Tratamiento
|
addjob.address.salutation=Tratamiento
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Sr.
|
|||||||
addjob.salutation.ms=Sra.
|
addjob.salutation.ms=Sra.
|
||||||
addjob.salutation.other=Otro
|
addjob.salutation.other=Otro
|
||||||
addjob.address.company.placeholder=Introducir empresa
|
addjob.address.company.placeholder=Introducir empresa
|
||||||
|
addjob.address.pickup.label=Direcci\u00f3n de recogida
|
||||||
|
addjob.address.pickup.placeholder=Seleccionar o introducir direcci\u00f3n de recogida
|
||||||
|
addjob.address.delivery.label=Direcci\u00f3n de entrega
|
||||||
|
addjob.address.delivery.placeholder=Seleccionar o introducir direcci\u00f3n de entrega
|
||||||
addjob.address.street.placeholder=Introducir calle
|
addjob.address.street.placeholder=Introducir calle
|
||||||
addjob.address.housenumber=N\u00famero de casa
|
addjob.address.housenumber=N\u00famero de casa
|
||||||
addjob.address.addition.placeholder=Complemento de direcci\u00f3n
|
addjob.address.addition.placeholder=Complemento de direcci\u00f3n
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Se ha alcanzado el n\u00famero m\u00e1ximo de 25 esta
|
|||||||
addjob.station.unused=No utilizada
|
addjob.station.unused=No utilizada
|
||||||
addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega.
|
addjob.appointment.delivery.info=Las fechas de entrega se establecen directamente en las estaciones de entrega.
|
||||||
addjob.tab.addresses=Cliente y direcciones
|
addjob.tab.addresses=Cliente y direcciones
|
||||||
|
addjob.tab.pickup.address=Ordenante y direcci\u00f3n de recogida
|
||||||
|
addjob.tab.delivery.address=Direcci\u00f3n de entrega
|
||||||
addjob.tab.appointments=Citas y procesamiento
|
addjob.tab.appointments=Citas y procesamiento
|
||||||
addjob.tab.cargo=Carga
|
addjob.tab.cargo=Carga
|
||||||
addjob.tab.tasks=Tareas
|
addjob.tab.tasks=Tareas
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Por favor, introduzca un motivo
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Cancelar
|
jobsummary.dialog.manualcomplete.cancel=Cancelar
|
||||||
jobsummary.dialog.manualcomplete.confirm=Aceptar
|
jobsummary.dialog.manualcomplete.confirm=Aceptar
|
||||||
jobsummary.history.manualcomplete.reason=Finalizado manualmente
|
jobsummary.history.manualcomplete.reason=Finalizado manualmente
|
||||||
|
jobmanualcomplete.route.hours=Horas
|
||||||
|
jobmanualcomplete.route.minutes=Minutos
|
||||||
|
jobmanualcomplete.route.manual.hint=No hay datos de ruta disponibles – introduzca la distancia y la duración manualmente.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Pedidos
|
jobs.title=Pedidos
|
||||||
@@ -973,8 +983,123 @@ misc.retry=Reintentar
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Tabla de precios
|
adminpricetable.title=Tabla de precios
|
||||||
adminpricetable.field.monthly=Paquete b\u00e1sico mensual
|
adminpricetable.field.monthly=Paquete b\u00e1sico mensual
|
||||||
adminpricetable.field.applicense=Licencia de uso de la app
|
adminpricetable.field.appuserfee=Tarifa por usuario de la app
|
||||||
adminpricetable.field.revenue=Participaci\u00f3n en los ingresos
|
adminpricetable.field.appuserfee.helper=Se factura mensualmente por cada usuario de la app configurado
|
||||||
adminpricetable.notification.saved=La tabla de precios ha sido guardada
|
adminpricetable.notification.saved=La tabla de precios ha sido guardada
|
||||||
adminpricetable.notification.save.error=Error al guardar: {0}
|
adminpricetable.notification.save.error=Error al guardar: {0}
|
||||||
adminpricetable.notification.load.error=Error al cargar: {0}
|
adminpricetable.notification.load.error=Error al cargar: {0}
|
||||||
|
|
||||||
|
# Generador de facturas - plantilla del sistema (facturas a usuarios)
|
||||||
|
invoicegenerator.issuer.header=Emisor de la factura
|
||||||
|
invoicegenerator.issuer.website=Sitio web
|
||||||
|
invoicegenerator.issuer.senderline=Línea de remitente
|
||||||
|
invoicegenerator.issuer.paymentterms=Condiciones de pago
|
||||||
|
invoicegenerator.issuer.footer=Pie de página
|
||||||
|
invoicegenerator.issuer.invoicedate=Fecha de la factura
|
||||||
|
invoicegenerator.recipient.header=Destinatario (usuario)
|
||||||
|
invoicegenerator.recipient.company=Empresa del usuario
|
||||||
|
invoicegenerator.recipient.name=Nombre del usuario
|
||||||
|
invoicegenerator.recipient.street=Calle del usuario
|
||||||
|
invoicegenerator.recipient.city=Ciudad del usuario
|
||||||
|
invoicegenerator.recipient.email=Correo del usuario
|
||||||
|
invoicegenerator.template.saved=Plantilla guardada
|
||||||
|
invoicegenerator.template.save.error=Error al guardar la plantilla: {0}
|
||||||
|
invoicegenerator.button.clear=Vaciar
|
||||||
|
invoicegenerator.notification.cleared=Lienzo vaciado
|
||||||
|
invoicegenerator.notification.canvas.error=No se pudieron leer los datos del lienzo
|
||||||
|
invoicegenerator.notification.preview.error=Error de vista previa: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=Vista previa del PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Resumen de clientes
|
||||||
|
admindashboard.section.customers=Clientes ({0})
|
||||||
|
admindashboard.customers.column.number=Número de cliente
|
||||||
|
admindashboard.customers.column.owner=Usuario
|
||||||
|
admindashboard.customers.empty=No hay clientes
|
||||||
|
invoicegenerator.positions.header=Posiciones (tabla de precios)
|
||||||
|
invoicegenerator.positions.list=Listar posiciones
|
||||||
|
button.undo=Deshacer
|
||||||
|
|
||||||
|
# Perfil de administrador (datos de la empresa)
|
||||||
|
page.title.admin.profile=Editar datos de la empresa
|
||||||
|
adminprofile.title=Datos de la empresa
|
||||||
|
adminprofile.field.companyname=Nombre de la empresa
|
||||||
|
adminprofile.field.companysubtitle=Complemento de la empresa
|
||||||
|
adminprofile.field.street=Calle y número
|
||||||
|
adminprofile.field.city=Código postal y ciudad
|
||||||
|
adminprofile.field.phone=Teléfono
|
||||||
|
adminprofile.field.fax=Fax
|
||||||
|
adminprofile.field.email=Correo electrónico
|
||||||
|
adminprofile.field.website=Sitio web
|
||||||
|
adminprofile.field.senderline=Línea de remitente
|
||||||
|
adminprofile.field.senderline.helper=Información del remitente en una línea sobre la dirección del destinatario en las facturas
|
||||||
|
adminprofile.field.paymentterms=Condiciones de pago
|
||||||
|
adminprofile.field.footer=Pie de página
|
||||||
|
adminprofile.field.footer.helper=Gerentes, número fiscal, datos bancarios – cada línea aparece como línea propia en la factura
|
||||||
|
adminprofile.notification.saved=Datos de la empresa guardados
|
||||||
|
adminprofile.notification.save.error=Error al guardar: {0}
|
||||||
|
adminprofile.notification.load.error=Error al cargar: {0}
|
||||||
|
|
||||||
|
# Crear facturas (admin)
|
||||||
|
page.title.admin.createinvoices=Crear facturas
|
||||||
|
admincreateinvoices.title=Crear facturas
|
||||||
|
admincreateinvoices.hint=Las facturas vencen a fin de mes. Si un cliente comienza a mitad de mes, solo se facturan los días restantes hasta fin de mes.
|
||||||
|
admincreateinvoices.column.customer=Cliente
|
||||||
|
admincreateinvoices.column.discount=Descuento
|
||||||
|
admincreateinvoices.discount.position=Descuento ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Registrar descuento
|
||||||
|
admincreateinvoices.discount.dialog.percent=Descuento en %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Motivo del descuento
|
||||||
|
admincreateinvoices.discount.dialog.apply=Aplicar
|
||||||
|
admincreateinvoices.column.start=Cliente desde
|
||||||
|
admincreateinvoices.column.days=Días facturados
|
||||||
|
admincreateinvoices.column.due=Vence el
|
||||||
|
admincreateinvoices.column.net=Importe neto
|
||||||
|
admincreateinvoices.days.full=Mes completo
|
||||||
|
admincreateinvoices.days.partial={0} de {1} días
|
||||||
|
admincreateinvoices.prorata.suffix=(prorrateado {0}/{1} días)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} usuarios de la app)
|
||||||
|
admincreateinvoices.button.create=Crear factura
|
||||||
|
admincreateinvoices.notification.notemplate=No hay plantilla de factura. Guarde primero una plantilla en el generador de facturas.
|
||||||
|
admincreateinvoices.notification.error=Error al crear la factura: {0}
|
||||||
|
admincreateinvoices.button.createselected=Crear facturas ({0})
|
||||||
|
admincreateinvoices.field.month=Mes de facturación
|
||||||
|
admincreateinvoices.filter.label=Mostrar
|
||||||
|
admincreateinvoices.filter.all=Todas las facturas
|
||||||
|
admincreateinvoices.filter.open=Solo abiertas
|
||||||
|
admincreateinvoices.filter.sent=Solo enviadas
|
||||||
|
admincreateinvoices.dialog.title=Revisar facturas
|
||||||
|
admincreateinvoices.dialog.position=Factura {0} de {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Atrás
|
||||||
|
admincreateinvoices.dialog.button.next=Siguiente
|
||||||
|
admincreateinvoices.dialog.button.delete=Quitar de la lista
|
||||||
|
admincreateinvoices.dialog.button.send=Enviar facturas
|
||||||
|
admincreateinvoices.pdf.button.send=Enviar
|
||||||
|
admincreateinvoices.dialog.notification.removed=Factura eliminada de la lista
|
||||||
|
admincreateinvoices.mail.subject=Su factura {0}
|
||||||
|
admincreateinvoices.mail.body=Estimados señores,\n\nadjunto encontrará su factura {0} correspondiente al período de facturación {1}.\n\nAtentamente\n{2}
|
||||||
|
admincreateinvoices.notification.sent={0} factura(s) enviada(s) por correo electrónico
|
||||||
|
admincreateinvoices.notification.sendfailed=No se pudieron enviar {0} factura(s)
|
||||||
|
admincreateinvoices.button.zugferd=Factura electrónica (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=Factura electrónica {0} creada
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Error al crear la factura electrónica: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=No hay posiciones facturables disponibles para la factura electrónica
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=No se pudo crear la factura electrónica {0}, no se envió ninguna factura: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=La validación falló para: {0}. No se envió ninguna factura.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Error al crear el archivo ZIP combinado: {0}
|
||||||
|
|
||||||
|
# Factura electrónica (ZUGFeRD) para facturas de usuarios a sus clientes
|
||||||
|
invoices.action.zugferd=Factura electrónica (ZIP)
|
||||||
|
invoices.action.sendinvoice=Enviar por correo
|
||||||
|
invoices.notification.zugferd.created=Factura electrónica {0} creada
|
||||||
|
invoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
|
||||||
|
invoices.notification.zugferd.invalidsend=Envío cancelado: la validación de la factura electrónica {0} ha fallado.
|
||||||
|
invoices.notification.zugferd.error=Error en la factura electrónica: {0}
|
||||||
|
invoices.notification.email.missing=No hay una dirección de correo electrónico registrada para el destinatario de la factura.
|
||||||
|
invoices.notification.emailsent=La factura {0} se envió como factura electrónica a {1}.
|
||||||
|
invoices.mail.subject=Su factura {0}
|
||||||
|
invoices.mail.body=Estimados señores:\n\nAdjunto encontrará su factura {0} como factura electrónica (ZIP con PDF y datos de la factura).\n\nAtentamente\n{1}
|
||||||
|
profile.billing.ustid=NIF-IVA
|
||||||
|
profile.billing.taxnumber=Número fiscal
|
||||||
|
profile.billing.bankname=Banco
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Tableau de bord
|
|||||||
page.title.appuser.create=Cr\u00e9er un nouvel utilisateur d'app
|
page.title.appuser.create=Cr\u00e9er un nouvel utilisateur d'app
|
||||||
page.title.messages=Messages
|
page.title.messages=Messages
|
||||||
page.title.register=S'inscrire sur VotianLT
|
page.title.register=S'inscrire sur VotianLT
|
||||||
page.title.customers=Clients
|
page.title.customers=Carnet d'adresses
|
||||||
page.title.customer.edit=Modifier le client
|
page.title.customer.edit=Modifier le client
|
||||||
page.title.verwaltung=Administration
|
page.title.verwaltung=Administration
|
||||||
page.title.company.create=Cr\u00e9er une nouvelle entreprise
|
page.title.company.create=Cr\u00e9er une nouvelle entreprise
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=Mentions l\u00e9gales
|
|||||||
page.title.profile.edit=Modifier le profil
|
page.title.profile.edit=Modifier le profil
|
||||||
page.title.admin.dashboard=Tableau de bord administrateur
|
page.title.admin.dashboard=Tableau de bord administrateur
|
||||||
page.title.invoice.create=Cr\u00e9er une facture
|
page.title.invoice.create=Cr\u00e9er une facture
|
||||||
page.title.customer.create=Cr\u00e9er un nouveau client
|
page.title.customer.create=Cr\u00e9er une nouvelle adresse
|
||||||
page.title.login=Se connecter \u00e0 VotianLT
|
page.title.login=Se connecter \u00e0 VotianLT
|
||||||
page.title.jobs=Missions
|
page.title.jobs=Missions
|
||||||
page.title.appuser.edit=Modifier l'utilisateur d'app
|
page.title.appuser.edit=Modifier l'utilisateur d'app
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Voulez-vous vraiment supprimer cet utilisateur d'
|
|||||||
editappuser.dialog.delete.confirm=Supprimer
|
editappuser.dialog.delete.confirm=Supprimer
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Clients
|
customers.title=Carnet d'adresses
|
||||||
customers.button.add=Ajouter un nouveau client
|
customers.button.add=Ajouter une nouvelle adresse
|
||||||
customers.hint.click=Cliquez sur un client pour voir les d\u00e9tails
|
customers.hint.click=Cliquez sur une entr\u00e9e d'adresse pour voir les d\u00e9tails
|
||||||
customers.column.company=Entreprise
|
customers.column.company=Entreprise
|
||||||
customers.column.name=Nom
|
customers.column.name=Nom
|
||||||
customers.column.email=E-mail
|
customers.column.email=E-mail
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Voulez-vous vraiment supprimer ce client ?
|
|||||||
editcustomer.dialog.delete.confirm=Supprimer
|
editcustomer.dialog.delete.confirm=Supprimer
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Cr\u00e9er un nouveau client
|
addcustomer.title=Cr\u00e9er une nouvelle adresse
|
||||||
addcustomer.button.submit=Cr\u00e9er le client
|
addcustomer.button.submit=Cr\u00e9er l'adresse
|
||||||
addcustomer.notification.validation=Veuillez remplir tous les champs obligatoires
|
addcustomer.notification.validation=Veuillez remplir tous les champs obligatoires
|
||||||
addcustomer.notification.success=Client cr\u00e9\u00e9 avec succ\u00e8s
|
addcustomer.notification.success=Client cr\u00e9\u00e9 avec succ\u00e8s
|
||||||
|
addcustomer.notification.duplicate=Une entr\u00e9e identique existe d\u00e9j\u00e0 dans le carnet d'adresses
|
||||||
addcustomer.notification.check=Veuillez v\u00e9rifier vos saisies
|
addcustomer.notification.check=Veuillez v\u00e9rifier vos saisies
|
||||||
addcustomer.notification.error=Erreur : {0}
|
addcustomer.notification.error=Erreur : {0}
|
||||||
addcustomer.validation.required=Ce champ est obligatoire
|
addcustomer.validation.required=Ce champ est obligatoire
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Exp\u00e9diteur inconnu
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Cr\u00e9er une nouvelle mission
|
addjob.title=Cr\u00e9er une nouvelle mission
|
||||||
addjob.customer.label=Client
|
addjob.customer.label=Donneur d'ordre
|
||||||
addjob.customer.placeholder=S\u00e9lectionner un client
|
addjob.customer.placeholder=S\u00e9lectionner le donneur d'ordre
|
||||||
addjob.customer.unnamed=Client sans nom
|
addjob.customer.unnamed=Donneur d'ordre sans nom
|
||||||
addjob.button.clearfields=Vider les champs
|
addjob.button.clearfields=Vider les champs
|
||||||
addjob.button.submit=Cr\u00e9er la mission
|
addjob.button.submit=Cr\u00e9er la mission
|
||||||
addjob.address.salutation=Civilit\u00e9
|
addjob.address.salutation=Civilit\u00e9
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Monsieur
|
|||||||
addjob.salutation.ms=Madame
|
addjob.salutation.ms=Madame
|
||||||
addjob.salutation.other=Autre
|
addjob.salutation.other=Autre
|
||||||
addjob.address.company.placeholder=Saisir l'entreprise
|
addjob.address.company.placeholder=Saisir l'entreprise
|
||||||
|
addjob.address.pickup.label=Adresse d'enl\u00e8vement
|
||||||
|
addjob.address.pickup.placeholder=S\u00e9lectionner ou saisir l'adresse d'enl\u00e8vement
|
||||||
|
addjob.address.delivery.label=Adresse de livraison
|
||||||
|
addjob.address.delivery.placeholder=S\u00e9lectionner ou saisir l'adresse de livraison
|
||||||
addjob.address.street.placeholder=Saisir la rue
|
addjob.address.street.placeholder=Saisir la rue
|
||||||
addjob.address.housenumber=Num\u00e9ro
|
addjob.address.housenumber=Num\u00e9ro
|
||||||
addjob.address.addition.placeholder=Compl\u00e9ment d'adresse
|
addjob.address.addition.placeholder=Compl\u00e9ment d'adresse
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Nombre maximum de 25 stations de livraison atteint
|
|||||||
addjob.station.unused=Non utilis\u00e9e
|
addjob.station.unused=Non utilis\u00e9e
|
||||||
addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison.
|
addjob.appointment.delivery.info=Les dates de livraison sont d\u00e9finies directement dans les stations de livraison.
|
||||||
addjob.tab.addresses=Donneur d'ordre & adresses
|
addjob.tab.addresses=Donneur d'ordre & adresses
|
||||||
|
addjob.tab.pickup.address=Donneur d'ordre & adresse d'enl\u00e8vement
|
||||||
|
addjob.tab.delivery.address=Adresse de livraison
|
||||||
addjob.tab.appointments=Rendez-vous & traitement
|
addjob.tab.appointments=Rendez-vous & traitement
|
||||||
addjob.tab.cargo=Fret
|
addjob.tab.cargo=Fret
|
||||||
addjob.tab.tasks=T\u00e2ches
|
addjob.tab.tasks=T\u00e2ches
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Veuillez saisir un motif
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Annuler
|
jobsummary.dialog.manualcomplete.cancel=Annuler
|
||||||
jobsummary.dialog.manualcomplete.confirm=Accepter
|
jobsummary.dialog.manualcomplete.confirm=Accepter
|
||||||
jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement
|
jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement
|
||||||
|
jobmanualcomplete.route.hours=Heures
|
||||||
|
jobmanualcomplete.route.minutes=Minutes
|
||||||
|
jobmanualcomplete.route.manual.hint=Aucune donn\u00e9e d'itin\u00e9raire disponible \u2013 veuillez saisir la distance et la dur\u00e9e manuellement.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Missions
|
jobs.title=Missions
|
||||||
@@ -973,8 +983,123 @@ misc.retry=R\u00e9essayer
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Tableau des prix
|
adminpricetable.title=Tableau des prix
|
||||||
adminpricetable.field.monthly=Forfait mensuel de base
|
adminpricetable.field.monthly=Forfait mensuel de base
|
||||||
adminpricetable.field.applicense=Licence d'utilisation de l'app
|
adminpricetable.field.appuserfee=Frais par utilisateur de l'app
|
||||||
adminpricetable.field.revenue=Participation au chiffre d'affaires
|
adminpricetable.field.appuserfee.helper=Facturé mensuellement par utilisateur de l'app configuré
|
||||||
adminpricetable.notification.saved=Le tableau des prix a \u00e9t\u00e9 enregistr\u00e9
|
adminpricetable.notification.saved=Le tableau des prix a \u00e9t\u00e9 enregistr\u00e9
|
||||||
adminpricetable.notification.save.error=Erreur lors de l'enregistrement : {0}
|
adminpricetable.notification.save.error=Erreur lors de l'enregistrement : {0}
|
||||||
adminpricetable.notification.load.error=Erreur lors du chargement : {0}
|
adminpricetable.notification.load.error=Erreur lors du chargement : {0}
|
||||||
|
|
||||||
|
# Générateur de factures - modèle système (factures aux utilisateurs)
|
||||||
|
invoicegenerator.issuer.header=Émetteur de la facture
|
||||||
|
invoicegenerator.issuer.website=Site web
|
||||||
|
invoicegenerator.issuer.senderline=Ligne d'expéditeur
|
||||||
|
invoicegenerator.issuer.paymentterms=Conditions de paiement
|
||||||
|
invoicegenerator.issuer.footer=Pied de page
|
||||||
|
invoicegenerator.issuer.invoicedate=Date de la facture
|
||||||
|
invoicegenerator.recipient.header=Destinataire (utilisateur)
|
||||||
|
invoicegenerator.recipient.company=Société de l'utilisateur
|
||||||
|
invoicegenerator.recipient.name=Nom de l'utilisateur
|
||||||
|
invoicegenerator.recipient.street=Rue de l'utilisateur
|
||||||
|
invoicegenerator.recipient.city=Ville de l'utilisateur
|
||||||
|
invoicegenerator.recipient.email=E-mail de l'utilisateur
|
||||||
|
invoicegenerator.template.saved=Modèle enregistré
|
||||||
|
invoicegenerator.template.save.error=Erreur lors de l''enregistrement du modèle : {0}
|
||||||
|
invoicegenerator.button.clear=Vider
|
||||||
|
invoicegenerator.notification.cleared=Canevas vidé
|
||||||
|
invoicegenerator.notification.canvas.error=Impossible de lire les données du canevas
|
||||||
|
invoicegenerator.notification.preview.error=Erreur d''aperçu : {0}
|
||||||
|
invoicegenerator.pdf.preview.title=Aperçu PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Aperçu des clients
|
||||||
|
admindashboard.section.customers=Clients ({0})
|
||||||
|
admindashboard.customers.column.number=Numéro de client
|
||||||
|
admindashboard.customers.column.owner=Utilisateur
|
||||||
|
admindashboard.customers.empty=Aucun client
|
||||||
|
invoicegenerator.positions.header=Positions (grille tarifaire)
|
||||||
|
invoicegenerator.positions.list=Lister les positions
|
||||||
|
button.undo=Annuler
|
||||||
|
|
||||||
|
# Profil administrateur (données de l'entreprise)
|
||||||
|
page.title.admin.profile=Modifier les données de l'entreprise
|
||||||
|
adminprofile.title=Données de l'entreprise
|
||||||
|
adminprofile.field.companyname=Nom de l'entreprise
|
||||||
|
adminprofile.field.companysubtitle=Complément de l'entreprise
|
||||||
|
adminprofile.field.street=Rue et numéro
|
||||||
|
adminprofile.field.city=Code postal et ville
|
||||||
|
adminprofile.field.phone=Téléphone
|
||||||
|
adminprofile.field.fax=Fax
|
||||||
|
adminprofile.field.email=E-mail
|
||||||
|
adminprofile.field.website=Site web
|
||||||
|
adminprofile.field.senderline=Ligne d'expéditeur
|
||||||
|
adminprofile.field.senderline.helper=Mention de l'expéditeur sur une ligne au-dessus de l'adresse du destinataire sur les factures
|
||||||
|
adminprofile.field.paymentterms=Conditions de paiement
|
||||||
|
adminprofile.field.footer=Pied de page
|
||||||
|
adminprofile.field.footer.helper=Gérants, numéro fiscal, coordonnées bancaires – chaque ligne apparaît comme une ligne distincte sur la facture
|
||||||
|
adminprofile.notification.saved=Données de l'entreprise enregistrées
|
||||||
|
adminprofile.notification.save.error=Erreur lors de l'enregistrement : {0}
|
||||||
|
adminprofile.notification.load.error=Erreur lors du chargement : {0}
|
||||||
|
|
||||||
|
# Créer des factures (admin)
|
||||||
|
page.title.admin.createinvoices=Créer des factures
|
||||||
|
admincreateinvoices.title=Créer des factures
|
||||||
|
admincreateinvoices.hint=Les factures sont exigibles en fin de mois. Si un client commence en cours de mois, seuls les jours restants jusqu'à la fin du mois sont facturés.
|
||||||
|
admincreateinvoices.column.customer=Client
|
||||||
|
admincreateinvoices.column.discount=Remise
|
||||||
|
admincreateinvoices.discount.position=Remise ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Saisir la remise
|
||||||
|
admincreateinvoices.discount.dialog.percent=Remise en %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Motif de la remise
|
||||||
|
admincreateinvoices.discount.dialog.apply=Appliquer
|
||||||
|
admincreateinvoices.column.start=Client depuis
|
||||||
|
admincreateinvoices.column.days=Jours facturés
|
||||||
|
admincreateinvoices.column.due=Échéance le
|
||||||
|
admincreateinvoices.column.net=Montant net
|
||||||
|
admincreateinvoices.days.full=Mois complet
|
||||||
|
admincreateinvoices.days.partial={0} sur {1} jours
|
||||||
|
admincreateinvoices.prorata.suffix=(au prorata {0}/{1} jours)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} utilisateurs de l'app)
|
||||||
|
admincreateinvoices.button.create=Créer la facture
|
||||||
|
admincreateinvoices.notification.notemplate=Aucun modèle de facture disponible. Veuillez d'abord enregistrer un modèle dans le générateur de factures.
|
||||||
|
admincreateinvoices.notification.error=Erreur lors de la création de la facture : {0}
|
||||||
|
admincreateinvoices.button.createselected=Créer les factures ({0})
|
||||||
|
admincreateinvoices.field.month=Mois de facturation
|
||||||
|
admincreateinvoices.filter.label=Afficher
|
||||||
|
admincreateinvoices.filter.all=Toutes les factures
|
||||||
|
admincreateinvoices.filter.open=Uniquement ouvertes
|
||||||
|
admincreateinvoices.filter.sent=Uniquement envoyées
|
||||||
|
admincreateinvoices.dialog.title=Vérifier les factures
|
||||||
|
admincreateinvoices.dialog.position=Facture {0} sur {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Retour
|
||||||
|
admincreateinvoices.dialog.button.next=Suivant
|
||||||
|
admincreateinvoices.dialog.button.delete=Retirer de la liste
|
||||||
|
admincreateinvoices.dialog.button.send=Envoyer les factures
|
||||||
|
admincreateinvoices.pdf.button.send=Envoyer
|
||||||
|
admincreateinvoices.dialog.notification.removed=Facture retirée de la liste
|
||||||
|
admincreateinvoices.mail.subject=Votre facture {0}
|
||||||
|
admincreateinvoices.mail.body=Madame, Monsieur,\n\nveuillez trouver ci-joint votre facture {0} pour la période de facturation {1}.\n\nCordialement\n{2}
|
||||||
|
admincreateinvoices.notification.sent={0} facture(s) envoyée(s) par e-mail
|
||||||
|
admincreateinvoices.notification.sendfailed={0} facture(s) n''ont pas pu être envoyées
|
||||||
|
admincreateinvoices.button.zugferd=Facture électronique (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=Facture électronique {0} créée
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Détails dans le rapport de validation du fichier ZIP.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Erreur lors de la création de la facture électronique : {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=Aucune position facturable disponible pour la facture électronique
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=La facture électronique {0} n''a pas pu être créée, aucune facture n''a été envoyée : {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=La validation a échoué pour : {0}. Aucune facture n''a été envoyée.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Erreur lors de la création du fichier ZIP combiné : {0}
|
||||||
|
|
||||||
|
# Facture électronique (ZUGFeRD) pour les factures des utilisateurs à leurs clients
|
||||||
|
invoices.action.zugferd=Facture électronique (ZIP)
|
||||||
|
invoices.action.sendinvoice=Envoyer par e-mail
|
||||||
|
invoices.notification.zugferd.created=Facture électronique {0} créée
|
||||||
|
invoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Voir le rapport de validation dans le fichier ZIP.
|
||||||
|
invoices.notification.zugferd.invalidsend=Envoi annulé : la validation de la facture électronique {0} a échoué.
|
||||||
|
invoices.notification.zugferd.error=Erreur de facture électronique : {0}
|
||||||
|
invoices.notification.email.missing=Aucune adresse e-mail n'est enregistrée pour le destinataire de la facture.
|
||||||
|
invoices.notification.emailsent=La facture {0} a été envoyée en tant que facture électronique à {1}.
|
||||||
|
invoices.mail.subject=Votre facture {0}
|
||||||
|
invoices.mail.body=Madame, Monsieur,\n\nVeuillez trouver ci-joint votre facture {0} sous forme de facture électronique (ZIP avec PDF et données de facturation).\n\nCordialement\n{1}
|
||||||
|
profile.billing.ustid=N° TVA intracommunautaire
|
||||||
|
profile.billing.taxnumber=Numéro fiscal
|
||||||
|
profile.billing.bankname=Banque
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Valdymo skydas
|
|||||||
page.title.appuser.create=Sukurti naują programėlės naudotoją
|
page.title.appuser.create=Sukurti naują programėlės naudotoją
|
||||||
page.title.messages=Žinutės
|
page.title.messages=Žinutės
|
||||||
page.title.register=Registracija VotianLT
|
page.title.register=Registracija VotianLT
|
||||||
page.title.customers=Klientai
|
page.title.customers=Adresų knyga
|
||||||
page.title.customer.edit=Redaguoti klientą
|
page.title.customer.edit=Redaguoti klientą
|
||||||
page.title.verwaltung=Administravimas
|
page.title.verwaltung=Administravimas
|
||||||
page.title.company.create=Sukurti naują įmonę
|
page.title.company.create=Sukurti naują įmonę
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=Informacija
|
|||||||
page.title.profile.edit=Redaguoti profilį
|
page.title.profile.edit=Redaguoti profilį
|
||||||
page.title.admin.dashboard=Administratoriaus valdymo skydas
|
page.title.admin.dashboard=Administratoriaus valdymo skydas
|
||||||
page.title.invoice.create=Sukurti sąskaitą
|
page.title.invoice.create=Sukurti sąskaitą
|
||||||
page.title.customer.create=Sukurti naują klientą
|
page.title.customer.create=Sukurti naują adresą
|
||||||
page.title.login=Prisijungti prie VotianLT
|
page.title.login=Prisijungti prie VotianLT
|
||||||
page.title.jobs=Užsakymai
|
page.title.jobs=Užsakymai
|
||||||
page.title.appuser.edit=Redaguoti programėlės naudotoją
|
page.title.appuser.edit=Redaguoti programėlės naudotoją
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Ar tikrai norite ištrinti šį programėlės nau
|
|||||||
editappuser.dialog.delete.confirm=Ištrinti
|
editappuser.dialog.delete.confirm=Ištrinti
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Klientai
|
customers.title=Adresų knyga
|
||||||
customers.button.add=Pridėti naują klientą
|
customers.button.add=Pridėti naują adresą
|
||||||
customers.hint.click=Spustelėkite klientą, kad pamatytumėte informaciją
|
customers.hint.click=Spustelėkite adreso įrašą, kad pamatytumėte informaciją
|
||||||
customers.column.company=Įmonė
|
customers.column.company=Įmonė
|
||||||
customers.column.name=Pavadinimas
|
customers.column.name=Pavadinimas
|
||||||
customers.column.email=El. paštas
|
customers.column.email=El. paštas
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Ar tikrai norite ištrinti šį klientą?
|
|||||||
editcustomer.dialog.delete.confirm=Ištrinti
|
editcustomer.dialog.delete.confirm=Ištrinti
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Sukurti naują klientą
|
addcustomer.title=Sukurti naują adresą
|
||||||
addcustomer.button.submit=Sukurti klientą
|
addcustomer.button.submit=Sukurti adresą
|
||||||
addcustomer.notification.validation=Prašome užpildyti visus privalomus laukus
|
addcustomer.notification.validation=Prašome užpildyti visus privalomus laukus
|
||||||
addcustomer.notification.success=Klientas sėkmingai sukurtas
|
addcustomer.notification.success=Klientas sėkmingai sukurtas
|
||||||
|
addcustomer.notification.duplicate=Identiškas adresų knygos įrašas jau egzistuoja
|
||||||
addcustomer.notification.check=Prašome patikrinti savo įvestį
|
addcustomer.notification.check=Prašome patikrinti savo įvestį
|
||||||
addcustomer.notification.error=Klaida: {0}
|
addcustomer.notification.error=Klaida: {0}
|
||||||
addcustomer.validation.required=Šis laukas yra privalomas
|
addcustomer.validation.required=Šis laukas yra privalomas
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Nežinomas siuntėjas
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Sukurti naują užsakymą
|
addjob.title=Sukurti naują užsakymą
|
||||||
addjob.customer.label=Klientas
|
addjob.customer.label=Užsakovas
|
||||||
addjob.customer.placeholder=Pasirinkite klientą
|
addjob.customer.placeholder=Pasirinkite užsakovą
|
||||||
addjob.customer.unnamed=Klientas be pavadinimo
|
addjob.customer.unnamed=Neįvardytas užsakovas
|
||||||
addjob.button.clearfields=Išvalyti laukus
|
addjob.button.clearfields=Išvalyti laukus
|
||||||
addjob.button.submit=Sukurti užsakymą
|
addjob.button.submit=Sukurti užsakymą
|
||||||
addjob.address.salutation=Kreipinys
|
addjob.address.salutation=Kreipinys
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Ponas
|
|||||||
addjob.salutation.ms=Ponia
|
addjob.salutation.ms=Ponia
|
||||||
addjob.salutation.other=Kita
|
addjob.salutation.other=Kita
|
||||||
addjob.address.company.placeholder=Įveskite įmonę
|
addjob.address.company.placeholder=Įveskite įmonę
|
||||||
|
addjob.address.pickup.label=Atsiėmimo adresas
|
||||||
|
addjob.address.pickup.placeholder=Pasirinkti arba įvesti atsiėmimo adresą
|
||||||
|
addjob.address.delivery.label=Pristatymo adresas
|
||||||
|
addjob.address.delivery.placeholder=Pasirinkti arba įvesti pristatymo adresą
|
||||||
addjob.address.street.placeholder=Įveskite gatvę
|
addjob.address.street.placeholder=Įveskite gatvę
|
||||||
addjob.address.housenumber=Namo numeris
|
addjob.address.housenumber=Namo numeris
|
||||||
addjob.address.addition.placeholder=Adreso priedas
|
addjob.address.addition.placeholder=Adreso priedas
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Pasiektas maksimalus 25 pristatymo stočių skaičius
|
|||||||
addjob.station.unused=Nenaudojama
|
addjob.station.unused=Nenaudojama
|
||||||
addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse.
|
addjob.appointment.delivery.info=Pristatymo terminai nustatomi tiesiogiai pristatymo stotyse.
|
||||||
addjob.tab.addresses=Užsakovas ir adresai
|
addjob.tab.addresses=Užsakovas ir adresai
|
||||||
|
addjob.tab.pickup.address=Užsakovas ir atsiėmimo adresas
|
||||||
|
addjob.tab.delivery.address=Pristatymo adresas
|
||||||
addjob.tab.appointments=Terminai ir apdorojimas
|
addjob.tab.appointments=Terminai ir apdorojimas
|
||||||
addjob.tab.cargo=Krovinys
|
addjob.tab.cargo=Krovinys
|
||||||
addjob.tab.tasks=Užduotys
|
addjob.tab.tasks=Užduotys
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Prašome įvesti priežastį
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Atšaukti
|
jobsummary.dialog.manualcomplete.cancel=Atšaukti
|
||||||
jobsummary.dialog.manualcomplete.confirm=Priimti
|
jobsummary.dialog.manualcomplete.confirm=Priimti
|
||||||
jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu
|
jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu
|
||||||
|
jobmanualcomplete.route.hours=Valandos
|
||||||
|
jobmanualcomplete.route.minutes=Minutės
|
||||||
|
jobmanualcomplete.route.manual.hint=Maršruto duomenų nėra – prašome įvesti atstumą ir trukmę rankiniu būdu.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Užsakymai
|
jobs.title=Užsakymai
|
||||||
@@ -975,8 +985,123 @@ misc.retry=Bandyti dar kartą
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Kainų lentelė
|
adminpricetable.title=Kainų lentelė
|
||||||
adminpricetable.field.monthly=Mėnesinis bazinis paketas
|
adminpricetable.field.monthly=Mėnesinis bazinis paketas
|
||||||
adminpricetable.field.applicense=Programėlės naudojimo licencija
|
adminpricetable.field.appuserfee=Mokestis už programėlės naudotoją
|
||||||
adminpricetable.field.revenue=Pajamų dalis
|
adminpricetable.field.appuserfee.helper=Skaičiuojamas kas mėnesį už kiekvieną sukurtą programėlės naudotoją
|
||||||
adminpricetable.notification.saved=Kainų lentelė išsaugota
|
adminpricetable.notification.saved=Kainų lentelė išsaugota
|
||||||
adminpricetable.notification.save.error=Klaida išsaugant: {0}
|
adminpricetable.notification.save.error=Klaida išsaugant: {0}
|
||||||
adminpricetable.notification.load.error=Klaida įkeliant: {0}
|
adminpricetable.notification.load.error=Klaida įkeliant: {0}
|
||||||
|
|
||||||
|
# Sąskaitų generatorius - sistemos šablonas (sąskaitos naudotojams)
|
||||||
|
invoicegenerator.issuer.header=Sąskaitos išrašytojas
|
||||||
|
invoicegenerator.issuer.website=Svetainė
|
||||||
|
invoicegenerator.issuer.senderline=Siuntėjo eilutė
|
||||||
|
invoicegenerator.issuer.paymentterms=Mokėjimo sąlygos
|
||||||
|
invoicegenerator.issuer.footer=Poraštė
|
||||||
|
invoicegenerator.issuer.invoicedate=Sąskaitos data
|
||||||
|
invoicegenerator.recipient.header=Gavėjas (naudotojas)
|
||||||
|
invoicegenerator.recipient.company=Naudotojo įmonė
|
||||||
|
invoicegenerator.recipient.name=Naudotojo vardas
|
||||||
|
invoicegenerator.recipient.street=Naudotojo gatvė
|
||||||
|
invoicegenerator.recipient.city=Naudotojo miestas
|
||||||
|
invoicegenerator.recipient.email=Naudotojo el. paštas
|
||||||
|
invoicegenerator.template.saved=Šablonas išsaugotas
|
||||||
|
invoicegenerator.template.save.error=Klaida išsaugant šabloną: {0}
|
||||||
|
invoicegenerator.button.clear=Išvalyti
|
||||||
|
invoicegenerator.notification.cleared=Drobė išvalyta
|
||||||
|
invoicegenerator.notification.canvas.error=Nepavyko nuskaityti drobės duomenų
|
||||||
|
invoicegenerator.notification.preview.error=Peržiūros klaida: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=PDF peržiūra
|
||||||
|
|
||||||
|
# Admin Dashboard - Klientų apžvalga
|
||||||
|
admindashboard.section.customers=Klientai ({0})
|
||||||
|
admindashboard.customers.column.number=Kliento numeris
|
||||||
|
admindashboard.customers.column.owner=Naudotojas
|
||||||
|
admindashboard.customers.empty=Klientų nėra
|
||||||
|
invoicegenerator.positions.header=Pozicijos (kainų lentelė)
|
||||||
|
invoicegenerator.positions.list=Pozicijų sąrašas
|
||||||
|
button.undo=Anuliuoti
|
||||||
|
|
||||||
|
# Administratoriaus profilis (įmonės duomenys)
|
||||||
|
page.title.admin.profile=Redaguoti įmonės duomenis
|
||||||
|
adminprofile.title=Įmonės duomenys
|
||||||
|
adminprofile.field.companyname=Įmonės pavadinimas
|
||||||
|
adminprofile.field.companysubtitle=Įmonės priedas
|
||||||
|
adminprofile.field.street=Gatvė ir namo numeris
|
||||||
|
adminprofile.field.city=Pašto kodas ir miestas
|
||||||
|
adminprofile.field.phone=Telefonas
|
||||||
|
adminprofile.field.fax=Faksas
|
||||||
|
adminprofile.field.email=El. paštas
|
||||||
|
adminprofile.field.website=Svetainė
|
||||||
|
adminprofile.field.senderline=Siuntėjo eilutė
|
||||||
|
adminprofile.field.senderline.helper=Vienos eilutės siuntėjo informacija virš gavėjo adreso sąskaitose
|
||||||
|
adminprofile.field.paymentterms=Mokėjimo sąlygos
|
||||||
|
adminprofile.field.footer=Poraštė
|
||||||
|
adminprofile.field.footer.helper=Vadovai, mokesčių numeris, banko rekvizitai – kiekviena eilutė sąskaitoje rodoma atskirai
|
||||||
|
adminprofile.notification.saved=Įmonės duomenys išsaugoti
|
||||||
|
adminprofile.notification.save.error=Klaida išsaugant: {0}
|
||||||
|
adminprofile.notification.load.error=Klaida įkeliant: {0}
|
||||||
|
|
||||||
|
# Sąskaitų kūrimas (admin)
|
||||||
|
page.title.admin.createinvoices=Kurti sąskaitas
|
||||||
|
admincreateinvoices.title=Kurti sąskaitas
|
||||||
|
admincreateinvoices.hint=Sąskaitos apmokamos mėnesio pabaigoje. Jei klientas pradeda mėnesio viduryje, apmokestinamos tik likusios dienos iki mėnesio pabaigos.
|
||||||
|
admincreateinvoices.column.customer=Klientas
|
||||||
|
admincreateinvoices.column.discount=Nuolaida
|
||||||
|
admincreateinvoices.discount.position=Nuolaida ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Įvesti nuolaidą
|
||||||
|
admincreateinvoices.discount.dialog.percent=Nuolaida %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Nuolaidos priežastis
|
||||||
|
admincreateinvoices.discount.dialog.apply=Taikyti
|
||||||
|
admincreateinvoices.column.start=Klientas nuo
|
||||||
|
admincreateinvoices.column.days=Apmokestintos dienos
|
||||||
|
admincreateinvoices.column.due=Terminas
|
||||||
|
admincreateinvoices.column.net=Neto suma
|
||||||
|
admincreateinvoices.days.full=Visas mėnuo
|
||||||
|
admincreateinvoices.days.partial={0} iš {1} dienų
|
||||||
|
admincreateinvoices.prorata.suffix=(proporcingai {0}/{1} dienų)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} programėlės naudotojų)
|
||||||
|
admincreateinvoices.button.create=Sukurti sąskaitą
|
||||||
|
admincreateinvoices.notification.notemplate=Nėra sąskaitos šablono. Pirmiausia išsaugokite šabloną sąskaitų generatoriuje.
|
||||||
|
admincreateinvoices.notification.error=Klaida kuriant sąskaitą: {0}
|
||||||
|
admincreateinvoices.button.createselected=Sukurti sąskaitas ({0})
|
||||||
|
admincreateinvoices.field.month=Atsiskaitymo mėnuo
|
||||||
|
admincreateinvoices.filter.label=Rodyti
|
||||||
|
admincreateinvoices.filter.all=Visos sąskaitos
|
||||||
|
admincreateinvoices.filter.open=Tik atviros
|
||||||
|
admincreateinvoices.filter.sent=Tik išsiųstos
|
||||||
|
admincreateinvoices.dialog.title=Peržiūrėti sąskaitas
|
||||||
|
admincreateinvoices.dialog.position=Sąskaita {0} iš {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Atgal
|
||||||
|
admincreateinvoices.dialog.button.next=Toliau
|
||||||
|
admincreateinvoices.dialog.button.delete=Pašalinti iš sąrašo
|
||||||
|
admincreateinvoices.dialog.button.send=Siųsti sąskaitas
|
||||||
|
admincreateinvoices.pdf.button.send=Siųsti
|
||||||
|
admincreateinvoices.dialog.notification.removed=Sąskaita pašalinta iš sąrašo
|
||||||
|
admincreateinvoices.mail.subject=Jūsų sąskaita {0}
|
||||||
|
admincreateinvoices.mail.body=Gerbiami klientai,\n\npridedame Jūsų sąskaitą {0} už atsiskaitymo laikotarpį {1}.\n\nPagarbiai\n{2}
|
||||||
|
admincreateinvoices.notification.sent=El. paštu išsiųsta sąskaitų: {0}
|
||||||
|
admincreateinvoices.notification.sendfailed=Nepavyko išsiųsti sąskaitų: {0}
|
||||||
|
admincreateinvoices.button.zugferd=E. sąskaita (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=E. sąskaita {0} sukurta
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E. sąskaita sukurta, tačiau patikra nepavyko. Išsami informacija patikros ataskaitoje ZIP faile.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Klaida kuriant e. sąskaitą: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=E. sąskaitai nėra apmokestinamų pozicijų
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=Nepavyko sukurti e. sąskaitos {0}, sąskaitos nebuvo išsiųstos: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Patikrinimas nepavyko: {0}. Sąskaitos nebuvo išsiųstos.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Klaida kuriant bendrą ZIP failą: {0}
|
||||||
|
|
||||||
|
# E. sąskaita (ZUGFeRD) naudotojų sąskaitoms jų klientams
|
||||||
|
invoices.action.zugferd=E. sąskaita (ZIP)
|
||||||
|
invoices.action.sendinvoice=Siųsti el. paštu
|
||||||
|
invoices.notification.zugferd.created=E. sąskaita {0} sukurta
|
||||||
|
invoices.notification.zugferd.invalid=E. sąskaita sukurta, bet patikra nepavyko. Žr. patikros ataskaitą ZIP faile.
|
||||||
|
invoices.notification.zugferd.invalidsend=Siuntimas nutrauktas: e. sąskaitos {0} patikra nepavyko.
|
||||||
|
invoices.notification.zugferd.error=E. sąskaitos klaida: {0}
|
||||||
|
invoices.notification.email.missing=Sąskaitos gavėjui nėra išsaugoto el. pašto adreso.
|
||||||
|
invoices.notification.emailsent=Sąskaita {0} išsiųsta kaip e. sąskaita adresu {1}.
|
||||||
|
invoices.mail.subject=Jūsų sąskaita {0}
|
||||||
|
invoices.mail.body=Gerbiamieji,\n\npridedame Jūsų sąskaitą {0} kaip e. sąskaitą (ZIP su PDF ir sąskaitos duomenimis).\n\nPagarbiai\n{1}
|
||||||
|
profile.billing.ustid=PVM mokėtojo kodas
|
||||||
|
profile.billing.taxnumber=Mokesčių mokėtojo numeris
|
||||||
|
profile.billing.bankname=Bankas
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Informācijas panelis
|
|||||||
page.title.appuser.create=Izveidot jaunu lietotnes lietotāju
|
page.title.appuser.create=Izveidot jaunu lietotnes lietotāju
|
||||||
page.title.messages=Ziņojumi
|
page.title.messages=Ziņojumi
|
||||||
page.title.register=Reģistrēties VotianLT
|
page.title.register=Reģistrēties VotianLT
|
||||||
page.title.customers=Klienti
|
page.title.customers=Adrešu grāmata
|
||||||
page.title.customer.edit=Rediģēt klientu
|
page.title.customer.edit=Rediģēt klientu
|
||||||
page.title.verwaltung=Pārvaldība
|
page.title.verwaltung=Pārvaldība
|
||||||
page.title.company.create=Izveidot jaunu uzņēmumu
|
page.title.company.create=Izveidot jaunu uzņēmumu
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=Impressum
|
|||||||
page.title.profile.edit=Rediģēt profilu
|
page.title.profile.edit=Rediģēt profilu
|
||||||
page.title.admin.dashboard=Administrācijas panelis
|
page.title.admin.dashboard=Administrācijas panelis
|
||||||
page.title.invoice.create=Izveidot rēķinu
|
page.title.invoice.create=Izveidot rēķinu
|
||||||
page.title.customer.create=Izveidot jaunu klientu
|
page.title.customer.create=Izveidot jaunu adresi
|
||||||
page.title.login=Pieteikties VotianLT
|
page.title.login=Pieteikties VotianLT
|
||||||
page.title.jobs=Uzdevumi
|
page.title.jobs=Uzdevumi
|
||||||
page.title.appuser.edit=Rediģēt lietotnes lietotāju
|
page.title.appuser.edit=Rediģēt lietotnes lietotāju
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Vai tiešām vēlaties dzēst šo lietotnes lieto
|
|||||||
editappuser.dialog.delete.confirm=Dzēst
|
editappuser.dialog.delete.confirm=Dzēst
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Klienti
|
customers.title=Adrešu grāmata
|
||||||
customers.button.add=Pievienot jaunu klientu
|
customers.button.add=Pievienot jaunu adresi
|
||||||
customers.hint.click=Noklikšķiniet uz klienta, lai redzētu detaļas
|
customers.hint.click=Noklikšķiniet uz adreses ieraksta, lai redzētu detaļas
|
||||||
customers.column.company=Uzņēmums
|
customers.column.company=Uzņēmums
|
||||||
customers.column.name=Nosaukums
|
customers.column.name=Nosaukums
|
||||||
customers.column.email=E-pasts
|
customers.column.email=E-pasts
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Vai tiešām vēlaties dzēst šo klientu?
|
|||||||
editcustomer.dialog.delete.confirm=Dzēst
|
editcustomer.dialog.delete.confirm=Dzēst
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Izveidot jaunu klientu
|
addcustomer.title=Izveidot jaunu adresi
|
||||||
addcustomer.button.submit=Izveidot klientu
|
addcustomer.button.submit=Izveidot adresi
|
||||||
addcustomer.notification.validation=Lūdzu, aizpildiet visus obligātos laukus
|
addcustomer.notification.validation=Lūdzu, aizpildiet visus obligātos laukus
|
||||||
addcustomer.notification.success=Klients veiksmīgi izveidots
|
addcustomer.notification.success=Klients veiksmīgi izveidots
|
||||||
|
addcustomer.notification.duplicate=Identisks adrešu grāmatas ieraksts jau pastāv
|
||||||
addcustomer.notification.check=Lūdzu, pārbaudiet savus ievadītos datus
|
addcustomer.notification.check=Lūdzu, pārbaudiet savus ievadītos datus
|
||||||
addcustomer.notification.error=Kļūda: {0}
|
addcustomer.notification.error=Kļūda: {0}
|
||||||
addcustomer.validation.required=Šis lauks ir obligāts
|
addcustomer.validation.required=Šis lauks ir obligāts
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Nezināms sūtītājs
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Izveidot jaunu uzdevumu
|
addjob.title=Izveidot jaunu uzdevumu
|
||||||
addjob.customer.label=Klients
|
addjob.customer.label=Pasūtītājs
|
||||||
addjob.customer.placeholder=Izvēlēties klientu
|
addjob.customer.placeholder=Izvēlēties pasūtītāju
|
||||||
addjob.customer.unnamed=Nenosaukts klients
|
addjob.customer.unnamed=Nenosaukts pasūtītājs
|
||||||
addjob.button.clearfields=Notīrīt laukus
|
addjob.button.clearfields=Notīrīt laukus
|
||||||
addjob.button.submit=Izveidot uzdevumu
|
addjob.button.submit=Izveidot uzdevumu
|
||||||
addjob.address.salutation=Uzruna
|
addjob.address.salutation=Uzruna
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Kungs
|
|||||||
addjob.salutation.ms=Kundze
|
addjob.salutation.ms=Kundze
|
||||||
addjob.salutation.other=Cits
|
addjob.salutation.other=Cits
|
||||||
addjob.address.company.placeholder=Ievadiet uzņēmumu
|
addjob.address.company.placeholder=Ievadiet uzņēmumu
|
||||||
|
addjob.address.pickup.label=Saņemšanas adrese
|
||||||
|
addjob.address.pickup.placeholder=Izvēlēties vai ievadīt saņemšanas adresi
|
||||||
|
addjob.address.delivery.label=Piegādes adrese
|
||||||
|
addjob.address.delivery.placeholder=Izvēlēties vai ievadīt piegādes adresi
|
||||||
addjob.address.street.placeholder=Ievadiet ielu
|
addjob.address.street.placeholder=Ievadiet ielu
|
||||||
addjob.address.housenumber=Mājas numurs
|
addjob.address.housenumber=Mājas numurs
|
||||||
addjob.address.addition.placeholder=Adreses papildinājums
|
addjob.address.addition.placeholder=Adreses papildinājums
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Sasniegts maksimālais piegādes staciju skaits - 25
|
|||||||
addjob.station.unused=Netiek izmantots
|
addjob.station.unused=Netiek izmantots
|
||||||
addjob.appointment.delivery.info=Piegādes termiņi tiek noteikti tieši piegādes stacijās.
|
addjob.appointment.delivery.info=Piegādes termiņi tiek noteikti tieši piegādes stacijās.
|
||||||
addjob.tab.addresses=Pasūtītājs un adreses
|
addjob.tab.addresses=Pasūtītājs un adreses
|
||||||
|
addjob.tab.pickup.address=Pasūtītājs un saņemšanas adrese
|
||||||
|
addjob.tab.delivery.address=Piegādes adrese
|
||||||
addjob.tab.appointments=Termiņi un apstrāde
|
addjob.tab.appointments=Termiņi un apstrāde
|
||||||
addjob.tab.cargo=Krava
|
addjob.tab.cargo=Krava
|
||||||
addjob.tab.tasks=Uzdevuma darbības
|
addjob.tab.tasks=Uzdevuma darbības
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Lūdzu, ievadiet pamatojumu
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Atcelt
|
jobsummary.dialog.manualcomplete.cancel=Atcelt
|
||||||
jobsummary.dialog.manualcomplete.confirm=Apstiprināt
|
jobsummary.dialog.manualcomplete.confirm=Apstiprināt
|
||||||
jobsummary.history.manualcomplete.reason=Pabeigts manuāli
|
jobsummary.history.manualcomplete.reason=Pabeigts manuāli
|
||||||
|
jobmanualcomplete.route.hours=Stundas
|
||||||
|
jobmanualcomplete.route.minutes=Minūtes
|
||||||
|
jobmanualcomplete.route.manual.hint=Maršruta dati nav pieejami – lūdzu, manuāli ievadiet attālumu un ilgumu.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Uzdevumi
|
jobs.title=Uzdevumi
|
||||||
@@ -973,8 +983,123 @@ misc.retry=Mēģināt vēlreiz
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Cenu tabula
|
adminpricetable.title=Cenu tabula
|
||||||
adminpricetable.field.monthly=Ikmēneša pamatpakete
|
adminpricetable.field.monthly=Ikmēneša pamatpakete
|
||||||
adminpricetable.field.applicense=Lietotnes licence
|
adminpricetable.field.appuserfee=Maksa par lietotnes lietotāju
|
||||||
adminpricetable.field.revenue=Ieņēmumu daļa
|
adminpricetable.field.appuserfee.helper=Tiek aprēķināta katru mēnesi par katru izveidoto lietotnes lietotāju
|
||||||
adminpricetable.notification.saved=Cenu tabula saglabāta
|
adminpricetable.notification.saved=Cenu tabula saglabāta
|
||||||
adminpricetable.notification.save.error=Kļūda saglabājot: {0}
|
adminpricetable.notification.save.error=Kļūda saglabājot: {0}
|
||||||
adminpricetable.notification.load.error=Kļūda ielādējot: {0}
|
adminpricetable.notification.load.error=Kļūda ielādējot: {0}
|
||||||
|
|
||||||
|
# Rēķinu ģenerators - sistēmas veidne (rēķini lietotājiem)
|
||||||
|
invoicegenerator.issuer.header=Rēķina izrakstītājs
|
||||||
|
invoicegenerator.issuer.website=Tīmekļa vietne
|
||||||
|
invoicegenerator.issuer.senderline=Sūtītāja rinda
|
||||||
|
invoicegenerator.issuer.paymentterms=Apmaksas nosacījumi
|
||||||
|
invoicegenerator.issuer.footer=Kājene
|
||||||
|
invoicegenerator.issuer.invoicedate=Rēķina datums
|
||||||
|
invoicegenerator.recipient.header=Saņēmējs (lietotājs)
|
||||||
|
invoicegenerator.recipient.company=Lietotāja uzņēmums
|
||||||
|
invoicegenerator.recipient.name=Lietotāja vārds
|
||||||
|
invoicegenerator.recipient.street=Lietotāja iela
|
||||||
|
invoicegenerator.recipient.city=Lietotāja pilsēta
|
||||||
|
invoicegenerator.recipient.email=Lietotāja e-pasts
|
||||||
|
invoicegenerator.template.saved=Veidne saglabāta
|
||||||
|
invoicegenerator.template.save.error=Kļūda saglabājot veidni: {0}
|
||||||
|
invoicegenerator.button.clear=Notīrīt
|
||||||
|
invoicegenerator.notification.cleared=Audekls notīrīts
|
||||||
|
invoicegenerator.notification.canvas.error=Neizdevās nolasīt audekla datus
|
||||||
|
invoicegenerator.notification.preview.error=Priekšskatījuma kļūda: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=PDF priekšskatījums
|
||||||
|
|
||||||
|
# Admin Dashboard - Klientu pārskats
|
||||||
|
admindashboard.section.customers=Klienti ({0})
|
||||||
|
admindashboard.customers.column.number=Klienta numurs
|
||||||
|
admindashboard.customers.column.owner=Lietotājs
|
||||||
|
admindashboard.customers.empty=Nav klientu
|
||||||
|
invoicegenerator.positions.header=Pozīcijas (cenu tabula)
|
||||||
|
invoicegenerator.positions.list=Pozīciju saraksts
|
||||||
|
button.undo=Atsaukt
|
||||||
|
|
||||||
|
# Administratora profils (uzņēmuma dati)
|
||||||
|
page.title.admin.profile=Rediģēt uzņēmuma datus
|
||||||
|
adminprofile.title=Uzņēmuma dati
|
||||||
|
adminprofile.field.companyname=Uzņēmuma nosaukums
|
||||||
|
adminprofile.field.companysubtitle=Uzņēmuma papildinājums
|
||||||
|
adminprofile.field.street=Iela un mājas numurs
|
||||||
|
adminprofile.field.city=Pasta indekss un pilsēta
|
||||||
|
adminprofile.field.phone=Tālrunis
|
||||||
|
adminprofile.field.fax=Fakss
|
||||||
|
adminprofile.field.email=E-pasts
|
||||||
|
adminprofile.field.website=Tīmekļa vietne
|
||||||
|
adminprofile.field.senderline=Sūtītāja rinda
|
||||||
|
adminprofile.field.senderline.helper=Vienas rindas sūtītāja informācija virs saņēmēja adreses rēķinos
|
||||||
|
adminprofile.field.paymentterms=Maksājuma nosacījumi
|
||||||
|
adminprofile.field.footer=Kājene
|
||||||
|
adminprofile.field.footer.helper=Vadītāji, nodokļu numurs, bankas rekvizīti – katra rinda rēķinā parādās atsevišķi
|
||||||
|
adminprofile.notification.saved=Uzņēmuma dati saglabāti
|
||||||
|
adminprofile.notification.save.error=Kļūda saglabājot: {0}
|
||||||
|
adminprofile.notification.load.error=Kļūda ielādējot: {0}
|
||||||
|
|
||||||
|
# Rēķinu izveide (admin)
|
||||||
|
page.title.admin.createinvoices=Izveidot rēķinus
|
||||||
|
admincreateinvoices.title=Izveidot rēķinus
|
||||||
|
admincreateinvoices.hint=Rēķini jāapmaksā mēneša beigās. Ja klients sāk mēneša vidū, tiek aprēķinātas tikai atlikušās dienas līdz mēneša beigām.
|
||||||
|
admincreateinvoices.column.customer=Klients
|
||||||
|
admincreateinvoices.column.discount=Atlaide
|
||||||
|
admincreateinvoices.discount.position=Atlaide ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Ievadīt atlaidi
|
||||||
|
admincreateinvoices.discount.dialog.percent=Atlaide %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Atlaides iemesls
|
||||||
|
admincreateinvoices.discount.dialog.apply=Piemērot
|
||||||
|
admincreateinvoices.column.start=Klients kopš
|
||||||
|
admincreateinvoices.column.days=Aprēķinātās dienas
|
||||||
|
admincreateinvoices.column.due=Termiņš
|
||||||
|
admincreateinvoices.column.net=Neto summa
|
||||||
|
admincreateinvoices.days.full=Pilns mēnesis
|
||||||
|
admincreateinvoices.days.partial={0} no {1} dienām
|
||||||
|
admincreateinvoices.prorata.suffix=(proporcionāli {0}/{1} dienas)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} lietotnes lietotāji)
|
||||||
|
admincreateinvoices.button.create=Izveidot rēķinu
|
||||||
|
admincreateinvoices.notification.notemplate=Nav rēķina veidnes. Lūdzu, vispirms saglabājiet veidni rēķinu ģeneratorā.
|
||||||
|
admincreateinvoices.notification.error=Kļūda, veidojot rēķinu: {0}
|
||||||
|
admincreateinvoices.button.createselected=Izveidot rēķinus ({0})
|
||||||
|
admincreateinvoices.field.month=Norēķinu mēnesis
|
||||||
|
admincreateinvoices.filter.label=Rādīt
|
||||||
|
admincreateinvoices.filter.all=Visi rēķini
|
||||||
|
admincreateinvoices.filter.open=Tikai atvērtie
|
||||||
|
admincreateinvoices.filter.sent=Tikai nosūtītie
|
||||||
|
admincreateinvoices.dialog.title=Pārskatīt rēķinus
|
||||||
|
admincreateinvoices.dialog.position=Rēķins {0} no {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Atpakaļ
|
||||||
|
admincreateinvoices.dialog.button.next=Tālāk
|
||||||
|
admincreateinvoices.dialog.button.delete=Noņemt no saraksta
|
||||||
|
admincreateinvoices.dialog.button.send=Sūtīt rēķinus
|
||||||
|
admincreateinvoices.pdf.button.send=Sūtīt
|
||||||
|
admincreateinvoices.dialog.notification.removed=Rēķins noņemts no saraksta
|
||||||
|
admincreateinvoices.mail.subject=Jūsu rēķins {0}
|
||||||
|
admincreateinvoices.mail.body=Cienījamie klienti,\n\npielikumā ir Jūsu rēķins {0} par norēķinu periodu {1}.\n\nAr cieņu\n{2}
|
||||||
|
admincreateinvoices.notification.sent=Pa e-pastu nosūtīti rēķini: {0}
|
||||||
|
admincreateinvoices.notification.sendfailed=Neizdevās nosūtīt rēķinus: {0}
|
||||||
|
admincreateinvoices.button.zugferd=E-rēķins (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=E-rēķins {0} izveidots
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Detaļas skatiet validācijas atskaitē ZIP failā.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Kļūda, veidojot e-rēķinu: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=E-rēķinam nav pieejamu norēķinu pozīciju
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=Neizdevās izveidot e-rēķinu {0}, rēķini netika nosūtīti: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Validācija neizdevās: {0}. Rēķini netika nosūtīti.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Kļūda, veidojot apvienoto ZIP failu: {0}
|
||||||
|
|
||||||
|
# E-rēķins (ZUGFeRD) lietotāju rēķiniem viņu klientiem
|
||||||
|
invoices.action.zugferd=E-rēķins (ZIP)
|
||||||
|
invoices.action.sendinvoice=Sūtīt pa e-pastu
|
||||||
|
invoices.notification.zugferd.created=E-rēķins {0} izveidots
|
||||||
|
invoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Skatiet pārbaudes atskaiti ZIP failā.
|
||||||
|
invoices.notification.zugferd.invalidsend=Sūtīšana pārtraukta: e-rēķina {0} validācija neizdevās.
|
||||||
|
invoices.notification.zugferd.error=E-rēķina kļūda: {0}
|
||||||
|
invoices.notification.email.missing=Rēķina saņēmējam nav saglabāta e-pasta adrese.
|
||||||
|
invoices.notification.emailsent=Rēķins {0} nosūtīts kā e-rēķins uz {1}.
|
||||||
|
invoices.mail.subject=Jūsu rēķins {0}
|
||||||
|
invoices.mail.body=Cienījamie klienti!\n\nPielikumā Jūsu rēķins {0} kā e-rēķins (ZIP ar PDF un rēķina datiem).\n\nAr cieņu\n{1}
|
||||||
|
profile.billing.ustid=PVN numurs
|
||||||
|
profile.billing.taxnumber=Nodokļu maksātāja numurs
|
||||||
|
profile.billing.bankname=Banka
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Panel g\u0142\u00f3wny
|
|||||||
page.title.appuser.create=Dodaj nowego u\u017cytkownika aplikacji
|
page.title.appuser.create=Dodaj nowego u\u017cytkownika aplikacji
|
||||||
page.title.messages=Wiadomo\u015bci
|
page.title.messages=Wiadomo\u015bci
|
||||||
page.title.register=Rejestracja w VotianLT
|
page.title.register=Rejestracja w VotianLT
|
||||||
page.title.customers=Klienci
|
page.title.customers=Książka adresowa
|
||||||
page.title.customer.edit=Edytuj klienta
|
page.title.customer.edit=Edytuj klienta
|
||||||
page.title.verwaltung=Zarz\u0105dzanie
|
page.title.verwaltung=Zarz\u0105dzanie
|
||||||
page.title.company.create=Dodaj now\u0105 firm\u0119
|
page.title.company.create=Dodaj now\u0105 firm\u0119
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=Impressum
|
|||||||
page.title.profile.edit=Edytuj profil
|
page.title.profile.edit=Edytuj profil
|
||||||
page.title.admin.dashboard=Panel administracyjny
|
page.title.admin.dashboard=Panel administracyjny
|
||||||
page.title.invoice.create=Utw\u00f3rz faktur\u0119
|
page.title.invoice.create=Utw\u00f3rz faktur\u0119
|
||||||
page.title.customer.create=Dodaj nowego klienta
|
page.title.customer.create=Dodaj nowy adres
|
||||||
page.title.login=Logowanie do VotianLT
|
page.title.login=Logowanie do VotianLT
|
||||||
page.title.jobs=Zlecenia
|
page.title.jobs=Zlecenia
|
||||||
page.title.appuser.edit=Edytuj u\u017cytkownika aplikacji
|
page.title.appuser.edit=Edytuj u\u017cytkownika aplikacji
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Czy na pewno chcesz usun\u0105\u0107 tego u\u017c
|
|||||||
editappuser.dialog.delete.confirm=Usu\u0144
|
editappuser.dialog.delete.confirm=Usu\u0144
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Klienci
|
customers.title=Ksi\u0105\u017cka adresowa
|
||||||
customers.button.add=Dodaj nowego klienta
|
customers.button.add=Dodaj nowy adres
|
||||||
customers.hint.click=Kliknij klienta, aby zobaczy\u0107 szczeg\u00f3\u0142y
|
customers.hint.click=Kliknij wpis adresowy, aby zobaczy\u0107 szczeg\u00f3\u0142y
|
||||||
customers.column.company=Firma
|
customers.column.company=Firma
|
||||||
customers.column.name=Nazwa
|
customers.column.name=Nazwa
|
||||||
customers.column.email=E-mail
|
customers.column.email=E-mail
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Czy na pewno chcesz usun\u0105\u0107 tego klient
|
|||||||
editcustomer.dialog.delete.confirm=Usu\u0144
|
editcustomer.dialog.delete.confirm=Usu\u0144
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Dodaj nowego klienta
|
addcustomer.title=Dodaj nowy adres
|
||||||
addcustomer.button.submit=Dodaj klienta
|
addcustomer.button.submit=Dodaj adres
|
||||||
addcustomer.notification.validation=Prosz\u0119 wype\u0142ni\u0107 wszystkie wymagane pola
|
addcustomer.notification.validation=Prosz\u0119 wype\u0142ni\u0107 wszystkie wymagane pola
|
||||||
addcustomer.notification.success=Klient zosta\u0142 pomy\u015blnie dodany
|
addcustomer.notification.success=Klient zosta\u0142 pomy\u015blnie dodany
|
||||||
|
addcustomer.notification.duplicate=Identyczny wpis w ksi\u0105\u017cce adresowej ju\u017c istnieje
|
||||||
addcustomer.notification.check=Prosz\u0119 sprawdzi\u0107 wprowadzone dane
|
addcustomer.notification.check=Prosz\u0119 sprawdzi\u0107 wprowadzone dane
|
||||||
addcustomer.notification.error=B\u0142\u0105d: {0}
|
addcustomer.notification.error=B\u0142\u0105d: {0}
|
||||||
addcustomer.validation.required=To pole jest wymagane
|
addcustomer.validation.required=To pole jest wymagane
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Nieznany nadawca
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Dodaj nowe zlecenie
|
addjob.title=Dodaj nowe zlecenie
|
||||||
addjob.customer.label=Klient
|
addjob.customer.label=Zleceniodawca
|
||||||
addjob.customer.placeholder=Wybierz klienta
|
addjob.customer.placeholder=Wybierz zleceniodawcę
|
||||||
addjob.customer.unnamed=Klient bez nazwy
|
addjob.customer.unnamed=Nienazwany zleceniodawca
|
||||||
addjob.button.clearfields=Wyczy\u015b\u0107 pola
|
addjob.button.clearfields=Wyczy\u015b\u0107 pola
|
||||||
addjob.button.submit=Utw\u00f3rz zlecenie
|
addjob.button.submit=Utw\u00f3rz zlecenie
|
||||||
addjob.address.salutation=Zwrot grzeczno\u015bciowy
|
addjob.address.salutation=Zwrot grzeczno\u015bciowy
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Pan
|
|||||||
addjob.salutation.ms=Pani
|
addjob.salutation.ms=Pani
|
||||||
addjob.salutation.other=Inna
|
addjob.salutation.other=Inna
|
||||||
addjob.address.company.placeholder=Wprowad\u017a firm\u0119
|
addjob.address.company.placeholder=Wprowad\u017a firm\u0119
|
||||||
|
addjob.address.pickup.label=Adres odbioru
|
||||||
|
addjob.address.pickup.placeholder=Wybierz lub wprowad\u017a adres odbioru
|
||||||
|
addjob.address.delivery.label=Adres dostawy
|
||||||
|
addjob.address.delivery.placeholder=Wybierz lub wprowad\u017a adres dostawy
|
||||||
addjob.address.street.placeholder=Wprowad\u017a ulic\u0119
|
addjob.address.street.placeholder=Wprowad\u017a ulic\u0119
|
||||||
addjob.address.housenumber=Numer domu
|
addjob.address.housenumber=Numer domu
|
||||||
addjob.address.addition.placeholder=Dodatek do adresu
|
addjob.address.addition.placeholder=Dodatek do adresu
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Osi\u0105gni\u0119to maksymaln\u0105 liczb\u0119 25 s
|
|||||||
addjob.station.unused=Nieu\u017cywana
|
addjob.station.unused=Nieu\u017cywana
|
||||||
addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy.
|
addjob.appointment.delivery.info=Terminy dostaw s\u0105 ustalane bezpo\u015brednio w stacjach dostawy.
|
||||||
addjob.tab.addresses=Zleceniodawca i adresy
|
addjob.tab.addresses=Zleceniodawca i adresy
|
||||||
|
addjob.tab.pickup.address=Zleceniodawca i adres odbioru
|
||||||
|
addjob.tab.delivery.address=Adres dostawy
|
||||||
addjob.tab.appointments=Terminy i przetwarzanie
|
addjob.tab.appointments=Terminy i przetwarzanie
|
||||||
addjob.tab.cargo=\u0141adunek
|
addjob.tab.cargo=\u0141adunek
|
||||||
addjob.tab.tasks=Zadania
|
addjob.tab.tasks=Zadania
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Prosz\u0119 poda\u0107 uzasadni
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Anuluj
|
jobsummary.dialog.manualcomplete.cancel=Anuluj
|
||||||
jobsummary.dialog.manualcomplete.confirm=Akceptuj
|
jobsummary.dialog.manualcomplete.confirm=Akceptuj
|
||||||
jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie
|
jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie
|
||||||
|
jobmanualcomplete.route.hours=Godziny
|
||||||
|
jobmanualcomplete.route.minutes=Minuty
|
||||||
|
jobmanualcomplete.route.manual.hint=Brak danych trasy \u2013 prosz\u0119 r\u0119cznie poda\u0107 odleg\u0142o\u015b\u0107 i czas trwania.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Zlecenia
|
jobs.title=Zlecenia
|
||||||
@@ -973,8 +983,123 @@ misc.retry=Spr\u00f3buj ponownie
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Cennik
|
adminpricetable.title=Cennik
|
||||||
adminpricetable.field.monthly=Miesi\u0119czny pakiet podstawowy
|
adminpricetable.field.monthly=Miesi\u0119czny pakiet podstawowy
|
||||||
adminpricetable.field.applicense=Licencja aplikacji
|
adminpricetable.field.appuserfee=Opłata za użytkownika aplikacji
|
||||||
adminpricetable.field.revenue=Udzia\u0142 w przychodach
|
adminpricetable.field.appuserfee.helper=Naliczana miesięcznie za każdego skonfigurowanego użytkownika aplikacji
|
||||||
adminpricetable.notification.saved=Cennik zosta\u0142 zapisany
|
adminpricetable.notification.saved=Cennik zosta\u0142 zapisany
|
||||||
adminpricetable.notification.save.error=B\u0142\u0105d podczas zapisywania: {0}
|
adminpricetable.notification.save.error=B\u0142\u0105d podczas zapisywania: {0}
|
||||||
adminpricetable.notification.load.error=B\u0142\u0105d podczas \u0142adowania: {0}
|
adminpricetable.notification.load.error=B\u0142\u0105d podczas \u0142adowania: {0}
|
||||||
|
|
||||||
|
# Generator faktur - szablon systemowy (faktury dla użytkowników)
|
||||||
|
invoicegenerator.issuer.header=Wystawca faktury
|
||||||
|
invoicegenerator.issuer.website=Strona internetowa
|
||||||
|
invoicegenerator.issuer.senderline=Linia nadawcy
|
||||||
|
invoicegenerator.issuer.paymentterms=Warunki płatności
|
||||||
|
invoicegenerator.issuer.footer=Stopka
|
||||||
|
invoicegenerator.issuer.invoicedate=Data faktury
|
||||||
|
invoicegenerator.recipient.header=Odbiorca (użytkownik)
|
||||||
|
invoicegenerator.recipient.company=Firma użytkownika
|
||||||
|
invoicegenerator.recipient.name=Nazwisko użytkownika
|
||||||
|
invoicegenerator.recipient.street=Ulica użytkownika
|
||||||
|
invoicegenerator.recipient.city=Miasto użytkownika
|
||||||
|
invoicegenerator.recipient.email=E-mail użytkownika
|
||||||
|
invoicegenerator.template.saved=Szablon zapisany
|
||||||
|
invoicegenerator.template.save.error=Błąd podczas zapisywania szablonu: {0}
|
||||||
|
invoicegenerator.button.clear=Wyczyść
|
||||||
|
invoicegenerator.notification.cleared=Obszar roboczy wyczyszczony
|
||||||
|
invoicegenerator.notification.canvas.error=Nie można odczytać danych obszaru roboczego
|
||||||
|
invoicegenerator.notification.preview.error=Błąd podglądu: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=Podgląd PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Przegląd klientów
|
||||||
|
admindashboard.section.customers=Klienci ({0})
|
||||||
|
admindashboard.customers.column.number=Numer klienta
|
||||||
|
admindashboard.customers.column.owner=Użytkownik
|
||||||
|
admindashboard.customers.empty=Brak klientów
|
||||||
|
invoicegenerator.positions.header=Pozycje (cennik)
|
||||||
|
invoicegenerator.positions.list=Lista pozycji
|
||||||
|
button.undo=Cofnij
|
||||||
|
|
||||||
|
# Profil administratora (dane firmy)
|
||||||
|
page.title.admin.profile=Edytuj dane firmy
|
||||||
|
adminprofile.title=Dane firmy
|
||||||
|
adminprofile.field.companyname=Nazwa firmy
|
||||||
|
adminprofile.field.companysubtitle=Dodatek do nazwy
|
||||||
|
adminprofile.field.street=Ulica i numer
|
||||||
|
adminprofile.field.city=Kod pocztowy i miasto
|
||||||
|
adminprofile.field.phone=Telefon
|
||||||
|
adminprofile.field.fax=Faks
|
||||||
|
adminprofile.field.email=E-mail
|
||||||
|
adminprofile.field.website=Strona internetowa
|
||||||
|
adminprofile.field.senderline=Linia nadawcy
|
||||||
|
adminprofile.field.senderline.helper=Jednoliniowa informacja o nadawcy nad adresem odbiorcy na fakturach
|
||||||
|
adminprofile.field.paymentterms=Warunki płatności
|
||||||
|
adminprofile.field.footer=Stopka
|
||||||
|
adminprofile.field.footer.helper=Zarząd, numer podatkowy, dane bankowe – każda linia pojawia się jako osobny wiersz na fakturze
|
||||||
|
adminprofile.notification.saved=Dane firmy zapisane
|
||||||
|
adminprofile.notification.save.error=Błąd podczas zapisywania: {0}
|
||||||
|
adminprofile.notification.load.error=Błąd podczas ładowania: {0}
|
||||||
|
|
||||||
|
# Tworzenie faktur (admin)
|
||||||
|
page.title.admin.createinvoices=Utwórz faktury
|
||||||
|
admincreateinvoices.title=Utwórz faktury
|
||||||
|
admincreateinvoices.hint=Faktury są płatne na koniec miesiąca. Jeśli klient rozpoczyna w trakcie miesiąca, rozliczane są tylko dni pozostałe do końca miesiąca.
|
||||||
|
admincreateinvoices.column.customer=Klient
|
||||||
|
admincreateinvoices.column.discount=Rabat
|
||||||
|
admincreateinvoices.discount.position=Rabat ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Wprowadź rabat
|
||||||
|
admincreateinvoices.discount.dialog.percent=Rabat w %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Powód rabatu
|
||||||
|
admincreateinvoices.discount.dialog.apply=Zastosuj
|
||||||
|
admincreateinvoices.column.start=Klient od
|
||||||
|
admincreateinvoices.column.days=Rozliczone dni
|
||||||
|
admincreateinvoices.column.due=Termin płatności
|
||||||
|
admincreateinvoices.column.net=Kwota netto
|
||||||
|
admincreateinvoices.days.full=Pełny miesiąc
|
||||||
|
admincreateinvoices.days.partial={0} z {1} dni
|
||||||
|
admincreateinvoices.prorata.suffix=(proporcjonalnie {0}/{1} dni)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} użytkowników aplikacji)
|
||||||
|
admincreateinvoices.button.create=Utwórz fakturę
|
||||||
|
admincreateinvoices.notification.notemplate=Brak szablonu faktury. Najpierw zapisz szablon w generatorze faktur.
|
||||||
|
admincreateinvoices.notification.error=Błąd podczas tworzenia faktury: {0}
|
||||||
|
admincreateinvoices.button.createselected=Utwórz faktury ({0})
|
||||||
|
admincreateinvoices.field.month=Miesiąc rozliczeniowy
|
||||||
|
admincreateinvoices.filter.label=Pokaż
|
||||||
|
admincreateinvoices.filter.all=Wszystkie faktury
|
||||||
|
admincreateinvoices.filter.open=Tylko otwarte
|
||||||
|
admincreateinvoices.filter.sent=Tylko wysłane
|
||||||
|
admincreateinvoices.dialog.title=Przegląd faktur
|
||||||
|
admincreateinvoices.dialog.position=Faktura {0} z {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Wstecz
|
||||||
|
admincreateinvoices.dialog.button.next=Dalej
|
||||||
|
admincreateinvoices.dialog.button.delete=Usuń z listy
|
||||||
|
admincreateinvoices.dialog.button.send=Wyślij faktury
|
||||||
|
admincreateinvoices.pdf.button.send=Wyślij
|
||||||
|
admincreateinvoices.dialog.notification.removed=Faktura usunięta z listy
|
||||||
|
admincreateinvoices.mail.subject=Państwa faktura {0}
|
||||||
|
admincreateinvoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} za okres rozliczeniowy {1}.\n\nZ poważaniem\n{2}
|
||||||
|
admincreateinvoices.notification.sent=Wysłano e-mailem faktur: {0}
|
||||||
|
admincreateinvoices.notification.sendfailed=Nie udało się wysłać faktur: {0}
|
||||||
|
admincreateinvoices.button.zugferd=E-faktura (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=Utworzono e-fakturę {0}
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Szczegóły w raporcie walidacji w pliku ZIP.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Błąd podczas tworzenia e-faktury: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=Brak pozycji rozliczeniowych dla e-faktury
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=Nie udało się utworzyć e-faktury {0}, żadne faktury nie zostały wysłane: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Walidacja nie powiodła się dla: {0}. Żadne faktury nie zostały wysłane.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Błąd podczas tworzenia zbiorczego pliku ZIP: {0}
|
||||||
|
|
||||||
|
# E-faktura (ZUGFeRD) dla faktur użytkowników dla ich klientów
|
||||||
|
invoices.action.zugferd=E-faktura (ZIP)
|
||||||
|
invoices.action.sendinvoice=Wyślij e-mailem
|
||||||
|
invoices.notification.zugferd.created=E-faktura {0} utworzona
|
||||||
|
invoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Zobacz raport walidacji w pliku ZIP.
|
||||||
|
invoices.notification.zugferd.invalidsend=Wysyłka przerwana: walidacja e-faktury {0} nie powiodła się.
|
||||||
|
invoices.notification.zugferd.error=Błąd e-faktury: {0}
|
||||||
|
invoices.notification.email.missing=Dla odbiorcy faktury nie zapisano adresu e-mail.
|
||||||
|
invoices.notification.emailsent=Faktura {0} została wysłana jako e-faktura na adres {1}.
|
||||||
|
invoices.mail.subject=Państwa faktura {0}
|
||||||
|
invoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} jako e-fakturę (ZIP z plikiem PDF i danymi faktury).\n\nZ poważaniem\n{1}
|
||||||
|
profile.billing.ustid=NIP UE
|
||||||
|
profile.billing.taxnumber=Numer podatkowy
|
||||||
|
profile.billing.bankname=Bank
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Панель управления
|
|||||||
page.title.appuser.create=Создать нового пользователя приложения
|
page.title.appuser.create=Создать нового пользователя приложения
|
||||||
page.title.messages=Сообщения
|
page.title.messages=Сообщения
|
||||||
page.title.register=Регистрация в VotianLT
|
page.title.register=Регистрация в VotianLT
|
||||||
page.title.customers=Клиенты
|
page.title.customers=Адресная книга
|
||||||
page.title.customer.edit=Редактирование клиента
|
page.title.customer.edit=Редактирование клиента
|
||||||
page.title.verwaltung=Управление
|
page.title.verwaltung=Управление
|
||||||
page.title.company.create=Создать новую компанию
|
page.title.company.create=Создать новую компанию
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=Выходные данные
|
|||||||
page.title.profile.edit=Редактирование профиля
|
page.title.profile.edit=Редактирование профиля
|
||||||
page.title.admin.dashboard=Панель администратора
|
page.title.admin.dashboard=Панель администратора
|
||||||
page.title.invoice.create=Создать счёт
|
page.title.invoice.create=Создать счёт
|
||||||
page.title.customer.create=Создать нового клиента
|
page.title.customer.create=Создать новый адрес
|
||||||
page.title.login=Вход в VotianLT
|
page.title.login=Вход в VotianLT
|
||||||
page.title.jobs=Заказы
|
page.title.jobs=Заказы
|
||||||
page.title.appuser.edit=Редактирование пользователя приложения
|
page.title.appuser.edit=Редактирование пользователя приложения
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Вы действительно хотите уд
|
|||||||
editappuser.dialog.delete.confirm=Удалить
|
editappuser.dialog.delete.confirm=Удалить
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=Клиенты
|
customers.title=Адресная книга
|
||||||
customers.button.add=Добавить нового клиента
|
customers.button.add=Добавить новый адрес
|
||||||
customers.hint.click=Нажмите на клиента, чтобы увидеть подробности
|
customers.hint.click=Нажмите на запись адреса, чтобы увидеть подробности
|
||||||
customers.column.company=Компания
|
customers.column.company=Компания
|
||||||
customers.column.name=Имя
|
customers.column.name=Имя
|
||||||
customers.column.email=Эл. почта
|
customers.column.email=Эл. почта
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Вы действительно хотите у
|
|||||||
editcustomer.dialog.delete.confirm=Удалить
|
editcustomer.dialog.delete.confirm=Удалить
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Создать нового клиента
|
addcustomer.title=Создать новый адрес
|
||||||
addcustomer.button.submit=Создать клиента
|
addcustomer.button.submit=Создать адрес
|
||||||
addcustomer.notification.validation=Пожалуйста, заполните все обязательные поля
|
addcustomer.notification.validation=Пожалуйста, заполните все обязательные поля
|
||||||
addcustomer.notification.success=Клиент успешно создан
|
addcustomer.notification.success=Клиент успешно создан
|
||||||
|
addcustomer.notification.duplicate=Идентичная запись в адресной книге уже существует
|
||||||
addcustomer.notification.check=Пожалуйста, проверьте ваши данные
|
addcustomer.notification.check=Пожалуйста, проверьте ваши данные
|
||||||
addcustomer.notification.error=Ошибка: {0}
|
addcustomer.notification.error=Ошибка: {0}
|
||||||
addcustomer.validation.required=Это поле обязательно для заполнения
|
addcustomer.validation.required=Это поле обязательно для заполнения
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Неизвестный отправитель
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Создать новый заказ
|
addjob.title=Создать новый заказ
|
||||||
addjob.customer.label=Клиент
|
addjob.customer.label=Заказчик
|
||||||
addjob.customer.placeholder=Выберите клиента
|
addjob.customer.placeholder=Выберите заказчика
|
||||||
addjob.customer.unnamed=Безымянный клиент
|
addjob.customer.unnamed=Безымянный заказчик
|
||||||
addjob.button.clearfields=Очистить поля
|
addjob.button.clearfields=Очистить поля
|
||||||
addjob.button.submit=Создать заказ
|
addjob.button.submit=Создать заказ
|
||||||
addjob.address.salutation=Обращение
|
addjob.address.salutation=Обращение
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Господин
|
|||||||
addjob.salutation.ms=Госпожа
|
addjob.salutation.ms=Госпожа
|
||||||
addjob.salutation.other=Другое
|
addjob.salutation.other=Другое
|
||||||
addjob.address.company.placeholder=Введите компанию
|
addjob.address.company.placeholder=Введите компанию
|
||||||
|
addjob.address.pickup.label=Адрес забора
|
||||||
|
addjob.address.pickup.placeholder=Выберите или введите адрес забора
|
||||||
|
addjob.address.delivery.label=Адрес доставки
|
||||||
|
addjob.address.delivery.placeholder=Выберите или введите адрес доставки
|
||||||
addjob.address.street.placeholder=Введите улицу
|
addjob.address.street.placeholder=Введите улицу
|
||||||
addjob.address.housenumber=Номер дома
|
addjob.address.housenumber=Номер дома
|
||||||
addjob.address.addition.placeholder=Дополнение к адресу
|
addjob.address.addition.placeholder=Дополнение к адресу
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Достигнуто максимальное кол
|
|||||||
addjob.station.unused=Не используется
|
addjob.station.unused=Не используется
|
||||||
addjob.appointment.delivery.info=Сроки доставки устанавливаются непосредственно в станциях доставки.
|
addjob.appointment.delivery.info=Сроки доставки устанавливаются непосредственно в станциях доставки.
|
||||||
addjob.tab.addresses=Заказчик и адреса
|
addjob.tab.addresses=Заказчик и адреса
|
||||||
|
addjob.tab.pickup.address=Заказчик и адрес забора
|
||||||
|
addjob.tab.delivery.address=Адрес доставки
|
||||||
addjob.tab.appointments=Сроки и обработка
|
addjob.tab.appointments=Сроки и обработка
|
||||||
addjob.tab.cargo=Груз
|
addjob.tab.cargo=Груз
|
||||||
addjob.tab.tasks=Задачи
|
addjob.tab.tasks=Задачи
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укаж
|
|||||||
jobsummary.dialog.manualcomplete.cancel=Отмена
|
jobsummary.dialog.manualcomplete.cancel=Отмена
|
||||||
jobsummary.dialog.manualcomplete.confirm=Принять
|
jobsummary.dialog.manualcomplete.confirm=Принять
|
||||||
jobsummary.history.manualcomplete.reason=Завершено вручную
|
jobsummary.history.manualcomplete.reason=Завершено вручную
|
||||||
|
jobmanualcomplete.route.hours=Часы
|
||||||
|
jobmanualcomplete.route.minutes=Минуты
|
||||||
|
jobmanualcomplete.route.manual.hint=Данные маршрута отсутствуют — пожалуйста, введите расстояние и продолжительность вручную.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=Заказы
|
jobs.title=Заказы
|
||||||
@@ -973,8 +983,123 @@ misc.retry=Повторить попытку
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Таблица цен
|
adminpricetable.title=Таблица цен
|
||||||
adminpricetable.field.monthly=Ежемесячный базовый пакет
|
adminpricetable.field.monthly=Ежемесячный базовый пакет
|
||||||
adminpricetable.field.applicense=Лицензия на приложение
|
adminpricetable.field.appuserfee=Плата за пользователя приложения
|
||||||
adminpricetable.field.revenue=Участие в выручке
|
adminpricetable.field.appuserfee.helper=Начисляется ежемесячно за каждого настроенного пользователя приложения
|
||||||
adminpricetable.notification.saved=Таблица цен сохранена
|
adminpricetable.notification.saved=Таблица цен сохранена
|
||||||
adminpricetable.notification.save.error=Ошибка при сохранении: {0}
|
adminpricetable.notification.save.error=Ошибка при сохранении: {0}
|
||||||
adminpricetable.notification.load.error=Ошибка при загрузке: {0}
|
adminpricetable.notification.load.error=Ошибка при загрузке: {0}
|
||||||
|
|
||||||
|
# Генератор счетов - системный шаблон (счета пользователям)
|
||||||
|
invoicegenerator.issuer.header=Выставитель счёта
|
||||||
|
invoicegenerator.issuer.website=Веб-сайт
|
||||||
|
invoicegenerator.issuer.senderline=Строка отправителя
|
||||||
|
invoicegenerator.issuer.paymentterms=Условия оплаты
|
||||||
|
invoicegenerator.issuer.footer=Нижний колонтитул
|
||||||
|
invoicegenerator.issuer.invoicedate=Дата счёта
|
||||||
|
invoicegenerator.recipient.header=Получатель (пользователь)
|
||||||
|
invoicegenerator.recipient.company=Компания пользователя
|
||||||
|
invoicegenerator.recipient.name=Имя пользователя
|
||||||
|
invoicegenerator.recipient.street=Улица пользователя
|
||||||
|
invoicegenerator.recipient.city=Город пользователя
|
||||||
|
invoicegenerator.recipient.email=E-mail пользователя
|
||||||
|
invoicegenerator.template.saved=Шаблон сохранён
|
||||||
|
invoicegenerator.template.save.error=Ошибка при сохранении шаблона: {0}
|
||||||
|
invoicegenerator.button.clear=Очистить
|
||||||
|
invoicegenerator.notification.cleared=Холст очищен
|
||||||
|
invoicegenerator.notification.canvas.error=Не удалось прочитать данные холста
|
||||||
|
invoicegenerator.notification.preview.error=Ошибка предпросмотра: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=Предпросмотр PDF
|
||||||
|
|
||||||
|
# Admin Dashboard - Обзор клиентов
|
||||||
|
admindashboard.section.customers=Клиенты ({0})
|
||||||
|
admindashboard.customers.column.number=Номер клиента
|
||||||
|
admindashboard.customers.column.owner=Пользователь
|
||||||
|
admindashboard.customers.empty=Клиенты отсутствуют
|
||||||
|
invoicegenerator.positions.header=Позиции (прайс-лист)
|
||||||
|
invoicegenerator.positions.list=Список позиций
|
||||||
|
button.undo=Отменить
|
||||||
|
|
||||||
|
# Профиль администратора (данные компании)
|
||||||
|
page.title.admin.profile=Редактировать данные компании
|
||||||
|
adminprofile.title=Данные компании
|
||||||
|
adminprofile.field.companyname=Название компании
|
||||||
|
adminprofile.field.companysubtitle=Дополнение к названию
|
||||||
|
adminprofile.field.street=Улица и номер дома
|
||||||
|
adminprofile.field.city=Индекс и город
|
||||||
|
adminprofile.field.phone=Телефон
|
||||||
|
adminprofile.field.fax=Факс
|
||||||
|
adminprofile.field.email=Эл. почта
|
||||||
|
adminprofile.field.website=Веб-сайт
|
||||||
|
adminprofile.field.senderline=Строка отправителя
|
||||||
|
adminprofile.field.senderline.helper=Однострочная информация об отправителе над адресом получателя в счетах
|
||||||
|
adminprofile.field.paymentterms=Условия оплаты
|
||||||
|
adminprofile.field.footer=Нижний колонтитул
|
||||||
|
adminprofile.field.footer.helper=Руководители, налоговый номер, банковские реквизиты – каждая строка отображается отдельной строкой в счете
|
||||||
|
adminprofile.notification.saved=Данные компании сохранены
|
||||||
|
adminprofile.notification.save.error=Ошибка при сохранении: {0}
|
||||||
|
adminprofile.notification.load.error=Ошибка при загрузке: {0}
|
||||||
|
|
||||||
|
# Создание счетов (админ)
|
||||||
|
page.title.admin.createinvoices=Создать счета
|
||||||
|
admincreateinvoices.title=Создать счета
|
||||||
|
admincreateinvoices.hint=Счета подлежат оплате в конце месяца. Если клиент начинает в середине месяца, оплачиваются только оставшиеся дни до конца месяца.
|
||||||
|
admincreateinvoices.column.customer=Клиент
|
||||||
|
admincreateinvoices.column.discount=Скидка
|
||||||
|
admincreateinvoices.discount.position=Скидка ({0} %)
|
||||||
|
admincreateinvoices.discount.dialog.title=Указать скидку
|
||||||
|
admincreateinvoices.discount.dialog.percent=Скидка в %
|
||||||
|
admincreateinvoices.discount.dialog.reason=Причина скидки
|
||||||
|
admincreateinvoices.discount.dialog.apply=Применить
|
||||||
|
admincreateinvoices.column.start=Клиент с
|
||||||
|
admincreateinvoices.column.days=Расчетные дни
|
||||||
|
admincreateinvoices.column.due=Срок оплаты
|
||||||
|
admincreateinvoices.column.net=Сумма нетто
|
||||||
|
admincreateinvoices.days.full=Полный месяц
|
||||||
|
admincreateinvoices.days.partial={0} из {1} дней
|
||||||
|
admincreateinvoices.prorata.suffix=(пропорционально {0}/{1} дней)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} пользователей приложения)
|
||||||
|
admincreateinvoices.button.create=Создать счет
|
||||||
|
admincreateinvoices.notification.notemplate=Шаблон счета отсутствует. Сначала сохраните шаблон в генераторе счетов.
|
||||||
|
admincreateinvoices.notification.error=Ошибка при создании счета: {0}
|
||||||
|
admincreateinvoices.button.createselected=Создать счета ({0})
|
||||||
|
admincreateinvoices.field.month=Расчетный месяц
|
||||||
|
admincreateinvoices.filter.label=Показать
|
||||||
|
admincreateinvoices.filter.all=Все счета
|
||||||
|
admincreateinvoices.filter.open=Только открытые
|
||||||
|
admincreateinvoices.filter.sent=Только отправленные
|
||||||
|
admincreateinvoices.dialog.title=Проверка счетов
|
||||||
|
admincreateinvoices.dialog.position=Счет {0} из {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Назад
|
||||||
|
admincreateinvoices.dialog.button.next=Далее
|
||||||
|
admincreateinvoices.dialog.button.delete=Удалить из списка
|
||||||
|
admincreateinvoices.dialog.button.send=Отправить счета
|
||||||
|
admincreateinvoices.pdf.button.send=Отправить
|
||||||
|
admincreateinvoices.dialog.notification.removed=Счет удален из списка
|
||||||
|
admincreateinvoices.mail.subject=Ваш счет {0}
|
||||||
|
admincreateinvoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Вы найдете счет {0} за расчетный период {1}.\n\nС уважением\n{2}
|
||||||
|
admincreateinvoices.notification.sent=Отправлено счетов по эл. почте: {0}
|
||||||
|
admincreateinvoices.notification.sendfailed=Не удалось отправить счетов: {0}
|
||||||
|
admincreateinvoices.button.zugferd=Электронный счёт (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created=Электронный счёт {0} создан
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. Подробности в отчёте о проверке в ZIP-файле.
|
||||||
|
admincreateinvoices.notification.zugferd.error=Ошибка при создании электронного счёта: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=Нет позиций для выставления электронного счёта
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {0}. Счета не были отправлены.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Ошибка при создании общего ZIP-файла: {0}
|
||||||
|
|
||||||
|
# Электронный счёт (ZUGFeRD) для счетов пользователей их клиентам
|
||||||
|
invoices.action.zugferd=Электронный счёт (ZIP)
|
||||||
|
invoices.action.sendinvoice=Отправить по эл. почте
|
||||||
|
invoices.notification.zugferd.created=Электронный счёт {0} создан
|
||||||
|
invoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. См. отчёт о проверке в ZIP-файле.
|
||||||
|
invoices.notification.zugferd.invalidsend=Отправка отменена: проверка электронного счёта {0} не пройдена.
|
||||||
|
invoices.notification.zugferd.error=Ошибка электронного счёта: {0}
|
||||||
|
invoices.notification.email.missing=Для получателя счёта не сохранён адрес электронной почты.
|
||||||
|
invoices.notification.emailsent=Счёт {0} отправлен как электронный счёт на {1}.
|
||||||
|
invoices.mail.subject=Ваш счёт {0}
|
||||||
|
invoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Ваш счёт {0} в виде электронного счёта (ZIP с PDF и данными счёта).\n\nС уважением\n{1}
|
||||||
|
profile.billing.ustid=Идентификатор плательщика НДС
|
||||||
|
profile.billing.taxnumber=Налоговый номер
|
||||||
|
profile.billing.bankname=Банк
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ page.title.dashboard=VotianLT - Kontrol Paneli
|
|||||||
page.title.appuser.create=Yeni Uygulama Kullan\u0131c\u0131s\u0131 Olu\u015ftur
|
page.title.appuser.create=Yeni Uygulama Kullan\u0131c\u0131s\u0131 Olu\u015ftur
|
||||||
page.title.messages=Mesajlar
|
page.title.messages=Mesajlar
|
||||||
page.title.register=VotianLT'ye Kay\u0131t Ol
|
page.title.register=VotianLT'ye Kay\u0131t Ol
|
||||||
page.title.customers=M\u00fc\u015fteriler
|
page.title.customers=Adres defteri
|
||||||
page.title.customer.edit=M\u00fc\u015fteriyi D\u00fczenle
|
page.title.customer.edit=M\u00fc\u015fteriyi D\u00fczenle
|
||||||
page.title.verwaltung=Y\u00f6netim
|
page.title.verwaltung=Y\u00f6netim
|
||||||
page.title.company.create=Yeni \u015eirket Olu\u015ftur
|
page.title.company.create=Yeni \u015eirket Olu\u015ftur
|
||||||
@@ -248,7 +248,7 @@ page.title.imprint=K\u00fcnye
|
|||||||
page.title.profile.edit=Profili D\u00fczenle
|
page.title.profile.edit=Profili D\u00fczenle
|
||||||
page.title.admin.dashboard=Y\u00f6netici Kontrol Paneli
|
page.title.admin.dashboard=Y\u00f6netici Kontrol Paneli
|
||||||
page.title.invoice.create=Fatura Olu\u015ftur
|
page.title.invoice.create=Fatura Olu\u015ftur
|
||||||
page.title.customer.create=Yeni M\u00fc\u015fteri Olu\u015ftur
|
page.title.customer.create=Yeni Adres Olu\u015ftur
|
||||||
page.title.login=VotianLT'ye Giri\u015f Yap
|
page.title.login=VotianLT'ye Giri\u015f Yap
|
||||||
page.title.jobs=\u0130\u015fler
|
page.title.jobs=\u0130\u015fler
|
||||||
page.title.appuser.edit=Uygulama Kullan\u0131c\u0131s\u0131n\u0131 D\u00fczenle
|
page.title.appuser.edit=Uygulama Kullan\u0131c\u0131s\u0131n\u0131 D\u00fczenle
|
||||||
@@ -327,9 +327,9 @@ editappuser.dialog.delete.text=Bu uygulama kullan\u0131c\u0131s\u0131n\u0131 sil
|
|||||||
editappuser.dialog.delete.confirm=Sil
|
editappuser.dialog.delete.confirm=Sil
|
||||||
|
|
||||||
# Customers
|
# Customers
|
||||||
customers.title=M\u00fc\u015fteriler
|
customers.title=Adres defteri
|
||||||
customers.button.add=Yeni M\u00fc\u015fteri Ekle
|
customers.button.add=Yeni Adres Ekle
|
||||||
customers.hint.click=Ayr\u0131nt\u0131lar\u0131 g\u00f6rmek i\u00e7in bir m\u00fc\u015fteriye t\u0131klay\u0131n
|
customers.hint.click=Ayr\u0131nt\u0131lar\u0131 g\u00f6rmek i\u00e7in bir adres giri\u015fine t\u0131klay\u0131n
|
||||||
customers.column.company=\u015eirket
|
customers.column.company=\u015eirket
|
||||||
customers.column.name=Ad
|
customers.column.name=Ad
|
||||||
customers.column.email=E-Posta
|
customers.column.email=E-Posta
|
||||||
@@ -348,10 +348,11 @@ editcustomer.dialog.delete.text=Bu m\u00fc\u015fteriyi silmek istedi\u011finizde
|
|||||||
editcustomer.dialog.delete.confirm=Sil
|
editcustomer.dialog.delete.confirm=Sil
|
||||||
|
|
||||||
# Add Customer
|
# Add Customer
|
||||||
addcustomer.title=Yeni M\u00fc\u015fteri Olu\u015ftur
|
addcustomer.title=Yeni Adres Olu\u015ftur
|
||||||
addcustomer.button.submit=M\u00fc\u015fteri Olu\u015ftur
|
addcustomer.button.submit=Adres Olu\u015ftur
|
||||||
addcustomer.notification.validation=L\u00fctfen t\u00fcm zorunlu alanlar\u0131 doldurun
|
addcustomer.notification.validation=L\u00fctfen t\u00fcm zorunlu alanlar\u0131 doldurun
|
||||||
addcustomer.notification.success=M\u00fc\u015fteri ba\u015far\u0131yla olu\u015fturuldu
|
addcustomer.notification.success=M\u00fc\u015fteri ba\u015far\u0131yla olu\u015fturuldu
|
||||||
|
addcustomer.notification.duplicate=Adres defterinde ayn\u0131 kay\u0131t zaten mevcut
|
||||||
addcustomer.notification.check=L\u00fctfen giri\u015flerinizi kontrol edin
|
addcustomer.notification.check=L\u00fctfen giri\u015flerinizi kontrol edin
|
||||||
addcustomer.notification.error=Hata: {0}
|
addcustomer.notification.error=Hata: {0}
|
||||||
addcustomer.validation.required=Bu alan gereklidir
|
addcustomer.validation.required=Bu alan gereklidir
|
||||||
@@ -428,9 +429,9 @@ messages.sender.unknown=Bilinmeyen G\u00f6nderici
|
|||||||
|
|
||||||
# Add Job
|
# Add Job
|
||||||
addjob.title=Yeni \u0130\u015f Olu\u015ftur
|
addjob.title=Yeni \u0130\u015f Olu\u015ftur
|
||||||
addjob.customer.label=M\u00fc\u015fteri
|
addjob.customer.label=Sipari\u015f veren
|
||||||
addjob.customer.placeholder=M\u00fc\u015fteri Se\u00e7in
|
addjob.customer.placeholder=Sipari\u015f vereni se\u00e7
|
||||||
addjob.customer.unnamed=\u0130simsiz M\u00fc\u015fteri
|
addjob.customer.unnamed=\u0130simsiz sipari\u015f veren
|
||||||
addjob.button.clearfields=Alanlar\u0131 Temizle
|
addjob.button.clearfields=Alanlar\u0131 Temizle
|
||||||
addjob.button.submit=\u0130\u015f Olu\u015ftur
|
addjob.button.submit=\u0130\u015f Olu\u015ftur
|
||||||
addjob.address.salutation=Hitap
|
addjob.address.salutation=Hitap
|
||||||
@@ -439,6 +440,10 @@ addjob.salutation.mr=Bay
|
|||||||
addjob.salutation.ms=Bayan
|
addjob.salutation.ms=Bayan
|
||||||
addjob.salutation.other=Di\u011fer
|
addjob.salutation.other=Di\u011fer
|
||||||
addjob.address.company.placeholder=\u015eirketi girin
|
addjob.address.company.placeholder=\u015eirketi girin
|
||||||
|
addjob.address.pickup.label=Al\u0131m adresi
|
||||||
|
addjob.address.pickup.placeholder=Al\u0131m adresi se\u00e7in veya girin
|
||||||
|
addjob.address.delivery.label=Teslimat adresi
|
||||||
|
addjob.address.delivery.placeholder=Teslimat adresi se\u00e7in veya girin
|
||||||
addjob.address.street.placeholder=Soka\u011f\u0131 girin
|
addjob.address.street.placeholder=Soka\u011f\u0131 girin
|
||||||
addjob.address.housenumber=Kap\u0131 Numaras\u0131
|
addjob.address.housenumber=Kap\u0131 Numaras\u0131
|
||||||
addjob.address.addition.placeholder=Adres eki
|
addjob.address.addition.placeholder=Adres eki
|
||||||
@@ -459,6 +464,8 @@ addjob.station.max.reached=Maksimum 25 teslimat istasyonu s\u0131n\u0131r\u0131n
|
|||||||
addjob.station.unused=Kullan\u0131lm\u0131yor
|
addjob.station.unused=Kullan\u0131lm\u0131yor
|
||||||
addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir.
|
addjob.appointment.delivery.info=Teslimat tarihleri do\u011frudan teslimat istasyonlar\u0131nda belirlenir.
|
||||||
addjob.tab.addresses=M\u00fc\u015fteri & Adresler
|
addjob.tab.addresses=M\u00fc\u015fteri & Adresler
|
||||||
|
addjob.tab.pickup.address=Sipari\u015f veren ve al\u0131m adresi
|
||||||
|
addjob.tab.delivery.address=Teslimat adresi
|
||||||
addjob.tab.appointments=Randevular & \u0130\u015fleme
|
addjob.tab.appointments=Randevular & \u0130\u015fleme
|
||||||
addjob.tab.cargo=Kargo
|
addjob.tab.cargo=Kargo
|
||||||
addjob.tab.tasks=G\u00f6revler
|
addjob.tab.tasks=G\u00f6revler
|
||||||
@@ -620,6 +627,9 @@ jobsummary.dialog.manualcomplete.reason.required=Lütfen bir gerekçe girin
|
|||||||
jobsummary.dialog.manualcomplete.cancel=İptal
|
jobsummary.dialog.manualcomplete.cancel=İptal
|
||||||
jobsummary.dialog.manualcomplete.confirm=Kabul et
|
jobsummary.dialog.manualcomplete.confirm=Kabul et
|
||||||
jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı
|
jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı
|
||||||
|
jobmanualcomplete.route.hours=Saat
|
||||||
|
jobmanualcomplete.route.minutes=Dakika
|
||||||
|
jobmanualcomplete.route.manual.hint=Rota verisi mevcut değil – lütfen mesafeyi ve süreyi elle girin.
|
||||||
|
|
||||||
# Jobs
|
# Jobs
|
||||||
jobs.title=\u0130\u015fler
|
jobs.title=\u0130\u015fler
|
||||||
@@ -973,8 +983,123 @@ misc.retry=Tekrar Dene
|
|||||||
# Admin Price Table
|
# Admin Price Table
|
||||||
adminpricetable.title=Fiyat Tablosu
|
adminpricetable.title=Fiyat Tablosu
|
||||||
adminpricetable.field.monthly=Ayl\u0131k Temel Paket
|
adminpricetable.field.monthly=Ayl\u0131k Temel Paket
|
||||||
adminpricetable.field.applicense=Uygulama Kullan\u0131m Lisans\u0131
|
adminpricetable.field.appuserfee=Uygulama kullanıcısı başına ücret
|
||||||
adminpricetable.field.revenue=Gelir Pay\u0131
|
adminpricetable.field.appuserfee.helper=Kurulan her uygulama kullanıcısı için aylık olarak faturalandırılır
|
||||||
adminpricetable.notification.saved=Fiyat tablosu kaydedildi
|
adminpricetable.notification.saved=Fiyat tablosu kaydedildi
|
||||||
adminpricetable.notification.save.error=Kaydetme hatas\u0131: {0}
|
adminpricetable.notification.save.error=Kaydetme hatas\u0131: {0}
|
||||||
adminpricetable.notification.load.error=Y\u00fckleme hatas\u0131: {0}
|
adminpricetable.notification.load.error=Y\u00fckleme hatas\u0131: {0}
|
||||||
|
|
||||||
|
# Fatura oluşturucu - sistem şablonu (kullanıcılara faturalar)
|
||||||
|
invoicegenerator.issuer.header=Fatura düzenleyen
|
||||||
|
invoicegenerator.issuer.website=Web sitesi
|
||||||
|
invoicegenerator.issuer.senderline=Gönderen satırı
|
||||||
|
invoicegenerator.issuer.paymentterms=Ödeme koşulları
|
||||||
|
invoicegenerator.issuer.footer=Alt bilgi
|
||||||
|
invoicegenerator.issuer.invoicedate=Fatura tarihi
|
||||||
|
invoicegenerator.recipient.header=Alıcı (kullanıcı)
|
||||||
|
invoicegenerator.recipient.company=Kullanıcı firması
|
||||||
|
invoicegenerator.recipient.name=Kullanıcı adı
|
||||||
|
invoicegenerator.recipient.street=Kullanıcı sokağı
|
||||||
|
invoicegenerator.recipient.city=Kullanıcı şehri
|
||||||
|
invoicegenerator.recipient.email=Kullanıcı e-postası
|
||||||
|
invoicegenerator.template.saved=Şablon kaydedildi
|
||||||
|
invoicegenerator.template.save.error=Şablon kaydedilirken hata: {0}
|
||||||
|
invoicegenerator.button.clear=Temizle
|
||||||
|
invoicegenerator.notification.cleared=Tuval temizlendi
|
||||||
|
invoicegenerator.notification.canvas.error=Tuval verileri okunamadı
|
||||||
|
invoicegenerator.notification.preview.error=Önizleme hatası: {0}
|
||||||
|
invoicegenerator.pdf.preview.title=PDF önizleme
|
||||||
|
|
||||||
|
# Admin Dashboard - Müşteri genel bakışı
|
||||||
|
admindashboard.section.customers=Müşteriler ({0})
|
||||||
|
admindashboard.customers.column.number=Müşteri numarası
|
||||||
|
admindashboard.customers.column.owner=Kullanıcı
|
||||||
|
admindashboard.customers.empty=Müşteri yok
|
||||||
|
invoicegenerator.positions.header=Kalemler (fiyat tablosu)
|
||||||
|
invoicegenerator.positions.list=Kalemleri listele
|
||||||
|
button.undo=Geri al
|
||||||
|
|
||||||
|
# Yönetici profili (şirket verileri)
|
||||||
|
page.title.admin.profile=Şirket verilerini düzenle
|
||||||
|
adminprofile.title=Şirket verileri
|
||||||
|
adminprofile.field.companyname=Şirket adı
|
||||||
|
adminprofile.field.companysubtitle=Şirket eki
|
||||||
|
adminprofile.field.street=Cadde ve numara
|
||||||
|
adminprofile.field.city=Posta kodu ve şehir
|
||||||
|
adminprofile.field.phone=Telefon
|
||||||
|
adminprofile.field.fax=Faks
|
||||||
|
adminprofile.field.email=E-posta
|
||||||
|
adminprofile.field.website=Web sitesi
|
||||||
|
adminprofile.field.senderline=Gönderen satırı
|
||||||
|
adminprofile.field.senderline.helper=Faturalarda alıcı adresinin üzerindeki tek satırlık gönderen bilgisi
|
||||||
|
adminprofile.field.paymentterms=Ödeme koşulları
|
||||||
|
adminprofile.field.footer=Alt bilgi
|
||||||
|
adminprofile.field.footer.helper=Yöneticiler, vergi numarası, banka bilgileri – her satır faturada ayrı satır olarak görünür
|
||||||
|
adminprofile.notification.saved=Şirket verileri kaydedildi
|
||||||
|
adminprofile.notification.save.error=Kaydetme hatası: {0}
|
||||||
|
adminprofile.notification.load.error=Yükleme hatası: {0}
|
||||||
|
|
||||||
|
# Fatura oluşturma (admin)
|
||||||
|
page.title.admin.createinvoices=Fatura oluştur
|
||||||
|
admincreateinvoices.title=Fatura oluştur
|
||||||
|
admincreateinvoices.hint=Faturalar ay sonunda ödenir. Bir müşteri ay ortasında başlarsa, yalnızca ay sonuna kadar kalan günler faturalandırılır.
|
||||||
|
admincreateinvoices.column.customer=Müşteri
|
||||||
|
admincreateinvoices.column.discount=İndirim
|
||||||
|
admincreateinvoices.discount.position=İndirim (%{0})
|
||||||
|
admincreateinvoices.discount.dialog.title=İndirim gir
|
||||||
|
admincreateinvoices.discount.dialog.percent=İndirim %
|
||||||
|
admincreateinvoices.discount.dialog.reason=İndirim nedeni
|
||||||
|
admincreateinvoices.discount.dialog.apply=Uygula
|
||||||
|
admincreateinvoices.column.start=Müşteri başlangıcı
|
||||||
|
admincreateinvoices.column.days=Faturalanan günler
|
||||||
|
admincreateinvoices.column.due=Vade tarihi
|
||||||
|
admincreateinvoices.column.net=Net tutar
|
||||||
|
admincreateinvoices.days.full=Tam ay
|
||||||
|
admincreateinvoices.days.partial={0} / {1} gün
|
||||||
|
admincreateinvoices.prorata.suffix=(orantılı {0}/{1} gün)
|
||||||
|
admincreateinvoices.appusers.suffix=({0} uygulama kullanıcısı)
|
||||||
|
admincreateinvoices.button.create=Fatura oluştur
|
||||||
|
admincreateinvoices.notification.notemplate=Fatura şablonu yok. Lütfen önce fatura oluşturucuda bir şablon kaydedin.
|
||||||
|
admincreateinvoices.notification.error=Fatura oluşturulurken hata: {0}
|
||||||
|
admincreateinvoices.button.createselected=Fatura oluştur ({0})
|
||||||
|
admincreateinvoices.field.month=Fatura ayı
|
||||||
|
admincreateinvoices.filter.label=Göster
|
||||||
|
admincreateinvoices.filter.all=Tüm faturalar
|
||||||
|
admincreateinvoices.filter.open=Yalnızca açık
|
||||||
|
admincreateinvoices.filter.sent=Yalnızca gönderilen
|
||||||
|
admincreateinvoices.dialog.title=Faturaları incele
|
||||||
|
admincreateinvoices.dialog.position=Fatura {0} / {1}
|
||||||
|
admincreateinvoices.dialog.button.previous=Geri
|
||||||
|
admincreateinvoices.dialog.button.next=İleri
|
||||||
|
admincreateinvoices.dialog.button.delete=Listeden kaldır
|
||||||
|
admincreateinvoices.dialog.button.send=Faturaları gönder
|
||||||
|
admincreateinvoices.pdf.button.send=Gönder
|
||||||
|
admincreateinvoices.dialog.notification.removed=Fatura listeden kaldırıldı
|
||||||
|
admincreateinvoices.mail.subject=Faturanız {0}
|
||||||
|
admincreateinvoices.mail.body=Sayın yetkili,\n\n{1} fatura dönemine ait {0} numaralı faturanız ektedir.\n\nSaygılarımızla\n{2}
|
||||||
|
admincreateinvoices.notification.sent={0} fatura e-posta ile gönderildi
|
||||||
|
admincreateinvoices.notification.sendfailed={0} fatura gönderilemedi
|
||||||
|
admincreateinvoices.button.zugferd=E-fatura (ZIP)
|
||||||
|
admincreateinvoices.notification.zugferd.created={0} numaralı e-fatura oluşturuldu
|
||||||
|
admincreateinvoices.notification.zugferd.invalid=E-fatura oluşturuldu ancak doğrulama başarısız oldu. Ayrıntılar ZIP dosyasındaki doğrulama raporunda.
|
||||||
|
admincreateinvoices.notification.zugferd.error=E-fatura oluşturulurken hata: {0}
|
||||||
|
admincreateinvoices.notification.zugferd.nopositions=E-fatura için faturalandırılabilir pozisyon yok
|
||||||
|
admincreateinvoices.notification.zugferd.batcherror=E-fatura {0} oluşturulamadı, hiçbir fatura gönderilmedi: {1}
|
||||||
|
admincreateinvoices.notification.zugferd.invalidsend=Doğrulama başarısız oldu: {0}. Hiçbir fatura gönderilmedi.
|
||||||
|
admincreateinvoices.notification.zip.combined.error=Toplu ZIP dosyası oluşturulurken hata: {0}
|
||||||
|
|
||||||
|
# Kullanıcıların müşterilerine kestiği faturalar için e-fatura (ZUGFeRD)
|
||||||
|
invoices.action.zugferd=E-fatura (ZIP)
|
||||||
|
invoices.action.sendinvoice=E-posta ile gönder
|
||||||
|
invoices.notification.zugferd.created=E-fatura {0} oluşturuldu
|
||||||
|
invoices.notification.zugferd.invalid=E-fatura oluşturuldu, ancak doğrulama başarısız oldu. ZIP dosyasındaki doğrulama raporuna bakın.
|
||||||
|
invoices.notification.zugferd.invalidsend=Gönderim iptal edildi: {0} e-faturasının doğrulaması başarısız oldu.
|
||||||
|
invoices.notification.zugferd.error=E-fatura hatası: {0}
|
||||||
|
invoices.notification.email.missing=Fatura alıcısı için kayıtlı bir e-posta adresi yok.
|
||||||
|
invoices.notification.emailsent={0} numaralı fatura e-fatura olarak {1} adresine gönderildi.
|
||||||
|
invoices.mail.subject=Faturanız {0}
|
||||||
|
invoices.mail.body=Sayın yetkili,\n\nekte {0} numaralı faturanızı e-fatura olarak (PDF ve fatura verilerini içeren ZIP) bulabilirsiniz.\n\nSaygılarımızla\n{1}
|
||||||
|
profile.billing.ustid=KDV kimlik no
|
||||||
|
profile.billing.taxnumber=Vergi numarası
|
||||||
|
profile.billing.bankname=Banka
|
||||||
|
profile.billing.iban=IBAN
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pflichtangaben-Validierung nach § 14 UStG. Jeder Test mutiert exakt eine
|
||||||
|
* Eigenschaft der Referenzrechnung — so bleibt sichtbar, welche Regel gerade
|
||||||
|
* geprüft wird, und die Tests fungieren gleichzeitig als ausführbare
|
||||||
|
* Spezifikation.
|
||||||
|
*/
|
||||||
|
class InvoiceComplianceValidatorTest {
|
||||||
|
|
||||||
|
private InvoiceComplianceValidator validator;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
validator = new InvoiceComplianceValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsCompleteInvoice() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingInvoiceNumber() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setInvoiceNumber(" ");
|
||||||
|
assertSingleViolation(invoice, "Rechnungsnummer fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingInvoiceDate() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setInvoiceDate(null);
|
||||||
|
assertSingleViolation(invoice, "Rechnungsdatum");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingDeliveryDate() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setDeliveryDate(null);
|
||||||
|
assertSingleViolation(invoice, "Leistungsdatum");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingSenderName() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setSenderName(null);
|
||||||
|
assertSingleViolation(invoice, "Name des Leistenden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsIncompleteSenderAddress() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setSenderPostcode("");
|
||||||
|
assertSingleViolation(invoice, "Anschrift des Leistenden");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingSenderTaxIdentification() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setSenderTaxNumber(null);
|
||||||
|
invoice.setSenderVatId(null);
|
||||||
|
assertSingleViolation(invoice, "Steuernummer oder USt-IdNr");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsSenderWithOnlyTaxNumber() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setSenderVatId(null);
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsSenderWithOnlyVatId() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setSenderTaxNumber(null);
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingRecipientName() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setRecipientName(null);
|
||||||
|
assertSingleViolation(invoice, "Name des Leistungsempfängers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsIncompleteRecipientAddress() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setRecipientCity(null);
|
||||||
|
assertSingleViolation(invoice, "Anschrift des Leistungsempfängers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEmptyItems() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setItems(new ArrayList<>());
|
||||||
|
assertSingleViolation(invoice, "Keine Positionen erfasst");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsItemWithoutDescription() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.getItems().get(0).setDescription("");
|
||||||
|
assertSingleViolation(invoice, "Bezeichnung der Leistung fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsItemWithZeroQuantity() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
CustomerInvoiceItem item = invoice.getItems().get(0);
|
||||||
|
item.setQuantity(BigDecimal.ZERO);
|
||||||
|
// Zwingt netTotal = 0, damit nur die Mengenregel anschlägt und nicht zusätzlich
|
||||||
|
// die Konsistenzprüfung der Summen.
|
||||||
|
item.setNetTotal(BigDecimal.ZERO);
|
||||||
|
invoice.setNetAmount(BigDecimal.ZERO);
|
||||||
|
invoice.setVatAmount(BigDecimal.ZERO);
|
||||||
|
invoice.setTotalAmount(BigDecimal.ZERO);
|
||||||
|
// VAT-Hinweis muss bei 0 % aber gesetzt sein, sonst lösen wir zwei Verstöße aus.
|
||||||
|
invoice.setVatRate(new BigDecimal("0.19"));
|
||||||
|
assertSingleViolation(invoice, "Menge muss größer 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsItemWithNegativeUnitPrice() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
|
||||||
|
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||||
|
() -> validator.validateForIssuance(invoice));
|
||||||
|
assertThat(ex).isNotNull();
|
||||||
|
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInconsistentTotals() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setTotalAmount(new BigDecimal("999.99"));
|
||||||
|
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||||
|
() -> validator.validateForIssuance(invoice));
|
||||||
|
assertThat(ex).isNotNull();
|
||||||
|
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsItemSumMismatchingNet() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
// Items summieren sich weiterhin auf 100.00, aber das deklarierte Netto wird verstellt.
|
||||||
|
invoice.setNetAmount(new BigDecimal("80.00"));
|
||||||
|
invoice.setVatAmount(new BigDecimal("15.20"));
|
||||||
|
invoice.setTotalAmount(new BigDecimal("95.20"));
|
||||||
|
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||||
|
() -> validator.validateForIssuance(invoice));
|
||||||
|
assertThat(ex).isNotNull();
|
||||||
|
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingVatRate() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setVatRate(null);
|
||||||
|
assertSingleViolation(invoice, "Steuersatz fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsZeroVatWithoutLegalNotice() {
|
||||||
|
CustomerInvoice invoice = zeroVatInvoice();
|
||||||
|
invoice.setReverseChargeNote(null);
|
||||||
|
invoice.setLegalNotes(null);
|
||||||
|
assertSingleViolation(invoice, "Bei 0 % USt ist ein rechtlicher Hinweis erforderlich");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsZeroVatWithReverseChargeNote() {
|
||||||
|
CustomerInvoice invoice = zeroVatInvoice();
|
||||||
|
invoice.setReverseChargeNote("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG).");
|
||||||
|
invoice.setLegalNotes(null);
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsZeroVatWithLegalNotes() {
|
||||||
|
CustomerInvoice invoice = zeroVatInvoice();
|
||||||
|
invoice.setReverseChargeNote(null);
|
||||||
|
invoice.setLegalNotes("Kleinunternehmer im Sinne des § 19 Abs. 1 UStG — keine Umsatzsteuer ausgewiesen.");
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMismatchingVatAmountForNonZeroRate() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
// Erwartet wären 19,00 € — wir tragen 25,00 € ein.
|
||||||
|
invoice.setVatAmount(new BigDecimal("25.00"));
|
||||||
|
invoice.setTotalAmount(new BigDecimal("125.00"));
|
||||||
|
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||||
|
() -> validator.validateForIssuance(invoice));
|
||||||
|
assertThat(ex).isNotNull();
|
||||||
|
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collectsAllViolationsInOnePass() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setInvoiceNumber(null);
|
||||||
|
invoice.setSenderName(null);
|
||||||
|
invoice.setItems(new ArrayList<>());
|
||||||
|
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||||
|
() -> validator.validateForIssuance(invoice));
|
||||||
|
assertThat(ex).isNotNull();
|
||||||
|
assertThat(ex.getViolations())
|
||||||
|
.as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen")
|
||||||
|
.hasSizeGreaterThanOrEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancellationIsNotValidatedHere() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setType(InvoiceType.CANCELLATION);
|
||||||
|
invoice.setItems(new ArrayList<>()); // wäre für reguläre Rechnung ein Fehler
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void correctionIsNotValidatedHere() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setType(InvoiceType.CORRECTION);
|
||||||
|
invoice.setSenderName(null); // wäre für reguläre Rechnung ein Fehler
|
||||||
|
validator.validateForIssuance(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullInvoiceIsRejectedDirectly() {
|
||||||
|
assertThatThrownBy(() -> validator.validateForIssuance(null))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
|
||||||
|
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||||
|
() -> validator.validateForIssuance(invoice));
|
||||||
|
assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull();
|
||||||
|
assertThat(ex.getViolations())
|
||||||
|
.as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations())
|
||||||
|
.anyMatch(v -> v.contains(fragment));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice validInvoice() {
|
||||||
|
CustomerInvoice invoice = new CustomerInvoice();
|
||||||
|
invoice.setType(InvoiceType.INVOICE);
|
||||||
|
invoice.setInvoiceNumber("R-2026-0001");
|
||||||
|
invoice.setInvoiceDate(LocalDate.of(2026, 5, 3));
|
||||||
|
invoice.setDeliveryDate(LocalDate.of(2026, 5, 3));
|
||||||
|
|
||||||
|
invoice.setSenderName("Votianlt Test GmbH");
|
||||||
|
invoice.setSenderAddress("Teststraße 1");
|
||||||
|
invoice.setSenderPostcode("12345");
|
||||||
|
invoice.setSenderCity("Berlin");
|
||||||
|
invoice.setSenderCountry("DE");
|
||||||
|
invoice.setSenderTaxNumber("12/345/67890");
|
||||||
|
invoice.setSenderVatId("DE123456789");
|
||||||
|
|
||||||
|
invoice.setRecipientName("Empfänger AG");
|
||||||
|
invoice.setRecipientAddress("Kundenweg 2");
|
||||||
|
invoice.setRecipientPostcode("54321");
|
||||||
|
invoice.setRecipientCity("Hamburg");
|
||||||
|
invoice.setRecipientCountry("DE");
|
||||||
|
|
||||||
|
CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung",
|
||||||
|
new BigDecimal("100.00"), new BigDecimal("0.19"));
|
||||||
|
invoice.setItems(new ArrayList<>(List.of(item)));
|
||||||
|
|
||||||
|
invoice.setNetAmount(new BigDecimal("100.00"));
|
||||||
|
invoice.setVatRate(new BigDecimal("0.19"));
|
||||||
|
invoice.setVatAmount(new BigDecimal("19.00"));
|
||||||
|
invoice.setTotalAmount(new BigDecimal("119.00"));
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice zeroVatInvoice() {
|
||||||
|
CustomerInvoice invoice = validInvoice();
|
||||||
|
invoice.setVatRate(BigDecimal.ZERO);
|
||||||
|
invoice.setVatAmount(BigDecimal.ZERO);
|
||||||
|
invoice.setTotalAmount(invoice.getNetAmount());
|
||||||
|
CustomerInvoiceItem item = invoice.getItems().get(0);
|
||||||
|
item.setVatRate(BigDecimal.ZERO);
|
||||||
|
item.setVatAmount(BigDecimal.ZERO);
|
||||||
|
item.setGrossTotal(item.getNetTotal());
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
|
||||||
|
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
|
||||||
|
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
|
||||||
|
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.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class InvoiceNumberAuditServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private InvoiceNumberReservationRepository repository;
|
||||||
|
|
||||||
|
private InvoiceNumberAuditService service;
|
||||||
|
|
||||||
|
private final ObjectId userId = new ObjectId();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new InvoiceNumberAuditService(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markUsedTransitionsExistingReservation() {
|
||||||
|
InvoiceNumberReservation reservation = reservation("R-2026-000010", 10L,
|
||||||
|
InvoiceNumberReservationStatus.RESERVED);
|
||||||
|
when(repository.findByUserIdAndNumber(userId, "R-2026-000010")).thenReturn(Optional.of(reservation));
|
||||||
|
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
CustomerInvoice invoice = invoice("R-2026-000010", "invoice-id-42");
|
||||||
|
service.markUsed(invoice);
|
||||||
|
|
||||||
|
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
|
||||||
|
verify(repository).save(captor.capture());
|
||||||
|
InvoiceNumberReservation saved = captor.getValue();
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
|
||||||
|
assertThat(saved.getInvoiceId()).isEqualTo("invoice-id-42");
|
||||||
|
assertThat(saved.getUsedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markUsedBootstrapsReservationForLegacyInvoiceWithoutPriorReservation() {
|
||||||
|
when(repository.findByUserIdAndNumber(userId, "RE-2024-0007")).thenReturn(Optional.empty());
|
||||||
|
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.markUsed(invoice("RE-2024-0007", "legacy-invoice"));
|
||||||
|
|
||||||
|
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
|
||||||
|
verify(repository).save(captor.capture());
|
||||||
|
InvoiceNumberReservation saved = captor.getValue();
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
|
||||||
|
assertThat(saved.getNumber()).isEqualTo("RE-2024-0007");
|
||||||
|
assertThat(saved.getSequence()).isEqualTo(7L);
|
||||||
|
assertThat(saved.getReservedBy()).contains("bootstrap");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markUsedSwallowsRepositoryFailures() {
|
||||||
|
when(repository.findByUserIdAndNumber(any(), any())).thenThrow(new RuntimeException("Mongo down"));
|
||||||
|
// Erwartung: keine Exception nach außen — Festschreiben darf an Audit nicht scheitern.
|
||||||
|
service.markUsed(invoice("R-2026-1", "i-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markUsedIgnoresInvoiceWithoutNumberOrUserId() {
|
||||||
|
CustomerInvoice missingNumber = new CustomerInvoice();
|
||||||
|
missingNumber.setUserId(userId.toHexString());
|
||||||
|
service.markUsed(missingNumber);
|
||||||
|
|
||||||
|
CustomerInvoice missingUser = new CustomerInvoice();
|
||||||
|
missingUser.setInvoiceNumber("R-1");
|
||||||
|
service.markUsed(missingUser);
|
||||||
|
|
||||||
|
verify(repository, never()).findByUserIdAndNumber(any(), any());
|
||||||
|
verify(repository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markVoidedRequiresReason() {
|
||||||
|
assertThatThrownBy(() -> service.markVoided(userId, "R-1", " "))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("Grund");
|
||||||
|
assertThatThrownBy(() -> service.markVoided(userId, "R-1", null))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markVoidedTransitionsReservedToVoided() {
|
||||||
|
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
|
||||||
|
InvoiceNumberReservationStatus.RESERVED);
|
||||||
|
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
|
||||||
|
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.markVoided(userId, "R-2026-000005", "Versehentlich vergeben, Kunde widerrufen");
|
||||||
|
|
||||||
|
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
|
||||||
|
verify(repository).save(captor.capture());
|
||||||
|
InvoiceNumberReservation saved = captor.getValue();
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.VOIDED);
|
||||||
|
assertThat(saved.getVoidReason()).isEqualTo("Versehentlich vergeben, Kunde widerrufen");
|
||||||
|
assertThat(saved.getVoidedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markVoidedRefusesToOverwriteUsedReservation() {
|
||||||
|
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
|
||||||
|
InvoiceNumberReservationStatus.USED);
|
||||||
|
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.markVoided(userId, "R-2026-000005", "Test"))
|
||||||
|
.isInstanceOf(InvoiceLifecycleException.class)
|
||||||
|
.hasMessageContaining("ausgestellten Rechnung");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markVoidedFailsWhenNoReservationFound() {
|
||||||
|
when(repository.findByUserIdAndNumber(userId, "R-NOPE")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.markVoided(userId, "R-NOPE", "irrelevant"))
|
||||||
|
.isInstanceOf(InvoiceLifecycleException.class)
|
||||||
|
.hasMessageContaining("Keine Reservierung");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findUnusedReturnsOnlyReservedAndVoided() {
|
||||||
|
when(repository.findByUserIdOrderBySequenceAsc(userId)).thenReturn(List.of(
|
||||||
|
reservation("R-1", 1L, InvoiceNumberReservationStatus.USED),
|
||||||
|
reservation("R-2", 2L, InvoiceNumberReservationStatus.RESERVED),
|
||||||
|
reservation("R-3", 3L, InvoiceNumberReservationStatus.USED),
|
||||||
|
reservation("R-4", 4L, InvoiceNumberReservationStatus.VOIDED)));
|
||||||
|
|
||||||
|
List<InvoiceNumberReservation> unused = service.findUnused(userId);
|
||||||
|
|
||||||
|
assertThat(unused).extracting(InvoiceNumberReservation::getNumber).containsExactly("R-2", "R-4");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice invoice(String number, String invoiceId) {
|
||||||
|
CustomerInvoice invoice = new CustomerInvoice();
|
||||||
|
invoice.setId(invoiceId);
|
||||||
|
invoice.setInvoiceNumber(number);
|
||||||
|
invoice.setUserId(userId.toHexString());
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceNumberReservation reservation(String number, long sequence,
|
||||||
|
InvoiceNumberReservationStatus status) {
|
||||||
|
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
|
||||||
|
reservation.setUserId(userId);
|
||||||
|
reservation.setNumber(number);
|
||||||
|
reservation.setSequence(sequence);
|
||||||
|
reservation.setStatus(status);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
|
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Captor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserInvoiceZugferdServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PdfToolService pdfToolService;
|
||||||
|
|
||||||
|
@Captor
|
||||||
|
private ArgumentCaptor<PdfToolService.InvoiceMetadata> metadataCaptor;
|
||||||
|
|
||||||
|
private UserInvoiceZugferdService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new UserInvoiceZugferdService(pdfToolService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomerInvoice fullyPopulatedInvoice() {
|
||||||
|
CustomerInvoice invoice = new CustomerInvoice();
|
||||||
|
invoice.setInvoiceNumber("RE-2026-0001");
|
||||||
|
invoice.setInvoiceDate(LocalDate.of(2026, 7, 14));
|
||||||
|
invoice.setDeliveryDate(LocalDate.of(2026, 7, 10));
|
||||||
|
invoice.setPaymentDueDate(LocalDate.of(2026, 7, 28));
|
||||||
|
invoice.setSenderName("Spedition Muster GmbH");
|
||||||
|
invoice.setSenderAddress("Musterstraße 1");
|
||||||
|
invoice.setSenderPostcode("20095");
|
||||||
|
invoice.setSenderCity("Hamburg");
|
||||||
|
invoice.setSenderVatId("DE123456789");
|
||||||
|
invoice.setSenderEmail("info@muster.de");
|
||||||
|
invoice.setSenderPhone("040 123456");
|
||||||
|
invoice.setRecipientCompany("Kunde AG");
|
||||||
|
invoice.setRecipientName("Erika Beispiel");
|
||||||
|
invoice.setRecipientAddress("Beispielweg 2");
|
||||||
|
invoice.setRecipientPostcode("10115");
|
||||||
|
invoice.setRecipientCity("Berlin");
|
||||||
|
invoice.setRecipientEmail("rechnung@kunde-ag.de");
|
||||||
|
invoice.setVatRate(new BigDecimal("0.19"));
|
||||||
|
invoice.setNetAmount(new BigDecimal("100.00"));
|
||||||
|
invoice.setIban("DE02120300000000202051");
|
||||||
|
invoice.setPaymentTerms("Zahlbar innerhalb von 14 Tagen");
|
||||||
|
invoice.setItems(List.of(
|
||||||
|
new CustomerInvoiceItem(new BigDecimal("12"), "km", "Transport", new BigDecimal("5.00"),
|
||||||
|
new BigDecimal("0.19")),
|
||||||
|
new CustomerInvoiceItem(BigDecimal.ONE, "pauschal", "Grundpreis", new BigDecimal("40.00"),
|
||||||
|
new BigDecimal("0.19"))));
|
||||||
|
invoice.setPdfData(new byte[] { 1, 2, 3 });
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signBuildsMetadataFromInvoice() throws Exception {
|
||||||
|
CustomerInvoice invoice = fullyPopulatedInvoice();
|
||||||
|
PdfToolService.SignedInvoice signed = new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true);
|
||||||
|
when(pdfToolService.signInvoice(eq(invoice.getPdfData()), any())).thenReturn(signed);
|
||||||
|
|
||||||
|
PdfToolService.SignedInvoice result = service.sign(invoice);
|
||||||
|
|
||||||
|
assertThat(result).isSameAs(signed);
|
||||||
|
verify(pdfToolService).signInvoice(eq(invoice.getPdfData()), metadataCaptor.capture());
|
||||||
|
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
|
||||||
|
assertThat(metadata.invoiceNumber()).isEqualTo("RE-2026-0001");
|
||||||
|
assertThat(metadata.issueDate()).isEqualTo(LocalDate.of(2026, 7, 14));
|
||||||
|
assertThat(metadata.deliveryDate()).isEqualTo(LocalDate.of(2026, 7, 10));
|
||||||
|
assertThat(metadata.dueDate()).isEqualTo(LocalDate.of(2026, 7, 28));
|
||||||
|
assertThat(metadata.currency()).isEqualTo("EUR");
|
||||||
|
assertThat(metadata.iban()).isEqualTo("DE02120300000000202051");
|
||||||
|
assertThat(metadata.sender().name()).isEqualTo("Spedition Muster GmbH");
|
||||||
|
assertThat(metadata.sender().vatId()).isEqualTo("DE123456789");
|
||||||
|
assertThat(metadata.recipient().name()).isEqualTo("Kunde AG");
|
||||||
|
assertThat(metadata.recipient().zip()).isEqualTo("10115");
|
||||||
|
assertThat(metadata.recipient().email()).isEqualTo("rechnung@kunde-ag.de");
|
||||||
|
assertThat(metadata.items()).hasSize(2);
|
||||||
|
assertThat(metadata.items().get(0).description()).isEqualTo("Transport");
|
||||||
|
assertThat(metadata.items().get(0).quantity()).isEqualByComparingTo("12");
|
||||||
|
assertThat(metadata.items().get(0).unitPriceNet()).isEqualByComparingTo("5.00");
|
||||||
|
assertThat(metadata.items().get(0).vatPercent()).isEqualByComparingTo("19");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signFallsBackToSingleLineForLegacyInvoiceWithoutItems() throws Exception {
|
||||||
|
CustomerInvoice invoice = fullyPopulatedInvoice();
|
||||||
|
invoice.setItems(null);
|
||||||
|
invoice.setDescription("Kurierfahrt Hamburg–Berlin");
|
||||||
|
when(pdfToolService.signInvoice(any(), any()))
|
||||||
|
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
|
||||||
|
|
||||||
|
service.sign(invoice);
|
||||||
|
|
||||||
|
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
|
||||||
|
List<PdfToolService.LineItem> items = metadataCaptor.getValue().items();
|
||||||
|
assertThat(items).hasSize(1);
|
||||||
|
assertThat(items.get(0).description()).isEqualTo("Kurierfahrt Hamburg–Berlin");
|
||||||
|
assertThat(items.get(0).quantity()).isEqualByComparingTo("1");
|
||||||
|
assertThat(items.get(0).unitPriceNet()).isEqualByComparingTo("100.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signUsesDefaultDatesWhenMissing() throws Exception {
|
||||||
|
CustomerInvoice invoice = fullyPopulatedInvoice();
|
||||||
|
invoice.setDeliveryDate(null);
|
||||||
|
invoice.setPaymentDueDate(null);
|
||||||
|
when(pdfToolService.signInvoice(any(), any()))
|
||||||
|
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
|
||||||
|
|
||||||
|
service.sign(invoice);
|
||||||
|
|
||||||
|
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
|
||||||
|
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
|
||||||
|
assertThat(metadata.deliveryDate()).isEqualTo(invoice.getInvoiceDate());
|
||||||
|
assertThat(metadata.dueDate()).isEqualTo(invoice.getInvoiceDate().plusDays(14));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signRejectsInvoiceWithoutPdf() {
|
||||||
|
CustomerInvoice invoice = fullyPopulatedInvoice();
|
||||||
|
invoice.setPdfData(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("kein PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signRejectsInvoiceWithoutParties() {
|
||||||
|
CustomerInvoice invoice = fullyPopulatedInvoice();
|
||||||
|
invoice.setSenderName(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("Absender");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signRejectsInvoiceWithoutAnyAmount() {
|
||||||
|
CustomerInvoice invoice = fullyPopulatedInvoice();
|
||||||
|
invoice.setItems(null);
|
||||||
|
invoice.setNetAmount(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("Positionen");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{
|
{
|
||||||
"_version": "9.1",
|
"_version": "9.1",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user