Rechnung/Lieferschein: - PDF-Gruppierung Abo- vs. Einmal-Artikel (is_abo_addon), rueckwaertskompatibel - Vorschau-Command sales:preview-invoice (umgeht Test-System-Gate, ohne Rechnungsnummer) Reminder-Mail (vorbereitet, Cron inaktiv): - MailAboExecutionReminder + Template, abo:send-execution-reminder, Spalte onetime_reminder_sent_for - Berater -> Sales Center, Kunde -> Portal; Vorschau abo:preview-reminder - Datum-Fix: formatDateDB() statt formatDate() (ICU-Format) Sperrfristen konfigurierbar (Admin-Settings): - abo-lock-days-change (Liefertag + Abo-Produkte), abo-lock-days-pause-cancel - Meldungen mit :lock-Platzhalter; Pausieren/Kuendigen vom Change-Lock entkoppelt - Abo-Produkt-Aenderungen im Lock-Fenster server- + clientseitig eingefroren - Selbstbedienung: Add-only/Lock nach $view (kein Admin-Bypass), Stale-Cache-Fix beim Mengen-Update Dashboard: - Einmalprodukte-Card + Team-Abo-KPI-Card, News-Card responsiv (col-xl-6) Lokalisierung: - Sprachdateien en/es/fr vollstaendig mit de synchronisiert (inkl. marketingplan/portal) - LocalizationSyncTest sichert Key-Gleichheit ab Tests: AboOneTimeInvoiceGrouping, AboExecutionReminder, AboLockDaysSetting, AboOnetimeDashboardAndMail, PreviewInvoiceCommand, TeamAboOverview + erweiterte View-Tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
247 lines
9.4 KiB
PHP
247 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories;
|
|
|
|
use App\Models\UserAbo;
|
|
use App\Services\AboHelper;
|
|
use Carbon\Carbon;
|
|
|
|
class AboRepository extends BaseRepository
|
|
{
|
|
private const DEFAULT_LOCK_DAYS_CHANGE = 10;
|
|
|
|
private const DEFAULT_LOCK_DAYS_PAUSE_CANCEL = 3;
|
|
|
|
/**
|
|
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Bearbeiten des Abos
|
|
* (Intervall/Produkte) blockiert wird. Konfigurierbar über das Setting
|
|
* `abo-lock-days-change`; Fallback auf den Default, wenn leer/0.
|
|
*/
|
|
public static function getLockDaysChange(): int
|
|
{
|
|
$days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-change');
|
|
|
|
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_CHANGE;
|
|
}
|
|
|
|
/**
|
|
* Sperrfrist (Kalendertage) vor der Ausführung, ab der das Pausieren/Kündigen
|
|
* des Abos blockiert wird. Konfigurierbar über das Setting
|
|
* `abo-lock-days-pause-cancel`; Fallback auf den Default, wenn leer/0.
|
|
*/
|
|
public static function getLockDaysPauseCancel(): int
|
|
{
|
|
$days = (int) \App\Models\Setting::getContentBySlug('abo-lock-days-pause-cancel');
|
|
|
|
return $days > 0 ? $days : self::DEFAULT_LOCK_DAYS_PAUSE_CANCEL;
|
|
}
|
|
|
|
public function __construct()
|
|
{
|
|
// $this->model = $model;
|
|
}
|
|
|
|
public function setModel(UserAbo $model)
|
|
{
|
|
$this->model = $model;
|
|
}
|
|
|
|
public function update($data)
|
|
{
|
|
if (isset($data['action'])) {
|
|
if ($data['action'] === 'abo_update_settings') {
|
|
if ($this->validate($data)) {
|
|
$this->updateStatus($data);
|
|
$this->model->abo_interval = (int) $data['abo_interval'];
|
|
$nextDate = $this->isAdminUpdate($data)
|
|
? $this->calculateAdminNextDate($data)
|
|
: $this->calculateNewNextDate((int) $data['abo_interval']);
|
|
$this->model->next_date = $nextDate;
|
|
$this->model->save();
|
|
|
|
$daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate);
|
|
\Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format('d.m.Y')]));
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function create($data) {}
|
|
|
|
private function updateStatus($data)
|
|
{
|
|
$isAdminUpdate = $this->isAdminUpdate($data);
|
|
|
|
// Handle cancellation
|
|
if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') {
|
|
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden
|
|
if (! $isAdminUpdate && $this->model->next_date) {
|
|
$daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
|
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
|
\Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Status 4 = abo_cancel (storniert/gekündigt)
|
|
$this->model->status = 4;
|
|
$this->model->active = false;
|
|
$this->model->cancel_date = now();
|
|
$this->model->save();
|
|
|
|
return;
|
|
}
|
|
|
|
$active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false;
|
|
// Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden
|
|
if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) {
|
|
$daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
|
if ($daysUntil >= 0 && $daysUntil < self::getLockDaysPauseCancel()) {
|
|
\Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil, 'lock' => self::getLockDaysPauseCancel()]));
|
|
|
|
return;
|
|
}
|
|
}
|
|
// if status is active and active is false, set status to inactive
|
|
if ($this->model->active && ! $active) {
|
|
if ($this->model->status == 2) { // okay
|
|
$this->model->status = 6; // inactive
|
|
$this->model->active = false;
|
|
$this->model->save();
|
|
}
|
|
}
|
|
if (! $this->model->active && $active) {
|
|
if ($this->model->status == 6) { // inactive
|
|
$this->model->status = 2; // okay
|
|
$this->model->active = true;
|
|
$this->model->save();
|
|
}
|
|
}
|
|
$this->model->active = $active;
|
|
}
|
|
|
|
private function validate($data)
|
|
{
|
|
if ($data['view'] !== 'admin' && $data['view'] !== 'portal') {
|
|
if ($this->model->is_for === 'me' && $this->model->user_id !== \Auth::user()->id) {
|
|
\Session()->flash('alert-error', 'Unauthorized action. User ID does not match.');
|
|
|
|
return false;
|
|
}
|
|
if ($this->model->is_for === 'ot' && $this->model->member_id !== \Auth::user()->id) {
|
|
\Session()->flash('alert-error', 'Unauthorized action. User ID does not match.');
|
|
|
|
return false;
|
|
}
|
|
if ($data['view'] === 'me' && $this->model->is_for !== 'me') {
|
|
\Session()->flash('alert-error', 'Unauthorized action. Is not for me');
|
|
|
|
return false;
|
|
}
|
|
if ($data['view'] === 'ot' && $this->model->is_for !== 'ot') {
|
|
\Session()->flash('alert-error', 'Unauthorized action. Is not your customer');
|
|
|
|
return false;
|
|
}
|
|
}
|
|
if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays, true)) {
|
|
\Session()->flash('alert-error', __('abo.error_abo_interval'));
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($this->isAdminUpdate($data)) {
|
|
if (! isset($data['abo_next_month']) || ! in_array($data['abo_next_month'], $this->getAdminExecutionMonths(), true)) {
|
|
\Session()->flash('alert-error', __('abo.error_next_date'));
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($this->calculateAdminNextDate($data)->startOfDay()->lt(now()->startOfDay())) {
|
|
\Session()->flash('alert-error', __('abo.error_next_date'));
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Die Change-Sperre (Liefertag/Intervall) greift NUR, wenn der Liefertag tatsächlich
|
|
// geändert wird. Reines Pausieren/Kündigen (Intervall unverändert) unterliegt
|
|
// ausschließlich der eigenen Sperrfrist in updateStatus() + der Mindestlaufzeit.
|
|
$intervalChanged = (int) $data['abo_interval'] !== (int) $this->model->abo_interval;
|
|
|
|
if ($intervalChanged) {
|
|
// Sperre: X Tage vor nächster Ausführung kein Liefertag-Wechsel mehr (Pakete werden vorgepackt)
|
|
if ($this->model->next_date) {
|
|
$daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false);
|
|
if ($daysUntilExecution >= 0 && $daysUntilExecution < self::getLockDaysChange()) {
|
|
\Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution, 'lock' => self::getLockDaysChange()]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Prüfung: Das neue berechnete Ausführungsdatum muss mindestens getLockDaysChange() Tage entfernt sein.
|
|
// Falls next_date bereits in einem zukünftigen Monat liegt, wird das neue Datum in diesem Monat berechnet.
|
|
$newNextDate = $this->calculateNewNextDate($data['abo_interval']);
|
|
$daysUntilNewDate = (int) now()->diffInDays($newNextDate, false);
|
|
if ($daysUntilNewDate < self::getLockDaysChange()) {
|
|
\Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate, 'lock' => self::getLockDaysChange()]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Berechnet das neue Ausführungsdatum unter Berücksichtigung des aktuellen next_date.
|
|
* Falls next_date bereits in einem zukünftigen Monat liegt, wird der Monatsanfang
|
|
* dieses Monats als Referenz verwendet, sodass der neue Tag im selben Monat landet.
|
|
*/
|
|
private function calculateNewNextDate(int $aboInterval): \Carbon\Carbon
|
|
{
|
|
$referenceDate = now();
|
|
|
|
if ($this->model->next_date) {
|
|
$currentNextDate = \Carbon\Carbon::parse($this->model->next_date);
|
|
|
|
if ($currentNextDate->format('Y-m') > now()->format('Y-m')) {
|
|
$referenceDate = $currentNextDate->startOfMonth();
|
|
}
|
|
}
|
|
|
|
return AboHelper::setNextDate($referenceDate, $aboInterval);
|
|
}
|
|
|
|
private function calculateAdminNextDate(array $data): Carbon
|
|
{
|
|
return Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01')
|
|
->startOfMonth()
|
|
->addDays(((int) $data['abo_interval']) - 1);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function getAdminExecutionMonths(): array
|
|
{
|
|
return collect(range(0, 3))
|
|
->map(fn (int $offset): string => now()->copy()->startOfMonth()->addMonths($offset)->format('Y-m'))
|
|
->all();
|
|
}
|
|
|
|
private function isAdminUpdate(array $data): bool
|
|
{
|
|
return ($data['view'] ?? null) === 'admin';
|
|
}
|
|
}
|