Umsatz pro Artikel pro Kund ein ROI, Pivot table angepasst

This commit is contained in:
2026-07-20 15:16:32 +02:00
parent b7ad70a6ea
commit 0b6ae3d933
+275 -16
View File
@@ -217,6 +217,78 @@ function allocateBundleRevenue(Epirent $Epi, int $bundlePk, float $bundleRevenue
return $alloc; return $alloc;
} }
/* =========================
Customer Cache
Zieht den Kunden aus dem Invoice-Objekt, fällt bei Bedarf auf den Order-Endpoint zurück.
Ergebnis pro Order gecached, damit mehrere Rechnungen desselben Auftrags nur 1x API-Kosten haben.
========================= */
$orderCustomerCache = []; // [orderPk => ['pk'=>int, 'name'=>string]]
function resolveInvoiceCustomer(Epirent $Epi, $invObj, int $orderPk): array {
global $orderCustomerCache;
$pk = 0; $name = '';
$pickInt = function($obj, array $fields) {
foreach ($fields as $f) {
$v = (int)($obj->{$f} ?? 0);
if ($v > 0) return $v;
}
return 0;
};
$pickStr = function($obj, array $fields) {
foreach ($fields as $f) {
$v = trim((string)($obj->{$f} ?? ''));
if ($v !== '') return $v;
}
return '';
};
if (is_object($invObj)) {
$pk = $pickInt($invObj, ['customer_pk','contact_pk','pk_customer','kunde_pk']);
$name = $pickStr($invObj, ['customer_name','contact_name','kunde_name','name_customer']);
foreach (['address_invoice','address_delivery','customer','contact'] as $af) {
if ($pk > 0 && $name !== '') break;
$sub = $invObj->{$af} ?? null;
if (is_object($sub)) {
if ($pk <= 0) $pk = $pickInt($sub, ['contact_pk','primary_key','pk','id']);
if ($name === '') $name = $pickStr($sub, ['company','name','display_name','name1']);
}
}
}
if (($pk <= 0 || $name === '') && $orderPk > 0) {
if (!array_key_exists($orderPk, $orderCustomerCache)) {
$res = apiJsonDecode($Epi->requestEpiApi('/v1/order/' . $orderPk . '?cl=' . Epirent_Mandant));
$ord = ($res && ($res->success ?? false) && !empty($res->payload[0])) ? $res->payload[0] : null;
$c = ['pk' => 0, 'name' => ''];
if (is_object($ord)) {
$c['pk'] = $pickInt($ord, ['customer_pk','contact_pk','pk_customer']);
$c['name'] = $pickStr($ord, ['customer_name','contact_name']);
foreach (['address_invoice','address_delivery','customer','contact'] as $af) {
if ($c['pk'] > 0 && $c['name'] !== '') break;
$sub = $ord->{$af} ?? null;
if (is_object($sub)) {
if ($c['pk'] <= 0) $c['pk'] = $pickInt($sub, ['contact_pk','primary_key','pk','id']);
if ($c['name'] === '') $c['name'] = $pickStr($sub, ['company','name','display_name','name1']);
}
}
}
$orderCustomerCache[$orderPk] = $c;
}
$c = $orderCustomerCache[$orderPk];
if ($pk <= 0) $pk = (int)$c['pk'];
if ($name === '') $name = (string)$c['name'];
}
if ($pk <= 0) $pk = 0;
if ($name === '') $name = $pk > 0 ? ('Kunde #' . $pk) : 'Unbekannt';
return ['pk' => $pk, 'name' => $name];
}
/* ========================= /* =========================
Journal Cache Journal Cache
========================= */ ========================= */
@@ -318,6 +390,41 @@ $allYears = [];
// Auftragsbasierte "Slots" (direct/incl) pro Produkt // Auftragsbasierte "Slots" (direct/incl) pro Produkt
$slotAgg = []; $slotAgg = [];
// Kunden-Aggregation pro Produkt: [productPk][customerPk] = ['pk','name','direct','incl','ext_direct','ext_incl']
// Semantik analog zu $pivot / $pivotB:
// direct = Umsatz aus Rechnungen die diesen productPk DIREKT abgerechnet haben
// incl = direct + Umsatz aus virtuellen Bundles, deren Leaf dieser productPk ist
$customerAgg = [];
function addCustomerRevenue(int $productPk, array $customer, float $direct, float $incl, float $extDirect, float $extIncl): void {
global $customerAgg;
if ($productPk <= 0) return;
if ($direct == 0.0 && $incl == 0.0 && $extDirect == 0.0 && $extIncl == 0.0) return;
$cpk = (int)($customer['pk'] ?? 0);
$name = (string)($customer['name'] ?? '');
if (!isset($customerAgg[$productPk])) $customerAgg[$productPk] = [];
if (!isset($customerAgg[$productPk][$cpk])) {
$customerAgg[$productPk][$cpk] = [
'pk' => $cpk,
'name' => $name,
'direct' => 0.0,
'incl' => 0.0,
'ext_direct' => 0.0,
'ext_incl' => 0.0,
];
} elseif ($customerAgg[$productPk][$cpk]['name'] === '' && $name !== '') {
// Nachträglich Namen ergänzen, falls anfangs leer
$customerAgg[$productPk][$cpk]['name'] = $name;
}
$customerAgg[$productPk][$cpk]['direct'] += $direct;
$customerAgg[$productPk][$cpk]['incl'] += $incl;
$customerAgg[$productPk][$cpk]['ext_direct'] += $extDirect;
$customerAgg[$productPk][$cpk]['ext_incl'] += $extIncl;
}
function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title, string $invoiceDate = ''): void { function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title, string $invoiceDate = ''): void {
global $meta; global $meta;
if (!isset($meta[$productPk])) { if (!isset($meta[$productPk])) {
@@ -600,7 +707,8 @@ function processLineItem(
?int $chapterId, ?int $chapterId,
object $li, object $li,
bool $isFromJournal, bool $isFromJournal,
bool $invoiceIsCredit // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0) bool $invoiceIsCredit, // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0)
array $customer = ['pk'=>0, 'name'=>'Unbekannt']
): void { ): void {
global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl; global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl;
@@ -640,6 +748,11 @@ function processLineItem(
// Direct Extern-Umsatz // Direct Extern-Umsatz
if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet); if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet);
// Kunden-Aggregation für direkten Umsatz (direct wird IMMER auch in incl gezählt)
if ($revenueNet != 0.0 || $extRevenueNet != 0.0) {
addCustomerRevenue($productPk, $customer, $revenueNet, $revenueNet, $extRevenueNet, $extRevenueNet);
}
// Peak/Histogram direct + incl (tagesbasiert) // Peak/Histogram direct + incl (tagesbasiert)
// -> qtySigned sorgt dafür, dass Storno zeitgleich aufhebt // -> qtySigned sorgt dafür, dass Storno zeitgleich aufhebt
if ($dateStart && $dateEnd && $qtySigned != 0.0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
@@ -681,9 +794,11 @@ function processLineItem(
if ($isVirtualBundle) { if ($isVirtualBundle) {
// Umsatz allokieren (inkl Bundle-Anteil) // Umsatz allokieren (inkl Bundle-Anteil)
$allocForCustomer = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : [];
$allocExtForCustomer = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : [];
if ($revenueNet != 0.0 && $year) { if ($revenueNet != 0.0 && $year) {
$alloc = allocateBundleRevenue($Epi, $productPk, $revenueNet); foreach ($allocForCustomer as $leafPk => $leafRevenue) {
foreach ($alloc as $leafPk => $leafRevenue) {
$leafPk = (int)$leafPk; $leafPk = (int)$leafPk;
ensureMeta($Epi, $leafPk, 0, ''); ensureMeta($Epi, $leafPk, 0, '');
addRevenue($pivotB, $leafPk, $year, (float)$leafRevenue); addRevenue($pivotB, $leafPk, $year, (float)$leafRevenue);
@@ -692,14 +807,25 @@ function processLineItem(
// Extern-Umsatz allokieren (inkl Bundle-Anteil) // Extern-Umsatz allokieren (inkl Bundle-Anteil)
if ($extRevenueNet != 0.0 && $year) { if ($extRevenueNet != 0.0 && $year) {
$allocExt = allocateBundleRevenue($Epi, $productPk, $extRevenueNet); foreach ($allocExtForCustomer as $leafPk => $leafRevenue) {
foreach ($allocExt as $leafPk => $leafRevenue) {
$leafPk = (int)$leafPk; $leafPk = (int)$leafPk;
ensureMeta($Epi, $leafPk, 0, ''); ensureMeta($Epi, $leafPk, 0, '');
addRevenue($pivotExtB, $leafPk, $year, (float)$leafRevenue); addRevenue($pivotExtB, $leafPk, $year, (float)$leafRevenue);
} }
} }
// Kunden-Aggregation für Leafs (nur "incl", direct ist 0, da nicht direkt abgerechnet)
if (!empty($allocForCustomer) || !empty($allocExtForCustomer)) {
$leafKeys = array_unique(array_merge(array_keys($allocForCustomer), array_keys($allocExtForCustomer)));
foreach ($leafKeys as $leafPk) {
$leafPk = (int)$leafPk;
if ($leafPk <= 0) continue;
$leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0);
$leafExt = (float)($allocExtForCustomer[$leafPk] ?? 0.0);
addCustomerRevenue($leafPk, $customer, 0.0, $leafRev, 0.0, $leafExt);
}
}
// Auslastung allokieren (Peak/Histogramm inkl Bundle) signed qty! // Auslastung allokieren (Peak/Histogramm inkl Bundle) signed qty!
if ($dateStart && $dateEnd && $qtySigned != 0.0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
foreach ($leafMap as $leafPk => $amt) { foreach ($leafMap as $leafPk => $amt) {
@@ -713,16 +839,14 @@ function processLineItem(
// Slotting allokieren (inkl Bundle) signed qty! // Slotting allokieren (inkl Bundle) signed qty!
if ($dateStart && $dateEnd && $qtySigned != 0.0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
$alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; // Bundle-Allokationen aus dem Umsatz-Block wiederverwenden
$allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : [];
foreach ($leafMap as $leafPk => $amt) { foreach ($leafMap as $leafPk => $amt) {
$leafPk = (int)$leafPk; $leafPk = (int)$leafPk;
$amt = (float)$amt; $amt = (float)$amt;
if ($leafPk <= 0 || $amt <= 0) continue; if ($leafPk <= 0 || $amt <= 0) continue;
$leafRev = (float)($alloc[$leafPk] ?? 0.0); $leafRev = (float)($allocForCustomer[$leafPk] ?? 0.0);
$leafExt = (float)($allocExt[$leafPk] ?? 0.0); $leafExt = (float)($allocExtForCustomer[$leafPk] ?? 0.0);
$qtyLeafSigned = $qtySigned * $amt; $qtyLeafSigned = $qtySigned * $amt;
@@ -783,6 +907,9 @@ foreach ($invoiceList as $inv) {
// Storno-Erkennung (Credit Note): sum_net < 0 // Storno-Erkennung (Credit Note): sum_net < 0
$invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0; $invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0;
// Kunde einmal pro Rechnung ermitteln (mit Order-Fallback + Cache)
$customer = resolveInvoiceCustomer($Epi, $invObj, $orderPk);
$orderItems = $invObj->order_items ?? []; $orderItems = $invObj->order_items ?? [];
if (!is_array($orderItems)) $orderItems = []; if (!is_array($orderItems)) $orderItems = [];
@@ -796,14 +923,14 @@ foreach ($invoiceList as $inv) {
$journalItems = getJournalByChapter($Epi, $chapterId); $journalItems = getJournalByChapter($Epi, $chapterId);
foreach ($journalItems as $ji) { foreach ($journalItems as $ji) {
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit); processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit, $customer);
} }
continue; continue;
} }
// Direkte Artikelposition ohne Kapitel (type=0) // Direkte Artikelposition ohne Kapitel (type=0)
if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) { if ($oiType === 0 && (int)($oi->product_pk ?? 0) > 0) {
processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false, $invoiceIsCredit); processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false, $invoiceIsCredit, $customer);
continue; continue;
} }
} }
@@ -870,8 +997,14 @@ foreach ($meta as $productPk => $m) {
'slots_incl' => $slotAgg[$productPk]['incl'] ?? [], 'slots_incl' => $slotAgg[$productPk]['incl'] ?? [],
'slots_direct_ext' => $slotAgg[$productPk]['direct_ext'] ?? [], 'slots_direct_ext' => $slotAgg[$productPk]['direct_ext'] ?? [],
'slots_incl_ext' => $slotAgg[$productPk]['incl_ext'] ?? [], 'slots_incl_ext' => $slotAgg[$productPk]['incl_ext'] ?? [],
'customers' => [],
]; ];
// Kundenliste absteigend nach "incl" sortieren größte Kunden zuerst
$custList = array_values($customerAgg[$productPk] ?? []);
usort($custList, fn($a,$b) => ($b['incl'] <=> $a['incl']));
$row['customers'] = $custList;
$sumDirect = 0.0; $sumDirect = 0.0;
$sumIncl = 0.0; $sumIncl = 0.0;
$sumExtDirect = 0.0; $sumExtDirect = 0.0;
@@ -1064,6 +1197,7 @@ function slotsToDisplay(array $slotMap): array {
<link href="css/styles.css" rel="stylesheet" /> <link href="css/styles.css" rel="stylesheet" />
<link href="https://cdn.datatables.net/1.10.20/css/dataTables.bootstrap4.min.css" rel="stylesheet" crossorigin="anonymous" /> <link href="https://cdn.datatables.net/1.10.20/css/dataTables.bootstrap4.min.css" rel="stylesheet" crossorigin="anonymous" />
<link href="https://cdn.datatables.net/fixedcolumns/3.3.3/css/fixedColumns.bootstrap4.min.css" rel="stylesheet" crossorigin="anonymous" />
<script src="js/jquery-3.5.1.min.js"></script> <script src="js/jquery-3.5.1.min.js"></script>
<script src="https://kit.fontawesome.com/93d71de8bc.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/93d71de8bc.js" crossorigin="anonymous"></script>
@@ -1072,6 +1206,7 @@ function slotsToDisplay(array $slotMap): array {
<script src="https://cdn.datatables.net/1.10.20/js/jquery.dataTables.min.js" crossorigin="anonymous"></script> <script src="https://cdn.datatables.net/1.10.20/js/jquery.dataTables.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/1.10.20/js/dataTables.bootstrap4.min.js" crossorigin="anonymous"></script> <script src="https://cdn.datatables.net/1.10.20/js/dataTables.bootstrap4.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/fixedcolumns/3.3.3/js/dataTables.fixedColumns.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
@@ -1170,6 +1305,7 @@ function slotsToDisplay(array $slotMap): array {
<?php endforeach; ?> <?php endforeach; ?>
<th class="nowrap">Histogramm</th> <th class="nowrap">Histogramm</th>
<th class="nowrap">Slots</th> <th class="nowrap">Slots</th>
<th class="nowrap">Kunden</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1199,6 +1335,10 @@ function slotsToDisplay(array $slotMap): array {
'incl_ext' => $slotsInclExt, 'incl_ext' => $slotsInclExt,
]; ];
$slotsB64 = base64Json($slotsPayload); $slotsB64 = base64Json($slotsPayload);
// Kunden-Payload für Modal (bereits nach 'incl' desc sortiert)
$customersB64 = base64Json($r['customers'] ?? []);
$customersCount = is_array($r['customers'] ?? null) ? count($r['customers']) : 0;
?> ?>
<tr> <tr>
<td class="mono"><?php echo (int)$r['product_pk']; ?></td> <td class="mono"><?php echo (int)$r['product_pk']; ?></td>
@@ -1274,6 +1414,16 @@ function slotsToDisplay(array $slotMap): array {
Slots Slots
</button> </button>
</td> </td>
<td class="nowrap">
<button
class="btn btn-outline-info btn-xs js-customers"
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
data-customers="<?php echo htmlspecialchars($customersB64); ?>"
<?php if ($customersCount === 0) echo 'disabled'; ?>>
Kunden <?php if ($customersCount > 0) echo '(' . (int)$customersCount . ')'; ?>
</button>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -1416,13 +1566,64 @@ function slotsToDisplay(array $slotMap): array {
</div> </div>
</div> </div>
<!-- Customers Modal -->
<div class="modal fade" id="customersModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Kunden-Umsatz</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="mb-2 text-muted">
Umsatz pro Kunde für diesen Artikel, gefiltert auf den oben gewählten Zeitraum.
<b>Umsatz</b> = direkt auf diesem Artikel abgerechnet.
<b>inkl. Bundle</b> = zusätzlich der Anteil, wenn der Artikel Bestandteil eines virtuellen Bundles war.
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="customersTable">
<thead>
<tr>
<th class="nowrap">#</th>
<th class="nowrap">Kunden-PK</th>
<th>Kunde</th>
<th class="nowrap text-right">Umsatz</th>
<th class="nowrap text-right">Umsatz inkl. Bundle</th>
<th class="nowrap text-right">davon extern</th>
<th class="nowrap text-right">davon extern inkl. Bundle</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr class="font-weight-bold" style="background:#f8f9fa;">
<td colspan="3" class="text-right">Summe:</td>
<td class="nowrap text-right" id="cust_sum_direct"></td>
<td class="nowrap text-right" id="cust_sum_incl"></td>
<td class="nowrap text-right" id="cust_sum_ext_direct"></td>
<td class="nowrap text-right" id="cust_sum_ext_incl"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
<script> <script>
let histChart = null; let histChart = null;
let slotsChart = null; let slotsChart = null;
function decodeB64Json(b64) { function decodeB64Json(b64) {
try { try {
const json = atob(b64); // atob() liefert Bytes als Latin-1-String zurück. Wir müssen die Rohbytes
// extrahieren und explizit als UTF-8 dekodieren, sonst wird "ü" zu "ü".
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const json = new TextDecoder('utf-8').decode(bytes);
return JSON.parse(json); return JSON.parse(json);
} catch (e) { } catch (e) {
return {}; return {};
@@ -1549,11 +1750,18 @@ function attachTooltips(context) {
$(function() { $(function() {
// scrollX: DataTables kümmert sich selbst um horizontales Scrollen der Tabelle, // scrollX: DataTables kümmert sich selbst um horizontales Scrollen der Tabelle,
// damit Search/Length/Pagination NICHT mit dem Scrollbalken mitwandern. // damit Search/Length/Pagination NICHT mit dem Scrollbalken mitwandern.
const pivotDT = $('#pivotTable').DataTable({ pageLength: 50, order: [[4,'desc']], scrollX: true }); // fixedColumns: linke Spalten bis inkl. "Summe" bleiben beim horizontalen Scrollen stehen.
const pivotDT = $('#pivotTable').DataTable({
pageLength: 50,
order: [[4,'desc']],
scrollX: true,
fixedColumns: { leftColumns: 5 }
});
const detailDT = $('#detailTable').DataTable({ pageLength: 50, order: [[1,'desc']], scrollX: true }); const detailDT = $('#detailTable').DataTable({ pageLength: 50, order: [[1,'desc']], scrollX: true });
// Tooltips (z.B. für Namens-Historie) auch nach Pagination/Sortierung erneut anhängen // Tooltips (z.B. für Namens-Historie) auch nach Pagination/Sortierung erneut anhängen.
pivotDT.on('draw', () => attachTooltips('#pivotTable')); // Ohne Kontext-Selector werden auch die FixedColumns-Overlay-Tabellen erfasst.
pivotDT.on('draw', () => attachTooltips());
attachTooltips(); attachTooltips();
$(document).on('click', '.js-hist', function() { $(document).on('click', '.js-hist', function() {
@@ -1571,7 +1779,58 @@ $(function() {
const payload = decodeB64Json(b64); const payload = decodeB64Json(b64);
openSlots(title, payload); openSlots(title, payload);
}); });
$(document).on('click', '.js-customers', function() {
const title = $(this).data('title') || '';
const b64 = $(this).data('customers') || '';
const payload = decodeB64Json(b64);
openCustomers(title, payload);
});
}); });
function openCustomers(title, rows) {
$('#customersModal .modal-title').text(title + ' Umsatz pro Kunde');
const list = Array.isArray(rows) ? rows.slice() : [];
list.sort((a, b) => Number(b.incl || 0) - Number(a.incl || 0));
let sumD = 0, sumI = 0, sumED = 0, sumEI = 0;
const $tb = $('#customersTable tbody').empty();
if (list.length === 0) {
$tb.append('<tr><td colspan="7" class="text-muted text-center">Keine Kunden-Daten vorhanden.</td></tr>');
} else {
list.forEach((c, idx) => {
const d = Number(c.direct || 0);
const i = Number(c.incl || 0);
const ed = Number(c.ext_direct || 0);
const ei = Number(c.ext_incl || 0);
sumD += d; sumI += i; sumED += ed; sumEI += ei;
const pk = c.pk ? String(c.pk) : '';
const name = c.name ? String(c.name) : (pk ? ('Kunde #' + pk) : 'Unbekannt');
$tb.append(`
<tr>
<td class="mono">${idx + 1}</td>
<td class="mono">${pk}</td>
<td>${$('<div>').text(name).html()}</td>
<td class="nowrap text-right">${fmtEuro(d)}</td>
<td class="nowrap text-right">${fmtEuro(i)}</td>
<td class="nowrap text-right">${fmtEuro(ed)}</td>
<td class="nowrap text-right">${fmtEuro(ei)}</td>
</tr>
`);
});
}
$('#cust_sum_direct').text(fmtEuro(sumD));
$('#cust_sum_incl').text(fmtEuro(sumI));
$('#cust_sum_ext_direct').text(fmtEuro(sumED));
$('#cust_sum_ext_incl').text(fmtEuro(sumEI));
$('#customersModal').modal('show');
}
</script> </script>
</body> </body>