package com.alexanderlogistics.metrics; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.metrics.Metadata; import org.eclipse.microprofile.metrics.MetricRegistry; import org.eclipse.microprofile.metrics.Tag; import org.eclipse.microprofile.metrics.annotation.RegistryScope; import org.imixs.archive.core.SnapshotService; import org.imixs.workflow.ItemCollection; import org.imixs.workflow.engine.DocumentService; import org.imixs.workflow.engine.ProcessingEvent; import org.imixs.workflow.engine.SetupEvent; import org.imixs.workflow.exceptions.PluginException; import com.alexanderlogistics.InvoiceUtil; import com.alexanderlogistics.KreditorDebitorService; import jakarta.annotation.security.DeclareRoles; import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RunAs; import jakarta.ejb.Singleton; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; /** * Dieser Service reagiert auch ProcessingEvents und speichert/aktualisiert die * zugehörige Creditor Metric Entity (type=metric.creditor). *

* Beim Rechnungseingang gilt eine Rechnung als Bezahlt wenn diese einen finalen * Status erreicht hat (>=5800) *

* Der Service liest in einem AFTER_PROCESS Event den alten invoice.saldo aus. * Hierzu wird die Rechnung in einer neuen Transaktion geladen was einem * 'Dirty-Read' entspricht. Dadurch kennt die Methode den letzten saldo der * Rechnung. Hat sich nun der aktuelle Saldo geändert, aktualisiert der Serivce * die entsprechende Metric. * * */ @DeclareRoles({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) @RolesAllowed({ "org.imixs.ACCESSLEVEL.NOACCESS", "org.imixs.ACCESSLEVEL.READERACCESS", "org.imixs.ACCESSLEVEL.AUTHORACCESS", "org.imixs.ACCESSLEVEL.EDITORACCESS", "org.imixs.ACCESSLEVEL.MANAGERACCESS" }) @RunAs("org.imixs.ACCESSLEVEL.MANAGERACCESS") @Singleton @ApplicationScoped public class MetricCreditorService { private static Logger logger = Logger.getLogger(MetricCreditorService.class.getName()); private final ConcurrentHashMap metricCache = new ConcurrentHashMap<>(); private final Set registeredGauges = ConcurrentHashMap.newKeySet(); public static final String TYPE_METRIC_CREDITOR = "metric.creditor"; public static final String ITEM_INVOICE_TOTAL = "invoice.total"; public static final String ITEM_INVOICE_SALDO = "invoice.saldo"; public static final String ITEM_METRIC_BALANCE = "invoice.balance"; public static final String ITEM_METRIC_SALES = "invoice.sales"; @Inject @RegistryScope(scope = MetricRegistry.APPLICATION_SCOPE) MetricRegistry metricRegistry; @Inject DocumentService documentService; @Inject KreditorDebitorService kreditorDebitorService; @Inject MetricDataService metricDataService; @Inject @ConfigProperty(name = "metrics.enabled", defaultValue = "false") private boolean metricsEnabled; /** * Init all metrics during setup. Called by the Imixs SetupService */ public void initializeMetrics(@Observes SetupEvent setupEvent) { if (!metricsEnabled) { return; } long l = System.currentTimeMillis(); int batchSize = 500; int totalMetrics = 0; try { logger.info("├── Initializing creditor metrics from database..."); String query = "(type:" + TYPE_METRIC_CREDITOR + ")"; // Gesamtanzahl ermitteln int totalCount = documentService.count(query); // Berechne Anzahl der benötigten Pages int totalPages = (int) Math.ceil((double) totalCount / batchSize); // Verarbeite Page für Page for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { List metrics = documentService.find(query, batchSize, pageIndex); for (ItemCollection metric : metrics) { updateMetric(metric, false); totalMetrics++; } // Fortschritt loggen logger.info("│ ├── Processed page " + (pageIndex + 1) + " of " + totalPages + " (" + totalMetrics + " of " + totalCount + " metrics)"); // Optional: Kurze Pause nach jedem Batch Thread.sleep(100); } long duration = System.currentTimeMillis() - l; double metricsPerSecond = totalMetrics / (duration / 1000.0); logger.info("├── Successfully initialized " + totalMetrics + " metrics in " + duration + "ms (" + String.format("%.1f", metricsPerSecond) + " metrics/sec)"); } catch (Exception e) { logger.warning("Failed to initialize metrics: " + e.getMessage()); } } /** * Reset the internal metricCache and clears all registered Gauges. */ public void reset() { metricCache.clear(); registeredGauges.clear(); } public long getMetricCount() { return metricCache.size(); } /** * Process Metric only if some data has changed.... * * @param processingEvent * @throws PluginException */ public void onProcessingEvent(@Observes ProcessingEvent processingEvent) { long l = System.currentTimeMillis(); if (!metricsEnabled) { return; } ItemCollection invoice = processingEvent.getDocument(); if (!InvoiceUtil.isCreditorInvoice(invoice)) { // skip event return; } // update metric and the metric cache if (processingEvent.getEventType() == ProcessingEvent.AFTER_PROCESS) { // load the last invoice metric and reduce the saldo... ItemCollection lastInvoice = metricDataService.readDirtyWorkitem(invoice.getUniqueID()); if (lastInvoice != null) { try { ItemCollection lastMetricData = getMetricByInvoice(lastInvoice); // update last metric only if exists... if (!isNewMetric(lastMetricData)) { subtractInvoice(lastMetricData, lastInvoice); updateMetric(lastMetricData, true); } } catch (PluginException e) { // invalid invoice - e.g. no cdtr. number } } try { // load the invoice metric and add the saldo... ItemCollection metricData = getMetricByInvoice(invoice); // Saldo-Berechnung addInvoice(metricData, invoice); updateMetric(metricData, true); logger.info("Metric cdtr update took " + (System.currentTimeMillis() - l) + "ms"); } catch (PluginException e) { // invalid invoice - e.g. no cdtr. number } } } /** * Returns true if the metric is not yet registered. This means we do not have * sales or balances for this metric * * @param metricData * @return */ public boolean isNewMetric(ItemCollection metricData) { return (!metricCache.containsKey(metricData.getItemValueString("name"))); } /** * Returns the corresponding metric data object for an invoice. The method uses * an internal cache. If the metric was not yet cached the method loads the * metric from the database. If not metric exists in the database the method * automatically creates a new metric data object. * * The method throws a PluginException if no metric can be build from this * invoice (e.g. missing cdtr.number). * * @param invoice * @return * @throws PluginException */ public ItemCollection getMetricByInvoice(ItemCollection invoice) throws PluginException { if (invoice == null) { return null; } String metricKey = MetricDataService.buildKeyByInvoice(invoice); ItemCollection metricData = metricCache.get(metricKey); // if metric still null we create a new metric data object. if (metricData == null) { metricData = createMetricData(invoice); } return metricData; } /** * Returns a metric data object by key * * @param key * @return */ public ItemCollection getMetric(String key) { return metricCache.get(key); } /** * Returns a list with all cached metric keys. * The method returns an unmodifiable list to prevent modifications. * * @return */ public List getMetricKeys() { return Collections.unmodifiableList(new ArrayList<>(metricCache.keySet())); } /** * Creates an empty Creditor Metric Data Object (ItemCollection) *

* The ItemCollection stores the name and number and all categories. * A new metric object does not yet have the items 'invoice.saldo' and * 'invoice.total' * * @param invoice - invoice ItemCollection * @return * @throws PluginException */ private ItemCollection createMetricData(ItemCollection invoice) throws PluginException { if (invoice == null) { return null; } String key = MetricDataService.buildKeyByInvoice(invoice); ItemCollection metricData = new ItemCollection(); metricData.setType(TYPE_METRIC_CREDITOR); metricData.setItemValue("name", key); metricData.setItemValue(SnapshotService.NOSNAPSHOT, true); metricData.setItemValue("bp.id", InvoiceUtil.getBPId(invoice)); metricData.setItemValue("bp.name", InvoiceUtil.getBPName(invoice)); metricData.setItemValue("country", invoice.getItemValueString("invoice.country")); metricData.setItemValue("currency", invoice.getItemValueString("invoice.currency")); metricData.setItemValue("department", invoice.getItemValueString("space.name")); return metricData; } /** * This method registers and updates the metric meta data objects based on a * given metricData object. Optional the metric data object is persisted. * * @param metricData - the metricData ItemCollection * @param persist - if true the metricData entity will be persisted */ public void updateMetric(ItemCollection metricData, boolean persist) { String metricKey = metricData.getItemValueString("name"); // Cache aktualisieren metricCache.put(metricKey, metricData); // In Datenbank persistieren if (persist) { metricData.setItemValue(SnapshotService.NOSNAPSHOT, true); documentService.save(metricData); } // Prüfen ob Gauge bereits registriert ist if (registeredGauges.add(metricKey)) { // returns true newly added String bpName = metricData.getItemValueString("bp.name"); String bpId = metricData.getItemValueString("bp.id"); String country = metricData.getItemValueString("country"); String department = metricData.getItemValueString("department"); String currency = metricData.getItemValueString("currency"); List tags = new ArrayList<>(); tags.add(new Tag("type", "cdtr")); tags.add(new Tag("id", bpId)); tags.add(new Tag("name", bpName)); tags.add(new Tag("country", country)); tags.add(new Tag("currency", currency)); tags.add(new Tag("department", department)); // Saldo Gauge Metadata balanceMetadata = Metadata.builder() .withName("cdtr.balance") .withDescription("Creditor Balance") .build(); metricRegistry.gauge(balanceMetadata, () -> metricCache.get(metricKey).getItemValueDouble(ITEM_METRIC_BALANCE), tags.toArray(new Tag[0])); // Umsatz Gauge Metadata revenueMetadata = Metadata.builder() .withName("cdtr.sales") .withDescription("Creditor Sales") .build(); metricRegistry.gauge(revenueMetadata, () -> metricCache.get(metricKey).getItemValueDouble(ITEM_METRIC_SALES), tags.toArray(new Tag[0])); } } // /** // * Helper Method that refreshes all gauges. The method is called by the // * RestService during a rebuild. // */ // public void updateAllMetrics() { // List keys = getMetricKeys(); // for (String hashKey : keys) { // ItemCollection metricData = getMetric(hashKey); // updateMetric(metricData, false); // } // } /** * Addiert den saldo einer Invoice zu einem metricData object * * @param metricData * @param invoice */ public void addInvoice(ItemCollection metricData, ItemCollection invoice) { if (metricData == null || invoice == null) { return; } double invoiceSaldo = invoice.getItemValueDouble(ITEM_INVOICE_TOTAL); double invoiceTotal = invoice.getItemValueDouble(ITEM_INVOICE_TOTAL); if (!"workitem".equals(invoice.getType()) || invoice.getTaskID() >= 5800) { // vorgang ist archiviert oder gelöscht worden => saldo = 0! invoiceSaldo = 0.0; } // update balance double lastBalance = metricData.getItemValueDouble(ITEM_METRIC_BALANCE); metricData.setItemValue(ITEM_METRIC_BALANCE, InvoiceUtil.round(lastBalance + invoiceSaldo)); // update sales double lastTotal = metricData.getItemValueDouble(ITEM_METRIC_SALES); metricData.setItemValue(ITEM_METRIC_SALES, InvoiceUtil.round(lastTotal + invoiceTotal)); } public void subtractInvoice(ItemCollection metricData, ItemCollection invoice) { if (metricData == null || invoice == null) { return; } double invoiceTotal = invoice.getItemValueDouble(ITEM_INVOICE_TOTAL); double invoiceSaldo = invoice.getItemValueDouble(ITEM_INVOICE_TOTAL); if (!"workitem".equals(invoice.getType()) || invoice.getTaskID() >= 5800) { // vorgang ist archiviert oder gelöscht worden => saldo = 0! invoiceSaldo = 0.0; } // subtract only if metric saldo exists double lastBalance = metricData.getItemValueDouble(ITEM_METRIC_BALANCE); metricData.setItemValue(ITEM_METRIC_BALANCE, InvoiceUtil.round(lastBalance - invoiceSaldo)); // Neue Umsatz Logik double lastSales = metricData.getItemValueDouble(ITEM_METRIC_SALES); metricData.setItemValue(ITEM_METRIC_SALES, InvoiceUtil.round(lastSales - invoiceTotal)); } }