feat: Admin-Firmendaten, Rechnungserstellung für Kunden mit Pro-rata-Abrechnung und Ultimo-Erinnerung
- Neue Seite /admin-profile: Firmendaten des Systembetreibers bearbeiten (MongoDB-persistiert), erreichbar über "Profil anzeigen" im Admin-Menü; Rechnungssteller-Daten aller System-Rechnungen kommen aus dem gespeicherten Profil - Neue Seite /admin-create-invoices mit Sidebar-Menüpunkt "Rechnungen erstellen": alle aktiven Kunden mit wählbarem Abrechnungsmonat (aktuell bis 12 Monate zurück), Fälligkeit zum Ultimo, anteilige Berechnung bei Monatsstart mitten im Monat, Einzel- und Sammel-PDF (zusammengeführt, eine Rechnung pro Seite) aus dem System-Template - Rechnungsgenerator: Undo-Vorbereitung aus Vorsession ergänzt um Firmendaten-Anbindung; Menüpunkt "Admin-Einstellungen" entfernt - MonthlySchedulerService: tägliche Prüfung, drei Tage vor Ultimo Erinnerungsmail an alle Administratoren - Session-Persistenz profilabhängig: dev und production aktiviert, Basis-Default aus (behebt SESSIONS.ser-Deserialisierungsfehler in anderen Kontexten) - ShowJobsView: überflüssiges Feld entfernt; AssertJ catchThrowableOfType auf neue Signatur umgestellt; tsconfig ignoreDeprecations - Übersetzungen für alle neuen Oberflächen in 10 Sprachen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import com.vaadin.flow.component.sidenav.SideNavItem;
|
||||
import com.vaadin.flow.router.Layout;
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed;
|
||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||
import de.assecutor.votianlt.pages.view.EditProfileView;
|
||||
import de.assecutor.votianlt.security.SecurityService;
|
||||
|
||||
import static com.vaadin.flow.theme.lumo.LumoUtility.*;
|
||||
@@ -105,6 +104,8 @@ public final class AdminLayout extends AppLayout {
|
||||
SideNavItem dashboard = new SideNavItem("Dashboard", "admin-dashboard", new Icon(VaadinIcon.DASHBOARD));
|
||||
SideNavItem invoiceGenerator = new SideNavItem("Rechnungsgenerator", "invoice-generator",
|
||||
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 systemSettings = new SideNavItem("Systemeinstellungen",
|
||||
// "admin-settings", new Icon(VaadinIcon.COG));
|
||||
@@ -115,6 +116,7 @@ public final class AdminLayout extends AppLayout {
|
||||
|
||||
nav.addItem(dashboard);
|
||||
nav.addItem(invoiceGenerator);
|
||||
nav.addItem(createInvoices);
|
||||
nav.addItem(priceTable);
|
||||
// nav.addItem(systemSettings);
|
||||
// nav.addItem(userManagement);
|
||||
@@ -149,9 +151,8 @@ public final class AdminLayout extends AppLayout {
|
||||
var userMenuItem = userMenu.addItem(avatar);
|
||||
userMenuItem.add(userNameSpan);
|
||||
|
||||
// Profile display with navigation
|
||||
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate(EditProfileView.class));
|
||||
userMenuItem.getSubMenu().addItem("Admin-Einstellungen");
|
||||
// Profile display with navigation to the admin company profile
|
||||
userMenuItem.getSubMenu().addItem("Profil anzeigen", e -> UI.getCurrent().navigate("admin-profile"));
|
||||
userMenuItem.getSubMenu().addItem("Abmelden", e -> openLogoutConfirmDialog());
|
||||
|
||||
// Update function for username and avatar
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
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.notification.Notification;
|
||||
import com.vaadin.flow.component.notification.NotificationVariant;
|
||||
import com.vaadin.flow.component.select.Select;
|
||||
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.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.PriceTableRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||
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.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 InvoiceTemplateService invoiceTemplateService;
|
||||
private final CustomerInvoiceService customerInvoiceService;
|
||||
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||
|
||||
/** Billing month, selectable by the user; defaults to the current month. */
|
||||
private YearMonth billingMonth = YearMonth.now();
|
||||
|
||||
public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository,
|
||||
InvoiceTemplateService invoiceTemplateService, CustomerInvoiceService customerInvoiceService,
|
||||
SystemCompanyProfileService systemCompanyProfileService) {
|
||||
this.userRepository = userRepository;
|
||||
this.priceTableRepository = priceTableRepository;
|
||||
this.invoiceTemplateService = invoiceTemplateService;
|
||||
this.customerInvoiceService = customerInvoiceService;
|
||||
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||
|
||||
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);
|
||||
|
||||
add(new ViewToolbar(getTranslation("admincreateinvoices.title")));
|
||||
|
||||
// Month selection left-aligned below the heading
|
||||
monthSelect.getStyle().set("align-self", "flex-start");
|
||||
add(monthSelect);
|
||||
|
||||
Paragraph hint = new Paragraph(getTranslation("admincreateinvoices.hint"));
|
||||
hint.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL, LumoUtility.Margin.NONE);
|
||||
add(hint);
|
||||
|
||||
Grid<BillingInfo> grid = new Grid<>();
|
||||
grid.setItems(loadBillingInfos());
|
||||
grid.addColumn(info -> customerDisplayName(info.user()))
|
||||
.setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true);
|
||||
grid.addColumn(info -> info.user().getEmail()).setHeader(getTranslation("admincreateinvoices.column.email"))
|
||||
.setFlexGrow(2);
|
||||
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(info.netTotal()) + " €")
|
||||
.setHeader(getTranslation("admincreateinvoices.column.net")).setFlexGrow(1)
|
||||
.setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END);
|
||||
grid.addComponentColumn(info -> {
|
||||
Button createButton = new Button(getTranslation("admincreateinvoices.button.create"));
|
||||
createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL);
|
||||
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 -> createInvoicesPdf(new ArrayList<>(grid.getSelectedItems())));
|
||||
monthSelect.addValueChangeListener(event -> {
|
||||
if (event.getValue() != null) {
|
||||
billingMonth = event.getValue();
|
||||
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);
|
||||
return userRepository.findAll().stream().filter(this::isCurrentCustomer)
|
||||
.filter(this::existedInBillingMonth).map(user -> computeBilling(user, priceTable)).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;
|
||||
|
||||
List<Map<String, String>> positions = new ArrayList<>();
|
||||
BigDecimal netTotal = BigDecimal.ZERO;
|
||||
String[][] entries = {
|
||||
{ getTranslation("adminpricetable.field.monthly"),
|
||||
priceTable != null ? priceTable.getMonthlyBasePackage() : null },
|
||||
{ getTranslation("adminpricetable.field.applicense"),
|
||||
priceTable != null ? priceTable.getAppUsageLicense() : null },
|
||||
{ getTranslation("adminpricetable.field.revenue"),
|
||||
priceTable != null ? priceTable.getRevenueParticipation() : null } };
|
||||
|
||||
for (String[] entry : entries) {
|
||||
String rawValue = entry[1] != null ? entry[1].trim() : "";
|
||||
BigDecimal amount = parseAmount(rawValue);
|
||||
Map<String, String> position = new HashMap<>();
|
||||
if (amount != null) {
|
||||
BigDecimal billed = amount.multiply(factor).setScale(2, RoundingMode.HALF_UP);
|
||||
String name = entry[0];
|
||||
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 (e.g. "15 %" revenue participation) are
|
||||
// shown as-is and are not part of the totals
|
||||
position.put("name", entry[0]);
|
||||
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);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PDF generation
|
||||
// ==========================================
|
||||
|
||||
private void createInvoicePdf(BillingInfo info) {
|
||||
String templateData = loadSystemTemplateData();
|
||||
if (templateData == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
|
||||
buildInvoiceVariables(info), null);
|
||||
showPdfInDialog(pdfBytes, buildInvoiceNumber(info.user()));
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the invoices of all selected customers as one merged PDF, one
|
||||
* invoice per page.
|
||||
*/
|
||||
private void createInvoicesPdf(List<BillingInfo> infos) {
|
||||
if (infos.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String templateData = loadSystemTemplateData();
|
||||
if (templateData == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<byte[]> pdfs = new ArrayList<>();
|
||||
for (BillingInfo info : infos) {
|
||||
pdfs.add(customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
|
||||
buildInvoiceVariables(info), null));
|
||||
}
|
||||
String fileName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||
showPdfInDialog(mergePdfs(pdfs), fileName);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 byte[] mergePdfs(List<byte[]> pdfs) throws Exception {
|
||||
if (pdfs.size() == 1) {
|
||||
return pdfs.get(0);
|
||||
}
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
com.itextpdf.kernel.pdf.PdfDocument merged = new com.itextpdf.kernel.pdf.PdfDocument(
|
||||
new com.itextpdf.kernel.pdf.PdfWriter(baos));
|
||||
com.itextpdf.kernel.utils.PdfMerger merger = new com.itextpdf.kernel.utils.PdfMerger(merged);
|
||||
for (byte[] pdf : pdfs) {
|
||||
com.itextpdf.kernel.pdf.PdfDocument source = new com.itextpdf.kernel.pdf.PdfDocument(
|
||||
new com.itextpdf.kernel.pdf.PdfReader(new java.io.ByteArrayInputStream(pdf)));
|
||||
merger.merge(source, 1, source.getNumberOfPages());
|
||||
source.close();
|
||||
}
|
||||
merged.close();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
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(byte[] pdfBytes, String invoiceNumber) {
|
||||
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(pdfBytes);
|
||||
|
||||
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_PRIMARY);
|
||||
|
||||
Button downloadButton = new Button(getTranslation("button.download"), e -> getElement()
|
||||
.executeJs("const link = document.createElement('a');" + "link.href = 'data:application/pdf;base64,"
|
||||
+ base64Pdf + "';" + "link.download = '" + invoiceNumber + ".pdf';" + "link.click();"));
|
||||
downloadButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
|
||||
pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
|
||||
pdfDialog.getFooter().add(downloadButton, closeButton);
|
||||
pdfDialog.open();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 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(".", ",");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
|
||||
import de.assecutor.votianlt.repository.PriceTableRepository;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||
import de.assecutor.votianlt.service.SystemCompanyProfileService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -68,14 +69,17 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
private final CustomerInvoiceService customerInvoiceService;
|
||||
private final InvoiceTemplateService invoiceTemplateService;
|
||||
private final PriceTableRepository priceTableRepository;
|
||||
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||
|
||||
private VerticalLayout propertiesPanel;
|
||||
|
||||
public InvoiceGeneratorView(CustomerInvoiceService customerInvoiceService,
|
||||
InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository) {
|
||||
InvoiceTemplateService invoiceTemplateService, PriceTableRepository priceTableRepository,
|
||||
SystemCompanyProfileService systemCompanyProfileService) {
|
||||
this.customerInvoiceService = customerInvoiceService;
|
||||
this.invoiceTemplateService = invoiceTemplateService;
|
||||
this.priceTableRepository = priceTableRepository;
|
||||
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||
|
||||
setId("invoice-generator-view");
|
||||
addClassNames("invoice-generator-view", "admin-form-view");
|
||||
@@ -174,7 +178,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
* invoice is generated.
|
||||
*/
|
||||
private Map<String, String> buildSystemVariables() {
|
||||
SystemInvoiceData data = new SystemInvoiceData();
|
||||
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
|
||||
Map<String, String> variables = new LinkedHashMap<>();
|
||||
String companyFull = (safe(data.getCompanyName()) + " " + safe(data.getCompanySubtitle())).trim();
|
||||
variables.put("masterdata.company_name", companyFull);
|
||||
@@ -853,13 +857,6 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
layout.setAlignItems(Alignment.CENTER);
|
||||
layout.addClassName("invoice-generator-actionbar");
|
||||
|
||||
Button clearButton = new Button(getTranslation("invoicegenerator.button.clear"), new Icon(VaadinIcon.TRASH));
|
||||
clearButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
|
||||
clearButton.addClickListener(e -> {
|
||||
getElement().executeJs("if (window.clearProfileCanvas) { window.clearProfileCanvas(); }");
|
||||
showNotification(getTranslation("invoicegenerator.notification.cleared"));
|
||||
});
|
||||
|
||||
Button undoButton = new Button(getTranslation("button.undo"), new Icon(VaadinIcon.ARROW_BACKWARD));
|
||||
undoButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||
undoButton.addClickListener(
|
||||
@@ -873,7 +870,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
|
||||
saveTemplateButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
saveTemplateButton.addClickListener(e -> saveSystemTemplate());
|
||||
|
||||
layout.add(undoButton, clearButton, previewButton, saveTemplateButton);
|
||||
layout.add(undoButton, previewButton, saveTemplateButton);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import jakarta.annotation.security.RolesAllowed;
|
||||
import com.vaadin.flow.component.UI;
|
||||
import com.vaadin.flow.server.StreamResource;
|
||||
import com.vaadin.flow.server.StreamRegistration;
|
||||
import de.assecutor.votianlt.service.SystemCompanyProfileService;
|
||||
import de.assecutor.votianlt.service.SystemInvoiceService;
|
||||
import de.assecutor.votianlt.util.DateTimeFormatUtil;
|
||||
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 Div emptyState = new Div();
|
||||
private final SystemInvoiceService systemInvoiceService;
|
||||
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||
|
||||
private static final NumberFormat CURRENCY_FMT = NumberFormat.getCurrencyInstance(Locale.GERMANY);
|
||||
|
||||
public MyInvoicesView(SystemInvoiceService systemInvoiceService) {
|
||||
public MyInvoicesView(SystemInvoiceService systemInvoiceService,
|
||||
SystemCompanyProfileService systemCompanyProfileService) {
|
||||
this.systemInvoiceService = systemInvoiceService;
|
||||
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||
addClassName("data-view");
|
||||
getStyle().set("max-width", "1100px");
|
||||
getStyle().set("margin-left", "auto");
|
||||
@@ -272,7 +276,7 @@ public class MyInvoicesView extends Main implements HasDynamicTitle {
|
||||
}
|
||||
|
||||
private byte[] generateSystemInvoicePdf(MyInvoiceRow row) throws Exception {
|
||||
SystemInvoiceData data = new SystemInvoiceData();
|
||||
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
|
||||
data.setInvoiceNumber(row.invoiceNumber());
|
||||
data.setInvoiceDate(DateTimeFormatUtil.formatDate(row.date()));
|
||||
data.setInvoiceText("Rechnung " + row.invoiceNumber());
|
||||
|
||||
@@ -50,7 +50,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
||||
private final SecurityService securityService;
|
||||
private final ClientConnectionService clientConnectionService;
|
||||
private final MessagingPublisher messagingPublisher;
|
||||
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
||||
|
||||
@Autowired
|
||||
@@ -62,7 +61,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
||||
this.securityService = securityService;
|
||||
this.clientConnectionService = clientConnectionService;
|
||||
this.messagingPublisher = messagingPublisher;
|
||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||
setSizeFull();
|
||||
setPadding(true);
|
||||
setSpacing(true);
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
@@ -7,6 +9,9 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
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
|
||||
@@ -17,6 +22,61 @@ public class MonthlySchedulerService {
|
||||
|
||||
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
|
||||
* des Monats ist. Wenn ja, wird die monatliche Aufgabe ausgeführt.
|
||||
|
||||
@@ -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
|
||||
public class SystemInvoiceService {
|
||||
|
||||
private final SystemCompanyProfileService systemCompanyProfileService;
|
||||
|
||||
public SystemInvoiceService(SystemCompanyProfileService systemCompanyProfileService) {
|
||||
this.systemCompanyProfileService = systemCompanyProfileService;
|
||||
}
|
||||
|
||||
public byte[] generateInvoicePdfFromHtml() throws Exception {
|
||||
// Read the HTML template
|
||||
String htmlContent = readHtmlTemplate();
|
||||
@@ -35,7 +41,7 @@ public class SystemInvoiceService {
|
||||
}
|
||||
|
||||
public SystemInvoiceData createSampleInvoiceData() {
|
||||
SystemInvoiceData data = new SystemInvoiceData();
|
||||
SystemInvoiceData data = systemCompanyProfileService.createSystemInvoiceData();
|
||||
|
||||
// Set sample data
|
||||
data.setInvoiceNumber("HHA-2021-007");
|
||||
|
||||
@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
|
||||
# Enable browser launch in development
|
||||
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
|
||||
logging.level.de.assecutor.votianlt=DEBUG
|
||||
logging.level.root=INFO
|
||||
|
||||
@@ -6,6 +6,11 @@ spring.data.mongodb.uri=${MONGODB_URI}
|
||||
# Disable browser launch in production
|
||||
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
|
||||
app.security.two-factor.enabled=true
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
server.port=${PORT:8082}
|
||||
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
|
||||
spring.profiles.active=dev
|
||||
|
||||
@@ -1086,3 +1086,42 @@ 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.email=E-Mail
|
||||
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.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
|
||||
|
||||
@@ -916,3 +916,42 @@ 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.email=E-post
|
||||
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.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
|
||||
|
||||
@@ -1086,3 +1086,42 @@ 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.email=Email
|
||||
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.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
|
||||
|
||||
@@ -1017,3 +1017,42 @@ 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.email=Correo electrónico
|
||||
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.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
|
||||
|
||||
@@ -1017,3 +1017,42 @@ 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.email=E-mail
|
||||
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.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
|
||||
|
||||
@@ -1019,3 +1019,42 @@ 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.email=El. paštas
|
||||
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.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
|
||||
|
||||
@@ -1017,3 +1017,42 @@ 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.email=E-pasts
|
||||
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.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
|
||||
|
||||
@@ -1017,3 +1017,42 @@ 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.email=E-mail
|
||||
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.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
|
||||
|
||||
@@ -1017,3 +1017,42 @@ 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.email=Эл. почта
|
||||
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.button.create=Создать счет
|
||||
admincreateinvoices.notification.notemplate=Шаблон счета отсутствует. Сначала сохраните шаблон в генераторе счетов.
|
||||
admincreateinvoices.notification.error=Ошибка при создании счета: {0}
|
||||
admincreateinvoices.button.createselected=Создать счета ({0})
|
||||
admincreateinvoices.field.month=Расчетный месяц
|
||||
|
||||
@@ -1017,3 +1017,42 @@ 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.email=E-posta
|
||||
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.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ı
|
||||
|
||||
@@ -141,8 +141,8 @@ class InvoiceComplianceValidatorTest {
|
||||
void rejectsItemWithNegativeUnitPrice() {
|
||||
CustomerInvoice invoice = validInvoice();
|
||||
invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
|
||||
InvoiceComplianceException ex = catchThrowableOfType(
|
||||
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
|
||||
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||
() -> validator.validateForIssuance(invoice));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
|
||||
}
|
||||
@@ -151,8 +151,8 @@ class InvoiceComplianceValidatorTest {
|
||||
void rejectsInconsistentTotals() {
|
||||
CustomerInvoice invoice = validInvoice();
|
||||
invoice.setTotalAmount(new BigDecimal("999.99"));
|
||||
InvoiceComplianceException ex = catchThrowableOfType(
|
||||
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
|
||||
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||
() -> validator.validateForIssuance(invoice));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
|
||||
}
|
||||
@@ -164,8 +164,8 @@ class InvoiceComplianceValidatorTest {
|
||||
invoice.setNetAmount(new BigDecimal("80.00"));
|
||||
invoice.setVatAmount(new BigDecimal("15.20"));
|
||||
invoice.setTotalAmount(new BigDecimal("95.20"));
|
||||
InvoiceComplianceException ex = catchThrowableOfType(
|
||||
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
|
||||
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||
() -> validator.validateForIssuance(invoice));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
|
||||
}
|
||||
@@ -207,8 +207,8 @@ class InvoiceComplianceValidatorTest {
|
||||
// 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(
|
||||
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
|
||||
InvoiceComplianceException ex = catchThrowableOfType(InvoiceComplianceException.class,
|
||||
() -> validator.validateForIssuance(invoice));
|
||||
assertThat(ex).isNotNull();
|
||||
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
|
||||
}
|
||||
@@ -219,8 +219,8 @@ class InvoiceComplianceValidatorTest {
|
||||
invoice.setInvoiceNumber(null);
|
||||
invoice.setSenderName(null);
|
||||
invoice.setItems(new ArrayList<>());
|
||||
InvoiceComplianceException ex = catchThrowableOfType(
|
||||
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
|
||||
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")
|
||||
@@ -250,8 +250,8 @@ class InvoiceComplianceValidatorTest {
|
||||
}
|
||||
|
||||
private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
|
||||
InvoiceComplianceException ex = catchThrowableOfType(
|
||||
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
|
||||
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())
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{
|
||||
"_version": "9.1",
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"inlineSources": true,
|
||||
|
||||
Reference in New Issue
Block a user