feat: Rechnungsvorlage und Statusmails für Version 0.9.25

- HHA-Systemrechnungsvorlage mit durchgehendem Rahmen und PDF-Vorschau ergänzen
- Zeichenreihenfolge im Editor und Schriftformatierung beim PDF-Export korrigieren
- Stationsbetreff vereinheitlichen und Tätigkeitsbeschreibung in Aufgabenmails aufnehmen
- Regressionstests, aktualisierte Frontend-Bundles und Backend-Version 0.9.25 aufnehmen
- Docker-Release-Build mit sauberem Maven-Build und Stacktrace ausführen
This commit is contained in:
Sven committed 2026-10-03 09:12:51 +02:00
1 parent b642f42954
commit 8044d82f04
13 files changed
+551 -34

No files matched your search

Binary file not shown.
Binary file not shown.
@@ -233,28 +233,9 @@ window.initProfileInvoiceGenerator = function() {
var img = new Image();
img.onload = function() {
imageCache[el.imageData] = img;
// Calculate dimensions to maintain aspect ratio
var imgAspect = img.width / img.height;
var boxAspect = w / h;
var drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = w;
drawH = w / imgAspect;
drawX = x;
drawY = y + (h - drawH) / 2;
} else {
drawW = h * imgAspect;
drawH = h;
drawX = x + (w - drawW) / 2;
drawY = y;
}
ctx.drawImage(img, drawX, drawY, drawW, drawH);
// Redraw selection if this element is selected
if (selectedElement && selectedElement.id === el.id) {
drawSelection(el);
}
// Redraw in element order: a letterhead loaded asynchronously must
// stay behind the invoice text, including after resize or removal.
draw();
};
img.src = el.imageData;
}
@@ -405,7 +405,8 @@ public class MessageController {
try {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String completedBy = resolveTaskActor(task, appUserId);
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy,
task.getDescription());
// Job completion is no longer auto-triggered by task completion.
// It is now driven by explicit station_completed messages from the app
// (see handleStationCompleted).
@@ -324,7 +324,7 @@ public class CustomerInvoiceService {
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -403,6 +403,9 @@ public class CustomerInvoiceService {
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
String fontStyle = element.path("fontStyle").asText("");
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
htmlBuilder.append("line-height:")
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";");
@@ -626,7 +629,7 @@ public class CustomerInvoiceService {
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -718,6 +721,9 @@ public class CustomerInvoiceService {
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
String fontStyle = element.path("fontStyle").asText("");
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
htmlBuilder.append("line-height:")
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";");
@@ -38,7 +38,8 @@ public class EmailService {
private String smtpUsername;
@Async
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy) {
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy,
String taskDescription) {
try {
// Load job to get context information
Optional<Job> jobOpt = jobRepository.findById(jobId);
@@ -65,7 +66,7 @@ public class EmailService {
String completedByName = resolveCompletedByName(job, completedBy);
// Send email
sendEmail(user, job, taskType, completedByName);
sendEmail(user, job, taskType, completedByName, taskDescription);
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
taskId);
@@ -75,7 +76,7 @@ public class EmailService {
}
}
private void sendEmail(User user, Job job, String taskType, String completedByName) {
private void sendEmail(User user, Job job, String taskType, String completedByName, String taskDescription) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername);
message.setTo(user.getEmail());
@@ -93,6 +94,9 @@ public class EmailService {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
}
body.append("Aufgabe: ").append(taskTypeName).append("\n");
if (taskDescription != null && !taskDescription.isBlank()) {
body.append("Beschreibung: ").append(taskDescription.strip()).append("\n");
}
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
String deliveryCities = job.getDeliveryCitiesDisplay();
@@ -227,7 +231,7 @@ public class EmailService {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername);
message.setTo(user.getEmail());
message.setSubject("Abgeschlossene Station - " + jobNumber + " - Station " + (stationOrder + 1));
message.setSubject("Station abgeschlossen - " + jobNumber + " - Station " + (stationOrder + 1));
StringBuilder body = new StringBuilder();
body.append("Hallo ").append(buildFullName(user)).append(",\n\n");
body.append("die folgende Station wurde abgeschlossen:\n\n");
@@ -0,0 +1,437 @@
{
"elements": [
{
"id": "hha-letterhead",
"type": "image",
"text": "",
"xPercent": "0.00000",
"yPercent": "0.00000",
"widthPercent": "100.00000",
"heightPercent": "100.00000",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null,
"imageData": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNTk1IiBoZWlnaHQ9Ijg0MiIgdmlld0JveD0iMCAwIDU5NSA4NDIiPgogIDxkZWZzPgogICAgPGltYWdlIGlkPSJvcmlnaW5hbC1sb2dvIiB4PSIwIiB5PSIwIiB3aWR0aD0iNTk1IiBoZWlnaHQ9IjcwLjg4IiB4bGluazpocmVmPSJkYXRhOmltYWdlL2pwZWc7YmFzZTY0LC85ai80QUFRU2taSlJnQUJBUUFBQVFBQkFBRC8yd0JEQUFNQ0FnTUNBZ01EQXdNRUF3TUVCUWdGQlFRRUJRb0hCd1lJREFvTURBc0tDd3NORGhJUURRNFJEZ3NMRUJZUUVSTVVGUlVWREE4WEdCWVVHQklVRlJULzJ3QkRBUU1FQkFVRUJRa0ZCUWtVRFFzTkZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlQvd2dBUkNBSlBFMkVEQVNJQUFoRUJBeEVCLzhRQUhRQUJBUUFEQVFFQkFRRUFBQUFBQUFBQUFBZ0dCd2tGQkFNQkF2L0VBQnNCQVFBQkJRRUFBQUFBQUFBQUFBQUFBQUFEQVFJRUJRWUgvOW9BREFNQkFBSVFBeEFBQUFHcVFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQLine truncated
},
{
"id": "hha-issuer-street",
"type": "text",
"text": "",
"xPercent": "64.36975",
"yPercent": "13.42043",
"widthPercent": "29.07563",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.street"
},
{
"id": "hha-issuer-city",
"type": "text",
"text": "",
"xPercent": "64.36975",
"yPercent": "14.98812",
"widthPercent": "29.07563",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.city"
},
{
"id": "hha-phone-label",
"type": "text",
"text": "Tel.:",
"xPercent": "64.36975",
"yPercent": "17.93349",
"widthPercent": "4.36975",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-issuer-phone",
"type": "text",
"text": "",
"xPercent": "68.73950",
"yPercent": "17.93349",
"widthPercent": "24.03361",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.phone"
},
{
"id": "hha-issuer-website",
"type": "text",
"text": "",
"xPercent": "64.36975",
"yPercent": "19.50119",
"widthPercent": "28.57143",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.website"
},
{
"id": "hha-sender-line",
"type": "text",
"text": "",
"xPercent": "9.57983",
"yPercent": "18.64608",
"widthPercent": "52.94118",
"heightPercent": "0.99762",
"fontSize": 7,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.sender_line"
},
{
"id": "hha-recipient-company_name",
"type": "text",
"text": "Musterfirma GmbH",
"xPercent": "9.57983",
"yPercent": "20.19002",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.company_name"
},
{
"id": "hha-recipient-contact_name",
"type": "text",
"text": "Max Mustermann",
"xPercent": "9.57983",
"yPercent": "21.90024",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.contact_name"
},
{
"id": "hha-recipient-street",
"type": "text",
"text": "Musterstraße 1",
"xPercent": "9.57983",
"yPercent": "23.61045",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.street"
},
{
"id": "hha-recipient-city",
"type": "text",
"text": "12345 Musterstadt",
"xPercent": "9.57983",
"yPercent": "25.32067",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.city"
},
{
"id": "hha-fax-label",
"type": "text",
"text": "Fax",
"xPercent": "64.36975",
"yPercent": "27.07838",
"widthPercent": "8.40336",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-fax-number",
"type": "text",
"text": "040-181237719",
"xPercent": "64.36975",
"yPercent": "28.50356",
"widthPercent": "9.74790",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-email-label",
"type": "text",
"text": "E-Mail",
"xPercent": "74.45378",
"yPercent": "27.07838",
"widthPercent": "17.81513",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-issuer-email",
"type": "text",
"text": "",
"xPercent": "74.45378",
"yPercent": "28.50356",
"widthPercent": "18.65546",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.email"
},
{
"id": "hha-your-reference",
"type": "text",
"text": "Ihr Zeichen, Ihre Nachricht vom",
"xPercent": "11.42857",
"yPercent": "35.51069",
"widthPercent": "23.19328",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-our-reference",
"type": "text",
"text": "Unser Zeichen, unsere Nachricht vom",
"xPercent": "35.63025",
"yPercent": "35.51069",
"widthPercent": "24.36975",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-contact-label",
"type": "text",
"text": "Telefon, Name",
"xPercent": "59.83193",
"yPercent": "35.51069",
"widthPercent": "16.80672",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-date-label",
"type": "text",
"text": "Datum",
"xPercent": "84.03361",
"yPercent": "35.51069",
"widthPercent": "8.73950",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-invoice-date",
"type": "text",
"text": "",
"xPercent": "84.03361",
"yPercent": "36.93587",
"widthPercent": "8.90756",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.invoice_date"
},
{
"id": "hha-invoice-title",
"type": "header",
"text": "Rechnung",
"xPercent": "9.57983",
"yPercent": "40.38005",
"widthPercent": "10.75630",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "bold",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-invoice-number",
"type": "text",
"text": "",
"xPercent": "20.33613",
"yPercent": "40.38005",
"widthPercent": "50.42017",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "bold",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.invoice_number"
},
{
"id": "hha-intro",
"type": "text",
"text": "Gemäß unserem Nutzungsvertrag berechnen wir Ihnen\nfolgende Leistungen:",
"xPercent": "9.57983",
"yPercent": "44.53682",
"widthPercent": "83.36134",
"heightPercent": "3.42043",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-positions",
"type": "text",
"text": "Rechnungspositionen",
"xPercent": "9.57983",
"yPercent": "49.04988",
"widthPercent": "83.36134",
"heightPercent": "26.72209",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "services.list"
},
{
"id": "hha-payment-label",
"type": "text",
"text": "Zahlungsbedingungen:",
"xPercent": "9.57983",
"yPercent": "85.03563",
"widthPercent": "22.68908",
"heightPercent": "1.71021",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-payment-text",
"type": "text",
"text": "Gesamtbetrag bis spätestens zum 10. Werktag nach\nRechnungserhalt auf unser u. g. Konto.",
"xPercent": "33.27731",
"yPercent": "85.03563",
"widthPercent": "59.66387",
"heightPercent": "3.42043",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-footer",
"type": "text",
"text": "",
"xPercent": "9.24370",
"yPercent": "92.75534",
"widthPercent": "84.03361",
"heightPercent": "3.42043",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "center",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.footer"
}
]
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
const generator = readFileSync(new URL('../../main/frontend/invoice-generator/profile-invoice-generator.js', import.meta.url), 'utf8');
function createEditor() {
const painted = [];
const pendingImages = [];
const context = new Proxy({
measureText: text => ({ width: text.length * 6 }),
drawImage: image => painted.push(['image', image.src]),
fillText: text => painted.push(['text', text])
}, { get: (target, key) => target[key] ?? (() => {}) });
const canvas = { style: {}, getContext: () => context, addEventListener() {}, setAttribute() {} };
const container = {
style: {},
getBoundingClientRect: () => ({ width: 615, height: 862 }),
appendChild() {},
addEventListener() {}
};
const window = { addEventListener() {} };
class Image {
width = 595;
height = 842;
constructor() { pendingImages.push(this); }
}
vm.runInNewContext(generator, {
window,
document: { getElementById: () => container, createElement: () => canvas },
Image,
console: { log() {}, error(error) { throw new Error(String(error)); } }
});
window.initProfileInvoiceGenerator();
window.loadProfileTemplate({ elements: [
{ id: 'letterhead', type: 'image', imageData: 'data:image/svg+xml;base64,test', x: 0, y: 0, width: 595, height: 842 },
{ id: 'invoice-text', type: 'text', text: 'Rechnung', x: 50, y: 250, width: 100, height: 30 }
] });
return { window, painted, pendingImages };
}
test('invoice text stays above a letterhead that finishes loading later', () => {
const { painted, pendingImages } = createEditor();
assert.deepEqual(painted, [['text', 'Rechnung']]);
painted.length = 0;
pendingImages[0].onload();
assert.deepEqual(painted, [
['image', 'data:image/svg+xml;base64,test'],
['text', 'Rechnung']
]);
});
test('a removed letterhead is not painted when its pending image finishes loading', () => {
const { window, painted, pendingImages } = createEditor();
window.deleteProfileElement('letterhead');
painted.length = 0;
pendingImages[0].onload();
assert.deepEqual(painted, [['text', 'Rechnung']]);
});
@@ -87,6 +87,8 @@ class TaskCompletionHistoryTest {
verify(taskRepository).save(task);
verify(jobHistoryService).logTaskCompletion(eq(job.getId()), eq(task.getTaskType()), eq(task.getIdAsString()),
eq(actor), eq(task.getDisplayName()), anyString(), eq(task.getDescription()));
verify(emailService).sendTaskCompletionNotification(job.getId(), task.getTaskType(), task.getIdAsString(),
actor, "Am Seiteneingang abgeben.");
if (task instanceof PhotoTask) {
verify(photoRepository).save(argThat(photo -> actor.equals(photo.getCompletedBy())));
} else if (task instanceof SignatureTask) {
@@ -20,6 +20,9 @@ import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
@@ -57,7 +60,7 @@ class EmailServiceTest {
}
@Test
void sendTaskCompletionNotificationUsesAssignedAppUserName() {
void sendTaskCompletionNotificationIncludesDescriptionAndAssignedAppUserName() {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
@@ -66,7 +69,8 @@ class EmailServiceTest {
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored");
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored",
" Ware am Seiteneingang fotografieren.\nAuch die Verpackung dokumentieren. ");
verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText();
@@ -74,6 +78,27 @@ class EmailServiceTest {
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
assertThat(body).contains("Auftrag: ").doesNotContain("Job:");
assertThat(body).contains("Aufgabe: Foto-Aufgabe\nBeschreibung: Ware am Seiteneingang fotografieren.\n"
+ "Auch die Verpackung dokumentieren.\nAbgeschlossen von: Max Mustermann");
assertThat(mailCaptor.getValue().getSubject()).isEqualTo("Aufgabe abgeschlossen - JOB-2026-001");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " \n\t " })
void taskCompletionEmailWithoutDescriptionStillSends(String description) {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
emailService.sendTaskCompletionNotification(job.getId(), "CONFIRMATION", "task-1", "ignored", description);
verify(mailSender).send(mailCaptor.capture());
assertThat(mailCaptor.getValue().getText()).contains("Aufgabe: Bestätigung\nAbgeschlossen von: Max Mustermann")
.doesNotContain("Beschreibung:", "null");
}
@Test
@@ -134,7 +159,7 @@ class EmailServiceTest {
SimpleMailMessage mail = mailCaptor.getValue();
assertThat(mail.getTo()).containsExactly("anna@example.com");
assertThat(mail.getFrom()).isEqualTo("noreply@example.com");
assertThat(mail.getSubject()).isEqualTo("Abgeschlossene Station - AUFTRAG20260929001 - Station 2");
assertThat(mail.getSubject()).isEqualTo("Station abgeschlossen - AUFTRAG20260929001 - Station 2");
assertThat(mail.getText()).contains("Hallo Dr. Anna Unternehmer,", "Auftrag: AUFTRAG20260929001",
"Station: 2", "Empfänger GmbH", "Petra Beispiel", "Hafenstraße 12", "Tor 3", "20457 Hamburg",
"Abgeschlossen von: Erika Musterfrau").doesNotContain("null", "Max Mustermann", "Job:");