WaterDeviceDashboard.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use Illuminate\Database\Eloquent\Builder;
  5. use OpenSpout\Common\Entity\Row;
  6. use OpenSpout\Writer\XLSX\Writer;
  7. use plugin\saiadmin\app\model\water\{WaterModel, WaterDevice, WaterDeviceUsage};
  8. use plugin\saiadmin\app\service\device\{DeviceFilters, DeviceStatusPolicy};
  9. use plugin\saiadmin\exception\ApiException;
  10. /** Device read model. Archive writes remain in WaterBusiness; reports remain in DeviceReport. */
  11. final class WaterDeviceDashboard
  12. {
  13. private readonly \DateTimeImmutable $now;
  14. private readonly DeviceStatusPolicy $status;
  15. public function __construct(?\DateTimeImmutable $now = null)
  16. {
  17. $this->now = $now ?? new \DateTimeImmutable('now', new \DateTimeZone('Asia/Shanghai'));
  18. $this->status = new DeviceStatusPolicy($this->now);
  19. }
  20. private function query(?DeviceFilters $filters = null): Builder
  21. {
  22. $query = WaterDevice::query()->alias('d')
  23. ->leftJoin('sa_water_enterprise as e', 'e.id', '=', 'd.enterprise_id')
  24. ->leftJoin('sa_water_sku as s', 's.id', '=', 'd.sku_id');
  25. if (!$filters) {
  26. return $query;
  27. }
  28. if ($filters->keyword !== '') {
  29. $query->matching('d.sn|d.name|e.name', '%' . $filters->keyword . '%');
  30. }
  31. if ($filters->status !== '') {
  32. $query->whereRaw('(' . $this->status->expression() . ') = ?', [$filters->status]);
  33. }
  34. if ($filters->region !== '') {
  35. $query->where('e.region', $filters->region);
  36. }
  37. if ($filters->firmware !== '') {
  38. $query->where('d.firmware', $filters->firmware);
  39. }
  40. return $query;
  41. }
  42. private function rows(DeviceFilters $filters): Builder
  43. {
  44. return $this->query($filters)
  45. ->selectRaw('d.*, e.name AS enterprise_name, e.region, s.name AS sku_name, (' . $this->status->expression() . ') AS connection_status')
  46. ->orderBy('d.id', 'desc');
  47. }
  48. private function present(array $row): array
  49. {
  50. $row['today_liters'] = $row['telemetry_date'] === $this->now->format('Y-m-d') && $row['today_liters'] !== null
  51. ? (float) $row['today_liters'] : null;
  52. $row['last_seen_seconds'] = $row['last_seen']
  53. ? max(0, $this->now->getTimestamp() - (new \DateTimeImmutable($row['last_seen'], $this->now->getTimezone()))->getTimestamp())
  54. : null;
  55. return $row;
  56. }
  57. public function dashboard(array $input): array
  58. {
  59. $filters = new DeviceFilters($input);
  60. return WaterModel::transaction(function () use ($filters) {
  61. $list = $this->rows($filters)->pageResult($filters->page, $filters->limit);
  62. $list['data'] = array_map($this->present(...), $list['data']);
  63. return [
  64. 'summary' => $this->summary(),
  65. 'list' => $list,
  66. 'options' => $this->options(),
  67. 'trend' => $this->trend(),
  68. 'anomalies' => $this->anomalies(),
  69. 'updated_at' => $this->now->format('Y-m-d H:i:s'),
  70. ];
  71. });
  72. }
  73. private function summary(): array
  74. {
  75. $summary = array_fill_keys(['online', 'offline', 'fault', 'unconnected'], 0);
  76. $groups = $this->query()->selectRaw($this->status->expression() . ' AS state, COUNT(*) AS total')
  77. ->groupByRaw($this->status->expression())->get();
  78. foreach ($groups as $group) {
  79. $summary[$group->state] = (int) $group->total;
  80. }
  81. $summary['total'] = array_sum($summary);
  82. $summary['new_month'] = WaterDevice::query()
  83. ->whereBetween('created_at', [$this->now->format('Y-m-01 00:00:00'), $this->now->format('Y-m-d H:i:s')])->count();
  84. $summary['offline_long'] = $this->query(new DeviceFilters(['status' => 'offline']))
  85. ->where('d.last_seen', '<', $this->now->modify('-24 hours')->format('Y-m-d H:i:s'))->count();
  86. $summary['online_rate'] = $summary['total'] ? round(100 * $summary['online'] / $summary['total'], 1) : 0;
  87. return $summary;
  88. }
  89. private function options(): array
  90. {
  91. return [
  92. 'regions' => $this->query()->whereNotNull('e.region')->where('e.region', '<>', '')
  93. ->distinct()->orderBy('e.region')->pluck('e.region')->all(),
  94. 'firmwares' => WaterDevice::query()->selectRaw('firmware, COUNT(*) AS total')
  95. ->groupBy('firmware')->orderBy('firmware')->get()->map(fn ($row) => [
  96. 'version' => (string) $row->firmware,
  97. 'total' => (int) $row->total,
  98. ])->all(),
  99. ];
  100. }
  101. private function trend(): array
  102. {
  103. $start = $this->now->modify('-29 days');
  104. $daily = WaterDeviceUsage::query()->whereBetween('day', [$start->format('Y-m-d'), $this->now->format('Y-m-d')])
  105. ->selectRaw('day, SUM(liters) AS liters, COUNT(*) AS reporting_devices')->groupBy('day')->get()->keyBy('day');
  106. $result = [];
  107. for ($i = 0; $i < 30; $i++) {
  108. $day = $start->modify('+' . $i . ' days')->format('Y-m-d');
  109. $row = $daily->get($day);
  110. $result[] = ['day' => $day, 'liters' => $row ? (float) $row->liters : null,
  111. 'reporting_devices' => $row ? (int) $row->reporting_devices : 0];
  112. }
  113. return $result;
  114. }
  115. private function anomalies(): array
  116. {
  117. $offline = $this->query(new DeviceFilters(['status' => 'offline']))->count();
  118. $faults = $this->query(new DeviceFilters(['status' => 'fault']))
  119. ->selectRaw('TRIM(d.fault) AS label, COUNT(*) AS total')->groupByRaw('TRIM(d.fault)')->orderByDesc('total')->get();
  120. $total = $offline + (int) $faults->sum('total');
  121. $rows = $offline ? [['label' => '网络离线', 'total' => $offline]] : [];
  122. foreach ($faults as $fault) {
  123. $rows[] = ['label' => $fault->label, 'total' => (int) $fault->total];
  124. }
  125. return array_map(fn ($row) => $row + ['percentage' => round(100 * $row['total'] / max(1, $total), 1)], $rows);
  126. }
  127. public function export(array $input): string
  128. {
  129. $rows = $this->rows(new DeviceFilters($input))->limit(50001)->get();
  130. if (count($rows) > 50000) {
  131. throw new ApiException('导出超过50000条,请缩小筛选范围');
  132. }
  133. $path = tempnam(sys_get_temp_dir(), 'water-devices-');
  134. if ($path === false) {
  135. throw new ApiException('无法创建导出文件');
  136. }
  137. $writer = new Writer();
  138. $opened = false;
  139. try {
  140. $writer->openToFile($path);
  141. $opened = true;
  142. $writer->addRow(Row::fromValues(['设备名称', 'SN', '企业', '区域', '商品规格', '固件版本', '状态', '今日用水/L', '最后上报', '故障说明']));
  143. foreach ($rows as $model) {
  144. $row = $this->present($model->toArray());
  145. $values = [$row['name'], $row['sn'], $row['enterprise_name'], $row['region'], $row['sku_name'],
  146. $row['firmware'], DeviceStatusPolicy::LABELS[$row['connection_status']], $row['today_liters'], $row['last_seen'], $row['fault']];
  147. $writer->addRow(Row::fromValues(array_map(static fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r\n]/u', $value) ? "'" . $value : $value, $values)));
  148. }
  149. $writer->close();
  150. $opened = false;
  151. $content = file_get_contents($path);
  152. if ($content === false) {
  153. throw new ApiException('读取设备导出失败');
  154. }
  155. return $content;
  156. } finally {
  157. try {
  158. if ($opened) {
  159. $writer->close();
  160. }
  161. } finally {
  162. if (is_file($path)) {
  163. unlink($path);
  164. }
  165. }
  166. }
  167. }
  168. }