12 Commits
7 changed files with 1280 additions and 146 deletions
+1
View File
@@ -1,3 +1,4 @@
config.php config.php
.vscode .vscode
sftp.json sftp.json
.claude
+83 -3
View File
@@ -1,6 +1,6 @@
# EPIWebview # EPIWebview
- **aktuellste stable Version:** 1.9.0 - **aktuellste stable Version:** 1.11
- **Lizenz:** Creative Commons Attribution-NonCommercial-ShareAlike 4.0 (CC BY-NC-SA 4.0). Einsehbar unter: (LICENSE.MD) - **Lizenz:** Creative Commons Attribution-NonCommercial-ShareAlike 4.0 (CC BY-NC-SA 4.0). Einsehbar unter: (LICENSE.MD)
- **Kompatibilität:** Erweiterung für **Epirent** und **CrewBrain** - **Kompatibilität:** Erweiterung für **Epirent** und **CrewBrain**
@@ -17,7 +17,7 @@ Die Anwendung ist speziell für den Einsatz in Lagerprozessen entwickelt.
- **Check-In / Check-Out Übersicht**: Lagermonitor - **Check-In / Check-Out Übersicht**: Lagermonitor
- **Integration mit Epirent API**: Vollständig kompatibel mit bestehenden Epirent-Systemen. - **Integration mit Epirent API**: Vollständig kompatibel mit bestehenden Epirent-Systemen.
- **Integration mit Crewbrain**: Anzeige einer Aufgabenliste aus CrewBraingit - **Integration mit Crewbrain**: Anzeige einer Aufgabenliste aus CrewBrain
--- ---
@@ -26,7 +26,7 @@ Die Anwendung ist speziell für den Einsatz in Lagerprozessen entwickelt.
## Systemanforderungen ## Systemanforderungen
- **Server:** PHP ≥ 8.2, Apache oder Nginx - **Server:** PHP ≥ 8.2, Apache oder Nginx
- Achtung !!: Für die Return of Invest Funktion oder den Warengruppencheck / Imagechecks sollten in der php.ini die max_execution_time und die maximale Dateigröße deutlich nach oben korrigiert werden. Die ROI Funktion kann gut und gerne 20 Minuten laden! - Achtung !!: Für die Return of Invest Funktion oder den Warengruppencheck / Imagechecks sollten in der php.ini die max_execution_time und die maximale Dateigröße deutlich nach oben korrigiert werden. Die ROI Funktion kann gut und gerne 20 Minuten laden!. Foglende PHP Funktionen müssen aktiviert werden: curl, gzcompress, Imagick, gd
- **Client:** Aktueller Browser (Chrome, Edge, Firefox, Safari) - **Client:** Aktueller Browser (Chrome, Edge, Firefox, Safari)
- **Datenquelle:** Bestehende Epirent-Installation mit aktivierter API sowie optional CrewBrain - **Datenquelle:** Bestehende Epirent-Installation mit aktivierter API sowie optional CrewBrain
@@ -54,3 +54,83 @@ Die Anwendung ist speziell für den Einsatz in Lagerprozessen entwickelt.
## Changelog ## Changelog
Verschoben in Releases (Git) Verschoben in Releases (Git)
--- ---
# Installation als Docker Containerr
Folgende drei Dateien anlegen:
docker-compose.yml
```
version: "3.8"
services:
web:
build: .
container_name: epiwebview-apache-php
restart: unless-stopped
ports:
- "8080:80"
volumes:
- /volume1/docker/epiwebview/www:/var/www/html
- /volume1/docker/epiwebview/php.ini:/usr/local/etc/php/php.ini
```
Dockerfile
```
FROM php:8.2-apache
RUN apt-get update && apt-get install -y \
git \
libcurl4-openssl-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
zlib1g-dev \
libmagickwand-dev \
unzip \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install curl gd \
&& pecl install imagick \
&& docker-php-ext-enable imagick
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /var/www/html
ENTRYPOINT ["/entrypoint.sh"]
```
entrypoint.sh
```
#!/bin/bash
set -e
if [ -z "$(ls -A /var/www/html 2>/dev/null)" ]; then
echo "Klonen von EpiWebview..."
git clone https://srvgitea01.vtm.zone/epi/EpiWebview /var/www/html
else
echo "www-Verzeichnis ist nicht leer, Clone wird übersprungen."
fi
chown -R www-data:www-data /var/www/html
apache2-foreground
```
Container starten:
```
sudo docker compose up -d --build
```
Containerstatus prüfen:
```
sudo docker compose ps
```
Dann die config example in eine config.php kopieren.
## Update des Containers aus dem Git Repo:
```
cd /volume1/docker/epiwebview
sudo docker exec -it epiwebview-apache-php bash
cd /var/www/html
git pull
```
+338 -133
View File
@@ -16,6 +16,26 @@ use PhpOffice\PhpSpreadsheet\Cell\DataType;
$Epi = new Epirent(); $Epi = new Epirent();
/* =========================
Filter (Jahres-Range)
ROI wird nur ausgeführt, wenn der User einen Zeitraum gewählt hat
oder explizit "Alles" anfordert. Das spart bei großen Datenbeständen
teure API-Calls.
========================= */
$filterMode = strtolower((string)($_GET['mode'] ?? ''));
$filterFrom = isset($_GET['from']) ? (int)$_GET['from'] : 0;
$filterTo = isset($_GET['to']) ? (int)$_GET['to'] : 0;
if ($filterFrom > 0 && $filterTo > 0 && $filterTo < $filterFrom) {
[$filterFrom, $filterTo] = [$filterTo, $filterFrom];
}
$computeRoi = in_array($filterMode, ['range', 'all'], true);
if ($filterMode === 'range' && ($filterFrom <= 0 || $filterTo <= 0)) {
$computeRoi = false;
$filterMode = '';
}
/* ========================= /* =========================
Helpers Helpers
========================= */ ========================= */
@@ -94,12 +114,7 @@ function getRentPrice(Epirent $Epi, int $productPk): float {
/** /**
* WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles. * WICHTIG: Bundle-Auflösung NUR für virtuelle Bundles.
* Damit ist die anteilige Bundlepreis-Berechnung wieder wie in der funktionierenden Version.
*
* Ergebnis: [leafProductPk => amount] * Ergebnis: [leafProductPk => amount]
* - nur wenn is_virtual == true UND rent_fix/materials vorhanden
* - sonst: leaf = [self=>1]
* - Cycle-Guard über $stack
*/ */
function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): array { function resolveBundleLeafMap(Epirent $Epi, int $productPk, array &$stack = []): array {
global $bundleLeafCache; global $bundleLeafCache;
@@ -222,11 +237,10 @@ function getJournalByChapter(Epirent $Epi, int $chapterId): array {
} }
/* ========================= /* =========================
Interval aggregation (Peak/Histogram) tagesbasiert (wie gehabt) Interval aggregation (Peak/Histogram) tagesbasiert
========================= */ ========================= */
// eventsDirect[productPk][ymd] += deltaQty $eventsDirect = []; // eventsDirect[productPk][ymd] += deltaQty
$eventsDirect = [];
$eventsIncl = []; // direct + bundle $eventsIncl = []; // direct + bundle
function addIntervalEvent(array &$events, int $productPk, string $startYmd, string $endYmd, float $qty): void { function addIntervalEvent(array &$events, int $productPk, string $startYmd, string $endYmd, float $qty): void {
@@ -264,6 +278,8 @@ function computePeakAndHistogram(array $eventMap): array {
for ($i = 0; $i < count($dates); $i++) { for ($i = 0; $i < count($dates); $i++) {
$d = $dates[$i]; $d = $dates[$i];
$level += (float)$eventMap[$d]; $level += (float)$eventMap[$d];
// clamp, damit kein negativer Bestand "Tage" erzeugt
if ($level < 0) $level = 0; if ($level < 0) $level = 0;
$intLevel = (int)round($level); $intLevel = (int)round($level);
@@ -300,10 +316,6 @@ $meta = []; // product meta [pk=>['product_no'=>..,'title'=>..]]
$allYears = []; $allYears = [];
// Auftragsbasierte "Slots" (direct/incl) pro Produkt // Auftragsbasierte "Slots" (direct/incl) pro Produkt
// slotAgg[productPk]['direct'][slot] += revenue
// slotAgg[productPk]['incl'][slot] += revenue
// slotAgg[productPk]['direct_ext'][slot] += extRevenue
// slotAgg[productPk]['incl_ext'][slot] += extRevenue
$slotAgg = []; $slotAgg = [];
function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title): void { function ensureMeta(Epirent $Epi, int $productPk, int $productNo, string $title): void {
@@ -329,7 +341,6 @@ function addRevenue(array &$pivotRef, int $productPk, int $year, float $revenueN
* Extern-Logik: * Extern-Logik:
* - Amount External ist eine ANZAHL (nicht Umsatz). * - Amount External ist eine ANZAHL (nicht Umsatz).
* - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0) * - Wir berechnen extRevenue = sum_total_net * (amount_external / amount_total) (wenn amount_total>0)
* - fallback: 0
*/ */
function calcExternalRevenueNet(object $li): float { function calcExternalRevenueNet(object $li): float {
$amountTotal = (float)($li->amount_total ?? 0); $amountTotal = (float)($li->amount_total ?? 0);
@@ -346,103 +357,219 @@ function calcExternalRevenueNet(object $li): float {
} }
/* ========================= /* =========================
Slot (auftragsbasiert) Intervall Coloring Slot (auftragsbasiert) Storno-sicher (SIGNED)
========================= */ ========================= */
/** /**
* Intervall-Item für Slotting (ein "Auftragsteil" pro Produkt) * Intervall-Item für Slotting (ein "Auftragsteil" pro Produkt)
* qty kann positiv oder negativ sein (negativ = Storno/Rückbuchung)
*/ */
function addSlotItem(array &$slotItems, int $productPk, int $invoicePk, int $orderNo, string $startYmd, string $endYmd, float $qty, float $revenueNet, float $extRevenueNet): void { function addSlotItem(
array &$slotItems,
int $productPk,
int $invoicePk,
int $orderPk,
int $invoiceNo,
string $startYmd,
string $endYmd,
float $qty,
float $revenueNet,
float $extRevenueNet
): void {
$startYmd = safeYmd($startYmd); $startYmd = safeYmd($startYmd);
$endYmd = safeYmd($endYmd); $endYmd = safeYmd($endYmd);
if ($productPk <= 0) return; if ($productPk <= 0) return;
if (!$startYmd || !$endYmd) return; if (!$startYmd || !$endYmd) return;
if ($qty <= 0) return; if ($qty == 0.0) return;
if (!isset($slotItems[$productPk])) $slotItems[$productPk] = [];
$slotItems[$productPk][] = [ $slotItems[$productPk][] = [
'invoice_pk' => $invoicePk, 'product_pk' => $productPk,
'order_no' => $orderNo, 'invoice_pk' => $invoicePk,
'start_ts' => ymdToTs($startYmd), 'order_pk' => $orderPk,
'end_ts' => ymdToTs($endYmd), 'invoice_no' => $invoiceNo,
'qty' => (float)$qty, 'start_ts' => ymdToTs($startYmd),
'rev' => (float)$revenueNet, 'end_ts' => ymdToTs($endYmd),
'rev_ext' => (float)$extRevenueNet, 'qty' => (float)$qty, // signed erlaubt
'start_ymd' => $startYmd, 'rev' => (float)$revenueNet,
'end_ymd' => $endYmd, 'rev_ext' => (float)$extRevenueNet,
'start_ymd' => $startYmd,
'end_ymd' => $endYmd,
]; ];
} }
/** /**
* Slotting-Regel: * Slotting (SIGNED, Storno gibt Slots wieder frei):
* - Aufträge werden nach start_ts sortiert, * - Positive qty belegt Slots.
* - bei gleichem start: zuerst der der später endet bekommt den "zweiten" Slot (also: end_ts DESC), * - Negative qty bucht Umsatz zurück UND gibt Slots frei.
* - wenn dann noch gleich: nach order_no (oder invoice_pk) ASC. * - Priorität bei Storno: zuerst Slots mit gleichem Zeitraum (gleicher release_ts) abbauen.
* - Belegung: für qty=2 werden 2 Slots gesucht; wenn Slots frei werden (end < start), wiederverwendbar.
* *
* Umsatzverteilung: * Ergebnis:
* - pro Auftrag & Produkt wird der UMSATZ auf die belegten Slots gleichmäßig verteilt (rev/qty) * - 'direct' => [slot => revenue]
* - analog für extern-umsatz. * - 'direct_ext' => [slot => extRevenue]
*/ */
function computeOrderBasedSlots(array $itemsForProduct): array { function computeOrderBasedSlots(array $itemsForProduct): array {
if (empty($itemsForProduct)) return [ if (empty($itemsForProduct)) {
'direct' => [], return ['direct' => [], 'direct_ext' => []];
'direct_ext' => [] }
];
// Sortierung: start ASC, end DESC, invoice_no ASC, invoice_pk ASC
usort($itemsForProduct, function($a, $b){ usort($itemsForProduct, function($a, $b){
if ($a['start_ts'] !== $b['start_ts']) return $a['start_ts'] <=> $b['start_ts']; if (($a['start_ts'] ?? 0) !== ($b['start_ts'] ?? 0)) return ($a['start_ts'] ?? 0) <=> ($b['start_ts'] ?? 0);
if ($a['end_ts'] !== $b['end_ts']) return $b['end_ts'] <=> $a['end_ts']; // später endend zuerst if (($a['end_ts'] ?? 0) !== ($b['end_ts'] ?? 0)) return ($b['end_ts'] ?? 0) <=> ($a['end_ts'] ?? 0);
if ($a['order_no'] !== $b['order_no']) return $a['order_no'] <=> $b['order_no']; if (($a['invoice_no'] ?? 0) !== ($b['invoice_no'] ?? 0)) return ($a['invoice_no'] ?? 0) <=> ($b['invoice_no'] ?? 0);
return $a['invoice_pk'] <=> $b['invoice_pk']; return ($a['invoice_pk'] ?? 0) <=> ($b['invoice_pk'] ?? 0);
}); });
$slotEnd = []; // slotIndex => end_ts // Active slots: slotIndex => release_ts
$slotRevenue = []; // slotIndex => revenue $active = [];
$slotRevenueExt = []; // slotIndex => revenueExt // release_ts => [slotIndex, ...]
$activeByRelease = [];
// free slot indices
$freeSlots = [];
// Revenues per slot
$slotRevenue = [];
$slotRevenueExt = [];
$releaseTsFor = function(int $endTs): int {
// Ende inklusiv -> frei am Folgetag 00:00
return $endTs + 86400;
};
$freeExpired = function(int $currentStartTs) use (&$active, &$activeByRelease, &$freeSlots) {
if (empty($active)) return;
foreach ($active as $slot => $relTs) {
if ($relTs <= $currentStartTs) {
unset($active[$slot]);
if (isset($activeByRelease[$relTs])) {
$activeByRelease[$relTs] = array_values(array_filter(
$activeByRelease[$relTs],
fn($s) => (int)$s !== (int)$slot
));
if (empty($activeByRelease[$relTs])) unset($activeByRelease[$relTs]);
}
$freeSlots[] = (int)$slot;
}
}
};
$takeFreeSlot = function() use (&$freeSlots, &$active): int {
if (!empty($freeSlots)) {
sort($freeSlots);
return (int)array_shift($freeSlots);
}
if (empty($active)) return 1;
$keys = array_keys($active);
return (int)(max($keys) + 1);
};
foreach ($itemsForProduct as $it) { foreach ($itemsForProduct as $it) {
$qty = (int)round($it['qty']); $startTs = (int)($it['start_ts'] ?? 0);
if ($qty <= 0) continue; $endTs = (int)($it['end_ts'] ?? 0);
if ($startTs <= 0 || $endTs <= 0) continue;
if ($endTs < $startTs) continue;
$revPer = ($it['rev'] ?? 0.0) / $qty; // vor jeder Aktion: abgelaufene Slots freigeben
$extPer = ($it['rev_ext'] ?? 0.0) / $qty; $freeExpired($startTs);
// finde freie Slots / neue Slots $qtySigned = (float)($it['qty'] ?? 0.0);
$assigned = []; if ($qtySigned == 0.0) continue;
for ($k=0; $k<$qty; $k++) {
$slot = null;
// freier Slot: end < start $qtyAbs = (int)round(abs($qtySigned));
foreach ($slotEnd as $idx => $endTs) { if ($qtyAbs <= 0) continue;
if ($endTs < $it['start_ts']) { // streng <, damit gleicher Tag als parallel zählt
$slot = (int)$idx; $rev = (float)($it['rev'] ?? 0.0);
break; $ext = (float)($it['rev_ext'] ?? 0.0);
$revPer = $rev / $qtyAbs;
$extPer = $ext / $qtyAbs;
$relTs = $releaseTsFor($endTs);
if ($qtySigned > 0) {
// --- Belegen ---
for ($k = 0; $k < $qtyAbs; $k++) {
$slot = $takeFreeSlot();
$active[$slot] = $relTs;
if (!isset($activeByRelease[$relTs])) $activeByRelease[$relTs] = [];
$activeByRelease[$relTs][] = $slot;
if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0;
if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0;
$slotRevenue[$slot] += $revPer;
$slotRevenueExt[$slot] += $extPer;
}
} else {
// --- Storno / Freigeben ---
// erst gleiche release_ts abbauen (damit "Rechnung+Storno im gleichen Zeitraum" sauber 0 Slots macht)
$candidates = $activeByRelease[$relTs] ?? [];
for ($k = 0; $k < $qtyAbs; $k++) {
$slot = null;
if (!empty($candidates)) {
rsort($candidates); // höchste Slotnummer zuerst abbauen
$slot = (int)array_shift($candidates);
} else {
if (!empty($active)) {
$keys = array_keys($active);
rsort($keys);
$slot = (int)$keys[0];
}
} }
}
if ($slot === null) {
$slot = count($slotEnd) + 1; // Slots sind 1-based
}
// reservieren if ($slot === null) break;
$slotEnd[$slot] = $it['end_ts'];
$assigned[] = $slot;
// Revenue sammeln // Umsatz auf Slot zurückbuchen
if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0; if (!isset($slotRevenue[$slot])) $slotRevenue[$slot] = 0.0;
if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0; if (!isset($slotRevenueExt[$slot])) $slotRevenueExt[$slot] = 0.0;
$slotRevenue[$slot] += $revPer; $slotRevenue[$slot] += $revPer; // revPer ist i.d.R. negativ
$slotRevenueExt[$slot] += $extPer; $slotRevenueExt[$slot] += $extPer; // extPer ist i.d.R. negativ
// Slot freigeben
$oldRel = $active[$slot] ?? null;
unset($active[$slot]);
if ($oldRel !== null && isset($activeByRelease[$oldRel])) {
$activeByRelease[$oldRel] = array_values(array_filter(
$activeByRelease[$oldRel],
fn($s) => (int)$s !== (int)$slot
));
if (empty($activeByRelease[$oldRel])) unset($activeByRelease[$oldRel]);
}
$freeSlots[] = $slot;
}
} }
} }
ksort($slotRevenue); // --- Cleanup: Slots ohne Umsatz entfernen + neu durchnummerieren ---
ksort($slotRevenueExt); $eps = 0.00001;
$allSlots = array_unique(array_merge(array_keys($slotRevenue), array_keys($slotRevenueExt)));
sort($allSlots);
// runden (2 Nachkommastellen für Anzeige später, intern float ok) $kept = [];
return [ foreach ($allSlots as $s) {
'direct' => $slotRevenue, $rev = (float)($slotRevenue[$s] ?? 0.0);
'direct_ext' => $slotRevenueExt $ext = (float)($slotRevenueExt[$s] ?? 0.0);
]; if (abs($rev) < $eps && abs($ext) < $eps) continue;
$kept[] = $s;
}
$newRev = [];
$newExt = [];
$idx = 1;
foreach ($kept as $old) {
$newRev[$idx] = (float)($slotRevenue[$old] ?? 0.0);
$newExt[$idx] = (float)($slotRevenueExt[$old] ?? 0.0);
$idx++;
}
return ['direct' => $newRev, 'direct_ext' => $newExt];
} }
/* ========================= /* =========================
@@ -454,13 +581,15 @@ function processLineItem(
array &$rows, array &$rows,
array &$slotItemsDirect, array &$slotItemsDirect,
array &$slotItemsIncl, array &$slotItemsIncl,
int $orderPk,
int $invoicePk, int $invoicePk,
int $invoiceNo, int $invoiceNo,
string $invoiceDate, string $invoiceDate,
int $invoiceYear, int $invoiceYear,
?int $chapterId, ?int $chapterId,
object $li, object $li,
bool $isFromJournal bool $isFromJournal,
bool $invoiceIsCredit // <-- wichtig: Storno-Rechnung (z.B. sum_net < 0)
): void { ): void {
global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl; global $pivot, $pivotB, $pivotExt, $pivotExtB, $allYears, $eventsDirect, $eventsIncl;
@@ -471,15 +600,23 @@ function processLineItem(
$title = (string)($li->title ?? ''); $title = (string)($li->title ?? '');
$productNo = (int)($li->product_no ?? 0); $productNo = (int)($li->product_no ?? 0);
$qty = (float)($li->amount_total ?? 0); $qtyRaw = (float)($li->amount_total ?? 0);
if ($qty <= 0) $qty = 0.0; if ($qtyRaw == 0.0) $qtyRaw = 0.0;
// Umsatz bleibt wie geliefert (kann negativ sein)
$revenueNet = (float)($li->sum_total_net ?? 0);
$extRevenueNet = calcExternalRevenueNet($li);
// Für Auslastung/Slots: wenn Storno-Rechnung, Menge "negativ signieren"
// (weil Epi bei Storno typischerweise Menge positiv lässt, aber Preise negativ macht)
$qtySigned = $qtyRaw;
if ($invoiceIsCredit && $qtySigned != 0.0) {
$qtySigned = -abs($qtySigned);
}
$dateStart = safeYmd((string)($li->date_start ?? '')) ?: null; $dateStart = safeYmd((string)($li->date_start ?? '')) ?: null;
$dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null; $dateEnd = safeYmd((string)($li->date_end ?? '')) ?: null;
$revenueNet = (float)($li->sum_total_net ?? 0);
$extRevenueNet = calcExternalRevenueNet($li);
ensureMeta($Epi, $productPk, $productNo, $title); ensureMeta($Epi, $productPk, $productNo, $title);
// Jahr: Rechnungsdatum // Jahr: Rechnungsdatum
@@ -493,15 +630,16 @@ function processLineItem(
if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet); if ($extRevenueNet != 0.0 && $year) addRevenue($pivotExt, $productPk, $year, $extRevenueNet);
// Peak/Histogram direct + incl (tagesbasiert) // Peak/Histogram direct + incl (tagesbasiert)
if ($dateStart && $dateEnd && $qty > 0) { // -> qtySigned sorgt dafür, dass Storno zeitgleich aufhebt
addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qty); if ($dateStart && $dateEnd && $qtySigned != 0.0) {
addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qty); addIntervalEvent($eventsDirect, $productPk, $dateStart, $dateEnd, $qtySigned);
addIntervalEvent($eventsIncl, $productPk, $dateStart, $dateEnd, $qtySigned);
} }
// Slotting: direct // Slotting: direct (signed qty)
if ($dateStart && $dateEnd && $qty > 0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
addSlotItem($slotItemsDirect, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet); addSlotItem($slotItemsDirect, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet);
addSlotItem($slotItemsIncl, $productPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qty, $revenueNet, $extRevenueNet); addSlotItem($slotItemsIncl, $productPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtySigned, $revenueNet, $extRevenueNet);
} }
// Debug Row // Debug Row
@@ -517,11 +655,13 @@ function processLineItem(
'title' => $title, 'title' => $title,
'date_start' => $dateStart ?? '', 'date_start' => $dateStart ?? '',
'date_end' => $dateEnd ?? '', 'date_end' => $dateEnd ?? '',
'qty' => $qty, 'qty' => $qtyRaw,
'qty_signed' => $qtySigned,
'revenue_net' => $revenueNet, 'revenue_net' => $revenueNet,
'ext_rev_net' => $extRevenueNet, 'ext_rev_net' => $extRevenueNet,
'amount_ext' => (float)($li->amount_external ?? 0), 'amount_ext' => (float)($li->amount_external ?? 0),
'amount_total'=> (float)($li->amount_total ?? 0), 'amount_total'=> (float)($li->amount_total ?? 0),
'is_credit' => $invoiceIsCredit ? 1 : 0,
]; ];
/* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */ /* ===== Bundle-Auflösung (nur virtuelle Bundles!) ===== */
@@ -549,20 +689,19 @@ function processLineItem(
} }
} }
// Auslastung allokieren (Peak/Histogramm inkl Bundle) // Auslastung allokieren (Peak/Histogramm inkl Bundle) signed qty!
if ($dateStart && $dateEnd && $qty > 0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
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;
ensureMeta($Epi, $leafPk, 0, ''); ensureMeta($Epi, $leafPk, 0, '');
addIntervalEvent($eventsIncl, $leafPk, $dateStart, $dateEnd, $qty * $amt); addIntervalEvent($eventsIncl, $leafPk, $dateStart, $dateEnd, $qtySigned * $amt);
} }
} }
// Slotting allokieren (inkl Bundle): wir erzeugen SlotItems für Leaf-Produkte // Slotting allokieren (inkl Bundle) signed qty!
if ($dateStart && $dateEnd && $qty > 0) { if ($dateStart && $dateEnd && $qtySigned != 0.0) {
// Revenue pro "Bundle-Item" wird in allocateBundleRevenue bereits verteilt.
$alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : []; $alloc = ($revenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $revenueNet) : [];
$allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : []; $allocExt = ($extRevenueNet != 0.0) ? allocateBundleRevenue($Epi, $productPk, $extRevenueNet) : [];
@@ -574,11 +713,10 @@ function processLineItem(
$leafRev = (float)($alloc[$leafPk] ?? 0.0); $leafRev = (float)($alloc[$leafPk] ?? 0.0);
$leafExt = (float)($allocExt[$leafPk] ?? 0.0); $leafExt = (float)($allocExt[$leafPk] ?? 0.0);
// qty_leaf = qty * amt $qtyLeafSigned = $qtySigned * $amt;
$qtyLeaf = $qty * $amt;
ensureMeta($Epi, $leafPk, 0, ''); ensureMeta($Epi, $leafPk, 0, '');
addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $invoiceNo, $dateStart, $dateEnd, $qtyLeaf, $leafRev, $leafExt); addSlotItem($slotItemsIncl, $leafPk, $invoicePk, $orderPk, $invoiceNo, $dateStart, $dateEnd, $qtyLeafSigned, $leafRev, $leafExt);
} }
} }
} }
@@ -588,6 +726,14 @@ function processLineItem(
1) Invoices holen + Daten sammeln 1) Invoices holen + Daten sammeln
========================= */ ========================= */
// Defaults für die HTML-Ausgabe, wenn keine Berechnung läuft
$years = [];
$pivotRows = [];
$excelFileName = '';
$excelDownloadUrl = '';
if ($computeRoi) {
$slotItemsDirect = []; // productPk => list of intervals $slotItemsDirect = []; // productPk => list of intervals
$slotItemsIncl = []; // productPk => list of intervals $slotItemsIncl = []; // productPk => list of intervals
@@ -598,6 +744,15 @@ foreach ($invoiceList as $inv) {
$invoicePk = (int)($inv->primary_key ?? 0); $invoicePk = (int)($inv->primary_key ?? 0);
if ($invoicePk <= 0) continue; if ($invoicePk <= 0) continue;
// Vorab-Filter: wenn das Listen-Item bereits invoice_date hat, sparen wir den teuren Detail-Call
if ($filterMode === 'range') {
$listYear = yearFromDate((string)($inv->invoice_date ?? '')) ?? 0;
if ($listYear > 0) {
if ($filterFrom > 0 && $listYear < $filterFrom) continue;
if ($filterTo > 0 && $listYear > $filterTo) continue;
}
}
$invRes = apiJsonDecode($Epi->requestEpiApi('/v1/invoice/' . $invoicePk . '?cl=' . Epirent_Mandant)); $invRes = apiJsonDecode($Epi->requestEpiApi('/v1/invoice/' . $invoicePk . '?cl=' . Epirent_Mandant));
$invObj = ($invRes && ($invRes->success ?? false) && !empty($invRes->payload[0])) ? $invRes->payload[0] : null; $invObj = ($invRes && ($invRes->success ?? false) && !empty($invRes->payload[0])) ? $invRes->payload[0] : null;
if (!$invObj) continue; if (!$invObj) continue;
@@ -605,6 +760,17 @@ foreach ($invoiceList as $inv) {
$invoiceDate = (string)($invObj->invoice_date ?? ''); $invoiceDate = (string)($invObj->invoice_date ?? '');
$invoiceYear = yearFromDate($invoiceDate) ?? 0; $invoiceYear = yearFromDate($invoiceDate) ?? 0;
$invoiceNo = (int)($invObj->invoice_no ?? 0); $invoiceNo = (int)($invObj->invoice_no ?? 0);
$orderPk = (int)($invObj->order_pk ?? 0);
// Endgültiger Year-Filter (falls Listen-Antwort kein Datum hatte)
if ($filterMode === 'range') {
if ($invoiceYear <= 0) continue;
if ($filterFrom > 0 && $invoiceYear < $filterFrom) continue;
if ($filterTo > 0 && $invoiceYear > $filterTo) continue;
}
// Storno-Erkennung (Credit Note): sum_net < 0
$invoiceIsCredit = ((float)($invObj->sum_net ?? 0.0)) < 0.0;
$orderItems = $invObj->order_items ?? []; $orderItems = $invObj->order_items ?? [];
if (!is_array($orderItems)) $orderItems = []; if (!is_array($orderItems)) $orderItems = [];
@@ -619,14 +785,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, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true); processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, $chapterId, $ji, true, $invoiceIsCredit);
} }
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, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false); processLineItem($Epi, $rows, $slotItemsDirect, $slotItemsIncl, $orderPk, $invoicePk, $invoiceNo, $invoiceDate, $invoiceYear, null, $oi, false, $invoiceIsCredit);
continue; continue;
} }
} }
@@ -717,7 +883,6 @@ foreach ($meta as $productPk => $m) {
$row['hist_direct'] = $pd['hist']; $row['hist_direct'] = $pd['hist'];
$row['hist_incl'] = $pi['hist']; $row['hist_incl'] = $pi['hist'];
// nur anzeigen, wenn was los ist
$hasAny = ( $hasAny = (
$row['total_direct'] != 0.0 || $row['total_direct'] != 0.0 ||
$row['total_incl'] != 0.0 || $row['total_incl'] != 0.0 ||
@@ -845,18 +1010,21 @@ $sheet->freezePane("A" . ($headerRow + 1));
$writer = new Xlsx($spreadsheet); $writer = new Xlsx($spreadsheet);
$writer->save($excelFilePath); $writer->save($excelFilePath);
} // end if ($computeRoi)
/* ========================= /* =========================
5) HTML Ausgabe 5) HTML Ausgabe
========================= */ ========================= */
function slotsToDisplay(array $slotMap): array { function slotsToDisplay(array $slotMap): array {
// slotMap: [slot=>revenue]
ksort($slotMap); ksort($slotMap);
$out = []; $out = [];
foreach ($slotMap as $slot => $rev) { foreach ($slotMap as $slot => $rev) {
$slot = (int)$slot; $slot = (int)$slot;
if ($slot <= 0) continue; if ($slot <= 0) continue;
$out[] = ['slot'=>$slot, 'rev'=>(float)$rev]; $v = (float)$rev;
if (abs($v) < 0.0000001) $v = 0.0;
$out[] = ['slot'=>$slot, 'rev'=>$v];
} }
return $out; return $out;
} }
@@ -884,11 +1052,9 @@ function slotsToDisplay(array $slotMap): array {
<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>
<style> <style>
.kpi-updated { font-size: .82rem; opacity: .85; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.btn-xs { padding:.15rem .4rem; font-size:.78rem; } .btn-xs { padding:.15rem .4rem; font-size:.78rem; }
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
.small-muted { font-size: .82rem; opacity:.85; }
</style> </style>
<script> <script>
@@ -915,6 +1081,49 @@ function slotsToDisplay(array $slotMap): array {
<li class="breadcrumb-item active">Umsatz / Peak / Histogramm pro Artikel</li> <li class="breadcrumb-item active">Umsatz / Peak / Histogramm pro Artikel</li>
</ol> </ol>
<!-- Zeitraum-Filter -->
<div class="card mb-4">
<div class="card-header"><i class="fas fa-filter mr-1"></i> Zeitraum wählen</div>
<div class="card-body">
<form method="get" class="form-row align-items-end">
<div class="form-group col-md-3 mb-2">
<label for="from">Startjahr</label>
<input type="number" name="from" id="from" class="form-control" min="2000" max="2100"
value="<?php echo $filterFrom > 0 ? (int)$filterFrom : ((int)date('Y') - 2); ?>">
</div>
<div class="form-group col-md-3 mb-2">
<label for="to">Endjahr</label>
<input type="number" name="to" id="to" class="form-control" min="2000" max="2100"
value="<?php echo $filterTo > 0 ? (int)$filterTo : (int)date('Y'); ?>">
</div>
<div class="form-group col-md-3 mb-2">
<button type="submit" name="mode" value="range" class="btn btn-primary btn-block">
<i class="fas fa-play"></i> Zeitraum berechnen
</button>
</div>
<div class="form-group col-md-3 mb-2">
<button type="submit" name="mode" value="all" class="btn btn-secondary btn-block">
<i class="fas fa-infinity"></i> Alles berechnen
</button>
</div>
</form>
<small class="text-muted">
<?php if ($computeRoi && $filterMode === 'range'): ?>
<br><b>Aktuell:</b> <?php echo (int)$filterFrom; ?>&nbsp;&nbsp;<?php echo (int)$filterTo; ?>
<?php elseif ($computeRoi && $filterMode === 'all'): ?>
<br><b>Aktuell:</b> alle Jahre
<?php endif; ?>
</small>
</div>
</div>
<?php if (!$computeRoi): ?>
<div class="alert alert-info">
Bitte oben einen Zeitraum auswählen und auf <b>Zeitraum berechnen</b> klicken,
oder <b>Alles berechnen</b> für die vollständige Auswertung.
</div>
<?php else: ?>
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fas fa-table mr-1"></i> Pivot: Artikel × Rechnungsjahr</div> <div><i class="fas fa-table mr-1"></i> Pivot: Artikel × Rechnungsjahr</div>
@@ -974,37 +1183,28 @@ function slotsToDisplay(array $slotMap): array {
<td><?php echo htmlspecialchars((string)$r['title']); ?></td> <td><?php echo htmlspecialchars((string)$r['title']); ?></td>
<td class="nowrap"> <td class="nowrap">
<?php <?php echo $peakDirect; ?>
echo $peakDirect; <span class="text-muted">(<?php echo $peakIncl; ?>)</span>
echo ' <span class="text-muted">(' . $peakIncl . ')</span>';
?>
</td> </td>
<td class="nowrap"> <td class="nowrap">
<?php <?php echo formatEuro($sumDirect); ?>
echo formatEuro($sumDirect); <span class="text-muted">(<?php echo formatEuro($sumIncl); ?>)</span>
echo ' <span class="text-muted">(' . formatEuro($sumIncl) . ')</span>';
?>
</td> </td>
<td class="nowrap"> <td class="nowrap">
<?php <?php echo formatEuro($extDirect); ?>
echo formatEuro($extDirect); <span class="text-muted">(<?php echo formatEuro($extIncl); ?>)</span>
echo ' <span class="text-muted">(' . formatEuro($extIncl) . ')</span>';
?>
</td> </td>
<?php foreach ($years as $y): ?> <?php foreach ($years as $y): ?>
<?php <?php
$d = (float)$r['years'][$y]['direct']; $d = (float)$r['years'][$y]['direct'];
$iVal = (float)$r['years'][$y]['incl']; $iVal = (float)$r['years'][$y]['incl'];
$de = (float)$r['years_ext'][$y]['direct'];
$ie = (float)$r['years_ext'][$y]['incl'];
?> ?>
<td class="nowrap"> <td class="nowrap">
<div><?php echo formatEuro($d); ?> <span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span></div> <?php echo formatEuro($d); ?>
<!--<div class="small-muted">ext: <?php echo formatEuro($de); ?> <span class="text-muted">(<?php echo formatEuro($ie); ?>)</span></div>--> <span class="text-muted">(<?php echo formatEuro($iVal); ?>)</span>
</td> </td>
<?php endforeach; ?> <?php endforeach; ?>
@@ -1024,7 +1224,7 @@ function slotsToDisplay(array $slotMap): array {
data-hist="<?php echo htmlspecialchars($histInclB64); ?>" data-hist="<?php echo htmlspecialchars($histInclB64); ?>"
data-peak="<?php echo (int)$peakIncl; ?>" data-peak="<?php echo (int)$peakIncl; ?>"
data-mode="incl"> data-mode="incl">
Histogramm inkl. Bundle inkl. Bundle
</button> </button>
</td> </td>
@@ -1033,7 +1233,7 @@ function slotsToDisplay(array $slotMap): array {
class="btn btn-outline-dark btn-xs js-slots" class="btn btn-outline-dark btn-xs js-slots"
data-title="<?php echo htmlspecialchars((string)$r['title']); ?>" data-title="<?php echo htmlspecialchars((string)$r['title']); ?>"
data-slots="<?php echo htmlspecialchars($slotsB64); ?>"> data-slots="<?php echo htmlspecialchars($slotsB64); ?>">
Slots (Auftragsbasiert) Slots
</button> </button>
</td> </td>
</tr> </tr>
@@ -1045,8 +1245,9 @@ function slotsToDisplay(array $slotMap): array {
Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span>. Jahr-Zuordnung Umsatz: <span class="mono">invoice_date</span>.
Peak/Histogramm: Zeitraum <span class="mono">date_start..date_end</span> (tagesbasiert). Peak/Histogramm: Zeitraum <span class="mono">date_start..date_end</span> (tagesbasiert).
Werte in Klammern = inkl. Bundle-Anteil (nur <b>virtuelle</b> Bundles werden zerlegt). Werte in Klammern = inkl. Bundle-Anteil (nur <b>virtuelle</b> Bundles werden zerlegt).
Extern-Umsatz: <span class="mono">sum_total_net * (amount_external/amount_total)</span> (amount_external ist Menge). Extern-Umsatz: <span class="mono">sum_total_net * (amount_external/amount_total)</span>.
Slots: auftragsbasierte Slotbelegung nach Start, dann Ende DESC, dann Auftragsnr. Slots: auftragsbasiert, SIGNED (Storno gibt Slots frei und bucht auf die gleichen Zeiträume zurück).
Storno-Erkennung: Rechnung mit <span class="mono">sum_net &lt; 0</span>.
</small> </small>
</div> </div>
</div> </div>
@@ -1073,9 +1274,11 @@ function slotsToDisplay(array $slotMap): array {
<th>Start</th> <th>Start</th>
<th>Ende</th> <th>Ende</th>
<th>Menge</th> <th>Menge</th>
<th>Menge (signed)</th>
<th>Ext-Menge</th> <th>Ext-Menge</th>
<th>Umsatz netto</th> <th>Umsatz netto</th>
<th>Umsatz extern</th> <th>Umsatz extern</th>
<th>Credit</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1093,21 +1296,25 @@ function slotsToDisplay(array $slotMap): array {
<td class="mono"><?php echo htmlspecialchars((string)$r['date_start']); ?></td> <td class="mono"><?php echo htmlspecialchars((string)$r['date_start']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['date_end']); ?></td> <td class="mono"><?php echo htmlspecialchars((string)$r['date_end']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)$r['qty']); ?></td> <td class="mono"><?php echo htmlspecialchars((string)$r['qty']); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)($r['qty_signed'] ?? $r['qty'])); ?></td>
<td class="mono"><?php echo htmlspecialchars((string)($r['amount_ext'] ?? '0')); ?></td> <td class="mono"><?php echo htmlspecialchars((string)($r['amount_ext'] ?? '0')); ?></td>
<td class="nowrap"><?php echo formatEuro((float)$r['revenue_net']); ?></td> <td class="nowrap"><?php echo formatEuro((float)$r['revenue_net']); ?></td>
<td class="nowrap"><?php echo formatEuro((float)$r['ext_rev_net']); ?></td> <td class="nowrap"><?php echo formatEuro((float)$r['ext_rev_net']); ?></td>
<td class="mono"><?php echo (int)($r['is_credit'] ?? 0); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
<small class="text-muted"> <small class="text-muted">
Wenn ein Artikel “ohne Kapitel” fehlt, muss er hier als Quelle <b>direct</b> auftauchen.
Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet. Ext-Menge ist Anzahl; Umsatz extern ist anteilig berechnet.
Menge(signed) ist für Peak/Slots relevant (Storno-Rechnung -> negativ).
</small> </small>
</div> </div>
</div> </div>
</div> </div>
<?php endif; // $computeRoi ?>
</div> </div>
</main> </main>
<div id="footerholder"></div> <div id="footerholder"></div>
@@ -1127,7 +1334,7 @@ function slotsToDisplay(array $slotMap): array {
<div class="modal-body"> <div class="modal-body">
<canvas id="histChart" height="140"></canvas> <canvas id="histChart" height="140"></canvas>
<div class="mt-2 text-muted"> <div class="mt-2 text-muted">
X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) &nbsp;|&nbsp; Y-Achse: Tage X-Achse: gleichzeitig vermietete Stückzahl (1..Peak) | Y-Achse: Tage
</div> </div>
</div> </div>
</div> </div>
@@ -1148,6 +1355,7 @@ function slotsToDisplay(array $slotMap): array {
<div class="mb-2 text-muted"> <div class="mb-2 text-muted">
Slot-Umsatz ist <b>auftragsbasiert</b>: Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt. Slot-Umsatz ist <b>auftragsbasiert</b>: Umsatz einer Position wird gleichmäßig auf die belegten Slots verteilt.
Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles). Werte in Klammern = inkl. Bundle-Anteil (nur virtuelle Bundles).
Storno bucht Umsatz auf die gleichen Slots zurück und gibt die Slots frei (SIGNED).
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
@@ -1228,7 +1436,6 @@ function openSlots(title, payload) {
const directExt = payload.direct_ext || []; const directExt = payload.direct_ext || [];
const inclExt = payload.incl_ext || []; const inclExt = payload.incl_ext || [];
// maps slot->rev
const mapD = {}; const mapD = {};
const mapI = {}; const mapI = {};
const mapDE = {}; const mapDE = {};
@@ -1289,9 +1496,7 @@ function openSlots(title, payload) {
options: { options: {
responsive: true, responsive: true,
animation: false, animation: false,
scales: { scales: { y: { beginAtZero: true } }
y: { beginAtZero: true }
}
} }
}); });
+11 -2
View File
@@ -81,6 +81,13 @@ foreach ($filteredProductArray as $filteredProduct) {
if($device->condition=="Nicht Versichert"){ if($device->condition=="Nicht Versichert"){
continue; continue;
} }
//Filtere alle bei denen Sold!=0 ist raus:
if($device->sold_state != 0){
continue;
}
//Sortiere jetzt die Geräte aus, die Nicht aussortiert wurden //Sortiere jetzt die Geräte aus, die Nicht aussortiert wurden
if(!$device->is_sorted_out){ if(!$device->is_sorted_out){
$rows[] = [ $rows[] = [
@@ -180,8 +187,10 @@ foreach ($rows as $r) {
$sheet->getStyle($cell($c, $rowIdx))->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]'); $sheet->getStyle($cell($c, $rowIdx))->getNumberFormat()->setFormatCode('#,##0.00 [$€-de-DE]');
$c++; $c++;
// Seriennummer // Seriennummer (immer als Text, damit Excel rein numerische Werte nicht als Zahl interpretiert)
$sheet->setCellValue($cell($c++, $rowIdx), $r['serial_no']); $sheet->getStyle($cell($c, $rowIdx))->getNumberFormat()->setFormatCode('@');
$sheet->setCellValueExplicit($cell($c, $rowIdx), $r['serial_no'], DataType::TYPE_STRING);
$c++;
// Anschaffung (Datum) // Anschaffung (Datum)
$excelDate = ''; $excelDate = '';
+24
View File
@@ -88,6 +88,9 @@ $productList = json_decode($Epi->requestEpiApi('/v1/product/all?ia=true&ir=true&
if (!$product->is_rent & $product->is_sale){ if (!$product->is_rent & $product->is_sale){
continue; continue;
} }
if (!$product->is_active){
continue;
}
echo "<tr>"; echo "<tr>";
echo "<td>" . htmlspecialchars($product->primary_key) . "</td>"; echo "<td>" . htmlspecialchars($product->primary_key) . "</td>";
echo "<td>" . htmlspecialchars($product->product_no) . "</td>"; echo "<td>" . htmlspecialchars($product->product_no) . "</td>";
@@ -99,6 +102,11 @@ $productList = json_decode($Epi->requestEpiApi('/v1/product/all?ia=true&ir=true&
href='../sources/getProductLabel.php?id=" . urlencode($product->primary_key) . "'> href='../sources/getProductLabel.php?id=" . urlencode($product->primary_key) . "'>
Export Export
</a> </a>
<a class='btn btn-sm btn-outline-primary'
target='_blank'
href='../sources/getProductLabelSmall.php?id=" . urlencode($product->primary_key) . "'>
Export small
</a>
<input type='number' <input type='number'
id='nrInput_" . htmlspecialchars($product->primary_key) . "' id='nrInput_" . htmlspecialchars($product->primary_key) . "'
@@ -121,6 +129,22 @@ $productList = json_decode($Epi->requestEpiApi('/v1/product/all?ia=true&ir=true&
\"> \">
Export mit ID Export mit ID
</a> </a>
<a class='btn btn-sm btn-outline-secondary'
target='_blank'
id='exportWithIdBtn_" . htmlspecialchars($product->primary_key) . "'
href='#'
onclick=\"
var nr = document.getElementById('nrInput_" . htmlspecialchars($product->primary_key) . "').value;
if(nr) {
this.href = '../sources/getProductLabelSmall.php?id=" . urlencode($product->primary_key) . "&nr=' + encodeURIComponent(nr);
} else {
alert('Bitte eine Nummer zwischen 1 und 9999 eingeben.');
return false;
}
\">
Export mit ID small
</a>
</td>"; </td>";
echo "</tr>"; echo "</tr>";
} }
+25 -5
View File
@@ -402,18 +402,31 @@ $indentMm = max(0, ($lvl - 1) * $INDENT_MM_PER_LVL);
// Name-Spalte: ohne Spacer-Tabelle bei Level 1 (indent=0), sonst mit // Name-Spalte: ohne Spacer-Tabelle bei Level 1 (indent=0), sonst mit
if ($indentMm > 0) { if ($indentMm > 0) {
$nameCellHtml = ' /*$nameCellHtml = '
<table cellspacing="0" cellpadding="0" width="100%"> <table cellspacing="0" cellpadding="0" width="100%">
<tr> <tr>
<td style="width:'.$indentMm.'mm; padding:0; margin:0;"></td> <td style="width:'.$indentMm.'mm; padding:0; margin:0;"></td>
<td style="font-size:'.$textFontPx.'px; line-height:'.$lineHeight.'; padding:0; margin:0;">'.$text.'</td> <td style="font-size:'.$textFontPx.'px; line-height:'.$lineHeight.'; padding:0; margin:0;">'.$text.'</td>
</tr> </tr>
</table>'; </table>';*/
$indentMm = max(0, ($lvl - 1) * $INDENT_MM_PER_LVL);
$indentSpaces = str_repeat('&nbsp;', max(0, ($lvl - 1) * 4));
$nameCellHtml = '
<span style="
font-size:'.$textFontPx.'px;
line-height:'.$lineHeight.';
">'.$indentSpaces.$text.'</span>';
//Ende ersetzter Teil
} else { } else {
// Level 1: direkt rendern // Level 1: direkt rendern
$nameCellHtml = '<span style="font-size:'.$textFontPx.'px; line-height:'.$lineHeight.';">'.$text.'</span>'; $nameCellHtml = '<span style="font-size:'.$textFontPx.'px; line-height:'.$lineHeight.';">'.$text.'</span>';
} }
$bundleRows .= ' $bundleRows .= '
<tr> <tr>
<!-- Anzahl --> <!-- Anzahl -->
@@ -429,7 +442,7 @@ if ($indentMm > 0) {
<!-- Name mit Einrückung --> <!-- Name mit Einrückung -->
<td style=" <td style="
width:68%; width:78%;
vertical-align:middle; vertical-align:middle;
padding:'.$ROW_PAD_V_MM.'mm '.$ROW_PAD_H_MM.'mm;"> padding:'.$ROW_PAD_V_MM.'mm '.$ROW_PAD_H_MM.'mm;">
'.$nameCellHtml.' '.$nameCellHtml.'
@@ -437,7 +450,7 @@ if ($indentMm > 0) {
<!-- Gewicht rechts mit extra Innenabstand --> <!-- Gewicht rechts mit extra Innenabstand -->
<td style=" <td style="
width:20%; width:10%;
text-align:right; text-align:right;
vertical-align:middle; vertical-align:middle;
font-size:'.$weightFontPx.'px; font-size:'.$weightFontPx.'px;
@@ -606,7 +619,8 @@ $metaWrap = '
<tr> <tr>
<td width="62%">'.$leftMeta.'</td> <td width="62%">'.$leftMeta.'</td>
<td width="2%"></td> <td width="2%"></td>
<td width="36%">'.$rightMeta.'</td> <td width="36%"></td>
</tr> </tr>
</table>'; </table>';
@@ -779,6 +793,12 @@ function addMaterialsRecursive($Epi, array $materials, int $level, array &$bundl
if ($level > $MAX_BUNDLE_LEVEL || empty($materials)) { if ($level > $MAX_BUNDLE_LEVEL || empty($materials)) {
return; return;
} }
// Nach "position" aufsteigend sortieren (pro Level)
usort($materials, function ($a, $b) {
$posA = isset($a->position) ? (int)$a->position : PHP_INT_MAX;
$posB = isset($b->position) ? (int)$b->position : PHP_INT_MAX;
return $posA <=> $posB;
});
foreach ($materials as $mat) { foreach ($materials as $mat) {
$isFree = !empty($mat->is_free_material); $isFree = !empty($mat->is_free_material);
+795
View File
@@ -0,0 +1,795 @@
<?php
error_reporting(E_ALL & ~E_DEPRECATED);
require('../config.php');
require('../EpiApi.php');
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../vendor/tecnickcom/tcpdf/tcpdf.php';
date_default_timezone_set('Europe/Berlin');
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$deviceNumber = (!empty($_GET['nr'])) ? '--' . trim($_GET['nr']) : '';
$Epi = new Epirent();
$product =json_decode($Epi->requestEpiApi('/v1/product/'.$id.'?cl=' . Epirent_Mandant))->payload[0];
$productImage =json_decode($Epi->requestEpiApi('/v1/product/image/'.$id.'?cl=' . Epirent_Mandant))->payload[0]->image_data;
function s(?string $v): string { return trim((string)$v); }
function fnum(?float $v, int $dec = 2): string {
if ($v === null) return '';
$n = number_format((float)$v, $dec, ',', '.');
return rtrim(rtrim($n, '0'), ',');
}
//Set Variables
$productName = $product->name;
$productNumber = $product->product_no;
$bruttoWeightSum = $product->tech_data->weight_gro;
$bundle = buildBundleForProduct($Epi, $product);
/** Test-Daten
*/
$dimsBHT =$product->tech_data->width_gro." x ".$product->tech_data->depth_gro." x ".$product->tech_data->height_gro;
$volume = ((float)$product->tech_data->height_gro * (float)$product->tech_data->width_gro * (float)$product->tech_data->depth_gro)* 0.000001;
$storageLoc = $product->product_data->storage_location_nearby;
/** Layout */
$PAGE_SIZE = 'A4';
$MARGIN_LEFT = 8;
$MARGIN_TOP = 8;
$MARGIN_RIGHT = 8;
$MARGIN_BOTTOM = 10;
$CELL_PAD = 1.6;
$LINE_GRAY = '#efefef';
$LEVEL_MIN = 1; $LEVEL_MAX = 5;
$INDENT_PX_PER_LEVEL = 10;
$BASE_FONT_PX = 10;
$FONT_STEP_PX = 1.5;
/** TCPDF */
$pdf = new TCPDF('P', 'mm', $PAGE_SIZE, true, 'UTF-8', false);
// 1) Pfade sauber
$fontDir = realpath(__DIR__ . '/../src/assets/font') . '/';
$tcpdfFontsDir = defined('K_PATH_FONTS') ? K_PATH_FONTS : (dirname((new \ReflectionClass('TCPDF'))->getFileName()) . '/fonts/');
// 2) Rechte (einmalig sicherstellen)
// -> hast du schon gefixt
// 3) TTFs registrieren und Rückgabewerte merken
$fontReg = file_exists($fontDir.'Hurme3/HurmeGeometricSans3-Regular.ttf')
? TCPDF_FONTS::addTTFfont($fontDir.'Hurme3/HurmeGeometricSans3-Regular.ttf', 'TrueTypeUnicode', '', 96)
: false;
$fontBold = file_exists($fontDir.'Hurme1/HurmeGeometricSans1-Bold.ttf')
? TCPDF_FONTS::addTTFfont($fontDir.'Hurme1/HurmeGeometricSans1-Bold.ttf', 'TrueTypeUnicode', '', 96)
: false;
$fontItal = file_exists($fontDir.'Hurme3/HurmeGeometricSans3-Italic.ttf')
? TCPDF_FONTS::addTTFfont($fontDir.'Hurme3/HurmeGeometricSans3-Italic.ttf', 'TrueTypeUnicode', '', 96)
: false;
$fontBI = file_exists($fontDir.'Hurme1/HurmeGeometricSans1-BoldItalic.ttf')
? TCPDF_FONTS::addTTFfont($fontDir.'Hurme1/HurmeGeometricSans1-BoldItalic.ttf', 'TrueTypeUnicode', '', 96)
: false;
// 4) Fallback setzen
$baseFont = $fontReg ?: 'dejavusans';
// 5) Header/Footer und Defaults
$pdf->setHeaderFont([$baseFont, '', 10]);
$pdf->setFooterFont([$baseFont, '', 9]);
$pdf->SetDefaultMonospacedFont('courier');
// 6) *** AB JETZT KEIN dejavusans MEHR SETZEN! ***
$pdf->SetFont($baseFont, '', 10.5);
$pdf->SetCreator('VT-Media EpiWebview');
$pdf->SetAuthor('VT-Media');
$pdf->SetTitle('Kistenetikett small'.$productName);
$pdf->SetSubject('Kistenetikett small');
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetMargins($MARGIN_LEFT, $MARGIN_TOP, $MARGIN_RIGHT);
$pdf->SetAutoPageBreak(true, $MARGIN_BOTTOM);
$pdf->setImageScale(1.0);
$pdf->setFontSubsetting(true);
$pdf->setCellPadding($CELL_PAD);
$pdf->AddPage();
// --- LOGO in fester Box mit Rahmen, zentriert & verzerrungsfrei ---
$logoPath = __DIR__ . Labelprint_Logopath;
$startX = $pdf->GetX();
$startY = $pdf->GetY();
// feste Rahmen-Größe (in mm)
$frameW = 20.0; // Gesamtbreite des Rahmens
$frameH = 9.8; // Gesamthöhe des Rahmens
$frameOffsetX = 1.4; // Abstand vom linken Margin (wie vorher genutzt)
$frameOffsetY = 1.5; // Abstand von oben (wie vorher genutzt)
// Rahmen-Position
$frameX = $startX + $frameOffsetX;
$frameY = $startY + $frameOffsetY;
// Rahmen zeichnen
$pdf->SetLineWidth(0.3);
$pdf->Rect($frameX, $frameY, $frameW, $frameH, 'D');
// Logo innerhalb des Rahmens mit Innenabstand
$innerPad = 1.2; // mm Luft im Rahmen
$maxW = $frameW - 2 * $innerPad;
$maxH = $frameH - 2 * $innerPad;
if (is_file($logoPath)) {
// Seitenverhältnis ermitteln
$imgWpx = $imgHpx = 0;
if (@list($imgWpx, $imgHpx) = @getimagesize($logoPath)) {
$ratio = ($imgHpx > 0) ? ($imgWpx / $imgHpx) : 1.0;
// proportional in den Rahmen einpassen
if ($maxW / $maxH > $ratio) {
$drawH = $maxH;
$drawW = $maxH * $ratio;
} else {
$drawW = $maxW;
$drawH = $maxW / $ratio;
}
// zentrieren
$imgX = $frameX + ($frameW - $drawW) / 2;
$imgY = $frameY + ($frameH - $drawH) / 2;
// ausgeben
$pdf->Image($logoPath, $imgX, $imgY, $drawW, $drawH, '', '', '', false, 300);
}
}
$afterLogoX = $frameX + $frameW - 1.4; // gleicher Abstand wie zuvor, jetzt basierend auf Rahmenbreite
$afterLogoBottomY = $frameY + $frameH; // falls du unten bündig etwas brauchst
$pdf->SetXY($afterLogoX, $startY); // Cursor rechts neben dem Logo positionieren
// Feste Box rechts neben dem Logo (direkt bündig)
$addrBoxW = 0.0; // feste Breite (mm)
$addrBoxH = 0.0; // feste Höhe (mm)
$addrPad = 0.5; // Innenabstand Inhalt → Rahmen (mm)
// bündig rechts neben dem Logo-Rahmen starten
$addrX = $frameX + $frameW; // kein zusätzlicher Abstand
$addrY = $startY+1.5; // gleiche Oberkante wie Logo
$pdf->StopTransform();
// Cursor steht bereits auf: $pdf->SetXY($addrX + $addrBoxW, $addrY);
// 1) HTML OHNE border (sonst Doppelrahmen)
$productionHtml = '
<div style="line-height:1.3; font-size:4px; margin:0; padding:0;">
<b>Produktion</b><br/><br><br><br>
</div>';
// 2) Feste Box rechts neben der Adress-Box
$prodX = $addrX + $addrBoxW; // bündig rechts neben Adresse
$prodY = $addrY; // gleiche Oberkante
// Breite: restliche Seitenbreite bis zum rechten Rand
$prodW = 27;
// Höhe: fix (anpassen nach Wunsch)
$prodH = 10.0; // mm
$prodPad = 0; // Innenabstand
// 3) EIN sichtbarer Rahmen zeichnen
$pdf->SetLineWidth(0.3);
$pdf->Rect($prodX, $prodY, $prodW, $prodH, 'D');
// 4) Inhalt in die Box schreiben und clippen (Überhang abgeschnitten)
$pdf->StartTransform();
$pdf->Rect($prodX, $prodY, $prodW, $prodH, 'CNZ'); // Clip auf Box setzen
$pdf->writeHTMLCell(
$prodW - 2*$prodPad, // feste Breite des Inhalts
$prodH - 2*$prodPad, // feste Höhe des Inhalts
$prodX + $prodPad, // x mit Innenabstand
$prodY + $prodPad, // y mit Innenabstand
$productionHtml, // Inhalt (ohne border!)
0, // kein Border (Rahmen haben wir schon)
0, // ln
false, // fill
true, // reseth
'L', // align
true // autopadding
);
$pdf->StopTransform();
// 5) (Optional) Cursor direkt rechts neben der Box positionieren
$pdf->SetXY($prodX + $prodW, $prodY);
// === Name-Box: feste Größe, Text bricht, kein Doppelrahmen ===
// Breite: gesamte Restbreite bis zum rechten Rand (unter Adresse + Produktion)
$nameX = $frameX; // beginnt rechts neben dem Logo/Adressbereich
$nameW = 67.1;
// Höhe & Padding der Box (anpassen nach Wunsch)
$nameBoxH = 8; // mm - feste Höhe
$namePad = 0.4; // mm - Innenabstand
// Y: direkt unter die höhere der beiden Boxen (Adresse vs. Produktion)
$yAfterRow = max($addrY + $addrBoxH, $prodY + $prodH) + 0.0; // +2 mm Abstand
$nameY = $yAfterRow;
// Inhalt OHNE border (sonst Doppelrahmen)
$nameHtml = '
<div style="font-size:8px; font-weight:700; line-height:1.3; margin:0; padding:0;">' . htmlspecialchars($productName) . '
</div>';
// 1) Sichtbaren Rahmen zeichnen (feste Größe)
$pdf->SetLineWidth(0.3);
$pdf->Rect($nameX, $nameY, $nameW, $nameBoxH, 'D');
// 2) Inhalt in die Box schreiben und clippen
$pdf->StartTransform();
$pdf->Rect($nameX, $nameY, $nameW, $nameBoxH, 'CNZ'); // Clip auf Box setzen
$pdf->writeHTMLCell(
$nameW - 2*$namePad, // feste Breite für den Text
$nameBoxH - 2*$namePad, // feste Höhe für den Text
$nameX + $namePad, // x mit Innenabstand
$nameY + $namePad, // y mit Innenabstand
$nameHtml,
0, // kein HTML-Border
0, // ln
false, // fill
true, // reseth
'L', // align
true // autopadding
);
$pdf->StopTransform();
// Optional: Cursor unter die Box setzen (falls du danach im Fluss weitermachen willst)
$pdf->SetXY($nameX, $nameY + $nameBoxH);
// 1) Box-Geometrie (immer gleich groß)
$pnBoxW = 20.0; // mm Gesamtbreite der Box
$pnBoxH = 10.0; // mm Gesamthöhe der Box
$pnBoxPad = 0.2; // mm Innenabstand innerhalb der Box
// Position der Box (aktuell am rechten Rand oben; passe frei an)
$pnBoxX = 56.5; // bündig am rechten Rand
$pnBoxY = $startY+1.5; // gleiche Oberkante wie Kopf
// 2) Sichtbaren Rahmen zeichnen (einmalig)
$pdf->SetLineWidth(0.3);
$pdf->Rect($pnBoxX, $pnBoxY, $pnBoxW, $pnBoxH, 'D');
// 3) Clipping aktivieren, damit Inhalt in der Box bleibt
$pdf->StartTransform();
$pdf->Rect($pnBoxX, $pnBoxY, $pnBoxW, $pnBoxH, 'CNZ'); // Clip auf die Box
// 4) Produktnummer: frei verschiebbar innerhalb der Box
$pnCombined = trim($productNumber . $deviceNumber);
$pnText = htmlspecialchars($pnCombined !== '' ? $pnCombined : 'N/A');
// Offsets innerhalb der Box hier stellst du die Position ein
$pnTextOffsetX = -2.9; // mm von linker Innenkante
$pnTextOffsetY = 2; // mm von oberer Innenkante
$pnTextW = 0; // nutzbare Breite
$pnTextH = 8.0; // feste Höhe für den PN-Textbereich (mm)
$pnHtml = '
<div style="font-size:8px; line-height:1.25; margin:0; padding:0;">
'.$pnText.'
</div>';
// PN-Text rendern (ohne HTML-Border!)
$pdf->writeHTMLCell(
$pnTextW, // Breite Textbereich
$pnTextH, // Höhe Textbereich
$pnBoxX + $pnBoxPad + $pnTextOffsetX, // X in der Box
$pnBoxY + $pnBoxPad + $pnTextOffsetY, // Y in der Box
$pnHtml,
0, 0, false, true, 'L', true
);
// 5) QR-Code: frei verschiebbar innerhalb der Box, verzerrungsfrei per size
$qrData = trim((string)($productNumber . $deviceNumber));
if ($qrData === '') {
$qrData = 'N/A';
}
$qrSize = 7.8; // mm Kantenlänge des QR
$qrOffsetX = 11; // mm von linker Innenkante
$qrOffsetY = 1.2; // mm von oberer Innenkante
$qrX = $pnBoxX + $qrOffsetX;
$qrY = $pnBoxY + $qrOffsetY;
$qrStyle = [
'border' => 0,
'vpadding' => 0,
'hpadding' => 0,
'fgcolor' => [0,0,0],
'bgcolor' => false,
];
// QR rendern (Warnings/Deprecated für diesen Block stumm schalten)
$_old_reporting = error_reporting();
error_reporting($_old_reporting & ~(E_WARNING | E_DEPRECATED | E_NOTICE));
$pdf->write2DBarcode($qrData, 'QRCODE,H', $qrX, $qrY, $qrSize, $qrSize, $qrStyle, 'N');
error_reporting($_old_reporting);
// 6) Clipping beenden
$pdf->StopTransform();
// 7) Optional: Cursor unter die PN-Box setzen, falls du im Fluss weiter willst
$pdf->SetXY($pnBoxX, $pnBoxY + $pnBoxH);
/** Bundle */
$bundleHeader = '<div style=" background-color: black; color: white; font-size:7px; font-weight:700;line-height:1.4; margin:4px 0 3px 0;">&nbsp;Bundleinhalt</div>';
$pdf->writeHTMLCell(40, 50, 7.65, 26, $bundleHeader, 0, 0, 0, true, 'L', true);
$bundleHeaderFillable = '<div style="border: 1px solid black; line-heigt:0.6"></div>';
//$pdf->writeHTMLCell(130.8, 39.8, 74.5, 46, $bundleHeaderFillable, 0, 0, 0, false, 'L', true);
drawFixedBox($pdf, 46.0, 27.5, 30.5, 3.45);
$bundleRows = '';
$BASE_FONT_PX = 7.0; // Level 1
$FONT_STEP_PX = 1; // je Ebene kleiner
$MIN_FONT_PX = 3.0;
$LINE_HEIGHT_BASE = 0.35; // Basis Zeilenhöhe
$LINE_HEIGHT_STEP = 0.05; // je Ebene etwas kompakter
$ROW_PAD_V_MM = 0.15; // vertikales Padding je Zeile
$ROW_PAD_H_MM = 0.0; // horizontales Padding
$WEIGHT_PAD_RIGHT_MM = 1.6; // mehr Abstand zum rechten Rand
$INDENT_MM_PER_LVL = 1.8; // Einrückung je Ebene in mm (stabil!)
foreach ($bundle as $item) {
$qty = (int)($item['qty'] ?? 1);
$text = htmlspecialchars($item['text'] ?? '');
$lvl = max($LEVEL_MIN, min($LEVEL_MAX, (int)($item['level'] ?? 1)));
$wkg = isset($item['weight_kg']) ? (float)$item['weight_kg'] : null;
$textFontPx = max($MIN_FONT_PX, $BASE_FONT_PX - ($lvl - 1) * $FONT_STEP_PX);
$weightFontPx = max($MIN_FONT_PX, $BASE_FONT_PX - ($lvl - 1) * $FONT_STEP_PX);
$lineHeight = max(1.0, $LINE_HEIGHT_BASE - ($lvl - 1) * $LINE_HEIGHT_STEP);
// Einrückung robust über mm aber nur Tabelle nutzen, wenn > 0 mm
$indentMm = max(0, ($lvl - 1) * $INDENT_MM_PER_LVL);
// Name-Spalte: ohne Spacer-Tabelle bei Level 1 (indent=0), sonst mit
if ($indentMm > 0) {
/*$nameCellHtml = '
<table cellspacing="0" cellpadding="0" width="100%">
<tr>
<td style="width:'.$indentMm.'mm; padding:0; margin:0;"></td>
<td style="font-size:'.$textFontPx.'px; line-height:'.$lineHeight.'; padding:0; margin:0;">'.$text.'</td>
</tr>
</table>';*/
$indentMm = max(0, ($lvl - 1) * $INDENT_MM_PER_LVL);
$indentSpaces = str_repeat('&nbsp;', max(0, ($lvl - 1) * 4));
$nameCellHtml = '
<span style="
font-size:'.$textFontPx.'px;
line-height:'.$lineHeight.';
">'.$indentSpaces.$text.'</span>';
//Ende ersetzter Teil
} else {
// Level 1: direkt rendern
$nameCellHtml = '<span style="font-size:'.$textFontPx.'px; line-height:'.$lineHeight.';">'.$text.'</span>';
}
$bundleRows .= '
<tr>
<!-- Anzahl -->
<td style="
line-height: 10px;
width:8%;
text-align:right;
vertical-align:middle;
font-size:'.$textFontPx.'px;
padding:'.$ROW_PAD_V_MM.'mm '.$ROW_PAD_H_MM.'mm;">
'.$qty.'
</td>
<!-- Name mit Einrückung -->
<td style="
line-height: 8px;
width:88%;
vertical-align:middle;
padding:'.$ROW_PAD_V_MM.'mm '.$ROW_PAD_H_MM.'mm;">
'.$nameCellHtml.'
</td>
</tr>';
}
$bundleHtml = '
<table cellspacing="0" cellpadding="0" style="border:1px solid #000; border-collapse:collapse;" width="100%">
<tbody>'.$bundleRows.'</tbody>
</table>';
$pdf->writeHTMLCell(
73.1,
0,
7.8,
29.3,
$bundleHtml,
0,
0,
0,
true,
'L',
true
);
/** ---------- Meta-Block dynamisch unterhalb der Bundle-Tabelle ---------- */
// Höhe der zuletzt gerenderten Bundle-Tabelle holen
$bundleH = $pdf->getLastH();
// Y-Position direkt nach der Bundle-Tabelle (mit 3 mm Abstand)
$metaY = 26.2+ $bundleH; // 50 = dein bisheriges Y der Bundle-Tabelle
$metaX = $MARGIN_LEFT;
// Falls Meta-Block zu nah am Seitenende wäre → neue Seite
$usableBottom = $pdf->getPageHeight() - $MARGIN_BOTTOM;
if ($metaY > $usableBottom - 40) { // 40 mm Puffer für Meta
$pdf->AddPage();
$metaY = $MARGIN_TOP;
}
// --- Inhalt wie gehabt ---
$leftMeta = '
<table cellspacing="0" cellpadding="'.$CELL_PAD.'" width="100%">
<tbody>
<!-- Lagerplatz mit innerer Tabelle (optisch perfekt ausgerichtet) -->
<tr>
<td width="45%" border="1" style="padding-left:20px;">
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="font-size: 8px" width="100%">Lagerplatz</td>
</tr>
</table>
</td>
<td width="55%" border="1" style="font-size: 8px"><b> '.htmlspecialchars($storageLoc).'</b></td>
</tr>
</tbody>
</table>';
// ---- Koordinaten der Meta-Zeile bestimmen (nimm deine Werte) ----
// Falls du sie schon berechnet hast, nutze deine $metaX, $metaY, $metaW.
// Beispiel, wie du sie meist setzt:
$metaX = $MARGIN_LEFT;
$metaW = 111.2;
// $metaY hast du zuvor als Ziel-Y für den Meta-Block berechnet:
/// $metaY = ... (z.B. 50 + $bundleH + 3);
// ---- Spaltenbreiten wie in deinem metaWrap: 62% | 2% | 36% ----
$leftW = $metaW * 0.62;
$gapW = $metaW * 0.02;
$rightW = $metaW * 0.36;
// Obere linke Ecke der rechten Spalte:
$rightX = $metaX + $leftW + $gapW;
$rightY = $metaY;
// ---- Fester Rahmen in der rechten Spalte ----
$metaWrap = '
<table cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top:2px;">
<tr>
<td width="62%">'.$leftMeta.'</td>
<td width="2%"></td>
<td width="36%"></td>
</tr>
</table>';
// Meta-Block exakt an berechneter Position ausgeben
$pdf->writeHTMLCell($metaW, 0, $metaX, $metaY, $metaWrap, 0, 0, 0, true, 'L', true);
/** Output */
// Falls trotz allem vorher etwas ausgegeben wurde (Warnings), Buffer leeren:
if (ob_get_length()) { ob_end_clean(); }
// Äußerer Rahmen, Größenberechnung
// 1) Höhe der META-Zelle (zuletzt geschrieben) holen:
$metaH = $pdf->getLastH();
// 2) Y der Bundle-Tabelle kennst du (50.8 in deinem Code) + deren Höhe:
$bundleY = 33.7; // dein fixer Y für die Bundle-Tabelle
// $bundleH hast du bereits oben: $bundleH = $pdf->getLastH();
// 3) Bottom-Kanten aller Boxen berechnen:
$bottoms = [
$frameY + $frameH, // Logo-Box
$addrY + $addrBoxH, // Adress-Box
$prodY + $prodH, // Produktion-Box
$nameY + $nameBoxH, // Name-Box
$pnBoxY + $pnBoxH, // PN/QR-Box
$bundleY + $bundleH, // Bundle-Tabelle
$metaY + $metaH, // Meta-Block (linke Tabelle + rechter Bildrahmen)
];
// 4) Start-Y des Contentbereichs: oben an deinem Kopf beginnen
$outerY = min($frameY, $addrY, $prodY, $nameY, $pnBoxY, 46.0 /*Bundle-Header-Y*/, $metaY);
// 5) Untere Kante ist die größte Bottom-Kante:
$outerBottom = max($bottoms);
// 6) Außenrahmen-Geometrie:
$outerX = $MARGIN_LEFT+1.2;
$outerW = 67.5;
$outerH = max(2, $outerBottom - $outerY)-1.2; // Mindeshöhe absichern
// 7) Dicke Linie zeichnen (z.B. 1.2 mm)
drawFixedBox($pdf, $outerX, $outerY, $outerW, $outerH, 1, '', 0, false);
$filename = 'Kistenetikett_'.preg_replace('/\s+/', '_', trim($productName)).'_'.$productNumber.$deviceNumber.'_'.date('Ymd_His').'.pdf';
$pdf->Output($filename, 'I');
/**
* Zeichnet eine flexible, leere oder beschriftete Box mit TCPDF.
*
* @param TCPDF $pdf Referenz auf dein TCPDF-Objekt
* @param float $x Linke obere Ecke (mm)
* @param float $y Obere Position (mm)
* @param float $w Breite (mm)
* @param float $h Höhe (mm)
* @param float $border Linienstärke in mm (z. B. 0.3)
* @param string $text (Optional) Inhalt oder Beschriftung
* @param float $pad Innenabstand in mm
* @param bool $fill true = grau hinterlegt, false = leer
* @param string|null $style (Optional) Linienstil 'solid', 'dashed', 'dotted'
*/
function drawFixedBox(
TCPDF $pdf,
float $x,
float $y,
float $w,
float $h,
float $border = 0.3,
string $text = '',
float $pad = 1.5,
bool $fill = false,
?string $style = 'solid'
): void {
// Farbe für Füllung (hellgrau, wenn aktiviert)
$fillColor = [240, 240, 240];
// Linienstil (falls angegeben)
switch ($style) {
case 'dashed':
$pdf->SetLineStyle(['width' => $border, 'dash' => '3,2']);
break;
case 'dotted':
$pdf->SetLineStyle(['width' => $border, 'dash' => '1,2']);
break;
default:
$pdf->SetLineWidth($border);
$pdf->SetLineStyle(['width' => $border]);
break;
}
// 1) Rahmen (und ggf. Füllung)
if ($fill) {
$pdf->SetFillColorArray($fillColor);
$pdf->Rect($x, $y, $w, $h, 'DF'); // Draw + Fill
} else {
$pdf->Rect($x, $y, $w, $h, 'D'); // nur Rahmen
}
// 2) Optionaler Textinhalt sauber eingepasst
if (trim($text) !== '') {
$pdf->StartTransform();
$pdf->Rect($x, $y, $w, $h, 'CNZ'); // Clip auf Box setzen
$pdf->writeHTMLCell(
$w - 2*$pad, $h - 2*$pad,
$x + $pad, $y + $pad,
'<div style="font-size:9px; line-height:1.2;">'.htmlspecialchars($text).'</div>',
0, 0, false, true, 'L', true
);
$pdf->StopTransform();
}
// Linienstil zurücksetzen
$pdf->SetLineStyle(['width' => 0.3, 'dash' => 0]);
}
/**
* Produkt-Details mit Cache holen (vermeidet doppelte API-Calls).
*
* @param Epirent $Epi
* @param int|string $productPk
* @param array $cache Referenz-Array für Produkt-Cache
* @return object|null
*/
function fetchProductDetailCached($Epi, $productPk, array &$cache) {
$key = (string)$productPk;
if (isset($cache[$key])) {
return $cache[$key];
}
try {
$res = $Epi->requestEpiApi('/v1/product/' . $key . '?cl=' . Epirent_Mandant);
$payload = json_decode($res, false);
$prod = $payload->payload[0] ?? null;
if ($prod) {
$cache[$key] = $prod;
return $prod;
}
} catch (\Throwable $e) {
// optional: loggen
}
$cache[$key] = null;
return null;
}
/**
* Rekursive Hilfsfunktion: fügt Materialien (und deren Submaterialien) zu $bundle hinzu
* und summiert alle Bruttogewichte in $bruttoWeightSum.
*
* @param Epirent $Epi
* @param array $materials Materialliste (vom Produkt)
* @param int $level aktuelle Ebene (1..MAX_BUNDLE_LEVEL)
* @param array &$bundle Ergebnisliste (wird befüllt)
* @param array &$cache Produkt-Cache
* @param array &$seenStack Schutz gegen Zyklen (Stack von Produkt-Keys)
* @param float $qtyMultiplier Multiplikator für Mengen aus übergeordneten Ebenen
* @param float &$bruttoWeightSum Summiert alle weight_gro-Werte × Menge
*/
function addMaterialsRecursive($Epi, array $materials, int $level, array &$bundle, array &$cache, array &$seenStack, float $qtyMultiplier = 1.0): void
{
global $bruttoWeightSum;
$MAX_BUNDLE_LEVEL = 5;
if ($level > $MAX_BUNDLE_LEVEL || empty($materials)) {
return;
}
// Nach "position" aufsteigend sortieren (pro Level)
usort($materials, function ($a, $b) {
$posA = isset($a->position) ? (int)$a->position : PHP_INT_MAX;
$posB = isset($b->position) ? (int)$b->position : PHP_INT_MAX;
return $posA <=> $posB;
});
foreach ($materials as $mat) {
$isFree = !empty($mat->is_free_material);
$amount = isset($mat->amount) ? (float)$mat->amount : 1.0;
$effQty = $amount * $qtyMultiplier;
// 🟢 1. FREIES MATERIAL → nur Name + Menge, kein weiterer Drilldown
if ($isFree) {
$name = isset($mat->name) ? (string)$mat->name : '(Unbenanntes freies Material)';
$bundle[] = [
'qty' => $effQty,
'text' => $name,
'level' => $level,
'weight_kg' => null,
];
continue; // ⬅️ ganz wichtig: hier abbrechen
}
// 🟡 2. "Normales" Produkt-Material
$childPk = $mat->mat_product_pk ?? null;
if (!$childPk) {
$bundle[] = [
'qty' => $effQty,
'text' => '(Unbekanntes Material)',
'level' => $level,
'weight_kg' => null,
];
continue;
}
// Zyklen-Schutz
$childKey = (string)$childPk;
if (in_array($childKey, $seenStack, true)) {
$bundle[] = [
'qty' => $effQty,
'text' => '[Zyklus erkannt bei Produkt ' . $childKey . ']',
'level' => $level,
'weight_kg' => null,
];
continue;
}
// Produktdetail abrufen (Cache-basiert)
$child = fetchProductDetailCached($Epi, $childPk, $cache);
// Basisinfos
$childName = $child->name ?? ('Produkt ' . $childKey);
$childWeightNet = $child->tech_data->weight_net ?? null;
$childWeightGross = $child->tech_data->weight_gro ?? null;
$childNumber = $child->product_no ?? null;
// 🧮 Bruttogewicht zur globalen Summe addieren
if (!empty($childWeightGross)) {
$bruttoWeightSum += $effQty * (float)$childWeightGross;
}
// 📦 Anzeige: Artikelnummer in Klammern voranstellen
$displayName = $childNumber ? '(' . $childNumber . ') ' . $childName : $childName;
$bundle[] = [
'qty' => $effQty,
'text' => (string)$displayName,
'level' => $level,
'weight_kg' => $childWeightNet ? (float)$childWeightNet : null,
];
// 🔁 Rekursion für Sub-Materialien
if ($child && !empty($child->materials) && $level < $MAX_BUNDLE_LEVEL) {
$seenStack[] = $childKey;
addMaterialsRecursive($Epi, (array)$child->materials, $level + 1, $bundle, $cache, $seenStack, $effQty);
array_pop($seenStack);
}
}
}
/**
* Einstieg: Baut das Bundle eines Produkts bis Tiefe MAX_BUNDLE_LEVEL
* und liefert zusätzlich die Bruttogewichtsumme zurück.
*
* @param Epirent $Epi
* @param object $product
* @param float &$bruttoWeightSum Rückgabe der aufsummierten weight_gro
* @return array
*/
function buildBundleForProduct($Epi, $product, float &$bruttoWeightSum = 0.0): array
{
$bundle = [];
$cache = [];
$seen = [];
$materials = isset($product->materials) ? (array)$product->materials : [];
addMaterialsRecursive($Epi, $materials, 1, $bundle, $cache, $seen, 1.0, $bruttoWeightSum);
return $bundle;
}