consultant.php 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. <?php
  2. /**
  3. * lib/consultant.php
  4. *
  5. * Shared functions for the Consultant Dashboard.
  6. * Provides client listing with aggregated test counts and
  7. * alert generation from soil test values vs. specifications.
  8. */
  9. // ─── Nutrient alert thresholds ────────────────────────────────────────────────
  10. //
  11. // These nutrient columns exist on both soil_records and soil_specifications.
  12. // Spec values are treated as the minimum acceptable target.
  13. // Where no spec row exists, we fall back to the hardcoded pH sanity ranges.
  14. const ALERT_NUTRIENTS = [
  15. 'ph_cacl2' => 'pH (CaCl₂)',
  16. 'ph_h2o' => 'pH (H₂O)',
  17. 'ec' => 'EC',
  18. 'NO3_N' => 'Nitrate-N',
  19. 'NH3_N' => 'Ammonium-N',
  20. 'p_morgan' => 'Phosphorus',
  21. 'k_morgan' => 'Potassium',
  22. 'ca_morgan' => 'Calcium',
  23. 'mg_morgan' => 'Magnesium',
  24. 's_morgan' => 'Sulphur',
  25. 'ocarbon' => 'Organic Carbon',
  26. 'b_cacl2' => 'Boron',
  27. 'zn_dtpa' => 'Zinc',
  28. 'mn_dtpa' => 'Manganese',
  29. 'cu_dtpa' => 'Copper',
  30. 'fe_dtpa' => 'Iron',
  31. ];
  32. // Hardcoded sanity ranges used when no soil_specifications row is found.
  33. // Format: [min, max] — null means unchecked on that side.
  34. const FALLBACK_RANGES = [
  35. 'ph_cacl2' => [5.5, 7.5],
  36. 'ph_h2o' => [6.0, 8.0],
  37. 'ec' => [null, 1.5],
  38. ];
  39. /**
  40. * Load all clients belonging to a consultant with aggregated test counts,
  41. * most-recent test date, and alert counts.
  42. *
  43. * @param PDO $pdo
  44. * @param int $userId consultant's modx_user_id
  45. * @return array[]
  46. */
  47. function getConsultantClients(PDO $pdo, int $userId): array
  48. {
  49. $sql = "
  50. SELECT
  51. cr.id,
  52. cr.client,
  53. cr.company,
  54. cr.address,
  55. cr.state_postcode,
  56. cr.email,
  57. cr.phone,
  58. COUNT(DISTINCT sr.id) AS soil_count,
  59. COUNT(DISTINCT pr.id) AS plant_count,
  60. COUNT(DISTINCT wr.id) AS water_count,
  61. MAX(sr.date_sampled) AS last_soil_date,
  62. MAX(pr.date_sampled) AS last_plant_date,
  63. MAX(wr.date_sampled) AS last_water_date,
  64. -- Most recent activity across all test types
  65. GREATEST(
  66. COALESCE(MAX(sr.date_sampled), '1970-01-01'),
  67. COALESCE(MAX(pr.date_sampled), '1970-01-01'),
  68. COALESCE(MAX(wr.date_sampled), '1970-01-01')
  69. ) AS last_activity
  70. FROM client_records cr
  71. LEFT JOIN soil_records sr ON CAST(sr.client_records_id AS UNSIGNED) = cr.id
  72. LEFT JOIN plant_records pr ON pr.client_records_id = cr.id
  73. LEFT JOIN water_records wr ON wr.client_records_id = cr.id
  74. WHERE cr.modx_user_id = ?
  75. GROUP BY cr.id
  76. ORDER BY last_activity DESC, cr.client ASC
  77. ";
  78. $stmt = $pdo->prepare($sql);
  79. $stmt->execute([$userId]);
  80. $clients = $stmt->fetchAll();
  81. // Attach alert summary to each client
  82. foreach ($clients as &$client) {
  83. $client['alerts'] = getClientAlertSummary($pdo, $client['id']);
  84. $client['last_activity'] = $client['last_activity'] === '1970-01-01' ? null : $client['last_activity'];
  85. }
  86. unset($client);
  87. return $clients;
  88. }
  89. /**
  90. * Return ['critical' => int, 'watch' => int] for a client's most recent soil test.
  91. */
  92. function getClientAlertSummary(PDO $pdo, int $clientId): array
  93. {
  94. // Fetch the most recent soil test for this client
  95. $stmt = $pdo->prepare("
  96. SELECT * FROM soil_records
  97. WHERE CAST(client_records_id AS UNSIGNED) = ?
  98. ORDER BY date_sampled DESC, id DESC
  99. LIMIT 1
  100. ");
  101. $stmt->execute([$clientId]);
  102. $soilRow = $stmt->fetch();
  103. if (!$soilRow) {
  104. return ['critical' => 0, 'watch' => 0];
  105. }
  106. // Try to load matching spec row
  107. $spec = null;
  108. if (!empty($soilRow['soil_type'])) {
  109. $stmt2 = $pdo->prepare("
  110. SELECT * FROM soil_specifications
  111. WHERE soil_type = ?
  112. ORDER BY id ASC LIMIT 1
  113. ");
  114. $stmt2->execute([$soilRow['soil_type']]);
  115. $spec = $stmt2->fetch() ?: null;
  116. }
  117. return generateAlerts($soilRow, $spec)['summary'];
  118. }
  119. /**
  120. * Generate full alert list for a soil record vs its spec.
  121. *
  122. * @param array $soilRow Row from soil_records
  123. * @param array|null $spec Row from soil_specifications (or null)
  124. * @return array ['summary' => ['critical'=>int,'watch'=>int], 'items' => [...]]
  125. */
  126. function generateAlerts(array $soilRow, ?array $spec): array
  127. {
  128. $critical = 0;
  129. $watch = 0;
  130. $items = [];
  131. foreach (ALERT_NUTRIENTS as $col => $label) {
  132. $measured = isset($soilRow[$col]) && $soilRow[$col] !== ''
  133. ? (float) $soilRow[$col]
  134. : null;
  135. if ($measured === null) {
  136. continue;
  137. }
  138. // Determine range from spec or fallback
  139. $min = null;
  140. $max = null;
  141. if ($spec !== null && isset($spec[$col]) && $spec[$col] !== '') {
  142. // Spec value is the minimum target
  143. $min = (float) $spec[$col];
  144. } elseif (isset(FALLBACK_RANGES[$col])) {
  145. [$min, $max] = FALLBACK_RANGES[$col];
  146. }
  147. if ($min === null && $max === null) {
  148. continue; // no reference — skip
  149. }
  150. $severity = null;
  151. if ($min !== null && $measured < $min) {
  152. // Below minimum — how far below determines severity
  153. $ratio = $min > 0 ? ($measured / $min) : 0;
  154. $severity = $ratio < 0.5 ? 'critical' : 'watch';
  155. } elseif ($max !== null && $measured > $max) {
  156. $severity = 'watch'; // above max is always a watch
  157. }
  158. if ($severity === null) {
  159. continue;
  160. }
  161. if ($severity === 'critical') $critical++;
  162. else $watch++;
  163. $items[] = [
  164. 'nutrient' => $label,
  165. 'col' => $col,
  166. 'measured' => $measured,
  167. 'min' => $min,
  168. 'max' => $max,
  169. 'severity' => $severity,
  170. ];
  171. }
  172. // Sort: critical first
  173. usort($items, fn($a, $b) => $a['severity'] === 'critical' ? -1 : 1);
  174. return [
  175. 'summary' => ['critical' => $critical, 'watch' => $watch],
  176. 'items' => $items,
  177. ];
  178. }
  179. /**
  180. * Format a date string for display ("12 Mar 2024"), returns '—' if null/empty.
  181. */
  182. function fmtDate(?string $date): string
  183. {
  184. if (!$date || $date === '1970-01-01') return '—';
  185. $ts = strtotime($date);
  186. return $ts ? date('j M Y', $ts) : '—';
  187. }
  188. /**
  189. * Return the Bootstrap badge class for an alert severity.
  190. */
  191. function alertBadgeClass(int $critical, int $watch): string
  192. {
  193. if ($critical > 0) return 'danger';
  194. if ($watch > 0) return 'warning';
  195. return 'success';
  196. }