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, ]; } diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index beeb365..2bd672b 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,45 @@ private function seedAdditionalCompany(): void 'Coulis de framboise' => 6, ]; + private const PERISHABLE_STATUS_SEQUENCE = ['FRESH', 'FRESH', 'SOON', 'EXPIRED']; + + private const CRC32_MAX = 4294967295; + + 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; + private PerishableService $perishableService; + /** @var array */ private array $missingIngredients = []; @@ -1698,10 +1735,34 @@ 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; + + /** + * @var array + */ + private array $historicalOrderWindows = []; + + /** + * @var array{start: CarbonImmutable, end: CarbonImmutable, cursor: CarbonImmutable, span: int}|null + */ + private ?array $activeOrderWindow = 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 +1812,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 +1866,10 @@ private function seedCompanyBusinessHours(Company $company, array $schedule): vo { $company->businessHours()->delete(); + $this->businessHoursSchedule = []; + $this->businessIntervalsCache = null; + $this->orderIntervalSlots = []; + $records = []; foreach ($schedule as $day => $entries) { @@ -1833,6 +1899,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 +2006,215 @@ 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; + } + + private function prepareOrderWindows(): void + { + $this->historicalOrderWindows = []; + $this->activeOrderWindow = null; + + $intervals = $this->businessIntervals(); + + if ($intervals === []) { + return; + } + + $now = CarbonImmutable::now(); + $historical = []; + $active = null; + + foreach ($intervals as $interval) { + $start = $interval['start']; + $end = $interval['end']; + + if ($start->greaterThan($now)) { + continue; + } + + $cursor = $end->greaterThan($now) ? $now : $end; + + if ($cursor->lessThanOrEqualTo($start)) { + continue; + } + + $span = max(1, $start->diffInMinutes($end)); + + $window = [ + 'start' => $start, + 'end' => $end, + 'cursor' => $cursor, + 'span' => $span, + ]; + + if ($active === null && $now->betweenIncluded($start, $end)) { + $active = $window; + + continue; + } + + $historical[] = $window; + } + + usort($historical, function (array $a, array $b): int { + if ($a['cursor']->equalTo($b['cursor'])) { + return 0; + } + + return $a['cursor']->lessThan($b['cursor']) ? 1 : -1; + }); + + if ($active === null && $historical !== []) { + $active = array_shift($historical); + } + + $this->activeOrderWindow = $active; + $this->historicalOrderWindows = $historical; + } + + /** + * @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 ($status === OrderStatus::PENDING) { + if ($this->activeOrderWindow === null) { + $this->prepareOrderWindows(); + } + + if ($this->activeOrderWindow === null) { + return null; + } + + return $this->activeOrderWindow + ['type' => 'active']; + } + + if ($this->historicalOrderWindows === []) { + if ($this->activeOrderWindow !== null) { + return $this->activeOrderWindow + ['type' => 'active']; + } + + return null; + } + + $window = array_shift($this->historicalOrderWindows); + $window['type'] = 'historical'; + + return $window; + } + + private function releaseOrderWindow(array $window): void + { + $type = $window['type'] ?? 'historical'; + unset($window['type']); + + if ($type === 'active') { + $this->activeOrderWindow = $window; + + return; + } + + $remaining = $window['start']->diffInMinutes($window['cursor'], false); + $threshold = max(10, (int) floor($window['span'] * 0.25)); + + if ($remaining > $threshold) { + $this->historicalOrderWindows[] = $window; + + usort($this->historicalOrderWindows, function (array $a, array $b): int { + if ($a['cursor']->equalTo($b['cursor'])) { + return 0; + } + + return $a['cursor']->lessThan($b['cursor']) ? 1 : -1; + }); + } + } + + private function orderTimelineGap(int $available, int $elapsed, string $orderKey): int + { + $elapsed = max(1, $elapsed); + $available = max($elapsed, $available); + + $minGap = max(4, min($elapsed, (int) ceil($elapsed * 0.3))); + $maxGapCandidate = max($minGap + 1, min($available, (int) floor($available * 0.35))); + + if ($maxGapCandidate <= $minGap) { + return $minGap; + } + + $ratio = $this->orderRandomFloat($orderKey.'|gap'); + + return $minGap + (int) round(($maxGapCandidate - $minGap) * $ratio); + } + + private function orderRandomFloat(string $seed): float + { + $hash = crc32($seed); + $unsigned = (int) sprintf('%u', $hash); + + if ($unsigned <= 0) { + return 0.5; + } + + return min(0.999999, $unsigned / self::CRC32_MAX); + } + /** * @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))); + + if ($shelfLife <= 0) { + $shelfLife = 24; + } + + $statusKey = strtoupper($status); + + switch ($statusKey) { + case 'SOON': + $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': + $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: + $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 ($statusKey === 'EXPIRED' && $targetExpiration->greaterThan($now)) { + $targetExpiration = $now->subHour(); + } + + if ($statusKey !== 'EXPIRED' && $targetExpiration->lessThanOrEqualTo($now)) { + $targetExpiration = $now->addHour(); + } + + $createdAt = $targetExpiration->subHours($shelfLife); + + if ($createdAt->greaterThan($now)) { + $createdAt = $now->subHour(); + $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 ($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; + } + + $deletedAt = $candidate; + } + } + + $perishable->forceFill([ + 'created_at' => $createdAt, + 'updated_at' => $updatedAt, + 'deleted_at' => $deletedAt, + 'is_read' => $markAsRead, + ])->saveQuietly(); + } + + private function perishableRandomFloat(Perishable $perishable, string $context): float + { + $seed = sprintf('%d|%d|%d|%s', $perishable->id, $perishable->ingredient_id, $perishable->location_id, $context); + $hash = crc32($seed); + $unsigned = (int) sprintf('%u', $hash); + + if ($unsigned <= 0) { + return 0.0; + } + + return min(0.999999, $unsigned / self::CRC32_MAX); + } + private function createIngredientMovement( Ingredient $ingredient, int $locationId, @@ -2998,6 +3464,8 @@ private function seedOrders(Company $company): void Order::query()->where('company_id', $company->id)->delete(); + $this->prepareOrderWindows(); + $menuMap = Menu::query() ->where('company_id', $company->id) ->get() @@ -3008,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; @@ -3024,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 !== '' @@ -3129,39 +3598,234 @@ 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 + private function buildOrderTimeline(OrderStatus $status, array $timelineDefinition, string $orderKey): array { - $minutesAgo = max(5, (int) ($timelineDefinition['minutes_ago'] ?? 60)); - $pendingAt = CarbonImmutable::now()->subMinutes($minutesAgo); + $desiredMinutesAgo = max(5, (int) ($timelineDefinition['minutes_ago'] ?? 60)); + $window = $this->claimOrderWindow($status); + + if ($window === null) { + return $this->buildFallbackTimeline($status, $timelineDefinition, $orderKey, $desiredMinutesAgo); + } + + $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)); + } + + $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 = $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; - $payedDelay = max(1, (int) ($timelineDefinition['payed_after'] ?? 20)); - $payedAt = $reference->addMinutes($payedDelay); + $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); + + $latestPayed = $referenceAt->subMinutes(max(1, min($buffer, 6))); + + if ($latestPayed->lessThanOrEqualTo($base)) { + $latestPayed = $base->addMinutes(max(2, min($afterBase - 1, 5))); + } + + if ($payedAt->greaterThan($latestPayed)) { + $payedAt = $latestPayed; + } + + if ($payedAt->lessThanOrEqualTo($base)) { + $payedAt = $base->addMinutes(max(2, min($afterBase - 1, 4))); + } } if ($status === OrderStatus::CANCELED) { - $canceledDelay = max(1, (int) ($timelineDefinition['canceled_after'] ?? 15)); + $availableWindow = max(1, $referenceAt->diffInMinutes($pendingAt)); + $canceledDelay = $timelineDefinition['canceled_after'] ?? null; + + if ($canceledDelay === null) { + $canceledDelay = (int) round(max( + 2, + min( + $availableWindow - 1, + $availableWindow * (0.35 + (0.3 * $this->orderRandomFloat($orderKey.'|canceled'))) + ) + )); + } + + $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, - ]; + return [$servedAt, $payedAt, $canceledAt]; } /** @@ -3171,6 +3835,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 +3844,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 +3857,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 +3885,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 +3900,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 +3932,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 +3947,40 @@ 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); + + static $placeholderPattern = null; + + if ($placeholderPattern === null) { + $escaped = array_map(static fn (string $keyword): string => preg_quote($keyword, '/'), self::PLACEHOLDER_NOTE_KEYWORDS); + $placeholderPattern = '/('.implode('|', $escaped).')/u'; + } + + if ($placeholderPattern !== null && preg_match($placeholderPattern, $normalized) === 1) { + return null; + } + + return $trimmed; + } + private function defaultStepMenuStatus(OrderStepStatus $status): StepMenuStatus { return match ($status) {