11package backend .fullstack .temperature .application ;
22
33import java .time .LocalDateTime ;
4+ import java .time .temporal .TemporalAdjusters ;
5+ import java .util .Comparator ;
46import java .util .List ;
7+ import java .util .Map ;
8+ import java .util .stream .Collectors ;
59
610import org .springframework .data .domain .Page ;
711import org .springframework .data .domain .Pageable ;
1216import backend .fullstack .exceptions .ResourceNotFoundException ;
1317import backend .fullstack .exceptions .UnitInactiveException ;
1418import backend .fullstack .exceptions .UnitNotFoundException ;
19+ import backend .fullstack .temperature .api .dto .TemperatureReadingDeviationResponse ;
1520import backend .fullstack .temperature .api .dto .TemperatureReadingMapper ;
1621import backend .fullstack .temperature .api .dto .TemperatureReadingRequest ;
1722import backend .fullstack .temperature .api .dto .TemperatureReadingResponse ;
23+ import backend .fullstack .temperature .api .dto .TemperatureReadingStatsGroupBy ;
24+ import backend .fullstack .temperature .api .dto .TemperatureReadingStatsPointResponse ;
25+ import backend .fullstack .temperature .api .dto .TemperatureReadingStatsResponse ;
26+ import backend .fullstack .temperature .api .dto .TemperatureReadingStatsSeriesResponse ;
1827import backend .fullstack .temperature .domain .TemperatureReading ;
1928import backend .fullstack .temperature .infrastructure .TemperatureReadingRepository ;
2029import backend .fullstack .units .domain .TemperatureUnit ;
@@ -69,6 +78,36 @@ public Page<TemperatureReadingResponse> listReadings(
6978 .map (readingMapper ::toResponse );
7079 }
7180
81+ @ Transactional (readOnly = true )
82+ public TemperatureReadingStatsResponse getReadingStats (
83+ Long organizationId ,
84+ List <Long > unitIds ,
85+ LocalDateTime from ,
86+ LocalDateTime to ,
87+ TemperatureReadingStatsGroupBy groupBy
88+ ) {
89+ List <Long > distinctUnitIds = sanitizeUnitIds (unitIds );
90+ List <TemperatureReading > readings = distinctUnitIds == null
91+ ? readingRepository .findForStatsByOrganizationAndRange (organizationId , from , to )
92+ : readingRepository .findForStatsByOrganizationAndUnitIdsAndRange (organizationId , distinctUnitIds , from , to );
93+
94+ Map <Long , List <TemperatureReading >> readingsByUnit = readings .stream ()
95+ .collect (Collectors .groupingBy (reading -> reading .getUnit ().getId ()));
96+
97+ List <TemperatureReadingStatsSeriesResponse > series = readingsByUnit .values ().stream ()
98+ .sorted (Comparator .comparing (unitReadings -> unitReadings .get (0 ).getUnit ().getName (), String .CASE_INSENSITIVE_ORDER ))
99+ .map (unitReadings -> toSeries (unitReadings , groupBy ))
100+ .toList ();
101+
102+ List <TemperatureReadingDeviationResponse > deviations = readings .stream ()
103+ .filter (TemperatureReading ::isDeviation )
104+ .sorted (Comparator .comparing (TemperatureReading ::getRecordedAt ).reversed ())
105+ .map (this ::toDeviationResponse )
106+ .toList ();
107+
108+ return new TemperatureReadingStatsResponse (series , deviations );
109+ }
110+
72111 public TemperatureReadingResponse createReading (
73112 JwtPrincipal principal ,
74113 Long unitId ,
@@ -115,4 +154,90 @@ private User findScopedUser(Long userId, Long organizationId) {
115154
116155 return user ;
117156 }
157+
158+ private List <Long > sanitizeUnitIds (List <Long > unitIds ) {
159+ if (unitIds == null || unitIds .isEmpty ()) {
160+ return null ;
161+ }
162+
163+ List <Long > sanitized = unitIds .stream ()
164+ .filter (java .util .Objects ::nonNull )
165+ .distinct ()
166+ .toList ();
167+
168+ return sanitized .isEmpty () ? null : sanitized ;
169+ }
170+
171+ private TemperatureReadingStatsSeriesResponse toSeries (
172+ List <TemperatureReading > unitReadings ,
173+ TemperatureReadingStatsGroupBy groupBy
174+ ) {
175+ TemperatureReading firstReading = unitReadings .get (0 );
176+ Map <LocalDateTime , List <TemperatureReading >> groupedByTime = unitReadings .stream ()
177+ .collect (Collectors .groupingBy (
178+ reading -> truncateTimestamp (reading .getRecordedAt (), groupBy ),
179+ java .util .TreeMap ::new ,
180+ Collectors .toList ()
181+ ));
182+
183+ List <TemperatureReadingStatsPointResponse > dataPoints = groupedByTime .entrySet ().stream ()
184+ .map (entry -> {
185+ double avg = entry .getValue ().stream ()
186+ .map (TemperatureReading ::getTemperature )
187+ .filter (java .util .Objects ::nonNull )
188+ .mapToDouble (Double ::doubleValue )
189+ .average ()
190+ .orElse (0.0d );
191+
192+ boolean hasDeviation = entry .getValue ().stream ().anyMatch (TemperatureReading ::isDeviation );
193+ return new TemperatureReadingStatsPointResponse (entry .getKey (), avg , hasDeviation );
194+ })
195+ .toList ();
196+
197+ return new TemperatureReadingStatsSeriesResponse (
198+ firstReading .getUnit ().getId (),
199+ firstReading .getUnit ().getName (),
200+ dataPoints
201+ );
202+ }
203+
204+ private TemperatureReadingDeviationResponse toDeviationResponse (TemperatureReading reading ) {
205+ return new TemperatureReadingDeviationResponse (
206+ reading .getId (),
207+ reading .getUnit ().getId (),
208+ reading .getUnit ().getName (),
209+ reading .getTemperature (),
210+ resolveBreachedThreshold (reading ),
211+ reading .getRecordedAt ()
212+ );
213+ }
214+
215+ private Double resolveBreachedThreshold (TemperatureReading reading ) {
216+ if (reading .getUnit () == null || reading .getTemperature () == null ) {
217+ return null ;
218+ }
219+
220+ Double minThreshold = reading .getUnit ().getMinThreshold ();
221+ Double maxThreshold = reading .getUnit ().getMaxThreshold ();
222+
223+ if (minThreshold != null && reading .getTemperature () < minThreshold ) {
224+ return minThreshold ;
225+ }
226+
227+ if (maxThreshold != null && reading .getTemperature () > maxThreshold ) {
228+ return maxThreshold ;
229+ }
230+
231+ return null ;
232+ }
233+
234+ private LocalDateTime truncateTimestamp (LocalDateTime timestamp , TemperatureReadingStatsGroupBy groupBy ) {
235+ return switch (groupBy ) {
236+ case HOUR -> timestamp .withMinute (0 ).withSecond (0 ).withNano (0 );
237+ case DAY -> timestamp .toLocalDate ().atStartOfDay ();
238+ case WEEK -> timestamp .toLocalDate ()
239+ .with (TemporalAdjusters .previousOrSame (java .time .DayOfWeek .MONDAY ))
240+ .atStartOfDay ();
241+ };
242+ }
118243}
0 commit comments