WaterAlertDashboard.php 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use Illuminate\Database\Eloquent\Builder;
  5. use plugin\saiadmin\app\model\water\WaterModel;
  6. use plugin\saiadmin\app\model\water\WaterAccount;
  7. use plugin\saiadmin\app\model\water\WaterDevice;
  8. use plugin\saiadmin\app\model\water\WaterDelivery;
  9. use plugin\saiadmin\app\service\alerts\AlertFilters;
  10. use plugin\saiadmin\app\service\alerts\ReplenishmentPolicy;
  11. final class WaterAlertDashboard
  12. {
  13. private readonly \DateTimeImmutable $now;
  14. private readonly ReplenishmentPolicy $policy;
  15. public function __construct(?\DateTimeImmutable $now = null)
  16. {
  17. $this->now = ($now ?? new \DateTimeImmutable())->setTimezone(new \DateTimeZone('Asia/Shanghai'));
  18. $this->policy = new ReplenishmentPolicy();
  19. }
  20. private function query(): Builder
  21. {
  22. return WaterAccount::query()->alias('a')
  23. ->join('sa_water_enterprise as e', 'e.id', '=', 'a.enterprise_id')
  24. ->join('sa_water_sku as s', 's.id', '=', 'a.sku_id')
  25. ->withActiveDelivery()
  26. ->where('e.status', 1)->where('s.status', 1);
  27. }
  28. private function exhaustionExpression(): string
  29. {
  30. $days = $this->policy->daysExpression();
  31. return "CASE WHEN $days <= 3650 THEN TIMESTAMPADD(SECOND, CEIL($days * 86400), ?) ELSE NULL END";
  32. }
  33. private function filtered(AlertFilters $filters): Builder
  34. {
  35. $query = $this->query();
  36. $alert = $this->policy->alertExpression();
  37. $urgent = $this->policy->urgentExpression();
  38. if ($filters->scope === 'alerts') {
  39. $query->whereRaw($alert);
  40. }
  41. if ($filters->severity === 'urgent') {
  42. $query->whereRaw("$alert AND $urgent");
  43. }
  44. if ($filters->severity === 'normal') {
  45. $query->whereRaw("$alert AND NOT $urgent");
  46. }
  47. if ($filters->severity === 'healthy') {
  48. $query->whereRaw("NOT $alert");
  49. }
  50. if ($filters->region !== '') {
  51. $query->where('e.region', $filters->region);
  52. }
  53. if ($filters->enterpriseId !== null) {
  54. $query->where('a.enterprise_id', $filters->enterpriseId);
  55. }
  56. if ($filters->keyword !== '') {
  57. $query->where(function ($q) use ($filters) {
  58. $pattern = '%' . $filters->keyword . '%';
  59. $q->where('e.name', 'like', $pattern)->orWhere('e.address', 'like', $pattern)
  60. ->orWhere('e.region', 'like', $pattern)->orWhere('s.name', 'like', $pattern)
  61. ->orWhereExists(WaterDevice::query()->selectRaw('1')->whereColumn('enterprise_id', 'a.enterprise_id')
  62. ->whereColumn('sku_id', 'a.sku_id')->where(fn ($device) => $device->where('sn', 'like', $pattern)->orWhere('name', 'like', $pattern)));
  63. });
  64. }
  65. if ($filters->exhaustDate !== '') {
  66. // Guard unrealistic horizons before timestamp arithmetic; no estimates without positive usage.
  67. $query->whereRaw(
  68. 'DATE(' . $this->exhaustionExpression() . ') = ?',
  69. [$this->now->format('Y-m-d H:i:s'), $filters->exhaustDate]
  70. );
  71. }
  72. return $query;
  73. }
  74. private function page(AlertFilters $filters): array
  75. {
  76. $alert = $this->policy->alertExpression();
  77. $urgent = $this->policy->urgentExpression();
  78. $days = $this->policy->daysExpression();
  79. $query = $this->filtered($filters)->selectRaw('a.*, e.name AS enterprise_name, e.region, e.address, e.employee_count,
  80. s.name AS sku_name, s.liters, s.available AS warehouse_available, d.id AS delivery_id, d.status AS delivery_status')
  81. ->selectRaw($this->exhaustionExpression() . ' AS exhausts_at', [$this->now->format('Y-m-d H:i:s')])
  82. ->selectSub(WaterDevice::query()->whereColumn('enterprise_id', 'a.enterprise_id')->whereColumn('sku_id', 'a.sku_id')->selectRaw('COUNT(*)'), 'device_count')
  83. ->orderByRaw("CASE WHEN $alert AND $urgent THEN 0 WHEN $alert THEN 1 ELSE 2 END")
  84. ->orderByRaw("($days IS NULL) ASC")->orderByRaw("$days ASC")->orderByDesc('a.id');
  85. $page = $query->pageResult($filters->page, $filters->limit);
  86. $page['data'] = array_map(function ($row) {
  87. foreach (['id', 'enterprise_id', 'sku_id', 'entitlement', 'frozen', 'full_qty', 'empty_qty', 'threshold_qty', 'warehouse_available', 'device_count'] as $key) {
  88. $row[$key] = (int) $row[$key];
  89. }
  90. $row['delivery_id'] = $row['delivery_id'] === null ? null : (int) $row['delivery_id'];
  91. $row['daily_liters'] = $row['daily_liters'] === null ? null : (float) $row['daily_liters'];
  92. $row['liters'] = (float) $row['liters'];
  93. $row = array_merge($row, $this->policy->evaluate($row));
  94. unset($row['raw_days_left']);
  95. $row['blocked_reason'] = $row['delivery_id'] ? '已有有效配送任务' : (!$row['is_alert'] ? '当前无需补货'
  96. : ($row['available_entitlement'] <= 0 ? '可用套餐权益不足' : ($row['warehouse_available'] <= 0 ? '仓库可用库存不足' : '')));
  97. $row['can_create'] = $row['blocked_reason'] === '';
  98. return $row;
  99. }, $page['data']);
  100. return $page;
  101. }
  102. public function listing(array $input): array
  103. {
  104. return $this->page(new AlertFilters($input));
  105. }
  106. public function dashboard(array $input): array
  107. {
  108. $filters = new AlertFilters($input);
  109. return WaterModel::transaction(function () use ($filters) {
  110. $alert = $this->policy->alertExpression();
  111. $urgent = $this->policy->urgentExpression();
  112. $counts = $this->query()->selectRaw("COUNT(*) AS accounts, COALESCE(SUM($alert),0) AS alerts,
  113. COALESCE(SUM($alert AND $urgent),0) AS urgent, COALESCE(SUM($alert AND NOT $urgent),0) AS normal,
  114. COALESCE(SUM($alert AND d.id IS NOT NULL),0) AS ordered")->first();
  115. $created = WaterDelivery::query()->where('status', '<>', 'cancelled')
  116. ->whereBetween('created_at', [$this->now->format('Y-m-d 00:00:00'), $this->now->format('Y-m-d H:i:s')])
  117. ->selectRaw('COUNT(*) AS count, COALESCE(SUM(quantity),0) AS quantity')->first();
  118. $days = WaterDelivery::query()->where('status', '<>', 'cancelled')
  119. ->whereBetween('created_at', [$this->now->modify('-6 days')->format('Y-m-d 00:00:00'), $this->now->format('Y-m-d H:i:s')])
  120. ->selectRaw('DATE(created_at) AS day, COUNT(*) AS count, SUM(quantity) AS quantity')->groupByRaw('DATE(created_at)')->get()->keyBy('day');
  121. $trend = [];
  122. for ($offset = 6; $offset >= 0; $offset--) {
  123. $date = $this->now->modify('-' . $offset . ' days')->format('Y-m-d');
  124. $record = $days->get($date);
  125. $trend[] = ['day' => $date, 'count' => (int) ($record->count ?? 0), 'quantity' => (int) ($record->quantity ?? 0)];
  126. }
  127. return ['list' => $this->page($filters), 'summary' => ['accounts' => (int) $counts->accounts, 'alerts' => (int) $counts->alerts,
  128. 'urgent' => (int) $counts->urgent, 'normal' => (int) $counts->normal, 'ordered' => (int) $counts->ordered,
  129. 'created_today' => (int) $created->count, 'quantity_today' => (int) $created->quantity, 'accuracy' => null],
  130. 'regions' => $this->query()->where('e.region', '<>', '')->distinct()->orderBy('e.region')->pluck('e.region')->all(),
  131. 'trend' => $trend, 'trend_kind' => 'delivery_created',
  132. 'policy' => $this->policy->settings(),
  133. 'updated_at' => $this->now->format('Y-m-d H:i:s')];
  134. });
  135. }
  136. }