RouteOptimizer.php 4.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service\routing;
  4. use plugin\saiadmin\exception\ApiException;
  5. /** 固定出发点、不返回仓库;对最多8站穷举,求给定有向道路矩阵下的最优站序。 */
  6. final class RouteOptimizer
  7. {
  8. public static function strategy(string $name): RouteStrategy
  9. {
  10. return match ($name) {
  11. 'shortest' => new ShortestDistanceStrategy(),
  12. 'fastest' => new FastestArrivalStrategy(),
  13. 'deadline' => new DeadlinePriorityStrategy(),
  14. default => throw new ApiException('不支持的线路优化策略'),
  15. };
  16. }
  17. public function optimize(array $tasks, array $matrix, string $strategy, string $departure, int $serviceMinutes): array
  18. {
  19. $policy = self::strategy($strategy);
  20. $count = count($tasks);
  21. if ($count < 1 || $count > 8) throw new ApiException('每条路线支持1至8个配送站点,请拆分批次');
  22. if ($serviceMinutes < 0 || $serviceMinutes > 120) throw new ApiException('每站配送停留须为0至120分钟');
  23. $date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $departure, new \DateTimeZone('Asia/Shanghai'));
  24. if (!$date || $date->format('Y-m-d H:i:s') !== $departure) throw new ApiException('出发时间格式不正确');
  25. if (!array_is_list($matrix) || count($matrix) !== $count + 1) throw new ApiException('道路矩阵与站点数量不符');
  26. foreach ($matrix as $row) {
  27. if (!is_array($row) || !array_is_list($row) || count($row) !== $count + 1) throw new ApiException('道路矩阵不完整');
  28. foreach ($row as $edge) {
  29. if ($edge === null) continue; // 无道路连接,不用直线替代。
  30. if (!is_array($edge)) throw new ApiException('道路指标格式不正确');
  31. foreach (['distance', 'time'] as $key) {
  32. if (!isset($edge[$key]) || !is_numeric($edge[$key]) || !is_finite((float)$edge[$key]) || $edge[$key] < 0 || $edge[$key] > 100000000) throw new ApiException('道路距离或时间无效');
  33. }
  34. }
  35. }
  36. $departureTimestamp = $date->getTimestamp();
  37. $deadlines = [];
  38. foreach ($tasks as $task) {
  39. $value = $task['deadline_at'] ?? null;
  40. $deadlines[] = $value ? (new \DateTimeImmutable($value, new \DateTimeZone('Asia/Shanghai')))->getTimestamp() : null;
  41. }
  42. $evaluate = static function (array $order) use ($tasks, $matrix, $departureTimestamp, $deadlines, $serviceMinutes): ?array {
  43. $metrics = ['distance'=>0, 'drive_seconds'=>0, 'duration'=>0, 'late_count'=>0, 'late_seconds'=>0];
  44. $stops = []; $previous = 0;
  45. foreach ($order as $index) {
  46. $edge = $matrix[$previous][$index + 1];
  47. if ($edge === null) return null;
  48. $metrics['distance'] += (float)$edge['distance'];
  49. $metrics['drive_seconds'] += (float)$edge['time'];
  50. $arrival = $departureTimestamp + $metrics['duration'] + (float)$edge['time'];
  51. $metrics['duration'] += (float)$edge['time'] + $serviceMinutes * 60;
  52. $complete = $departureTimestamp + $metrics['duration'];
  53. $late = $deadlines[$index] === null ? 0 : max(0, $complete - $deadlines[$index]);
  54. $metrics['late_count'] += $late > 0 ? 1 : 0;
  55. $metrics['late_seconds'] += $late;
  56. $stops[] = ['id'=>(int)$tasks[$index]['id'], 'arrival_at'=>date('Y-m-d H:i:s', (int)ceil($arrival)),
  57. 'completed_at'=>date('Y-m-d H:i:s', (int)ceil($complete)), 'late_seconds'=>$late];
  58. $previous = $index + 1;
  59. }
  60. return $metrics + ['stops'=>$stops, 'task_ids'=>array_column($stops, 'id')];
  61. };
  62. $baseline = $evaluate(range(0, $count - 1));
  63. $best = null; $bestScore = null;
  64. $search = function (array $order, array $remaining) use (&$search, &$best, &$bestScore, $evaluate, $policy): void {
  65. if (!$remaining) {
  66. $candidate = $evaluate($order);
  67. if ($candidate === null) return;
  68. $score = $policy->score($candidate);
  69. if ($bestScore === null || $score < $bestScore) { $best = $candidate; $bestScore = $score; }
  70. return;
  71. }
  72. foreach ($remaining as $key=>$index) {
  73. $next = $remaining; unset($next[$key]);
  74. $search([...$order, $index], $next);
  75. }
  76. };
  77. $search([], range(0, $count - 1));
  78. if ($best === null) throw new ApiException('部分站点无可通行道路,无法生成完整路线');
  79. return ['strategy'=>$strategy, 'departure_at'=>$departure, 'service_minutes'=>$serviceMinutes,
  80. 'baseline'=>$baseline, 'optimized'=>$best, 'exact'=>true];
  81. }
  82. }