From 4d2603819a20836a12ce5a00e2a4a2e7b3cee969 Mon Sep 17 00:00:00 2001 From: Adrien Albuquerque Date: Wed, 24 Sep 2025 16:58:15 +0200 Subject: [PATCH 1/5] Align demo seed data with business hours and expirations --- database/seeders/DemoSeeder.php | 335 +++++++++++++++++++++++++++++++- 1 file changed, 326 insertions(+), 9 deletions(-) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index beeb365..524f845 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -20,6 +20,7 @@ use App\Models\MenuTypePublicOrder; use App\Models\Order; use App\Models\OrderStep; +use App\Models\Perishable; use App\Models\Preparation; use App\Models\QuickAccess; use App\Models\Room; @@ -29,6 +30,7 @@ use App\Models\User; use App\Services\ImageService; use App\Services\OpenFoodFactsService; +use App\Services\PerishableService; use Carbon\CarbonImmutable; use Illuminate\Database\Seeder; use Illuminate\Support\Collection; @@ -1663,10 +1665,14 @@ private function seedAdditionalCompany(): void 'Coulis de framboise' => 6, ]; + private const PERISHABLE_STATUS_SEQUENCE = ['FRESH', 'FRESH', 'SOON', 'EXPIRED']; + private ImageService $images; private OpenFoodFactsService $openFoodFacts; + private PerishableService $perishableService; + /** @var array */ private array $missingIngredients = []; @@ -1698,10 +1704,24 @@ private function seedAdditionalCompany(): void private ?int $preparationLocationId = null; - public function __construct(ImageService $images, OpenFoodFactsService $openFoodFacts) - { + /** + * @var array> + */ + private array $businessHoursSchedule = []; + + /** + * @var array|null + */ + private ?array $businessIntervalsCache = null; + + public function __construct( + ImageService $images, + OpenFoodFactsService $openFoodFacts, + PerishableService $perishableService + ) { $this->images = $images; $this->openFoodFacts = $openFoodFacts; + $this->perishableService = $perishableService; } public function run(): void @@ -1751,6 +1771,7 @@ public function run(): void $categoryIds = $this->ensureCategories($company, $locationTypes); $ingredients = $this->seedIngredients($company, $categoryIds, $locations, $defaultLocation); $this->seedIngredientStockMovements($ingredients); + $this->seedPerishables($company, $ingredients); $preparationCategoryId = $categoryIds['Préparations Maison'] ?? (int) reset($categoryIds); $preparations = $this->seedPreparations( $company, @@ -1804,6 +1825,9 @@ private function seedCompanyBusinessHours(Company $company, array $schedule): vo { $company->businessHours()->delete(); + $this->businessHoursSchedule = []; + $this->businessIntervalsCache = null; + $records = []; foreach ($schedule as $day => $entries) { @@ -1833,6 +1857,12 @@ private function seedCompanyBusinessHours(Company $company, array $schedule): vo (bool) ($entry['is_overnight'] ?? false) ); + $this->businessHoursSchedule[$dayOfWeek][] = [ + 'opens_at' => $opensAt, + 'closes_at' => $closesAt, + 'is_overnight' => $isOvernight, + ]; + $records[] = [ 'day_of_week' => $dayOfWeek, 'opens_at' => $opensAt, @@ -1934,6 +1964,133 @@ private function timeToMinutes(string $time): int return ($hour * 60) + $minute; } + private function combineDateAndTime(CarbonImmutable $date, string $time): CarbonImmutable + { + $segments = array_pad(explode(':', $time), 3, '0'); + $hour = (int) $segments[0]; + $minute = (int) $segments[1]; + $second = (int) $segments[2]; + + return $date->setTime($hour, $minute, $second); + } + + /** + * @return array + */ + private function businessIntervals(): array + { + if ($this->businessIntervalsCache !== null) { + return $this->businessIntervalsCache; + } + + if ($this->businessHoursSchedule === []) { + return $this->businessIntervalsCache = []; + } + + $now = CarbonImmutable::now(); + $baseDate = $now->startOfDay(); + $intervals = []; + + for ($offset = -14; $offset <= 0; $offset++) { + $currentDate = $baseDate->addDays($offset); + $dayOfWeek = (int) $currentDate->isoWeekday(); + $entries = $this->businessHoursSchedule[$dayOfWeek] ?? []; + + foreach ($entries as $entry) { + $start = $this->combineDateAndTime($currentDate, $entry['opens_at']); + $end = $this->combineDateAndTime($currentDate, $entry['closes_at']); + + if ($entry['is_overnight'] || $end->lessThanOrEqualTo($start)) { + $end = $end->addDay(); + } + + $intervals[] = ['start' => $start, 'end' => $end]; + } + } + + usort($intervals, function (array $a, array $b): int { + if ($a['end']->equalTo($b['end'])) { + return 0; + } + + return $a['end']->lessThan($b['end']) ? -1 : 1; + }); + + return $this->businessIntervalsCache = $intervals; + } + + /** + * @return array{start: CarbonImmutable, end: CarbonImmutable, anchor: CarbonImmutable, available: int}|null + */ + private function resolveOrderInterval(int $desiredMinutes): ?array + { + $intervals = $this->businessIntervals(); + + if ($intervals === []) { + return null; + } + + $now = CarbonImmutable::now(); + $candidates = []; + + foreach ($intervals as $interval) { + if ($interval['start']->greaterThan($now)) { + continue; + } + + $anchor = $interval['end']; + + if ($now->betweenIncluded($interval['start'], $interval['end'])) { + $anchor = $now; + } elseif ($interval['end']->greaterThan($now)) { + $anchor = $now; + } + + if ($anchor->lessThanOrEqualTo($interval['start'])) { + continue; + } + + $available = $interval['start']->diffInMinutes($anchor); + + if ($available <= 0) { + continue; + } + + $candidates[] = [ + 'start' => $interval['start'], + 'end' => $interval['end'], + 'anchor' => $anchor, + 'available' => $available, + ]; + } + + if ($candidates === []) { + return null; + } + + usort($candidates, function (array $a, array $b): int { + if ($a['anchor']->equalTo($b['anchor'])) { + return 0; + } + + return $a['anchor']->lessThan($b['anchor']) ? 1 : -1; + }); + + $bestByAvailability = $candidates[0]; + + foreach ($candidates as $candidate) { + if ($candidate['available'] > $bestByAvailability['available']) { + $bestByAvailability = $candidate; + } + + if ($candidate['available'] >= $desiredMinutes) { + return $candidate; + } + } + + return $bestByAvailability; + } + /** * @return array $ingredients + */ + private function seedPerishables(Company $company, array $ingredients): void + { + Perishable::withTrashed() + ->where('company_id', $company->id) + ->forceDelete(); + + if ($ingredients === []) { + return; + } + + $sequence = self::PERISHABLE_STATUS_SEQUENCE; + $count = count($sequence); + + if ($count === 0) { + return; + } + + $index = 0; + + foreach ($ingredients as $ingredient) { + $ingredient->loadMissing('locations'); + + foreach ($ingredient->locations as $location) { + $quantity = (float) ($location->pivot->quantity ?? 0.0); + + if ($quantity <= 0.0) { + continue; + } + + $perishable = $this->perishableService->add( + $ingredient->id, + $location->id, + $company->id, + $quantity + ); + + if (! $perishable instanceof Perishable) { + continue; + } + + $status = $sequence[$index % $count]; + $index++; + + $markAsRead = $status === 'FRESH' && ($index % 3 === 0); + + $this->configurePerishableStatus($perishable, $status, $markAsRead); + } + } + } + + private function configurePerishableStatus(Perishable $perishable, string $status, bool $markAsRead = false): void + { + $now = CarbonImmutable::now(); + $expiration = $this->perishableService->expiration($perishable); + $shelfLife = max(1, (int) round($expiration->diffInHours($perishable->created_at))); + + $createdAt = $now; + $deletedAt = null; + + switch ($status) { + case 'SOON': + $remaining = max(1, min(12, $shelfLife - 1)); + $age = max(1, $shelfLife - $remaining); + $createdAt = $now->subHours($age); + break; + case 'EXPIRED': + $overdue = max(1, min(24, (int) ceil($shelfLife * 0.15))); + $createdAt = $now->subHours($shelfLife + $overdue); + $deletedAt = $now->subHours(max(1, min($overdue, 6))); + break; + default: + $age = max(1, min($shelfLife - 1, (int) ceil($shelfLife * 0.3))); + $createdAt = $now->subHours($age); + break; + } + + $perishable->forceFill([ + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + 'deleted_at' => $deletedAt, + 'is_read' => $markAsRead, + ])->saveQuietly(); + } + private function createIngredientMovement( Ingredient $ingredient, int $locationId, @@ -3129,12 +3373,27 @@ private function ensureTables(Company $company, array $rooms): array * served_at: CarbonImmutable|null, * payed_at: CarbonImmutable|null, * canceled_at: CarbonImmutable|null, + * reference_at: CarbonImmutable, * } */ private function buildOrderTimeline(OrderStatus $status, array $timelineDefinition): array { - $minutesAgo = max(5, (int) ($timelineDefinition['minutes_ago'] ?? 60)); - $pendingAt = CarbonImmutable::now()->subMinutes($minutesAgo); + $desiredMinutesAgo = max(5, (int) ($timelineDefinition['minutes_ago'] ?? 60)); + $interval = $this->resolveOrderInterval($desiredMinutesAgo); + + if ($interval !== null) { + $available = max(1, (int) $interval['available']); + $minutesAgo = $available >= 5 + ? max(5, min($desiredMinutesAgo, $available)) + : min($desiredMinutesAgo, $available); + $minutesAgo = max(1, $minutesAgo); + $referenceAt = $interval['anchor']; + } else { + $minutesAgo = $desiredMinutesAgo; + $referenceAt = CarbonImmutable::now(); + } + + $pendingAt = $referenceAt->subMinutes($minutesAgo); $servedAt = null; $payedAt = null; @@ -3142,18 +3401,38 @@ private function buildOrderTimeline(OrderStatus $status, array $timelineDefiniti if (in_array($status, [OrderStatus::SERVED, OrderStatus::PAYED], true)) { $servedDelay = max(1, (int) ($timelineDefinition['served_after'] ?? 30)); + $servedDelay = min($servedDelay, max(1, $minutesAgo)); $servedAt = $pendingAt->addMinutes($servedDelay); } if ($status === OrderStatus::PAYED) { $reference = $servedAt ?? $pendingAt; + $availableAfterReference = $reference->lessThan($referenceAt) + ? $reference->diffInMinutes($referenceAt) + : 0; + $payedDelay = max(1, (int) ($timelineDefinition['payed_after'] ?? 20)); - $payedAt = $reference->addMinutes($payedDelay); + + if ($availableAfterReference <= 0) { + $payedDelay = 0; + } elseif ($payedDelay > $availableAfterReference) { + $payedDelay = max(1, $availableAfterReference); + } + + $payedAt = $payedDelay > 0 ? $reference->addMinutes($payedDelay) : $reference; } if ($status === OrderStatus::CANCELED) { + $availableAfterPending = $minutesAgo; $canceledDelay = max(1, (int) ($timelineDefinition['canceled_after'] ?? 15)); - $canceledAt = $pendingAt->addMinutes($canceledDelay); + + if ($availableAfterPending <= 0) { + $canceledDelay = 0; + } elseif ($canceledDelay > $availableAfterPending) { + $canceledDelay = max(1, $availableAfterPending); + } + + $canceledAt = $canceledDelay > 0 ? $pendingAt->addMinutes($canceledDelay) : $pendingAt; } return [ @@ -3161,6 +3440,7 @@ private function buildOrderTimeline(OrderStatus $status, array $timelineDefiniti 'served_at' => $servedAt, 'payed_at' => $payedAt, 'canceled_at' => $canceledAt, + 'reference_at' => $referenceAt, ]; } @@ -3171,6 +3451,7 @@ private function buildOrderTimeline(OrderStatus $status, array $timelineDefiniti * served_at: CarbonImmutable|null, * payed_at: CarbonImmutable|null, * canceled_at: CarbonImmutable|null, + * reference_at: CarbonImmutable, * } $timeline */ private function seedOrderSteps(Order $order, array $steps, Collection $menuMap, array $timeline): void @@ -3179,6 +3460,8 @@ private function seedOrderSteps(Order $order, array $steps, Collection $menuMap, /** @var CarbonImmutable $pendingAt */ $pendingAt = $timeline['pending_at']; + /** @var CarbonImmutable $latestAt */ + $latestAt = $timeline['reference_at'] ?? $pendingAt; foreach ($steps as $index => $definition) { $status = $definition['status'] ?? OrderStepStatus::IN_PREP; @@ -3190,7 +3473,12 @@ private function seedOrderSteps(Order $order, array $steps, Collection $menuMap, $servedAt = null; if (array_key_exists('served_after', $definition)) { - $servedAt = $pendingAt->addMinutes((int) $definition['served_after']); + $delay = max(0, (int) $definition['served_after']); + $candidate = $pendingAt->addMinutes($delay); + if ($candidate->greaterThan($latestAt)) { + $candidate = $latestAt; + } + $servedAt = $candidate; } elseif ($status === OrderStepStatus::SERVED) { $servedAt = $timeline['served_at']; } @@ -3213,6 +3501,7 @@ private function seedOrderSteps(Order $order, array $steps, Collection $menuMap, * served_at: CarbonImmutable|null, * payed_at: CarbonImmutable|null, * canceled_at: CarbonImmutable|null, + * reference_at: CarbonImmutable, * } $timeline */ private function seedStepMenus( @@ -3227,6 +3516,8 @@ private function seedStepMenus( /** @var CarbonImmutable $pendingAt */ $pendingAt = $timeline['pending_at']; + /** @var CarbonImmutable $latestAt */ + $latestAt = $timeline['reference_at'] ?? $pendingAt; foreach ($menus as $menuDefinition) { $menuName = $menuDefinition['name'] ?? null; @@ -3257,7 +3548,12 @@ private function seedStepMenus( $servedAt = null; if (array_key_exists('served_after', $menuDefinition)) { - $servedAt = $pendingAt->addMinutes((int) $menuDefinition['served_after']); + $delay = max(0, (int) $menuDefinition['served_after']); + $candidate = $pendingAt->addMinutes($delay); + if ($candidate->greaterThan($latestAt)) { + $candidate = $latestAt; + } + $servedAt = $candidate; } elseif ($menuStatus === StepMenuStatus::SERVED) { $servedAt = $stepServedAt ?? $timeline['served_at']; } @@ -3267,12 +3563,33 @@ private function seedStepMenus( 'menu_id' => $menu->id, 'quantity' => $quantity, 'status' => $menuStatus, - 'note' => $menuDefinition['note'] ?? null, + 'note' => $this->sanitizeNote($menuDefinition['note'] ?? null), 'served_at' => $servedAt, ]); } } + private function sanitizeNote(mixed $note): ?string + { + if (! is_string($note)) { + return null; + } + + $trimmed = trim($note); + + if ($trimmed === '') { + return null; + } + + $normalized = mb_strtolower($trimmed); + + if (str_contains($normalized, 'lorem')) { + return null; + } + + return $trimmed; + } + private function defaultStepMenuStatus(OrderStepStatus $status): StepMenuStatus { return match ($status) { From d634394717cbe34c451ff07fec39aff3a2673bd4 Mon Sep 17 00:00:00 2001 From: Adrien Albuquerque Date: Wed, 24 Sep 2025 17:42:19 +0200 Subject: [PATCH 2/5] Refine demo timeline, perishable status and notes --- database/seeders/DemoSeeder.php | 127 +++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index 524f845..10b8815 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -1667,6 +1667,35 @@ private function seedAdditionalCompany(): void private const PERISHABLE_STATUS_SEQUENCE = ['FRESH', 'FRESH', 'SOON', 'EXPIRED']; + private const PLACEHOLDER_NOTE_KEYWORDS = [ + 'lorem', + 'ipsum', + 'dolor', + 'amet', + 'consectetur', + 'adipisci', + 'perspiciatis', + 'voluptatem', + 'voluptatum', + 'architecto', + 'beatae', + 'vitae', + 'quasi', + 'quia', + 'nobis', + 'ullam', + 'laboriosam', + 'repellat', + 'praesentium', + 'deleniti', + 'expedita', + 'magnam', + 'aliquam', + 'fugit', + 'sapiente', + 'fuga', + ]; + private ImageService $images; private OpenFoodFactsService $openFoodFacts; @@ -2606,26 +2635,48 @@ private function configurePerishableStatus(Perishable $perishable, string $statu $expiration = $this->perishableService->expiration($perishable); $shelfLife = max(1, (int) round($expiration->diffInHours($perishable->created_at))); - $createdAt = $now; - $deletedAt = null; + if ($shelfLife <= 0) { + $shelfLife = 24; + } switch ($status) { case 'SOON': - $remaining = max(1, min(12, $shelfLife - 1)); - $age = max(1, $shelfLife - $remaining); - $createdAt = $now->subHours($age); + $targetExpiration = $now->addHours($this->hoursUntilExpirationForSoon($shelfLife)); break; case 'EXPIRED': - $overdue = max(1, min(24, (int) ceil($shelfLife * 0.15))); - $createdAt = $now->subHours($shelfLife + $overdue); - $deletedAt = $now->subHours(max(1, min($overdue, 6))); + $targetExpiration = $now->subHours($this->hoursSinceExpirationForExpired($shelfLife)); break; default: - $age = max(1, min($shelfLife - 1, (int) ceil($shelfLife * 0.3))); - $createdAt = $now->subHours($age); + $targetExpiration = $now->addHours($this->hoursUntilExpirationForFresh($shelfLife)); break; } + if ($status === 'EXPIRED' && $targetExpiration->greaterThan($now)) { + $targetExpiration = $now->subHour(); + } + + if ($status !== 'EXPIRED' && $targetExpiration->lessThanOrEqualTo($now)) { + $targetExpiration = $now->addHour(); + } + + $createdAt = $targetExpiration->subHours($shelfLife); + + if ($createdAt->greaterThan($now)) { + $createdAt = $now->subHour(); + $targetExpiration = $createdAt->addHours($shelfLife); + } + + $deletedAt = null; + + if ($status === 'EXPIRED') { + $cleanupDelay = $this->hoursUntilCleanupForExpired($shelfLife); + $deletedAt = $targetExpiration->addHours($cleanupDelay); + + if ($deletedAt->greaterThanOrEqualTo($now)) { + $deletedAt = $now->subMinutes(30); + } + } + $perishable->forceFill([ 'created_at' => $createdAt, 'updated_at' => $createdAt, @@ -2634,6 +2685,42 @@ private function configurePerishableStatus(Perishable $perishable, string $statu ])->saveQuietly(); } + private function hoursUntilExpirationForFresh(int $shelfLife): int + { + if ($shelfLife <= 1) { + return 1; + } + + $candidate = (int) round($shelfLife * 0.6); + $candidate = max(2, $candidate); + $candidate = min($candidate, max($shelfLife - 1, 1)); + + return max(1, $candidate); + } + + private function hoursUntilExpirationForSoon(int $shelfLife): int + { + $candidate = (int) round(max(1, $shelfLife * 0.1)); + $candidate = max(1, $candidate); + $candidate = min($candidate, min(12, max($shelfLife - 1, 1))); + + return max(1, $candidate); + } + + private function hoursSinceExpirationForExpired(int $shelfLife): int + { + $candidate = (int) round(max(1, $shelfLife * 0.15)); + + return max(1, min($candidate, 48)); + } + + private function hoursUntilCleanupForExpired(int $shelfLife): int + { + $candidate = (int) round(max(1, $shelfLife * 0.05)); + + return max(1, min($candidate, 6)); + } + private function createIngredientMovement( Ingredient $ingredient, int $locationId, @@ -3387,6 +3474,17 @@ private function buildOrderTimeline(OrderStatus $status, array $timelineDefiniti ? max(5, min($desiredMinutesAgo, $available)) : min($desiredMinutesAgo, $available); $minutesAgo = max(1, $minutesAgo); + + if ($status !== OrderStatus::PENDING && $available > 5 && $minutesAgo >= $available) { + $buffer = max(5, min(45, (int) floor($available * 0.2))); + + if ($available > $buffer) { + $minutesAgo = max(5, $available - $buffer); + } else { + $minutesAgo = max(1, $available - 1); + } + } + $referenceAt = $interval['anchor']; } else { $minutesAgo = $desiredMinutesAgo; @@ -3583,7 +3681,14 @@ private function sanitizeNote(mixed $note): ?string $normalized = mb_strtolower($trimmed); - if (str_contains($normalized, 'lorem')) { + static $placeholderPattern = null; + + if ($placeholderPattern === null) { + $escaped = array_map(static fn (string $keyword): string => preg_quote($keyword, '/'), self::PLACEHOLDER_NOTE_KEYWORDS); + $placeholderPattern = '/\\b('.implode('|', $escaped).')\\b/u'; + } + + if ($placeholderPattern !== null && preg_match($placeholderPattern, $normalized) === 1) { return null; } From d0b367bb51697dc52e960c1d4f998076fce7bd0c Mon Sep 17 00:00:00 2001 From: Adrien Albuquerque Date: Wed, 24 Sep 2025 17:50:35 +0200 Subject: [PATCH 3/5] Stop generating placeholder step menu notes --- database/factories/StepMenuFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/factories/StepMenuFactory.php b/database/factories/StepMenuFactory.php index 9503a21..6e99b4c 100644 --- a/database/factories/StepMenuFactory.php +++ b/database/factories/StepMenuFactory.php @@ -34,7 +34,7 @@ public function definition(): array 'menu_id' => Menu::factory()->for($company), 'quantity' => $this->faker->numberBetween(1, 10), 'status' => StepMenuStatus::IN_PREP, - 'note' => $this->faker->optional()->sentence(), + 'note' => null, 'served_at' => null, ]; } From a5ad805662f0c7401a01f7f8780db624b1f1a8f3 Mon Sep 17 00:00:00 2001 From: Adrien Albuquerque Date: Wed, 24 Sep 2025 18:14:25 +0200 Subject: [PATCH 4/5] Improve demo order scheduling and perishable states --- database/seeders/DemoSeeder.php | 300 +++++++++++++++++++++++++------- 1 file changed, 236 insertions(+), 64 deletions(-) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index 10b8815..440235e 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -1667,6 +1667,8 @@ private function seedAdditionalCompany(): void private const PERISHABLE_STATUS_SEQUENCE = ['FRESH', 'FRESH', 'SOON', 'EXPIRED']; + private const CRC32_MAX = 4294967295; + private const PLACEHOLDER_NOTE_KEYWORDS = [ 'lorem', 'ipsum', @@ -1743,6 +1745,11 @@ private function seedAdditionalCompany(): void */ private ?array $businessIntervalsCache = null; + /** + * @var array + */ + private array $orderIntervalSlots = []; + public function __construct( ImageService $images, OpenFoodFactsService $openFoodFacts, @@ -1856,6 +1863,7 @@ private function seedCompanyBusinessHours(Company $company, array $schedule): vo $this->businessHoursSchedule = []; $this->businessIntervalsCache = null; + $this->orderIntervalSlots = []; $records = []; @@ -2048,6 +2056,126 @@ private function businessIntervals(): array return $this->businessIntervalsCache = $intervals; } + private function resetOrderIntervalSlots(): void + { + $this->orderIntervalSlots = []; + + $intervals = $this->businessIntervals(); + + if ($intervals === []) { + return; + } + + $now = CarbonImmutable::now(); + + foreach (array_reverse($intervals) as $interval) { + $start = $interval['start']; + $end = $interval['end']; + + if ($start->greaterThan($now)) { + continue; + } + + $anchor = $end->greaterThan($now) ? $now : $end; + + if ($anchor->lessThanOrEqualTo($start)) { + continue; + } + + $this->orderIntervalSlots[] = [ + 'start' => $start, + 'end' => $end, + 'cursor' => $anchor, + ]; + } + } + + /** + * @return array{reference_at: CarbonImmutable, pending_at: CarbonImmutable, available: int}|null + */ + private function allocateOrderTimelineWindow(int $desiredMinutesAgo, OrderStatus $status): ?array + { + if ($this->orderIntervalSlots === []) { + $this->resetOrderIntervalSlots(); + } + + foreach ($this->orderIntervalSlots as $index => &$slot) { + $start = $slot['start']; + $cursor = $slot['cursor']; + + $available = (int) $start->diffInMinutes($cursor, false); + + if ($available <= 0) { + unset($this->orderIntervalSlots[$index]); + + continue; + } + + $minutesAgo = max(1, min($desiredMinutesAgo, $available)); + + if ($status !== OrderStatus::PENDING && $available > 5 && $minutesAgo >= $available) { + $buffer = max(5, min(45, (int) floor($available * 0.2))); + + if ($available > $buffer) { + $minutesAgo = max(5, $available - $buffer); + } else { + $minutesAgo = max(1, $available - 1); + } + } + + $pendingAt = $cursor->subMinutes($minutesAgo); + + if ($pendingAt->lessThanOrEqualTo($start)) { + $padding = max(1, min($available - 1, (int) floor($available * 0.3))); + $pendingAt = $start->addMinutes($padding); + $minutesAgo = max(1, $cursor->diffInMinutes($pendingAt)); + } + + $referenceAt = $cursor; + $gap = $this->orderTimelineGap($available, $minutesAgo); + $maxGap = max(1, $start->diffInMinutes($pendingAt, false)); + + if ($gap > $maxGap) { + $gap = $maxGap; + } + + $newCursor = $pendingAt->subMinutes($gap); + + if ($newCursor->lessThanOrEqualTo($start)) { + unset($this->orderIntervalSlots[$index]); + } else { + $slot['cursor'] = $newCursor; + } + + $this->orderIntervalSlots = array_values($this->orderIntervalSlots); + + return [ + 'reference_at' => $referenceAt, + 'pending_at' => $pendingAt, + 'available' => $available, + ]; + } + + $this->orderIntervalSlots = array_values($this->orderIntervalSlots); + + return null; + } + + private function orderTimelineGap(int $available, int $minutesAgo): int + { + $minutesAgo = max(1, $minutesAgo); + $available = max($minutesAgo, $available); + + $minGap = max(1, min($minutesAgo, (int) ceil($minutesAgo * 0.25))); + $maxGapCandidate = max($minGap, min($minutesAgo, (int) floor($available * 0.25))); + + if ($maxGapCandidate <= $minGap) { + return $minGap; + } + + return random_int($minGap, $maxGapCandidate); + } + /** * @return array{start: CarbonImmutable, end: CarbonImmutable, anchor: CarbonImmutable, available: int}|null */ @@ -2639,23 +2767,34 @@ private function configurePerishableStatus(Perishable $perishable, string $statu $shelfLife = 24; } - switch ($status) { + $statusKey = strtoupper($status); + + switch ($statusKey) { case 'SOON': - $targetExpiration = $now->addHours($this->hoursUntilExpirationForSoon($shelfLife)); + $ratio = 0.08 + (0.12 * $this->perishableRandomFloat($perishable, 'soon-window')); + $hoursUntil = max(1, (int) round($shelfLife * $ratio)); + $hoursUntil = min($hoursUntil, max(1, (int) ceil($shelfLife * 0.25))); + $targetExpiration = $now->addHours($hoursUntil); break; case 'EXPIRED': - $targetExpiration = $now->subHours($this->hoursSinceExpirationForExpired($shelfLife)); + $ratio = 0.18 + (0.32 * $this->perishableRandomFloat($perishable, 'expired-window')); + $hoursPast = max(1, (int) round($shelfLife * $ratio)); + $hoursPast = min($hoursPast, max(24, (int) ceil($shelfLife * 0.8))); + $targetExpiration = $now->subHours($hoursPast); break; default: - $targetExpiration = $now->addHours($this->hoursUntilExpirationForFresh($shelfLife)); + $ratio = 0.35 + (0.45 * $this->perishableRandomFloat($perishable, 'fresh-window')); + $hoursUntil = max(1, (int) round($shelfLife * $ratio)); + $hoursUntil = min($hoursUntil, max($shelfLife - 1, 1)); + $targetExpiration = $now->addHours($hoursUntil); break; } - if ($status === 'EXPIRED' && $targetExpiration->greaterThan($now)) { + if ($statusKey === 'EXPIRED' && $targetExpiration->greaterThan($now)) { $targetExpiration = $now->subHour(); } - if ($status !== 'EXPIRED' && $targetExpiration->lessThanOrEqualTo($now)) { + if ($statusKey !== 'EXPIRED' && $targetExpiration->lessThanOrEqualTo($now)) { $targetExpiration = $now->addHour(); } @@ -2666,59 +2805,82 @@ private function configurePerishableStatus(Perishable $perishable, string $statu $targetExpiration = $createdAt->addHours($shelfLife); } + $updateRatio = 0.2 + (0.45 * $this->perishableRandomFloat($perishable, $statusKey.'-update')); + $updateHours = max(0, (int) round($shelfLife * $updateRatio)); + $updatedAt = $createdAt->addHours(min($shelfLife, $updateHours)); + $latestUpdate = $targetExpiration->subMinutes(5); + + if ($updatedAt->greaterThan($latestUpdate)) { + $updatedAt = $latestUpdate; + } + + if ($updatedAt->greaterThan($now)) { + $minutesBack = max(1, (int) round($this->perishableRandomFloat($perishable, $statusKey.'-update-back') * 180)); + $updatedAt = $now->subMinutes($minutesBack); + } + + if ($updatedAt->lessThan($createdAt)) { + $updatedAt = $createdAt; + } + $deletedAt = null; - if ($status === 'EXPIRED') { - $cleanupDelay = $this->hoursUntilCleanupForExpired($shelfLife); - $deletedAt = $targetExpiration->addHours($cleanupDelay); + if ($statusKey === 'EXPIRED' && $this->perishableRandomFloat($perishable, 'expired-visible') >= 0.35) { + $cleanupRatio = 0.05 + (0.2 * $this->perishableRandomFloat($perishable, 'expired-cleanup')); + $cleanupHours = max(1, (int) round($shelfLife * $cleanupRatio)); + $deletedAt = $targetExpiration->addHours($cleanupHours); + + $minSpacingMinutes = max(5, min(120, (int) ceil($shelfLife * 0.1))); + $earliestCleanup = $targetExpiration->addMinutes($minSpacingMinutes); + $latestCleanup = $now->subMinutes(2); + + if ($latestCleanup->lessThanOrEqualTo($targetExpiration)) { + $latestCleanup = $targetExpiration->addMinutes($minSpacingMinutes); + } + + if ($earliestCleanup->greaterThan($latestCleanup)) { + $earliestCleanup = $latestCleanup; + } + + if ($deletedAt->lessThan($earliestCleanup)) { + $deletedAt = $earliestCleanup; + } + + if ($deletedAt->greaterThan($latestCleanup)) { + $backMinutes = max(5, (int) round($this->perishableRandomFloat($perishable, 'expired-cleanup-back') * 240)); + $candidate = $now->subMinutes($backMinutes); + + if ($candidate->lessThan($earliestCleanup)) { + $candidate = $earliestCleanup; + } + + if ($candidate->greaterThan($latestCleanup)) { + $candidate = $latestCleanup; + } - if ($deletedAt->greaterThanOrEqualTo($now)) { - $deletedAt = $now->subMinutes(30); + $deletedAt = $candidate; } } $perishable->forceFill([ 'created_at' => $createdAt, - 'updated_at' => $createdAt, + 'updated_at' => $updatedAt, 'deleted_at' => $deletedAt, 'is_read' => $markAsRead, ])->saveQuietly(); } - private function hoursUntilExpirationForFresh(int $shelfLife): int - { - if ($shelfLife <= 1) { - return 1; - } - - $candidate = (int) round($shelfLife * 0.6); - $candidate = max(2, $candidate); - $candidate = min($candidate, max($shelfLife - 1, 1)); - - return max(1, $candidate); - } - - private function hoursUntilExpirationForSoon(int $shelfLife): int + private function perishableRandomFloat(Perishable $perishable, string $context): float { - $candidate = (int) round(max(1, $shelfLife * 0.1)); - $candidate = max(1, $candidate); - $candidate = min($candidate, min(12, max($shelfLife - 1, 1))); + $seed = sprintf('%d|%d|%d|%s', $perishable->id, $perishable->ingredient_id, $perishable->location_id, $context); + $hash = crc32($seed); + $unsigned = (int) sprintf('%u', $hash); - return max(1, $candidate); - } - - private function hoursSinceExpirationForExpired(int $shelfLife): int - { - $candidate = (int) round(max(1, $shelfLife * 0.15)); - - return max(1, min($candidate, 48)); - } - - private function hoursUntilCleanupForExpired(int $shelfLife): int - { - $candidate = (int) round(max(1, $shelfLife * 0.05)); + if ($unsigned <= 0) { + return 0.0; + } - return max(1, min($candidate, 6)); + return min(0.999999, $unsigned / self::CRC32_MAX); } private function createIngredientMovement( @@ -3329,6 +3491,8 @@ private function seedOrders(Company $company): void Order::query()->where('company_id', $company->id)->delete(); + $this->resetOrderIntervalSlots(); + $menuMap = Menu::query() ->where('company_id', $company->id) ->get() @@ -3466,33 +3630,41 @@ private function ensureTables(Company $company, array $rooms): array private function buildOrderTimeline(OrderStatus $status, array $timelineDefinition): array { $desiredMinutesAgo = max(5, (int) ($timelineDefinition['minutes_ago'] ?? 60)); - $interval = $this->resolveOrderInterval($desiredMinutesAgo); - - if ($interval !== null) { - $available = max(1, (int) $interval['available']); - $minutesAgo = $available >= 5 - ? max(5, min($desiredMinutesAgo, $available)) - : min($desiredMinutesAgo, $available); - $minutesAgo = max(1, $minutesAgo); - - if ($status !== OrderStatus::PENDING && $available > 5 && $minutesAgo >= $available) { - $buffer = max(5, min(45, (int) floor($available * 0.2))); + $allocation = $this->allocateOrderTimelineWindow($desiredMinutesAgo, $status); - if ($available > $buffer) { - $minutesAgo = max(5, $available - $buffer); - } else { - $minutesAgo = max(1, $available - 1); + if ($allocation !== null) { + $referenceAt = $allocation['reference_at']; + $pendingAt = $allocation['pending_at']; + $minutesAgo = max(1, $referenceAt->diffInMinutes($pendingAt)); + } else { + $interval = $this->resolveOrderInterval($desiredMinutesAgo); + + if ($interval !== null) { + $available = max(1, (int) $interval['available']); + $minutesAgo = $available >= 5 + ? max(5, min($desiredMinutesAgo, $available)) + : min($desiredMinutesAgo, $available); + $minutesAgo = max(1, $minutesAgo); + + if ($status !== OrderStatus::PENDING && $available > 5 && $minutesAgo >= $available) { + $buffer = max(5, min(45, (int) floor($available * 0.2))); + + if ($available > $buffer) { + $minutesAgo = max(5, $available - $buffer); + } else { + $minutesAgo = max(1, $available - 1); + } } + + $referenceAt = $interval['anchor']; + } else { + $minutesAgo = $desiredMinutesAgo; + $referenceAt = CarbonImmutable::now(); } - $referenceAt = $interval['anchor']; - } else { - $minutesAgo = $desiredMinutesAgo; - $referenceAt = CarbonImmutable::now(); + $pendingAt = $referenceAt->subMinutes($minutesAgo); } - $pendingAt = $referenceAt->subMinutes($minutesAgo); - $servedAt = null; $payedAt = null; $canceledAt = null; From 3f7ff3d08fac499d6ecb3decf6a3fcb994219adb Mon Sep 17 00:00:00 2001 From: Adrien Albuquerque Date: Wed, 24 Sep 2025 18:47:14 +0200 Subject: [PATCH 5/5] Refine demo order timelines and perishable states --- database/seeders/DemoSeeder.php | 496 ++++++++++++++++++++------------ 1 file changed, 305 insertions(+), 191 deletions(-) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index 440235e..2bd672b 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -1746,9 +1746,14 @@ private function seedAdditionalCompany(): void private ?array $businessIntervalsCache = null; /** - * @var array + * @var array */ - private array $orderIntervalSlots = []; + private array $historicalOrderWindows = []; + + /** + * @var array{start: CarbonImmutable, end: CarbonImmutable, cursor: CarbonImmutable, span: int}|null + */ + private ?array $activeOrderWindow = null; public function __construct( ImageService $images, @@ -2056,9 +2061,10 @@ private function businessIntervals(): array return $this->businessIntervalsCache = $intervals; } - private function resetOrderIntervalSlots(): void + private function prepareOrderWindows(): void { - $this->orderIntervalSlots = []; + $this->historicalOrderWindows = []; + $this->activeOrderWindow = null; $intervals = $this->businessIntervals(); @@ -2067,8 +2073,10 @@ private function resetOrderIntervalSlots(): void } $now = CarbonImmutable::now(); + $historical = []; + $active = null; - foreach (array_reverse($intervals) as $interval) { + foreach ($intervals as $interval) { $start = $interval['start']; $end = $interval['end']; @@ -2076,176 +2084,135 @@ private function resetOrderIntervalSlots(): void continue; } - $anchor = $end->greaterThan($now) ? $now : $end; + $cursor = $end->greaterThan($now) ? $now : $end; - if ($anchor->lessThanOrEqualTo($start)) { + if ($cursor->lessThanOrEqualTo($start)) { continue; } - $this->orderIntervalSlots[] = [ + $span = max(1, $start->diffInMinutes($end)); + + $window = [ 'start' => $start, 'end' => $end, - 'cursor' => $anchor, + 'cursor' => $cursor, + 'span' => $span, ]; - } - } - /** - * @return array{reference_at: CarbonImmutable, pending_at: CarbonImmutable, available: int}|null - */ - private function allocateOrderTimelineWindow(int $desiredMinutesAgo, OrderStatus $status): ?array - { - if ($this->orderIntervalSlots === []) { - $this->resetOrderIntervalSlots(); - } - - foreach ($this->orderIntervalSlots as $index => &$slot) { - $start = $slot['start']; - $cursor = $slot['cursor']; + if ($active === null && $now->betweenIncluded($start, $end)) { + $active = $window; - $available = (int) $start->diffInMinutes($cursor, false); + continue; + } - if ($available <= 0) { - unset($this->orderIntervalSlots[$index]); + $historical[] = $window; + } - continue; + usort($historical, function (array $a, array $b): int { + if ($a['cursor']->equalTo($b['cursor'])) { + return 0; } - $minutesAgo = max(1, min($desiredMinutesAgo, $available)); + return $a['cursor']->lessThan($b['cursor']) ? 1 : -1; + }); - if ($status !== OrderStatus::PENDING && $available > 5 && $minutesAgo >= $available) { - $buffer = max(5, min(45, (int) floor($available * 0.2))); + if ($active === null && $historical !== []) { + $active = array_shift($historical); + } - if ($available > $buffer) { - $minutesAgo = max(5, $available - $buffer); - } else { - $minutesAgo = max(1, $available - 1); - } - } + $this->activeOrderWindow = $active; + $this->historicalOrderWindows = $historical; + } - $pendingAt = $cursor->subMinutes($minutesAgo); + /** + * @return array{start: CarbonImmutable, end: CarbonImmutable, cursor: CarbonImmutable, span: int, type: string}|null + */ + private function claimOrderWindow(OrderStatus $status): ?array + { + if ($this->activeOrderWindow === null && $this->historicalOrderWindows === []) { + $this->prepareOrderWindows(); + } - if ($pendingAt->lessThanOrEqualTo($start)) { - $padding = max(1, min($available - 1, (int) floor($available * 0.3))); - $pendingAt = $start->addMinutes($padding); - $minutesAgo = max(1, $cursor->diffInMinutes($pendingAt)); + if ($status === OrderStatus::PENDING) { + if ($this->activeOrderWindow === null) { + $this->prepareOrderWindows(); } - $referenceAt = $cursor; - $gap = $this->orderTimelineGap($available, $minutesAgo); - $maxGap = max(1, $start->diffInMinutes($pendingAt, false)); - - if ($gap > $maxGap) { - $gap = $maxGap; + if ($this->activeOrderWindow === null) { + return null; } - $newCursor = $pendingAt->subMinutes($gap); + return $this->activeOrderWindow + ['type' => 'active']; + } - if ($newCursor->lessThanOrEqualTo($start)) { - unset($this->orderIntervalSlots[$index]); - } else { - $slot['cursor'] = $newCursor; + if ($this->historicalOrderWindows === []) { + if ($this->activeOrderWindow !== null) { + return $this->activeOrderWindow + ['type' => 'active']; } - $this->orderIntervalSlots = array_values($this->orderIntervalSlots); - - return [ - 'reference_at' => $referenceAt, - 'pending_at' => $pendingAt, - 'available' => $available, - ]; + return null; } - $this->orderIntervalSlots = array_values($this->orderIntervalSlots); + $window = array_shift($this->historicalOrderWindows); + $window['type'] = 'historical'; - return null; + return $window; } - private function orderTimelineGap(int $available, int $minutesAgo): int + private function releaseOrderWindow(array $window): void { - $minutesAgo = max(1, $minutesAgo); - $available = max($minutesAgo, $available); - - $minGap = max(1, min($minutesAgo, (int) ceil($minutesAgo * 0.25))); - $maxGapCandidate = max($minGap, min($minutesAgo, (int) floor($available * 0.25))); - - if ($maxGapCandidate <= $minGap) { - return $minGap; - } - - return random_int($minGap, $maxGapCandidate); - } + $type = $window['type'] ?? 'historical'; + unset($window['type']); - /** - * @return array{start: CarbonImmutable, end: CarbonImmutable, anchor: CarbonImmutable, available: int}|null - */ - private function resolveOrderInterval(int $desiredMinutes): ?array - { - $intervals = $this->businessIntervals(); + if ($type === 'active') { + $this->activeOrderWindow = $window; - if ($intervals === []) { - return null; + return; } - $now = CarbonImmutable::now(); - $candidates = []; + $remaining = $window['start']->diffInMinutes($window['cursor'], false); + $threshold = max(10, (int) floor($window['span'] * 0.25)); - foreach ($intervals as $interval) { - if ($interval['start']->greaterThan($now)) { - continue; - } + if ($remaining > $threshold) { + $this->historicalOrderWindows[] = $window; - $anchor = $interval['end']; - - if ($now->betweenIncluded($interval['start'], $interval['end'])) { - $anchor = $now; - } elseif ($interval['end']->greaterThan($now)) { - $anchor = $now; - } - - if ($anchor->lessThanOrEqualTo($interval['start'])) { - continue; - } + usort($this->historicalOrderWindows, function (array $a, array $b): int { + if ($a['cursor']->equalTo($b['cursor'])) { + return 0; + } - $available = $interval['start']->diffInMinutes($anchor); + return $a['cursor']->lessThan($b['cursor']) ? 1 : -1; + }); + } + } - if ($available <= 0) { - continue; - } + private function orderTimelineGap(int $available, int $elapsed, string $orderKey): int + { + $elapsed = max(1, $elapsed); + $available = max($elapsed, $available); - $candidates[] = [ - 'start' => $interval['start'], - 'end' => $interval['end'], - 'anchor' => $anchor, - 'available' => $available, - ]; - } + $minGap = max(4, min($elapsed, (int) ceil($elapsed * 0.3))); + $maxGapCandidate = max($minGap + 1, min($available, (int) floor($available * 0.35))); - if ($candidates === []) { - return null; + if ($maxGapCandidate <= $minGap) { + return $minGap; } - usort($candidates, function (array $a, array $b): int { - if ($a['anchor']->equalTo($b['anchor'])) { - return 0; - } + $ratio = $this->orderRandomFloat($orderKey.'|gap'); - return $a['anchor']->lessThan($b['anchor']) ? 1 : -1; - }); - - $bestByAvailability = $candidates[0]; + return $minGap + (int) round(($maxGapCandidate - $minGap) * $ratio); + } - foreach ($candidates as $candidate) { - if ($candidate['available'] > $bestByAvailability['available']) { - $bestByAvailability = $candidate; - } + private function orderRandomFloat(string $seed): float + { + $hash = crc32($seed); + $unsigned = (int) sprintf('%u', $hash); - if ($candidate['available'] >= $desiredMinutes) { - return $candidate; - } + if ($unsigned <= 0) { + return 0.5; } - return $bestByAvailability; + return min(0.999999, $unsigned / self::CRC32_MAX); } /** @@ -2777,9 +2744,15 @@ private function configurePerishableStatus(Perishable $perishable, string $statu $targetExpiration = $now->addHours($hoursUntil); break; case 'EXPIRED': - $ratio = 0.18 + (0.32 * $this->perishableRandomFloat($perishable, 'expired-window')); - $hoursPast = max(1, (int) round($shelfLife * $ratio)); - $hoursPast = min($hoursPast, max(24, (int) ceil($shelfLife * 0.8))); + $minExpiredHours = max(12, (int) round($shelfLife * 0.5)); + $maxExpiredHours = max($minExpiredHours + 12, (int) round($shelfLife * 1.25), 72); + $spread = max(0, $maxExpiredHours - $minExpiredHours); + $hoursPast = $minExpiredHours; + + if ($spread > 0) { + $hoursPast += (int) round($spread * $this->perishableRandomFloat($perishable, 'expired-window')); + } + $targetExpiration = $now->subHours($hoursPast); break; default: @@ -3491,7 +3464,7 @@ private function seedOrders(Company $company): void Order::query()->where('company_id', $company->id)->delete(); - $this->resetOrderIntervalSlots(); + $this->prepareOrderWindows(); $menuMap = Menu::query() ->where('company_id', $company->id) @@ -3503,7 +3476,7 @@ private function seedOrders(Company $company): void /** @var User|null $defaultUser */ $defaultUser = $users->first(); - foreach (self::ORDER_BLUEPRINTS as $definition) { + foreach (self::ORDER_BLUEPRINTS as $index => $definition) { $tableLabel = $definition['table'] ?? null; if (! is_string($tableLabel) || $tableLabel === '') { continue; @@ -3519,7 +3492,8 @@ private function seedOrders(Company $company): void $status = OrderStatus::from((string) $status); } - $timeline = $this->buildOrderTimeline($status, $definition['timeline'] ?? []); + $orderKey = sprintf('%s|%s|%d', $tableLabel, $status->value, (int) $index); + $timeline = $this->buildOrderTimeline($status, $definition['timeline'] ?? [], $orderKey); $userEmail = $definition['user'] ?? null; $user = is_string($userEmail) && $userEmail !== '' @@ -3627,91 +3601,231 @@ private function ensureTables(Company $company, array $rooms): array * reference_at: CarbonImmutable, * } */ - private function buildOrderTimeline(OrderStatus $status, array $timelineDefinition): array + private function buildOrderTimeline(OrderStatus $status, array $timelineDefinition, string $orderKey): array { $desiredMinutesAgo = max(5, (int) ($timelineDefinition['minutes_ago'] ?? 60)); - $allocation = $this->allocateOrderTimelineWindow($desiredMinutesAgo, $status); + $window = $this->claimOrderWindow($status); - if ($allocation !== null) { - $referenceAt = $allocation['reference_at']; - $pendingAt = $allocation['pending_at']; - $minutesAgo = max(1, $referenceAt->diffInMinutes($pendingAt)); - } else { - $interval = $this->resolveOrderInterval($desiredMinutesAgo); - - if ($interval !== null) { - $available = max(1, (int) $interval['available']); - $minutesAgo = $available >= 5 - ? max(5, min($desiredMinutesAgo, $available)) - : min($desiredMinutesAgo, $available); - $minutesAgo = max(1, $minutesAgo); - - if ($status !== OrderStatus::PENDING && $available > 5 && $minutesAgo >= $available) { - $buffer = max(5, min(45, (int) floor($available * 0.2))); - - if ($available > $buffer) { - $minutesAgo = max(5, $available - $buffer); - } else { - $minutesAgo = max(1, $available - 1); - } - } + if ($window === null) { + return $this->buildFallbackTimeline($status, $timelineDefinition, $orderKey, $desiredMinutesAgo); + } - $referenceAt = $interval['anchor']; - } else { - $minutesAgo = $desiredMinutesAgo; - $referenceAt = CarbonImmutable::now(); + $start = $window['start']; + $referenceAt = $window['cursor']; + $available = max(1, $start->diffInMinutes($referenceAt)); + + if ($available <= 5) { + $this->releaseOrderWindow($window); + + return $this->buildFallbackTimeline($status, $timelineDefinition, $orderKey, $desiredMinutesAgo); + } + + $buffer = max(5, min(30, (int) floor($available * 0.15))); + $elapsed = min($desiredMinutesAgo, max(5, $available - $buffer)); + + if ($elapsed >= $available) { + $elapsed = max(5, $available - $buffer); + } + + if ($elapsed <= 0) { + $elapsed = max(5, min($desiredMinutesAgo, $available - 1)); + } + + $pendingAt = $referenceAt->subMinutes($elapsed); + $startPadding = max(5, min(45, (int) floor($available * 0.25))); + + if ($pendingAt->lessThanOrEqualTo($start->addMinutes(2))) { + $pendingAt = $start->addMinutes($startPadding); + + if ($pendingAt->greaterThanOrEqualTo($referenceAt)) { + $pendingAt = $start->addMinutes(max(5, min($startPadding, $available - 5))); + $referenceAt = $pendingAt->addMinutes(max(5, $buffer)); } - $pendingAt = $referenceAt->subMinutes($minutesAgo); + $elapsed = max(1, $referenceAt->diffInMinutes($pendingAt)); + } + + if ($referenceAt->lessThanOrEqualTo($pendingAt)) { + $referenceAt = $pendingAt->addMinutes(max(5, min($available, $buffer))); + } + + $available = max(1, $start->diffInMinutes($referenceAt)); + $elapsed = max(1, $referenceAt->diffInMinutes($pendingAt)); + $window['cursor'] = $pendingAt->subMinutes($this->orderTimelineGap($available, $elapsed, $orderKey)); + + if ($window['cursor']->lessThanOrEqualTo($start->addMinutes(5))) { + $window['cursor'] = $start->addMinutes(5); } + $this->releaseOrderWindow($window); + + [$servedAt, $payedAt, $canceledAt] = $this->computeOrderMilestones( + $status, + $timelineDefinition, + $pendingAt, + $referenceAt, + $orderKey + ); + + return [ + 'pending_at' => $pendingAt, + 'served_at' => $servedAt, + 'payed_at' => $payedAt, + 'canceled_at' => $canceledAt, + 'reference_at' => $referenceAt, + ]; + } + + private function buildFallbackTimeline( + OrderStatus $status, + array $timelineDefinition, + string $orderKey, + int $desiredMinutesAgo + ): array { + $offset = (int) round($this->orderRandomFloat($orderKey.'|fallback-anchor') * 60); + $referenceAt = CarbonImmutable::now()->subMinutes($offset); + $pendingAt = $referenceAt->subMinutes($desiredMinutesAgo); + + if ($pendingAt->greaterThanOrEqualTo($referenceAt)) { + $pendingAt = $referenceAt->subMinutes(max(5, $desiredMinutesAgo)); + } + + if ($pendingAt->greaterThanOrEqualTo($referenceAt)) { + $referenceAt = $pendingAt->addMinutes(max(5, $desiredMinutesAgo)); + } + + if ($pendingAt->greaterThanOrEqualTo($referenceAt)) { + $referenceAt = CarbonImmutable::now(); + $pendingAt = $referenceAt->subMinutes(max(5, $desiredMinutesAgo)); + } + + [$servedAt, $payedAt, $canceledAt] = $this->computeOrderMilestones( + $status, + $timelineDefinition, + $pendingAt, + $referenceAt, + $orderKey + ); + + return [ + 'pending_at' => $pendingAt, + 'served_at' => $servedAt, + 'payed_at' => $payedAt, + 'canceled_at' => $canceledAt, + 'reference_at' => $referenceAt, + ]; + } + + /** + * @return array{CarbonImmutable|null, CarbonImmutable|null, CarbonImmutable|null} + */ + private function computeOrderMilestones( + OrderStatus $status, + array $timelineDefinition, + CarbonImmutable $pendingAt, + CarbonImmutable $referenceAt, + string $orderKey + ): array { + $windowMinutes = max(1, $referenceAt->diffInMinutes($pendingAt)); + $buffer = max(2, min(20, (int) floor($windowMinutes * 0.18))); + $servedAt = null; $payedAt = null; $canceledAt = null; if (in_array($status, [OrderStatus::SERVED, OrderStatus::PAYED], true)) { - $servedDelay = max(1, (int) ($timelineDefinition['served_after'] ?? 30)); - $servedDelay = min($servedDelay, max(1, $minutesAgo)); + $servedDelay = $timelineDefinition['served_after'] ?? null; + + if ($servedDelay === null) { + $servedDelay = (int) round(max( + 5, + min( + $windowMinutes - 2, + $windowMinutes * (0.5 + (0.3 * $this->orderRandomFloat($orderKey.'|served'))) + ) + )); + } + + $servedDelay = max(3, min((int) $servedDelay, max(1, $windowMinutes - 1))); $servedAt = $pendingAt->addMinutes($servedDelay); + + $latestServed = $referenceAt->subMinutes(max(2, min($buffer, 12))); + + if ($latestServed->lessThanOrEqualTo($pendingAt)) { + $latestServed = $pendingAt->addMinutes(max(2, min($windowMinutes - 1, 5))); + } + + if ($servedAt->greaterThan($latestServed)) { + $servedAt = $latestServed; + } + + if ($servedAt->lessThanOrEqualTo($pendingAt)) { + $servedAt = $pendingAt->addMinutes(max(2, min($windowMinutes - 1, 5))); + } } if ($status === OrderStatus::PAYED) { - $reference = $servedAt ?? $pendingAt; - $availableAfterReference = $reference->lessThan($referenceAt) - ? $reference->diffInMinutes($referenceAt) - : 0; + $base = $servedAt ?? $pendingAt; + $afterBase = max(1, $referenceAt->diffInMinutes($base)); + $payedDelay = $timelineDefinition['payed_after'] ?? null; + + if ($payedDelay === null) { + $payedDelay = (int) round(max( + 3, + min( + $afterBase - 1, + $afterBase * (0.45 + (0.35 * $this->orderRandomFloat($orderKey.'|payed'))) + ) + )); + } + + $payedDelay = max(2, min((int) $payedDelay, max(1, $afterBase - 1))); + $payedAt = $base->addMinutes($payedDelay); - $payedDelay = max(1, (int) ($timelineDefinition['payed_after'] ?? 20)); + $latestPayed = $referenceAt->subMinutes(max(1, min($buffer, 6))); - if ($availableAfterReference <= 0) { - $payedDelay = 0; - } elseif ($payedDelay > $availableAfterReference) { - $payedDelay = max(1, $availableAfterReference); + if ($latestPayed->lessThanOrEqualTo($base)) { + $latestPayed = $base->addMinutes(max(2, min($afterBase - 1, 5))); } - $payedAt = $payedDelay > 0 ? $reference->addMinutes($payedDelay) : $reference; + if ($payedAt->greaterThan($latestPayed)) { + $payedAt = $latestPayed; + } + + if ($payedAt->lessThanOrEqualTo($base)) { + $payedAt = $base->addMinutes(max(2, min($afterBase - 1, 4))); + } } if ($status === OrderStatus::CANCELED) { - $availableAfterPending = $minutesAgo; - $canceledDelay = max(1, (int) ($timelineDefinition['canceled_after'] ?? 15)); + $availableWindow = max(1, $referenceAt->diffInMinutes($pendingAt)); + $canceledDelay = $timelineDefinition['canceled_after'] ?? null; - if ($availableAfterPending <= 0) { - $canceledDelay = 0; - } elseif ($canceledDelay > $availableAfterPending) { - $canceledDelay = max(1, $availableAfterPending); + if ($canceledDelay === null) { + $canceledDelay = (int) round(max( + 2, + min( + $availableWindow - 1, + $availableWindow * (0.35 + (0.3 * $this->orderRandomFloat($orderKey.'|canceled'))) + ) + )); } - $canceledAt = $canceledDelay > 0 ? $pendingAt->addMinutes($canceledDelay) : $pendingAt; + $canceledDelay = max(2, min((int) $canceledDelay, max(1, $availableWindow - 1))); + $canceledAt = $pendingAt->addMinutes($canceledDelay); + + $latestCancel = $referenceAt->subMinutes(1); + + if ($canceledAt->greaterThan($latestCancel)) { + $canceledAt = $latestCancel; + } + + if ($canceledAt->lessThanOrEqualTo($pendingAt)) { + $canceledAt = $pendingAt->addMinutes(max(1, min($availableWindow - 1, 3))); + } } - return [ - 'pending_at' => $pendingAt, - 'served_at' => $servedAt, - 'payed_at' => $payedAt, - 'canceled_at' => $canceledAt, - 'reference_at' => $referenceAt, - ]; + return [$servedAt, $payedAt, $canceledAt]; } /** @@ -3857,7 +3971,7 @@ private function sanitizeNote(mixed $note): ?string if ($placeholderPattern === null) { $escaped = array_map(static fn (string $keyword): string => preg_quote($keyword, '/'), self::PLACEHOLDER_NOTE_KEYWORDS); - $placeholderPattern = '/\\b('.implode('|', $escaped).')\\b/u'; + $placeholderPattern = '/('.implode('|', $escaped).')/u'; } if ($placeholderPattern !== null && preg_match($placeholderPattern, $normalized) === 1) {