Abo (A1+A2): Liefertag frei waehlbar 1-31, Aenderung nur nach vorn, Sperre erst am Ausfuehrungstag

A1: Jeder Monatstag ist als Liefertag erlaubt (UserAbo::allowedDeliveryDays),
Tage 29-31 liefern in kuerzeren Monaten am letzten Tag. Eine Stelle
AboHelper::dateForDayInMonth klammert; setNextDate, Erstausfuehrung,
Admin-Termin und Pause rechnen darueber, der Folgemonat immer aus dem
Liefertag (31.01 -> 28.02 -> 31.03). Liefertag-Aenderung gilt fuer den
laufenden Zyklus und nur nach vorn: ist der Tag im Referenzmonat vorbei,
wird abgelehnt, der Monat verschiebt sich nie (Rueckfrage R1).
Admin-Filter "naechste Ausfuehrung" als Datumsbereich. Prognose-Kacheln
bekommen die Kachel Monatsende, Team-Abos rechnen bis Monatsende.

A2: Sperrfristen Default 0, Setting 0 ist ausdruecklich 0 (fiel bisher auf
den Default zurueck). Bedingung Kalendertage <= Sperre, zentral in
AboHelper::isWithinLock: am Ausfuehrungstag ab 0:00 Uhr fixiert, Vortag bis
23:59 Uhr frei, fuer Liefertag, Produkte, Pause und Kuendigung (R2). Texte
mit _zero-Varianten ueber AboHelper::lockMessage.

Sprachdateien de/en/es/fr, neuer AboDeliveryDayTest, bestehende Tests
angepasst, Plan-Status mit Deploy-Schritt (Settings auf 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-16 11:28:55 +00:00
parent d6efe56cd4
commit cf0311afc1
30 changed files with 638 additions and 185 deletions

View file

@ -203,13 +203,52 @@ class AboHelper
*/
public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool
{
if ($view === 'admin' || ! $userAbo->next_date) {
if ($view === 'admin') {
return false;
}
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
return self::isWithinLock($userAbo, AboRepository::getLockDaysChange());
}
return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysChange();
/**
* Kalendertage von heute bis zum naechsten Liefertermin (negativ, wenn der
* Termin liegen geblieben ist); null ohne Termin.
*/
public static function daysUntilNextDate(UserAbo $userAbo): ?int
{
$raw = $userAbo->getRawOriginal('next_date') ?: $userAbo->next_date;
if (! $raw) {
return null;
}
return (int) Carbon::today()->diffInDays(Carbon::parse($raw)->startOfDay(), false);
}
/**
* Sperre (Ticket A2): fixiert ab 0:00 Uhr des Tages `next_date - lockDays`.
* Mit lockDays = 0 ist das genau der Ausfuehrungstag — bis 23:59 Uhr des
* Vortags bleibt alles aenderbar. Ein liegen gebliebener Termin (negativ)
* sperrt nicht.
*/
public static function isWithinLock(UserAbo $userAbo, int $lockDays): bool
{
$daysUntil = self::daysUntilNextDate($userAbo);
return $daysUntil !== null && $daysUntil >= 0 && $daysUntil <= $lockDays;
}
/**
* Sperrtext fuer Aenderung / Pause / Kuendigung: bei Sperre 0 der Text
* "bis zum Vortag" (`abo.<key>_zero`), sonst der Text mit Vorlauf
* (`abo.<key>`, Platzhalter :lock, :days, :date).
*/
public static function lockMessage(string $key, UserAbo $userAbo, int $lockDays): string
{
$daysUntil = self::daysUntilNextDate($userAbo) ?? 0;
$date = $userAbo->getRawOriginal('next_date') ? Carbon::parse($userAbo->getRawOriginal('next_date'))->format(\Util::formatDateDB()) : '-';
return __('abo.'.$key.($lockDays <= 0 ? '_zero' : ''), ['lock' => $lockDays, 'days' => max(0, $daysUntil), 'date' => $date]);
}
/**
@ -222,13 +261,11 @@ class AboHelper
*/
public static function isPauseCancelLocked(UserAbo $userAbo, string $view = 'user'): bool
{
if ($view === 'admin' || ! $userAbo->next_date) {
if ($view === 'admin') {
return false;
}
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysPauseCancel();
return self::isWithinLock($userAbo, AboRepository::getLockDaysPauseCancel());
}
public static function canEditAbo($user_abo, $view = 'user')
@ -285,12 +322,33 @@ class AboHelper
return '';
}
/**
* Liefertermin fuer den gewuenschten Monatstag im Monat von $month, in
* kuerzeren Monaten der letzte Tag (31 im Februar => 28./29.). Einzige
* Stelle, die aus Tag + Monat ein Datum macht (Ticket A1).
*/
public static function dateForDayInMonth(Carbon|string $month, int $day): Carbon
{
$first = Carbon::parse($month)->startOfMonth()->startOfDay();
return $first->day(max(1, min($day, $first->daysInMonth)));
}
/**
* Naechster Liefertermin nach $date fuer den Monatstag $abo_interval: im
* Monat von $date, wenn dieser Termin noch nach $date liegt, sonst im
* Folgemonat. Der Folgemonat wird aus dem Monatstag gerechnet, nicht aus dem
* (ggf. geklammerten) Vortermin — ein 31er-Abo bleibt nach dem Februar nicht
* auf dem 28.
*/
public static function setNextDate($date, $abo_interval)
{
$nextDate = Carbon::parse($date)->firstOfMonth();
$nextDate->addDays($abo_interval - 1);
$reference = Carbon::parse($date);
$nextDate = self::dateForDayInMonth($reference, (int) $abo_interval);
return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1);
return $nextDate->gt($reference)
? $nextDate
: self::dateForDayInMonth($reference->copy()->startOfMonth()->addMonth(), (int) $abo_interval);
}
/**
@ -374,14 +432,13 @@ class AboHelper
*/
private static function computeFirstAboCandidateWithoutMinDays(Carbon $reference, int $aboDayOfMonth): Carbon
{
$nextDate = $reference->copy()->firstOfMonth()->addMonth(1);
$nextDate->addDays($aboDayOfMonth - 1);
$nextDate = self::dateForDayInMonth($reference->copy()->startOfMonth()->addMonth(), $aboDayOfMonth);
if (! $nextDate->gt($reference)) {
$nextDate->addMonth(1);
$nextDate = self::advanceAboCandidateOneMonth($nextDate, $aboDayOfMonth);
}
return $nextDate->copy()->startOfDay();
return $nextDate;
}
/**
@ -389,11 +446,7 @@ class AboHelper
*/
private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon
{
$next = $candidate->copy()->addMonthNoOverflow();
$dim = $next->daysInMonth;
$day = min($aboDayOfMonth, $dim);
return $next->day($day)->startOfDay();
return self::dateForDayInMonth($candidate->copy()->startOfMonth()->addMonth(), $aboDayOfMonth);
}
public static function createNewAbo(ShoppingPayment $shopping_payment)