22-05-2026 Optimierung der User und Admin Panels
This commit is contained in:
parent
d2ba22c0cf
commit
e8c47b7553
73 changed files with 10282 additions and 1546 deletions
108
app/Console/Commands/PublishScheduledPressReleases.php
Normal file
108
app/Console/Commands/PublishScheduledPressReleases.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Veröffentlicht Pressemitteilungen mit Status `review` und einem
|
||||
* `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
|
||||
*
|
||||
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
|
||||
* berührt nur PRs in Review-Status — bereits publishte werden ignoriert.
|
||||
*
|
||||
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
|
||||
* Mail-Benachrichtigung des Autors.
|
||||
*/
|
||||
class PublishScheduledPressReleases extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'press-releases:publish-scheduled
|
||||
{--dry-run : Nur anzeigen, was publiziert würde, ohne DB zu ändern}
|
||||
{--limit=200 : Maximale Anzahl pro Lauf}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Veröffentlicht fällige geplante Pressemitteilungen (Status review + scheduled_at <= now).';
|
||||
|
||||
public function handle(PressReleaseService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$now = now();
|
||||
|
||||
$candidates = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '<=', $now)
|
||||
->orderBy('scheduled_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('Keine fälligen geplanten Pressemitteilungen gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%d fällige Pressemitteilung(en) gefunden.%s',
|
||||
$candidates->count(),
|
||||
$dryRun ? ' (Dry-Run)' : '',
|
||||
));
|
||||
|
||||
$published = 0;
|
||||
$rejected = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $pressRelease) {
|
||||
$line = sprintf(
|
||||
' #%d scheduled_at=%s title="%s"',
|
||||
$pressRelease->id,
|
||||
$pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-',
|
||||
Str::limit($pressRelease->title, 60),
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line($line.' [DRY]');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->publish($pressRelease, source: 'scheduler');
|
||||
$published++;
|
||||
$this->line($line.' [OK]');
|
||||
} catch (BlacklistViolationException $e) {
|
||||
$rejected++;
|
||||
$this->warn($line.' [REJECT: '.$e->word.']');
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->error($line.' [FAIL: '.$e->getMessage().']');
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d fehlgeschlagen.',
|
||||
$published,
|
||||
$rejected,
|
||||
$failed,
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,14 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
class LegacyInvoicePdfRenderer
|
||||
{
|
||||
private const PAGE_WIDTH = 595;
|
||||
|
||||
private const PAGE_HEIGHT = 842;
|
||||
|
||||
private const LEFT = 91;
|
||||
|
||||
private const RIGHT = 524;
|
||||
|
||||
public function inlineResponse(LegacyInvoice $invoice): Response
|
||||
{
|
||||
$pdf = $this->render($invoice);
|
||||
|
|
@ -38,14 +46,13 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
public function render(LegacyInvoice $invoice): string
|
||||
{
|
||||
$lines = $this->lines($invoice);
|
||||
$content = "BT\n/F1 11 Tf\n50 790 Td\n14 TL\n";
|
||||
$data = $this->data($invoice);
|
||||
$content = '';
|
||||
$isMedia = $data['is_media'];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$content .= '('.$this->escapePdfText($line).") Tj\nT*\n";
|
||||
}
|
||||
|
||||
$content .= "ET\n";
|
||||
$this->drawHeader($content, $isMedia);
|
||||
$this->drawInvoiceBody($content, $data);
|
||||
$this->drawFooter($content, $isMedia);
|
||||
|
||||
return $this->buildPdf($content);
|
||||
}
|
||||
|
|
@ -61,16 +68,30 @@ class LegacyInvoicePdfRenderer
|
|||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
* @return array{
|
||||
* invoice: LegacyInvoice,
|
||||
* billing_address: array<string, mixed>,
|
||||
* invoice_data: array<string, mixed>,
|
||||
* is_media: bool,
|
||||
* is_netto: bool,
|
||||
* tax_percent: int,
|
||||
* amount: float,
|
||||
* net_amount: float,
|
||||
* tax_amount: float,
|
||||
* service_name: string,
|
||||
* service_period_begin: string,
|
||||
* service_period_end: string
|
||||
* }
|
||||
*/
|
||||
private function lines(LegacyInvoice $invoice): array
|
||||
private function data(LegacyInvoice $invoice): array
|
||||
{
|
||||
$payload = $invoice->pdf_payload ?? [];
|
||||
$billingAddress = data_get($payload, 'billing_address', []);
|
||||
$billingAddress = data_get($payload, 'billing_address') ?: [];
|
||||
$invoiceData = data_get($payload, 'invoice', $invoice->raw_snapshot ?? []);
|
||||
$invoice->loadMissing('user.profile');
|
||||
|
||||
$isNetto = (bool) data_get($invoiceData, 'is_netto', false);
|
||||
$isMedia = (bool) data_get($invoiceData, 'is_media', true);
|
||||
$taxPercent = $this->taxPercent($invoice);
|
||||
$amount = $invoice->total_cents / 100;
|
||||
$netAmount = $isNetto ? $amount : $amount / (1 + ($taxPercent / 100));
|
||||
|
|
@ -81,50 +102,211 @@ class LegacyInvoicePdfRenderer
|
|||
?? data_get($payload, 'payment_option.article_number')
|
||||
?? 'Legacy-Leistung';
|
||||
|
||||
return array_values(array_filter([
|
||||
$invoice->legacy_portal->label(),
|
||||
'adametz.media, Kevin Adametz, In der Lake 4, 33739 Bielefeld',
|
||||
'www.businessportal24.com',
|
||||
str_repeat('-', 68),
|
||||
'',
|
||||
'Legacy-Rechnung',
|
||||
'Rechnungsdatum: '.$invoice->invoice_date?->format('d.m.Y'),
|
||||
'',
|
||||
'Rechnungsadresse',
|
||||
data_get($billingAddress, 'name'),
|
||||
data_get($billingAddress, 'title'),
|
||||
data_get($billingAddress, 'address'),
|
||||
trim((string) data_get($billingAddress, 'postal_code').' '.(string) data_get($billingAddress, 'city')),
|
||||
data_get($billingAddress, 'country_name'),
|
||||
return [
|
||||
'invoice' => $invoice,
|
||||
'billing_address' => $billingAddress,
|
||||
'invoice_data' => $invoiceData,
|
||||
'is_media' => $isMedia,
|
||||
'is_netto' => $isNetto,
|
||||
'tax_percent' => $taxPercent,
|
||||
'amount' => $amount,
|
||||
'net_amount' => $netAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
'service_name' => (string) $serviceName,
|
||||
'service_period_begin' => $servicePeriodBegin,
|
||||
'service_period_end' => $servicePeriodEnd,
|
||||
];
|
||||
}
|
||||
|
||||
private function drawHeader(string &$content, bool $isMedia): void
|
||||
{
|
||||
if ($isMedia) {
|
||||
$content .= $this->text(0, 790, 'adametz.media', 21, 'F2', align: 'center');
|
||||
$content .= $this->line(180, 778, 415, 778, 0.7);
|
||||
$content .= $this->text(0, 762, 'adametz.media, Kevin Adametz, In der Lake 4, 33739 Bielefeld', 9, align: 'center');
|
||||
$content .= $this->text(0, 748, 'www.businessportal24.com', 10, 'F2', align: 'center');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$content .= $this->text(self::LEFT, 790, 'Stern Consulting GmbH', 19, 'F2');
|
||||
$content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7);
|
||||
$content .= $this->text(self::LEFT, 760, 'Stern Consulting GmbH * Emser Straße 3 * 10719 Berlin', 10, 'F2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function drawInvoiceBody(string &$content, array $data): void
|
||||
{
|
||||
/** @var LegacyInvoice $invoice */
|
||||
$invoice = $data['invoice'];
|
||||
$billingAddress = $data['billing_address'];
|
||||
$invoiceDate = $invoice->invoice_date?->format('d.m.Y') ?? 'n/a';
|
||||
$invoiceNumber = $invoice->number ?? '#'.$invoice->legacy_id;
|
||||
$y = 700.0;
|
||||
|
||||
$content .= $this->text(self::LEFT, $y, 'Rechnungsdatum: '.$invoiceDate, 9);
|
||||
$y -= 34;
|
||||
|
||||
foreach ($this->addressLines($invoice, $billingAddress) as $line) {
|
||||
$content .= $this->text(self::LEFT, $y, $line, 9);
|
||||
$y -= 13;
|
||||
}
|
||||
|
||||
$y -= 26;
|
||||
$content .= $this->text(self::LEFT, $y, 'Leistung:', 9);
|
||||
$y -= 15;
|
||||
$content .= $this->text(self::LEFT, $y, $data['service_name'].' auf '.$invoice->legacy_portal->label(), 10, 'F2');
|
||||
$y = $this->wrappedText($content, self::LEFT, $y - 16, $this->pressReleaseTitle($data), 9, 360);
|
||||
$y -= 6;
|
||||
|
||||
$periodLabel = $data['service_period_begin'] === $data['service_period_end']
|
||||
? 'Leistungsdatum: '.$data['service_period_begin']
|
||||
: 'Leistungszeitraum: '.$data['service_period_begin'].' - '.$data['service_period_end'];
|
||||
$content .= $this->text(self::LEFT, $y, $periodLabel, 9);
|
||||
$y -= 17;
|
||||
$content .= $this->text(self::LEFT, $y, 'Rechnungsnummer: '.$invoiceNumber, 10, 'F2');
|
||||
$y -= 34;
|
||||
|
||||
$y = $this->invoiceTable($content, self::LEFT, $y, [
|
||||
['Rechnungsstellung:', $invoiceDate, false],
|
||||
['Netto:', $this->formatEuro($data['net_amount']), false],
|
||||
$data['is_netto'] ? null : ['MwSt. '.$data['tax_percent'].'%:', $this->formatEuro($data['tax_amount']), false],
|
||||
['Rechnungsbetrag:', $this->formatEuro($data['amount']), true],
|
||||
]);
|
||||
|
||||
$y -= 22;
|
||||
foreach ($this->paymentLines($data) as $line) {
|
||||
$content .= $this->text(self::LEFT, $y, $line, 9);
|
||||
$y -= $line === '' ? 9 : 12;
|
||||
}
|
||||
}
|
||||
|
||||
private function drawFooter(string &$content, bool $isMedia): void
|
||||
{
|
||||
$content .= $this->line(self::LEFT, 76, self::RIGHT, 76, 0.5);
|
||||
|
||||
if ($isMedia) {
|
||||
$columns = [
|
||||
["adametz.media\nKevin Adametz\nIn der Lake 4\n33739 Bielefeld", 91, 62],
|
||||
["Tel: +49 5206 7076721\nMail: info@businessportal24.com\nSite: www.businessportal24.com", 165, 62],
|
||||
["Bankverbindung\nSparkasse Bielefeld\nIBAN: DE96 4805 0161 0065 0356 02\nBIC: SPBIDE3BXXX", 292, 62],
|
||||
["Steuernummer:\n349 / 5001 / 4350\nUSt-ID: DE298729654", 445, 62],
|
||||
];
|
||||
} else {
|
||||
$columns = [
|
||||
["Stern Consulting GmbH\nEmser Straße 3\n10719 Berlin\nGF: Thomas Stern", 91, 62],
|
||||
["Tel: +49 (0)30 / 700 9410 0\nFax: +49 (0)30 / 700 9410 44\nMail: info@stern-consulting.de\nSite: www.stern-consulting.de", 185, 62],
|
||||
["Bankverbindung\nHypo Vereinsbank\nBLZ: 10020890\nK-Nr: 22865552", 325, 62],
|
||||
["Registergericht:\nAmtsgericht Charlottenburg\nHRB 134586 B\nUSt-Id-Nr.: DE277693156", 430, 62],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($columns as [$text, $x, $y]) {
|
||||
$this->wrappedText($content, $x, $y, $text, 6, 105, 8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $billingAddress
|
||||
* @return list<string>
|
||||
*/
|
||||
private function addressLines(LegacyInvoice $invoice, array $billingAddress): array
|
||||
{
|
||||
$salutation = trim((string) data_get($billingAddress, 'salutation'));
|
||||
$name = trim($salutation.' '.(string) data_get($billingAddress, 'name'));
|
||||
$city = trim((string) data_get($billingAddress, 'postal_code').' '.(string) data_get($billingAddress, 'city'));
|
||||
$lines = [
|
||||
$name,
|
||||
(string) data_get($billingAddress, 'title'),
|
||||
...$this->splitLines((string) data_get($billingAddress, 'address')),
|
||||
$city,
|
||||
(string) data_get($billingAddress, 'country_name'),
|
||||
$invoice->user?->profile?->tax_id_number ? 'UID-Nr.: '.$invoice->user->profile->tax_id_number : null,
|
||||
'',
|
||||
'Leistung: '.$serviceName.' auf '.$invoice->legacy_portal->label(),
|
||||
$servicePeriodBegin === $servicePeriodEnd
|
||||
? 'Leistungsdatum: '.$servicePeriodBegin
|
||||
: 'Leistungszeitraum: '.$servicePeriodBegin.' - '.$servicePeriodEnd,
|
||||
'Rechnungsnummer: '.($invoice->number ?? '#'.$invoice->legacy_id),
|
||||
'',
|
||||
str_repeat('-', 68),
|
||||
'Rechnungsstellung: '.$invoice->invoice_date?->format('d.m.Y'),
|
||||
str_repeat('-', 68),
|
||||
'Netto: '.$this->formatEuro($netAmount),
|
||||
$isNetto ? null : 'MwSt. '.$taxPercent.'%: '.$this->formatEuro($taxAmount),
|
||||
str_repeat('-', 68),
|
||||
'Betrag: '.$this->formatEuro($amount),
|
||||
str_repeat('-', 68),
|
||||
'',
|
||||
'Zahlart: '.($invoice->payment_method ?? 'n/a'),
|
||||
'Status: '.($invoice->status ?? 'unknown'),
|
||||
$invoice->paid_at ? 'Bezahlt am: '.$invoice->paid_at->format('d.m.Y') : 'Faellig am: '.$invoice->due_date?->format('d.m.Y'),
|
||||
'',
|
||||
'Bitte ueberweisen Sie den Rechnungsbetrag unter Angabe der Rechnungsnummer '.($invoice->number ?? '#'.$invoice->legacy_id).'.',
|
||||
'Bankverbindung: Sparkasse Bielefeld, IBAN DE96 4805 0161 0065 0356 02, BIC SPBIDE3BXXX',
|
||||
$isNetto ? 'Reverse Charge: Steuerschuldnerschaft des Leistungsempfaengers.' : null,
|
||||
'',
|
||||
str_repeat('-', 68),
|
||||
'adametz.media | Tel: +49 5206 7076721 | Mail: info@businessportal24.com',
|
||||
'Steuernummer: 349 / 5001 / 4350 | USt-ID: DE298729654',
|
||||
], fn (mixed $line): bool => $line !== null && $line !== ''));
|
||||
];
|
||||
|
||||
return array_values(array_filter($lines, fn (?string $line): bool => filled($line)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function pressReleaseTitle(array $data): string
|
||||
{
|
||||
return (string) (
|
||||
data_get($data, 'invoice_data.press_release_title')
|
||||
?? data_get($data, 'invoice_data.press_release.name')
|
||||
?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: string, 1: string, 2: bool}|null> $rows
|
||||
*/
|
||||
private function invoiceTable(string &$content, float $x, float $y, array $rows): float
|
||||
{
|
||||
$width = 275.0;
|
||||
$rowHeight = 22.0;
|
||||
$valueX = $x + 160;
|
||||
|
||||
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
|
||||
foreach (array_values(array_filter($rows)) as [$label, $value, $bold]) {
|
||||
$y -= $rowHeight;
|
||||
$content .= $this->text($x, $y + 7, $label, 9, $bold ? 'F2' : 'F1');
|
||||
$content .= $this->text($valueX, $y + 7, $value, 9, $bold ? 'F2' : 'F1');
|
||||
$content .= $this->line($x, $y, $x + $width, $y, 0.5);
|
||||
}
|
||||
|
||||
return $y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
private function paymentLines(array $data): array
|
||||
{
|
||||
/** @var LegacyInvoice $invoice */
|
||||
$invoice = $data['invoice'];
|
||||
$invoiceNumber = $invoice->number ?? '#'.$invoice->legacy_id;
|
||||
|
||||
if (! $data['is_media']) {
|
||||
$lines = [
|
||||
'Bitte überweisen Sie den Rechnungsbetrag innerhalb von 7 Werktagen auf unser Geschäftskonto:',
|
||||
'',
|
||||
'Kontoinhaber: Stern Consulting GmbH',
|
||||
'IBAN: DE23100208900022865552',
|
||||
'BIC: HYVEDEMM488',
|
||||
'Bank: Hypo Vereinsbank',
|
||||
'',
|
||||
'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!',
|
||||
'',
|
||||
'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.',
|
||||
];
|
||||
} else {
|
||||
$lines = [
|
||||
'Bitte überweisen Sie den Rechnungsbetrag innerhalb von 7 Werktagen auf unser Geschäftskonto:',
|
||||
'',
|
||||
'Wichtig! Ab Januar 2024 gilt die neue Bankverbindung:',
|
||||
'',
|
||||
'Kontoinhaber: adametz.media, Kevin Adametz',
|
||||
'IBAN: DE96 4805 0161 0065 0356 02',
|
||||
'BIC: SPBIDE3BXXX',
|
||||
'Bank: Sparkasse Bielefeld',
|
||||
'',
|
||||
'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!',
|
||||
'',
|
||||
'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['is_netto']) {
|
||||
$lines[] = '';
|
||||
$lines[] = 'Leistungen im Reverse-Charge Verfahren - Steuerschuldnerschaft des Leistungsempfängers';
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function taxPercent(LegacyInvoice $invoice): int
|
||||
|
|
@ -149,7 +331,7 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
private function formatEuro(float $amount): string
|
||||
{
|
||||
return number_format($amount, 2, ',', '.').' EUR';
|
||||
return number_format($amount, 2, ',', '.').' €';
|
||||
}
|
||||
|
||||
private function buildPdf(string $content): string
|
||||
|
|
@ -157,9 +339,10 @@ class LegacyInvoicePdfRenderer
|
|||
$objects = [
|
||||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n",
|
||||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
|
||||
"5 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
|
||||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||||
];
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
|
|
@ -190,4 +373,95 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||||
}
|
||||
|
||||
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1', string $align = 'left'): string
|
||||
{
|
||||
if ($align === 'center') {
|
||||
$x = (self::PAGE_WIDTH - $this->textWidth($text, $size)) / 2;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$y,
|
||||
$this->escapePdfText($text),
|
||||
);
|
||||
}
|
||||
|
||||
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
|
||||
{
|
||||
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
|
||||
}
|
||||
|
||||
private function wrappedText(
|
||||
string &$content,
|
||||
float $x,
|
||||
float $y,
|
||||
string $text,
|
||||
int $size,
|
||||
float $width,
|
||||
float $lineHeight = 12,
|
||||
string $font = 'F1',
|
||||
): float {
|
||||
foreach ($this->wrap($text, $width, $size) as $line) {
|
||||
if ($line !== '') {
|
||||
$content .= $this->text($x, $y, $line, $size, $font);
|
||||
}
|
||||
|
||||
$y -= $lineHeight;
|
||||
}
|
||||
|
||||
return $y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function wrap(string $text, float $width, int $size): array
|
||||
{
|
||||
$lines = [];
|
||||
$maxCharacters = max(8, (int) floor($width / ($size * 0.48)));
|
||||
|
||||
foreach ($this->splitLines($text) as $paragraph) {
|
||||
if ($paragraph === '') {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = '';
|
||||
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
|
||||
$candidate = trim($line.' '.$word);
|
||||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $candidate;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitLines(string $text): array
|
||||
{
|
||||
return explode("\n", str_replace(["\r\n", "\r"], "\n", $text));
|
||||
}
|
||||
|
||||
private function textWidth(string $text, int $size): float
|
||||
{
|
||||
return Str::length($text) * $size * 0.48;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Models\Company;
|
|||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class CustomerCompanyContext
|
||||
{
|
||||
|
|
@ -60,7 +62,54 @@ class CustomerCompanyContext
|
|||
return null;
|
||||
}
|
||||
|
||||
return $this->companiesFor($user)->firstWhere('id', $companyId);
|
||||
return $this->findFor($user, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function latestCompaniesFor(User $user, int $limit = 10): Collection
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at'])
|
||||
->latest('companies.created_at')
|
||||
->latest('companies.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function companyCountFor(User $user): int
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function switcherCompaniesFor(User $user, ?int $selectedCompanyId = null, int $limit = 50): Collection
|
||||
{
|
||||
$companies = $this->switcherCompanyQuery($user)
|
||||
->latest('companies.created_at')
|
||||
->latest('companies.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($selectedCompanyId === null || $companies->contains('id', $selectedCompanyId)) {
|
||||
return $companies;
|
||||
}
|
||||
|
||||
$selectedCompany = $this->switcherCompanyQuery($user)
|
||||
->whereKey($selectedCompanyId)
|
||||
->first();
|
||||
|
||||
if (! $selectedCompany) {
|
||||
return $companies;
|
||||
}
|
||||
|
||||
return $companies
|
||||
->prepend($selectedCompany)
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,14 +149,14 @@ class CustomerCompanyContext
|
|||
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SessionKey);
|
||||
Session::forget(self::SessionKey);
|
||||
}
|
||||
|
||||
public function roleLabelFor(Company $company, User $user): string
|
||||
{
|
||||
$role = $company->owner_user_id === $user->id
|
||||
? 'owner'
|
||||
: ($company->pivot?->role ?? 'member');
|
||||
: ($company->getAttribute('current_user_role') ?? $company->pivot?->role ?? 'member');
|
||||
|
||||
return match ($role) {
|
||||
'owner' => __('Owner'),
|
||||
|
|
@ -116,6 +165,22 @@ class CustomerCompanyContext
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Company>
|
||||
*/
|
||||
private function switcherCompanyQuery(User $user): Builder
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at'])
|
||||
->addSelect([
|
||||
'current_user_role' => DB::table('company_user')
|
||||
->select('role')
|
||||
->whereColumn('company_user.company_id', 'companies.id')
|
||||
->where('company_user.user_id', $user->id)
|
||||
->limit(1),
|
||||
]);
|
||||
}
|
||||
|
||||
private function userCanAccessCompany(User $user, int $companyId): bool
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
|
|
|
|||
113
app/Services/PressRelease/PressReleaseAttachmentStorage.php
Normal file
113
app/Services/PressRelease/PressReleaseAttachmentStorage.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Storage for press release attachments (PDFs and other documents).
|
||||
*
|
||||
* Files are stored on the `public` disk under
|
||||
* press-releases/{id}/attachments/{uuid}-{slug}.{ext}
|
||||
*
|
||||
* Path-obscurity is sufficient for MVP: anonymous downloads are intentional
|
||||
* for published press releases, and embargo logic lives at the PR level.
|
||||
*/
|
||||
class PressReleaseAttachmentStorage
|
||||
{
|
||||
public const MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
/** @var list<string> */
|
||||
public const ALLOWED_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'txt'];
|
||||
|
||||
/** @var list<string> */
|
||||
public const ALLOWED_MIMES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
/**
|
||||
* Store the uploaded attachment for the given press release.
|
||||
*
|
||||
* @return array{disk:string,path:string,original_name:string,mime:string,size:int}
|
||||
*/
|
||||
public function store(UploadedFile $upload, int $pressReleaseId): array
|
||||
{
|
||||
$this->assertValidUpload($upload);
|
||||
|
||||
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension());
|
||||
$originalName = $upload->getClientOriginalName();
|
||||
$baseSlug = Str::slug(pathinfo($originalName, PATHINFO_FILENAME)) ?: 'attachment';
|
||||
$baseSlug = Str::limit($baseSlug, 60, '');
|
||||
|
||||
$directory = sprintf('press-releases/%d/attachments', $pressReleaseId);
|
||||
$filename = Str::uuid()->toString().'-'.$baseSlug.'.'.$extension;
|
||||
$relativePath = $directory.'/'.$filename;
|
||||
|
||||
$disk = $this->disk();
|
||||
$stream = fopen($upload->getRealPath(), 'r');
|
||||
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException('Could not open uploaded file stream.');
|
||||
}
|
||||
|
||||
try {
|
||||
$disk->put($relativePath, $stream, 'public');
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => 'public',
|
||||
'path' => $relativePath,
|
||||
'original_name' => $originalName,
|
||||
'mime' => $upload->getMimeType() ?: 'application/octet-stream',
|
||||
'size' => $upload->getSize() ?: 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(string $disk, string $path): void
|
||||
{
|
||||
if (blank($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::disk($disk)->delete($path);
|
||||
} catch (\Throwable) {
|
||||
// Swallow — deletion is best-effort; a missing file is acceptable.
|
||||
}
|
||||
}
|
||||
|
||||
private function assertValidUpload(UploadedFile $upload): void
|
||||
{
|
||||
$extension = strtolower($upload->getClientOriginalExtension() ?: $upload->extension());
|
||||
|
||||
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new RuntimeException('Unsupported attachment extension: '.$extension);
|
||||
}
|
||||
|
||||
if ($upload->getSize() > self::MAX_BYTES) {
|
||||
throw new RuntimeException('Attachment exceeds maximum size.');
|
||||
}
|
||||
}
|
||||
|
||||
private function disk(): Filesystem
|
||||
{
|
||||
return Storage::disk('public');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Models\AdminPreset;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
|
@ -44,7 +45,7 @@ class PressReleaseService
|
|||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
||||
}
|
||||
|
||||
public function publish(PressRelease $pressRelease): void
|
||||
public function publish(PressRelease $pressRelease, string $source = 'admin'): void
|
||||
{
|
||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||
|
||||
|
|
@ -62,13 +63,42 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->update([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'published_at' => $pressRelease->published_at ?? now(),
|
||||
'published_at' => $this->resolvePublishedAt($pressRelease),
|
||||
]);
|
||||
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, 'admin');
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
|
||||
$this->notifyAuthor($pressRelease, 'published');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt das wirksame `published_at` einer PM.
|
||||
*
|
||||
* Reihenfolge:
|
||||
* 1. Bereits gesetztes `published_at` bleibt erhalten (z.B. Re-Publish)
|
||||
* 2. `scheduled_at` (geplanter Veröffentlichungstermin) hat Vorrang vor "jetzt"
|
||||
* 3. `embargo_at` (Sperrfrist) verschiebt zusätzlich nach hinten — egal ob
|
||||
* Scheduled vorhanden ist oder nicht
|
||||
* 4. Fallback: now()
|
||||
*
|
||||
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
||||
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
||||
* öffentlichen Listing.
|
||||
*/
|
||||
private function resolvePublishedAt(PressRelease $pressRelease): Carbon
|
||||
{
|
||||
if ($pressRelease->published_at) {
|
||||
return $pressRelease->published_at;
|
||||
}
|
||||
|
||||
$base = $pressRelease->scheduled_at ?: now();
|
||||
|
||||
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
||||
return $pressRelease->embargo_at;
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
public function reject(PressRelease $pressRelease, ?string $reason = null): void
|
||||
{
|
||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue