Subversion Repositories SmartDukaan

Rev

Rev 36716 | Rev 36728 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
36618 ranu 1
package com.spice.profitmandi.web.controller;
2
 
3
import com.google.gson.Gson;
4
import com.google.gson.reflect.TypeToken;
5
import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;
6
import com.spice.profitmandi.common.model.CustomRetailer;
7
import com.spice.profitmandi.common.web.util.ResponseSender;
8
import com.spice.profitmandi.dao.entity.auth.AuthUser;
9
import com.spice.profitmandi.dao.entity.fofo.FofoStore;
10
import com.spice.profitmandi.dao.entity.logistics.PublicHolidays;
36644 ranu 11
import com.spice.profitmandi.dao.entity.user.*;
36618 ranu 12
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
13
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
14
import com.spice.profitmandi.dao.repository.cs.CsService;
15
import com.spice.profitmandi.dao.repository.dtr.*;
16
import com.spice.profitmandi.dao.repository.logistics.PublicHolidaysRepository;
17
import com.spice.profitmandi.service.user.RetailerService;
18
import com.spice.profitmandi.web.model.LoginDetails;
19
import com.spice.profitmandi.web.util.CookiesProcessor;
20
import org.apache.logging.log4j.LogManager;
21
import org.apache.logging.log4j.Logger;
22
import org.springframework.beans.factory.annotation.Autowired;
23
import org.springframework.http.ResponseEntity;
24
import org.springframework.stereotype.Controller;
25
import org.springframework.transaction.annotation.Transactional;
26
import org.springframework.ui.Model;
27
import org.springframework.web.bind.annotation.GetMapping;
28
import org.springframework.web.bind.annotation.PostMapping;
29
import org.springframework.web.bind.annotation.RequestParam;
30
 
31
import javax.servlet.http.HttpServletRequest;
32
import java.lang.reflect.Type;
33
import java.time.DayOfWeek;
34
import java.time.LocalDate;
35
import java.time.LocalDateTime;
36
import java.time.YearMonth;
37
import java.time.format.DateTimeFormatter;
38
import java.util.*;
39
import java.util.stream.Collectors;
40
 
41
@Controller
42
@Transactional(rollbackFor = Throwable.class)
43
public class BeatPlanController {
44
	private static final Logger LOGGER = LogManager.getLogger(BeatPlanController.class);
45
	private static final String[] BEAT_COLORS = {
46
			"#3498DB", "#E74C3C", "#2ECC71", "#9B59B6", "#F39C12",
47
			"#1ABC9C", "#E67E22", "#34495E", "#16A085", "#C0392B"
48
	};
49
	@Autowired
50
	private CsService csService;
51
	@Autowired
52
	private AuthRepository authRepository;
36686 ranu 53
	// Emails that bypass hierarchy and role gates — single source of truth.
54
	private static final Set<String> SUPER_ADMIN_EMAILS = new HashSet<>(Arrays.asList(
55
			"tarun.verma@smartdukaan.com"
56
	));
36618 ranu 57
	@Autowired
36686 ranu 58
	private com.spice.profitmandi.service.AuthService authService;
36618 ranu 59
	@Autowired
36686 ranu 60
	private com.spice.profitmandi.dao.repository.cs.PositionRepository positionRepository;
61
	@Autowired
36618 ranu 62
	private RetailerService retailerService;
63
	@Autowired
36644 ranu 64
	private BeatRepository beatRepository;
36618 ranu 65
	@Autowired
36644 ranu 66
	private BeatRouteRepository beatRouteRepository;
36618 ranu 67
	@Autowired
36644 ranu 68
	private BeatScheduleRepository beatScheduleRepository;
69
	@Autowired
70
	private LeadRouteRepository leadRouteRepository;
71
	@Autowired
36650 ranu 72
	private com.spice.profitmandi.dao.service.BeatPlanQueryService beatPlanQueryService;
73
	@Autowired
36618 ranu 74
	private AuthUserLocationRepository authUserLocationRepository;
75
	@Autowired
76
	private LeadRepository leadRepository;
77
	@Autowired
78
	private PublicHolidaysRepository publicHolidaysRepository;
79
	@Autowired
80
	private com.spice.profitmandi.service.GeocodingService geocodingService;
81
	@Autowired
82
	private CookiesProcessor cookiesProcessor;
83
	@Autowired
84
	private ResponseSender responseSender;
36686 ranu 85
	@Autowired
86
	private FofoStoreRepository fofoStoreRepository;
36618 ranu 87
 
88
	@GetMapping(value = "/beatPlan")
36686 ranu 89
	public String beatPlan(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
90
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36618 ranu 91
		return "beat-plan";
92
	}
93
 
36650 ranu 94
	@Autowired
95
	private com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository leadLiveLocationRepositoryAuto;
36651 ranu 96
	@Autowired
97
	private com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository leadActivityRepositoryAuto;
36663 ranu 98
	@Autowired
99
	private com.spice.profitmandi.dao.repository.dtr.UserRepository userRepositoryAuto;
100
	@Autowired
101
	private com.spice.profitmandi.common.web.client.RestClient restClientAuto;
102
	@Autowired
103
	private com.spice.profitmandi.dao.repository.auth.LocationTrackingRepository locationTrackingRepositoryAuto;
36650 ranu 104
 
36655 ranu 105
	private static Double parseDoubleOrNull(String s) {
106
		if (s == null || s.trim().isEmpty()) return null;
107
		try {
108
			return Double.parseDouble(s.trim());
109
		} catch (NumberFormatException e) {
110
			return null;
111
		}
112
	}
113
 
114
	private static double haversineKm(double lat1, double lng1, double lat2, double lng2) {
115
		double R = 6371;
116
		double dLat = Math.toRadians(lat2 - lat1);
117
		double dLng = Math.toRadians(lng2 - lng1);
118
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
119
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
120
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
121
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
122
		return R * c;
123
	}
124
 
36711 ranu 125
    // Mirrors the JS recalcDay() formula. Used by schedule/repeat endpoints
126
    // which create fresh BeatSchedule rows — they need to fill totals from the
127
    // existing beat_route table, not from anything the client posted.
128
    // Returns {totalKm, totalMins}.
129
    private double[] computeDayTotals(int beatId, int dayNumber, String endAction) {
130
        Beat beat = beatRepository.selectById(beatId);
131
        if (beat == null) return new double[]{0d, 0d};
132
 
133
        List<BeatRoute> dayRoutes = beatRouteRepository.selectByBeatId(beatId).stream()
134
                .filter(r -> r.getDayNumber() == dayNumber)
135
                .sorted(java.util.Comparator.comparingInt(BeatRoute::getSequenceOrder))
136
                .collect(Collectors.toList());
137
        if (dayRoutes.isEmpty()) return new double[]{0d, 0d};
138
 
139
        List<Integer> fofoIds = dayRoutes.stream().map(BeatRoute::getFofoId).distinct().collect(Collectors.toList());
140
        Map<Integer, FofoStore> storeMap = new HashMap<>();
141
        try {
142
            for (FofoStore fs : fofoStoreRepository.selectByRetailerIds(fofoIds)) {
143
                storeMap.put(fs.getId(), fs);
144
            }
145
        } catch (Exception ignored) { /* fall through with empty map */ }
146
 
147
        double ROAD_FACTOR = 1.3;
148
        double AVG_SPEED = 30.0; // km/h
149
        int VISIT_MINS = 30;
150
 
151
        Double prevLat = parseDoubleOrNull(beat.getStartLatitude());
152
        Double prevLng = parseDoubleOrNull(beat.getStartLongitude());
153
 
154
        double totalKm = 0d;
155
        for (BeatRoute r : dayRoutes) {
156
            FofoStore fs = storeMap.get(r.getFofoId());
157
            if (fs == null) continue;
158
            Double curLat = parseDoubleOrNull(fs.getLatitude());
159
            Double curLng = parseDoubleOrNull(fs.getLongitude());
160
            if (prevLat != null && prevLng != null && curLat != null && curLng != null) {
161
                totalKm += haversineKm(prevLat, prevLng, curLat, curLng) * ROAD_FACTOR;
162
            }
163
            if (curLat != null && curLng != null) {
164
                prevLat = curLat;
165
                prevLng = curLng;
166
            }
167
        }
168
 
169
        // Return-home leg only when end_action='HOME'
170
        if ("HOME".equalsIgnoreCase(endAction)) {
171
            Double homeLat = parseDoubleOrNull(beat.getStartLatitude());
172
            Double homeLng = parseDoubleOrNull(beat.getStartLongitude());
173
            if (prevLat != null && prevLng != null && homeLat != null && homeLng != null) {
174
                totalKm += haversineKm(prevLat, prevLng, homeLat, homeLng) * ROAD_FACTOR;
175
            }
176
        }
177
 
178
        double totalMins = (totalKm / AVG_SPEED) * 60.0 + dayRoutes.size() * VISIT_MINS;
179
        return new double[]{Math.round(totalKm * 1000d) / 1000d, Math.round(totalMins)};
180
    }
181
 
36663 ranu 182
	// ====================== ASSIGN VISIT ======================
183
	// Day View "Assign Visit" — lets an admin pick parties (stores) for a specific
184
	// auth user on a specific date and pushes them as visit tasks to the v2
185
	// /profitmandi-web/v2/beat-tracking/batch endpoint.
186
 
36716 ranu 187
	// List of parties (stores) assigned to this auth user + their dtr.users.id.
188
	// When date+beatId are passed, each party is also tagged with:
189
	//   inBeat        = is this store part of the scheduled beat's route on that date
190
	//   existingAgendas[] = agendas already saved for this store on that date (so the
191
	//                       modal can pre-fill them and let the user refill rather than re-assign)
36663 ranu 192
	@GetMapping(value = "/beatPlan/assignVisit/parties")
36716 ranu 193
	public ResponseEntity<?> assignVisitParties(
194
			@RequestParam int authUserId,
195
			@RequestParam(required = false) String date,
196
			@RequestParam(required = false) Integer beatId) throws Exception {
36663 ranu 197
		AuthUser au = authRepository.selectById(authUserId);
198
		if (au == null) return responseSender.badRequest("Auth user not found");
199
 
200
		// Map auth_user → dtr.users via email
201
		Integer dtrUserId = null;
202
		try {
203
			com.spice.profitmandi.dao.entity.dtr.User dtrUser =
204
					userRepositoryAuto.selectByEmailId(au.getEmailId());
205
			if (dtrUser != null) dtrUserId = dtrUser.getId();
206
		} catch (Exception ignored) {
207
		}
208
 
36716 ranu 209
		// Parse optional date
210
		LocalDate parsedDate = null;
211
		if (date != null && !date.isEmpty()) {
212
			try {
213
				parsedDate = LocalDate.parse(date);
214
			} catch (Exception ignored) {
215
			}
216
		}
217
 
218
		// Build (fofoId → dayNumber) of partners already in the beat's scheduled route for this date
219
		Set<Integer> inBeatFofoIds = new HashSet<>();
220
		if (beatId != null && parsedDate != null) {
221
			final LocalDate dateF = parsedDate; // capture for lambda (parsedDate is reassigned earlier so not effectively final)
222
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
223
			BeatSchedule match = schedules.stream()
224
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(dateF))
225
					.findFirst().orElse(null);
226
			if (match != null) {
227
				List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
228
				routes.stream()
229
						.filter(r -> r.getDayNumber() == match.getDayNumber() && r.isActive())
230
						.forEach(r -> inBeatFofoIds.add(r.getFofoId()));
231
			}
232
		}
233
 
234
		// Build (fofoId → existingAgendas) and (fofoId → existingDescription) from
235
		// any already-saved location_tracking rows for this user on this date.
236
		// Agenda is stored as task_name = "agenda1, agenda2 | OutletName"
237
		// so we split on " | " to peel the outlet suffix off, then split agendas by ", ".
238
		// Description is stored on task_description (free text).
239
		Map<Integer, List<String>> existingAgendaByFofo = new HashMap<>();
240
		Map<Integer, String> existingDescByFofo = new HashMap<>();
241
		Map<Integer, Integer> existingTrackingIdByFofo = new HashMap<>();
242
		if (dtrUserId != null && parsedDate != null) {
243
			List<com.spice.profitmandi.dao.entity.auth.LocationTracking> existing =
244
					locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, parsedDate);
245
			for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt : existing) {
246
				if (!"franchisee-visit".equals(lt.getTaskType())) continue;
247
				if (existingAgendaByFofo.containsKey(lt.getTaskId())) continue; // first wins
248
				String taskName = lt.getTaskName() == null ? "" : lt.getTaskName();
249
				String agendaPart = taskName;
250
				int pipeIdx = taskName.lastIndexOf(" | ");
251
				if (pipeIdx > 0) agendaPart = taskName.substring(0, pipeIdx);
252
				List<String> agendas = new ArrayList<>();
253
				for (String a : agendaPart.split(",")) {
254
					String trimmed = a.trim();
255
					if (!trimmed.isEmpty()) agendas.add(trimmed);
256
				}
257
				existingAgendaByFofo.put(lt.getTaskId(), agendas);
258
				existingDescByFofo.put(lt.getTaskId(), lt.getTaskDescription() != null ? lt.getTaskDescription() : "");
259
				existingTrackingIdByFofo.put(lt.getTaskId(), lt.getId());
260
			}
261
		}
262
 
36663 ranu 263
		Map<Integer, List<Integer>> mapping = csService.getAuthUserIdPartnerIdMapping();
264
		List<Integer> fofoIds = mapping.get(authUserId);
265
 
266
		List<Map<String, Object>> parties = new ArrayList<>();
267
		if (fofoIds != null && !fofoIds.isEmpty()) {
268
			List<FofoStore> stores = fofoStoreRepository.selectByRetailerIds(fofoIds);
269
			Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
270
			for (FofoStore store : stores) {
271
				if (!store.isActive() || store.isClosed()) continue;
272
				CustomRetailer retailer = retailerMap.get(store.getId());
273
				Map<String, Object> p = new HashMap<>();
274
				p.put("fofoStoreId", store.getId());
275
				p.put("code", store.getCode());
276
				p.put("outletName", store.getOutletName() != null ? store.getOutletName()
277
						: (retailer != null ? retailer.getBusinessName() : "Store #" + store.getId()));
278
				p.put("latitude", store.getLatitude());
279
				p.put("longitude", store.getLongitude());
280
				p.put("city", retailer != null && retailer.getAddress() != null ? retailer.getAddress().getCity() : null);
36716 ranu 281
				p.put("inBeat", inBeatFofoIds.contains(store.getId()));
282
				p.put("existingAgendas", existingAgendaByFofo.getOrDefault(store.getId(), new ArrayList<>()));
283
				p.put("existingDescription", existingDescByFofo.getOrDefault(store.getId(), ""));
284
				p.put("existingTrackingId", existingTrackingIdByFofo.get(store.getId()));
36663 ranu 285
				parties.add(p);
286
			}
36716 ranu 287
			// In-beat first, then by code
288
			parties.sort((a, b) -> {
289
				boolean ai = Boolean.TRUE.equals(a.get("inBeat"));
290
				boolean bi = Boolean.TRUE.equals(b.get("inBeat"));
291
				if (ai != bi) return ai ? -1 : 1;
292
				return String.valueOf(a.get("code")).compareToIgnoreCase(String.valueOf(b.get("code")));
293
			});
36663 ranu 294
		}
295
 
296
		Map<String, Object> result = new HashMap<>();
297
		result.put("dtrUserId", dtrUserId);
298
		result.put("authUserId", authUserId);
299
		result.put("userName", au.getFirstName() + " " + au.getLastName());
300
		result.put("parties", parties);
36716 ranu 301
		result.put("agendaOptions", com.spice.profitmandi.dao.enumuration.dtr.VisitAgenda.labels());
36663 ranu 302
		return responseSender.ok(result);
303
	}
304
 
305
	// Submit assignment — accepts a JSON body, builds the v2 payload, posts it
306
	@PostMapping(value = "/beatPlan/assignVisit/submit")
307
	public ResponseEntity<?> assignVisitSubmit(
308
			HttpServletRequest request,
309
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
310
 
311
		Integer authUserId = body.get("authUserId") != null ? ((Number) body.get("authUserId")).intValue() : null;
312
		String planDate = (String) body.get("planDate");
313
		List<Map<String, Object>> selected = (List<Map<String, Object>>) body.get("parties");
314
		if (authUserId == null || planDate == null || selected == null || selected.isEmpty()) {
315
			return responseSender.badRequest("authUserId, planDate and parties are required");
316
		}
317
 
318
		AuthUser au = authRepository.selectById(authUserId);
319
		if (au == null) return responseSender.badRequest("Auth user not found");
320
 
321
		// Map auth → dtr.users.id (this is the userId the v2 endpoint expects)
322
		com.spice.profitmandi.dao.entity.dtr.User dtrUser;
323
		try {
324
			dtrUser = userRepositoryAuto.selectByEmailId(au.getEmailId());
325
		} catch (Exception e) {
326
			return responseSender.badRequest("Failed to look up dtr.users for this auth user");
327
		}
328
		if (dtrUser == null) {
329
			return responseSender.badRequest("No dtr.users record found for auth user " + authUserId);
330
		}
331
		int dtrUserId = dtrUser.getId();
332
 
333
		// Persist directly via the shared DAO — mirrors BeatTrackingController.createBatch
334
		// in profitmandi-web. We can't autowire a controller across WARs, but
335
		// LocationTrackingRepository lives in profitmandi-dao and is shared.
336
		LocalDate taskDate;
337
		try {
338
			taskDate = LocalDate.parse(planDate);
339
		} catch (Exception e) {
340
			return responseSender.badRequest("Invalid planDate (expected yyyy-MM-dd): " + planDate);
341
		}
342
 
36716 ranu 343
		// Existing rows for this user on this date — keyed by fofoStoreId.
344
		// If a row already exists for a party, we UPDATE its agenda instead of
345
		// creating a duplicate (this is the "refill agenda" path for already-
346
		// assigned parties).
347
		Map<Integer, com.spice.profitmandi.dao.entity.auth.LocationTracking> existingByFofo = new HashMap<>();
348
		for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt :
349
				locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, taskDate)) {
350
			if (!"franchisee-visit".equals(lt.getTaskType())) continue;
351
			existingByFofo.putIfAbsent(lt.getTaskId(), lt);
352
		}
353
 
354
		// Defence-in-depth: Assign Visit is only valid for today's run. Hiding the
355
		// button on the UI isn't enough — block at the API too.
356
		if (!taskDate.equals(LocalDate.now())) {
357
			return responseSender.badRequest("Visits can only be assigned for today's date (" + LocalDate.now() + ")");
358
		}
359
 
36663 ranu 360
		LocalDateTime now = LocalDateTime.now();
36716 ranu 361
		int createdCount = 0, updatedCount = 0;
36663 ranu 362
 
363
		for (Map<String, Object> p : selected) {
364
			Integer fofoStoreId = ((Number) p.get("fofoStoreId")).intValue();
365
			String outletName = (String) p.get("outletName");
366
			String lat = (String) p.get("latitude");
367
			String lng = (String) p.get("longitude");
36716 ranu 368
			String description = (String) p.get("description");
369
			if (description != null) description = description.trim();
370
			if (description == null) description = "";
36663 ranu 371
 
36716 ranu 372
			// Multi-agenda: accept agendas[] (new format) or fall back to agenda (legacy single)
373
			List<String> agendas = new ArrayList<>();
374
			Object rawAgendas = p.get("agendas");
375
			if (rawAgendas instanceof List) {
376
				for (Object o : (List<?>) rawAgendas) {
377
					if (o != null) {
378
						String s = String.valueOf(o).trim();
379
						if (!s.isEmpty()) agendas.add(s);
380
					}
381
				}
382
			}
383
			if (agendas.isEmpty()) {
384
				String single = (String) p.get("agenda");
385
				if (single != null && !single.trim().isEmpty()) agendas.add(single.trim());
386
			}
387
			if (agendas.isEmpty()) agendas.add("Visit");
388
			String agendaJoined = String.join(", ", agendas);
389
 
36663 ranu 390
			String visitLocation = (lat != null && lng != null && !lat.isEmpty() && !lng.isEmpty())
391
					? (lat + "," + lng) : "0.0000,0.0000";
392
 
393
			String displayName = (outletName != null && !outletName.isEmpty()) ? outletName : ("Store #" + fofoStoreId);
36716 ranu 394
			String newTaskName = agendaJoined + " | " + displayName;
36663 ranu 395
 
36716 ranu 396
			com.spice.profitmandi.dao.entity.auth.LocationTracking existing = existingByFofo.get(fofoStoreId);
397
			if (existing != null) {
398
				// Refill — agenda (task_name), description, and visit location change
399
				existing.setTaskName(newTaskName);
400
				existing.setTaskDescription(description);
401
				existing.setVisitLocation(visitLocation);
402
				existing.setUpdatedTimestamp(now);
403
				locationTrackingRepositoryAuto.persist(existing);
404
				updatedCount++;
405
				continue;
406
			}
407
 
36663 ranu 408
			com.spice.profitmandi.dao.entity.auth.LocationTracking row =
409
					new com.spice.profitmandi.dao.entity.auth.LocationTracking();
410
			row.setUserId(dtrUserId);
411
			row.setDeviceId("0");
412
			row.setTaskId(fofoStoreId);
413
			row.setTaskDate(taskDate);
36716 ranu 414
			row.setTaskName(newTaskName);
415
			row.setTaskDescription(description);
36663 ranu 416
			row.setTaskType("franchisee-visit");
417
			row.setMarkType("PENDING");
418
			row.setAddress("");
419
			row.setVisitLocation(visitLocation);
420
			row.setCheckInLatLng("0.0000,0.0000");
421
			row.setCheckOutLatLng("0.0000,0.0000");
422
			row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
423
			row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
424
			row.setTransitTime(java.time.LocalTime.MIDNIGHT);
425
			row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
426
			row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
427
			row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
428
			row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
429
			row.setTotalDistance("0.0");
430
			row.setStatus(false);
431
			row.setCreatedTimestamp(now);
432
			row.setUpdatedTimestamp(now);
433
 
434
			// Do NOT try/catch this — if persist throws, let it propagate so
435
			// @Transactional(rollbackFor = Throwable.class) rolls back cleanly.
436
			locationTrackingRepositoryAuto.persist(row);
36716 ranu 437
			createdCount++;
36663 ranu 438
		}
36716 ranu 439
		LOGGER.info("assignVisit dtrUserId={} created={} updated={}", dtrUserId, createdCount, updatedCount);
36663 ranu 440
 
441
		Map<String, Object> result = new HashMap<>();
442
		result.put("status", true);
36716 ranu 443
		result.put("createdCount", createdCount);
444
		result.put("updatedCount", updatedCount);
36663 ranu 445
		result.put("dtrUserId", dtrUserId);
36716 ranu 446
		StringBuilder msg = new StringBuilder();
447
		if (createdCount > 0)
448
			msg.append(createdCount).append(" new visit").append(createdCount == 1 ? "" : "s").append(" assigned");
449
		if (updatedCount > 0) {
450
			if (msg.length() > 0) msg.append(", ");
451
			msg.append(updatedCount).append(" existing agenda").append(updatedCount == 1 ? "" : "s").append(" refilled");
452
		}
453
		msg.append(" for ").append(au.getFirstName()).append(" ").append(au.getLastName());
454
		result.put("message", msg.toString());
36663 ranu 455
		return responseSender.ok(result);
456
	}
457
 
36686 ranu 458
	@GetMapping(value = "/beatPlanWindow")
459
	public String beatPlanWindow(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
460
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
461
		return "beat-plan-window";
36655 ranu 462
	}
463
 
36668 ranu 464
	// Helpers for XLSX bulk upload
465
	private static String readCell(org.apache.poi.ss.usermodel.Cell cell) {
466
		if (cell == null) return null;
467
		switch (cell.getCellType()) {
468
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:
469
				return cell.getStringCellValue();
470
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:
471
				if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
472
					return cell.getDateCellValue().toInstant()
473
							.atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();
474
				}
475
				double n = cell.getNumericCellValue();
476
				return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);
477
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:
478
				return String.valueOf(cell.getBooleanCellValue());
479
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:
480
				return cell.getCellFormula();
481
			default:
482
				return null;
36655 ranu 483
		}
484
	}
485
 
486
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
487
	// For each active fofo_store, compare its stored lat/lng with the geocoded
488
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
489
	// OR the store has no lat/lng yet, update the store with the geocoded
490
	// coordinates. Otherwise keep the existing values.
491
	//
492
	// Usage:
493
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
494
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
495
	//   ?thresholdKm=3      -> use a different threshold
496
	//   ?limit=100          -> process only N stores (for staged runs)
497
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
498
	public ResponseEntity<?> migrateStoreLatLng(
499
			@RequestParam(required = false, defaultValue = "false") boolean apply,
500
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
36660 ranu 501
			@RequestParam(required = false, defaultValue = "0") int limit,
36727 ranu 502
			@RequestParam(required = false, defaultValue = "0") int offset,
503
			@RequestParam(required = false, defaultValue = "40") int maxSeconds) throws ProfitMandiBusinessException {
36655 ranu 504
 
36660 ranu 505
		List<FofoStore> all = fofoStoreRepository.selectActiveStores();
506
		int totalAvailable = all.size();
507
		int from = Math.max(0, Math.min(offset, totalAvailable));
36655 ranu 508
 
36727 ranu 509
		// Hard cap (if limit given), else go to the end of the list.
510
		int hardTo = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;
511
 
512
		// Time budget: stop processing once we approach the gateway timeout and
513
		// return nextOffset so the caller can resume. Geocoding is the slow part
514
		// (network/cache), so a fixed batch size could still time out on a cache-miss
515
		// run — a wall-clock budget is safer. maxSeconds defaults to 40 (< typical 60s gateway).
516
		long deadlineMs = System.currentTimeMillis() + Math.max(5, maxSeconds) * 1000L;
517
 
518
		List<FofoStore> stores = all.subList(from, hardTo);
36655 ranu 519
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
520
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
521
 
36727 ranu 522
		int total = 0;            // stores actually processed this call
36655 ranu 523
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
36727 ranu 524
		boolean stoppedOnTime = false;
525
		int nextIndex = from;     // absolute index of next unprocessed store
36655 ranu 526
		List<Map<String, Object>> changes = new ArrayList<>();
527
 
528
		for (FofoStore store : stores) {
36727 ranu 529
			// Stop before doing more slow geocoding work if we've spent our budget.
530
			if (System.currentTimeMillis() >= deadlineMs) {
531
				stoppedOnTime = true;
532
				break;
533
			}
534
			total++;
535
			nextIndex++;
36655 ranu 536
			try {
537
				CustomRetailer retailer = retailerMap.get(store.getId());
538
				if (retailer == null || retailer.getAddress() == null) {
539
					noAddress++;
540
					continue;
541
				}
542
 
543
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
544
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
545
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
546
				if (geoAddr == null || geoAddr.isEmpty()) {
547
					noAddress++;
548
					continue;
549
				}
550
 
551
				double[] coords = geocodingService.geocodeAddress(geoAddr);
552
				if (coords == null) {
553
					noGeocode++;
554
					continue;
555
				}
556
 
557
				Double existingLat = parseDoubleOrNull(store.getLatitude());
558
				Double existingLng = parseDoubleOrNull(store.getLongitude());
559
 
560
				boolean shouldUpdate;
561
				double distKm = -1;
562
				String reason;
563
				if (existingLat == null || existingLng == null) {
564
					shouldUpdate = true;
565
					reason = "missing existing lat/lng";
566
				} else {
567
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
568
					shouldUpdate = distKm > thresholdKm;
569
					reason = shouldUpdate
570
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
571
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
572
				}
573
 
574
				if (shouldUpdate) {
575
					if (apply) {
576
						store.setLatitude(String.valueOf(coords[0]));
577
						store.setLongitude(String.valueOf(coords[1]));
36727 ranu 578
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
36655 ranu 579
						fofoStoreRepository.persist(store);
580
					}
581
					updated++;
582
					Map<String, Object> ch = new HashMap<>();
583
					ch.put("storeId", store.getId());
584
					ch.put("code", store.getCode());
585
					ch.put("oldLat", existingLat);
586
					ch.put("oldLng", existingLng);
587
					ch.put("newLat", coords[0]);
588
					ch.put("newLng", coords[1]);
589
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
590
					ch.put("reason", reason);
591
					changes.add(ch);
592
				} else {
36727 ranu 593
					// Verified-kept: lat/lng was already within threshold. Still stamp it
594
					// so "processed vs pending" can be told from lat_lng_updated_timestamp.
595
					if (apply) {
596
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
597
						fofoStoreRepository.persist(store);
598
					}
36655 ranu 599
					kept++;
600
				}
601
			} catch (Exception e) {
602
				errored++;
603
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
604
			}
605
		}
606
 
607
		Map<String, Object> result = new HashMap<>();
608
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
609
		result.put("thresholdKm", thresholdKm);
36727 ranu 610
		result.put("totalAvailable", totalAvailable);   // total active stores in DB
36660 ranu 611
		result.put("offset", from);
36727 ranu 612
		result.put("processed", total);                  // stores processed this call
613
		result.put("nextOffset", nextIndex);             // resume here next call
614
		result.put("done", nextIndex >= totalAvailable); // true when nothing left
615
		result.put("stoppedOnTimeBudget", stoppedOnTime);// true if we paused for time, not because we finished
36655 ranu 616
		result.put("updated", updated);
617
		result.put("kept", kept);
618
		result.put("noAddress", noAddress);
619
		result.put("noGeocode", noGeocode);
620
		result.put("errored", errored);
621
		// Limit changes preview to avoid huge responses
622
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
623
		result.put("changesShownCount", Math.min(changes.size(), 200));
624
		return responseSender.ok(result);
625
	}
626
 
36651 ranu 627
	// ====================== EDIT BEAT ======================
628
	// Update an existing beat — name + partner stops (routes).
629
	// Schedules are NOT touched here; manage them via calendar drag-drop.
630
	@PostMapping(value = "/beatPlan/updateBeat")
631
	public ResponseEntity<?> updateBeat(
632
			HttpServletRequest request,
633
			@RequestParam int beatId,
634
			@RequestParam String planData) throws Exception {
635
 
636
		Beat beat = beatRepository.selectById(beatId);
637
		if (beat == null) return responseSender.badRequest("Beat not found");
638
 
639
		Gson gson = new Gson();
640
		Type type = new TypeToken<Map<String, Object>>() {
641
		}.getType();
642
		Map<String, Object> plan = gson.fromJson(planData, type);
643
 
644
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
645
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
646
 
647
		// Update name if changed (and not colliding with another beat)
648
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
649
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
36698 ranu 650
			// Make sure no other ACTIVE beat for this user already uses this name.
651
			// Soft-deleted beats keep their name in the table; we don't want them
652
			// to block a legitimate rename.
653
			boolean collides = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId()).stream()
36651 ranu 654
					.anyMatch(b -> b.getId() != beat.getId()
655
							&& b.getName() != null
656
							&& newName.equalsIgnoreCase(b.getName().trim()));
657
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
658
			beat.setName(newName);
659
		}
660
 
661
		// Update start location from first day if present
662
		Map<String, Object> firstDay = days.get(0);
663
		if (firstDay.get("startLocationName") != null)
664
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
665
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
666
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
667
 
36681 ranu 668
		int oldTotalDays = beat.getTotalDays();
669
		int newTotalDays = days.size();
670
 
671
		// Hard rule: you cannot grow the number of days on an existing beat.
672
		// If you need more days, create a new beat. (Shrinking is allowed and
673
		// the schedules for dropped day numbers are cleaned below.)
674
		if (newTotalDays > oldTotalDays) {
675
			return responseSender.badRequest(
676
					"Cannot increase the number of days on an existing beat. "
677
							+ "Original: " + oldTotalDays + " day(s), tried: " + newTotalDays + " day(s). "
678
							+ "Please create a new beat for additional days.");
679
		}
680
		beat.setTotalDays(newTotalDays);
681
 
682
		// Replace routes (partner stops). Schedules stay intact (except for
36711 ranu 683
        // dayNumber > newTotalDays cleanup + total km/min refresh below).
36651 ranu 684
		beatRouteRepository.deleteByBeatId(beatId);
36681 ranu 685
		// Collect lead IDs the user kept on the plan
36651 ranu 686
		Set<Integer> keptLeadIds = new HashSet<>();
687
		for (int d = 0; d < days.size(); d++) {
688
			Map<String, Object> day = days.get(d);
689
			int dayNumber = d + 1;
690
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
691
			if (visits == null) continue;
692
			int partnerSeq = 0;
693
			for (int i = 0; i < visits.size(); i++) {
694
				Map<String, Object> v = visits.get(i);
695
				if ("lead".equals(v.get("type"))) {
696
					keptLeadIds.add(((Number) v.get("id")).intValue());
697
					continue; // leads live in lead_route, handled below
698
				}
699
				BeatRoute route = new BeatRoute();
700
				route.setBeatId(beatId);
701
				route.setFofoId(((Number) v.get("id")).intValue());
702
				route.setSequenceOrder(partnerSeq++);
703
				route.setDayNumber(dayNumber);
704
				route.setActive(true);
36711 ranu 705
                if (v.get("distanceFromPrevKm") != null)
706
                    route.setDistanceFromPrevKm(((Number) v.get("distanceFromPrevKm")).doubleValue());
707
                if (v.get("timeFromPrevMins") != null)
708
                    route.setTimeFromPrevMins(((Number) v.get("timeFromPrevMins")).intValue());
36651 ranu 709
				beatRouteRepository.persist(route);
710
			}
711
		}
712
 
36681 ranu 713
		// If the beat shrank, drop schedule rows for day numbers that no longer exist
714
		if (newTotalDays < oldTotalDays) {
715
			List<BeatSchedule> currentSchedules = beatScheduleRepository.selectByBeatId(beatId);
716
			for (BeatSchedule s : currentSchedules) {
717
				if (s.getDayNumber() > newTotalDays) beatScheduleRepository.delete(s);
718
			}
719
		}
720
 
36711 ranu 721
        // Refresh the day-level totals on every remaining schedule row so they
722
        // reflect the post-edit route. Previously updateBeat left these stale
723
        // (or NULL, for beats created before this fix), which is what the user
724
        // reported. Keyed by dayNumber so multi-instance beats all get updated.
725
        Map<Integer, Map<String, Object>> dayByNumber = new HashMap<>();
726
        for (int d = 0; d < days.size(); d++) {
727
            dayByNumber.put(d + 1, days.get(d));
728
        }
729
        List<BeatSchedule> allSchedules = beatScheduleRepository.selectByBeatId(beatId);
730
        for (BeatSchedule s : allSchedules) {
731
            Map<String, Object> day = dayByNumber.get(s.getDayNumber());
732
            if (day == null) continue;
733
            if (day.get("totalDistanceKm") != null)
734
                s.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
735
            if (day.get("totalTimeMins") != null)
736
                s.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
737
        }
738
 
36681 ranu 739
		// Process per-lead actions sent from the editor's removed-leads popup.
740
		// Each entry: {leadId, action: "cancel"|"reschedule", toDate?: "yyyy-MM-dd"}.
741
		// - cancel: mark the lead's current APPROVED row for this beat as CANCELLED.
742
		// - reschedule: cancel here, then create a fresh APPROVED LeadRoute on
743
		//   whichever beat this user has scheduled on toDate. If no beat exists
744
		//   on toDate, the whole update fails (so the caller can prompt again).
745
		int leadsCancelled = 0, leadsRescheduled = 0;
746
		List<String> leadFailures = new ArrayList<>();
747
		String removedLeadActionsJson = (String) plan.get("removedLeadActions");
748
		if (removedLeadActionsJson != null && !removedLeadActionsJson.isEmpty()) {
749
			Type listType = new TypeToken<List<Map<String, Object>>>() {
750
			}.getType();
751
			List<Map<String, Object>> actions = gson.fromJson(removedLeadActionsJson, listType);
752
 
753
			List<LeadRoute> beatLeads = leadRouteRepository.selectByBeatId(beatId);
754
 
755
			for (Map<String, Object> act : actions) {
756
				int leadId = ((Number) act.get("leadId")).intValue();
757
				String mode = (String) act.get("action");
758
 
759
				// Find this lead's most-recent APPROVED row on this beat
760
				LeadRoute current = beatLeads.stream()
761
						.filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus()))
762
						.findFirst().orElse(null);
763
				if (current == null) continue; // already removed/cancelled; nothing to do
764
 
765
				if ("reschedule".equalsIgnoreCase(mode)) {
766
					String toDateStr = (String) act.get("toDate");
767
					if (toDateStr == null || toDateStr.isEmpty()) {
768
						leadFailures.add("Lead " + leadId + ": reschedule date missing");
769
						continue;
770
					}
771
					LocalDate toDate = LocalDate.parse(toDateStr);
772
 
773
					// Find ANY beat this user has scheduled on toDate
774
					Beat targetBeat = null;
775
					Integer targetDayNumber = null;
776
					List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId());
777
					for (Beat b : userBeats) {
778
						List<BeatSchedule> sl = beatScheduleRepository.selectByBeatId(b.getId());
779
						for (BeatSchedule s : sl) {
780
							if (s.getStartDate() != null && s.getStartDate().equals(toDate)) {
781
								targetBeat = b;
782
								targetDayNumber = s.getDayNumber();
783
								break;
784
							}
785
						}
786
						if (targetBeat != null) break;
787
					}
788
					if (targetBeat == null) {
789
						return responseSender.badRequest(
790
								"No beat is scheduled for this user on " + toDateStr
791
										+ ". Pick a different date for lead " + leadId
792
										+ ", or choose Cancel for it.");
793
					}
794
 
795
					// Cancel the current attachment to this beat
796
					current.setStatus("CANCELLED");
797
					current.setUpdatedTimestamp(LocalDateTime.now());
798
 
799
					// Create the new attachment on the target beat/date
800
					LeadRoute fresh = new LeadRoute();
801
					fresh.setBeatId(targetBeat.getId());
802
					fresh.setLeadId(leadId);
803
					fresh.setNearestStoreId(current.getNearestStoreId());
804
					fresh.setScheduleDate(toDate);
805
					fresh.setSequenceOrder(9999); // append; the planner can reorder
806
					fresh.setStatus("APPROVED");
807
					fresh.setRequestedBy(current.getRequestedBy());
808
					fresh.setApprovedBy(current.getApprovedBy());
809
					fresh.setApprovedTimestamp(LocalDateTime.now());
810
					fresh.setCreatedTimestamp(LocalDateTime.now());
811
					fresh.setUpdatedTimestamp(LocalDateTime.now());
812
					leadRouteRepository.persist(fresh);
813
 
814
					LeadActivity la = new LeadActivity();
815
					la.setLeadId(leadId);
816
					la.setRemark("Rescheduled from beat '" + beat.getName() + "' to '"
817
							+ targetBeat.getName() + "' on " + toDateStr + " (day " + targetDayNumber + ")");
818
					la.setAuthId(0);
819
					la.setCreatedTimestamp(LocalDateTime.now());
820
					leadActivityRepositoryAuto.persist(la);
821
					leadsRescheduled++;
822
				} else {
823
					// cancel (default)
824
					current.setStatus("CANCELLED");
825
					current.setUpdatedTimestamp(LocalDateTime.now());
826
 
827
					LeadActivity la = new LeadActivity();
828
					la.setLeadId(leadId);
829
					la.setRemark("Cancelled from beat '" + beat.getName() + "' during edit");
830
					la.setAuthId(0);
831
					la.setCreatedTimestamp(LocalDateTime.now());
832
					leadActivityRepositoryAuto.persist(la);
833
					leadsCancelled++;
36651 ranu 834
				}
835
			}
836
		}
837
 
838
		Map<String, Object> response = new HashMap<>();
839
		response.put("status", true);
840
		response.put("planGroupId", String.valueOf(beat.getId()));
36681 ranu 841
		response.put("leadsCancelled", leadsCancelled);
842
		response.put("leadsRescheduled", leadsRescheduled);
843
		response.put("leadFailures", leadFailures);
844
		response.put("message", "Beat updated successfully"
845
				+ (leadsCancelled > 0 ? " (" + leadsCancelled + " lead(s) cancelled)" : "")
846
				+ (leadsRescheduled > 0 ? " (" + leadsRescheduled + " lead(s) rescheduled)" : ""));
36651 ranu 847
		return responseSender.ok(response);
848
	}
849
 
36681 ranu 850
	// Used by the edit-mode "removed leads" popup so the date picker can warn
851
	// upfront when the user picks a date that has no beat for them.
852
	@GetMapping(value = "/beatPlan/userBeatsOnDate")
853
	public ResponseEntity<?> userBeatsOnDate(
854
			@RequestParam int authUserId,
855
			@RequestParam String date) {
856
		LocalDate target;
857
		try {
858
			target = LocalDate.parse(date);
859
		} catch (Exception e) {
860
			return responseSender.badRequest("Invalid date");
861
		}
862
 
863
		List<Map<String, Object>> hits = new ArrayList<>();
864
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
865
		for (Beat b : userBeats) {
866
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
867
			for (BeatSchedule s : schedules) {
868
				if (s.getStartDate() != null && s.getStartDate().equals(target)) {
869
					Map<String, Object> m = new HashMap<>();
870
					m.put("beatId", b.getId());
871
					m.put("beatName", b.getName());
872
					m.put("dayNumber", s.getDayNumber());
873
					hits.add(m);
874
				}
875
			}
876
		}
877
		Map<String, Object> result = new HashMap<>();
878
		result.put("date", date);
879
		result.put("authUserId", authUserId);
880
		result.put("beats", hits);
881
		return responseSender.ok(result);
882
	}
883
 
36686 ranu 884
	// ====================== BASE LOCATION MANAGEMENT ======================
885
	// Inline page that lets Sales L3+ pick a user and set their base (home)
886
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
887
	// go through the L3+-guarded endpoint below.
888
	@GetMapping(value = "/beatPlan/baseLocationPage")
889
	public String baseLocationPage(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
890
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
891
		return "beat-plan-base-location";
36650 ranu 892
	}
893
 
894
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
895
	@GetMapping(value = "/beatPlan/scheduledList")
896
	public ResponseEntity<?> scheduledList(
897
			@RequestParam(required = false) String startDate,
898
			@RequestParam(required = false) String endDate) {
899
 
900
		LocalDate start, end;
901
		try {
902
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
903
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
904
		} catch (Exception e) {
905
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
906
		}
907
 
908
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
909
				beatPlanQueryService.getAllScheduledBeats(start, end);
910
 
911
		// Resolve user names in bulk
912
		Set<Integer> userIds = beats.stream()
913
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
914
				.collect(java.util.stream.Collectors.toSet());
915
		Map<Integer, AuthUser> userMap = new HashMap<>();
916
		if (!userIds.isEmpty()) {
917
			authRepository.selectByIds(new ArrayList<>(userIds))
918
					.forEach(u -> userMap.put(u.getId(), u));
919
		}
920
 
921
		List<Map<String, Object>> rows = new ArrayList<>();
922
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
923
			AuthUser u = userMap.get(b.getAuthUserId());
924
			Map<String, Object> row = new HashMap<>();
925
			row.put("authUserId", b.getAuthUserId());
926
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
927
			row.put("scheduleDate", b.getScheduleDate().toString());
928
			row.put("dayNumber", b.getDayNumber());
929
			row.put("beatId", b.getBeatId());
930
			row.put("beatName", b.getBeatName());
931
			row.put("beatColor", b.getBeatColor());
932
			row.put("partnerCount", b.getPartnerStops().size());
933
			row.put("leadCount", b.getLeadStops().size());
934
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
935
			rows.add(row);
936
		}
937
 
938
		Map<String, Object> result = new HashMap<>();
939
		result.put("rows", rows);
940
		result.put("startDate", start.toString());
941
		result.put("endDate", end.toString());
942
		return responseSender.ok(result);
943
	}
944
 
945
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
946
	@GetMapping(value = "/beatPlan/dayViewData")
947
	public ResponseEntity<?> beatPlanDayViewData(
948
			@RequestParam int authUserId,
949
			@RequestParam String date) throws ProfitMandiBusinessException {
950
 
951
		LocalDate localDate;
952
		try {
953
			localDate = LocalDate.parse(date);
954
		} catch (Exception e) {
955
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
956
		}
957
 
958
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
959
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
960
 
961
		// Collect all partner & lead IDs to fetch metadata in bulk
962
		Set<Integer> partnerIds = new HashSet<>();
963
		Set<Integer> leadIds = new HashSet<>();
964
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
965
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
966
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
967
		}
968
 
969
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
970
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
971
				? new HashMap<>()
972
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
973
		Map<Integer, FofoStore> storeMap = new HashMap<>();
974
		if (!partnerIds.isEmpty()) {
975
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
976
					.forEach(fs -> storeMap.put(fs.getId(), fs));
977
		}
978
 
979
		// Leads: name + geo
980
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
981
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
982
		for (int leadId : leadIds) {
983
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
984
			if (l != null) leadMap.put(leadId, l);
985
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
986
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
987
			if (lg != null) leadGeoMap.put(leadId, lg);
988
		}
989
 
990
		// Enrich each stop
991
		List<Map<String, Object>> out = new ArrayList<>();
992
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
993
			Map<String, Object> beatJson = new HashMap<>();
994
			beatJson.put("beatId", b.getBeatId());
995
			beatJson.put("beatName", b.getBeatName());
996
			beatJson.put("beatColor", b.getBeatColor());
997
			beatJson.put("dayNumber", b.getDayNumber());
998
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
999
			beatJson.put("endAction", b.getEndAction());
1000
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
1001
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
1002
			beatJson.put("startLocationName", b.getStartLocationName());
1003
			beatJson.put("startLatitude", b.getStartLatitude());
1004
			beatJson.put("startLongitude", b.getStartLongitude());
1005
 
1006
			List<Map<String, Object>> stops = new ArrayList<>();
1007
			// Partners
1008
			for (Map<String, Object> ps : b.getPartnerStops()) {
1009
				int fofoId = (Integer) ps.get("fofoId");
1010
				Map<String, Object> stop = new HashMap<>();
1011
				stop.put("type", "partner");
1012
				stop.put("id", fofoId);
1013
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
1014
				FofoStore fs = storeMap.get(fofoId);
1015
				CustomRetailer cr = retailerMap.get(fofoId);
1016
				stop.put("code", fs != null ? fs.getCode() : null);
1017
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
1018
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 1019
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
1020
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
1021
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 1022
					try {
36655 ranu 1023
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
1024
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
1025
					} catch (NumberFormatException ignored) {
36650 ranu 1026
					}
1027
				}
36655 ranu 1028
				if (cr != null && cr.getAddress() != null) {
1029
					stop.put("address", cr.getAddress().getAddressString());
1030
				}
36650 ranu 1031
				stops.add(stop);
1032
			}
1033
			// Leads
1034
			for (Map<String, Object> ls : b.getLeadStops()) {
1035
				int leadId = (Integer) ls.get("leadId");
1036
				Map<String, Object> stop = new HashMap<>();
1037
				stop.put("type", "lead");
1038
				stop.put("id", leadId);
1039
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
1040
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
1041
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
1042
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
1043
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
1044
				stop.put("city", l != null ? l.getCity() : null);
1045
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
1046
				if (lg != null) {
1047
					stop.put("lat", lg.getLatitude());
1048
					stop.put("lng", lg.getLongitude());
1049
				}
1050
				stops.add(stop);
1051
			}
1052
			beatJson.put("stops", stops);
1053
			beatJson.put("partnerCount", b.getPartnerStops().size());
1054
			beatJson.put("leadCount", b.getLeadStops().size());
1055
			out.add(beatJson);
1056
		}
1057
 
1058
		Map<String, Object> result = new HashMap<>();
1059
		result.put("beats", out);
1060
		return responseSender.ok(result);
1061
	}
1062
 
36686 ranu 1063
	// ====================== DAY VIEW ======================
1064
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
1065
	// scheduled in a date range across all users. Each row has a View button that
1066
	// opens that user's calendar in a modal.
1067
	@GetMapping(value = "/beatPlan/dayView")
1068
	public String beatPlanDayView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1069
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
1070
		return "beat-plan-day-view";
36618 ranu 1071
	}
1072
 
36644 ranu 1073
	// Returns visits for a beat.
1074
	// - Partner stops (beat_route) belong to the beat template — always returned.
1075
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
1076
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 1077
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 1078
	public ResponseEntity<?> getBeatVisits(
1079
			@RequestParam String planGroupId,
1080
			@RequestParam(required = false) String planDate) {
1081
 
1082
		int beatId;
1083
		try {
1084
			beatId = Integer.parseInt(planGroupId);
1085
		} catch (NumberFormatException e) {
1086
			return responseSender.ok(new ArrayList<>());
1087
		}
1088
 
1089
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
1090
		List<Map<String, Object>> result = new ArrayList<>();
1091
 
1092
		// Partner stops — always (they belong to the beat template)
1093
		for (BeatRoute r : routes) {
36632 ranu 1094
			Map<String, Object> map = new HashMap<>();
36644 ranu 1095
			map.put("fofoId", r.getFofoId());
1096
			map.put("dayNumber", r.getDayNumber());
1097
			map.put("sequenceOrder", r.getSequenceOrder());
1098
			map.put("visitType", "partner");
1099
			result.add(map);
1100
		}
1101
 
1102
		// Lead stops — only for the requested run date
1103
		if (planDate != null && !planDate.isEmpty()) {
1104
			LocalDate date = LocalDate.parse(planDate);
1105
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
1106
			for (LeadRoute lr : leads) {
1107
				if ("APPROVED".equals(lr.getStatus())
1108
						&& lr.getScheduleDate() != null
1109
						&& lr.getScheduleDate().equals(date)) {
1110
					Map<String, Object> map = new HashMap<>();
1111
					map.put("fofoId", lr.getLeadId());
1112
					map.put("dayNumber", 1);
1113
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
1114
					map.put("visitType", "lead");
1115
					result.add(map);
1116
				}
1117
			}
1118
		}
1119
 
1120
		// Sort by dayNumber then sequenceOrder
1121
		result.sort((a, b) -> {
1122
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
1123
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
1124
		});
1125
 
36632 ranu 1126
		return responseSender.ok(result);
1127
	}
1128
 
36681 ranu 1129
	// Returns the user's DEFAULT base location. Falls back to most-recent for
1130
	// legacy users who pre-date the is_default column.
36618 ranu 1131
	@GetMapping(value = "/beatPlan/getBaseLocation")
1132
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
36681 ranu 1133
		AuthUserLocation baseLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36618 ranu 1134
		if (baseLoc == null) {
1135
			return responseSender.ok(new HashMap<>());
1136
		}
1137
		Map<String, Object> result = new HashMap<>();
1138
		result.put("id", baseLoc.getId());
1139
		result.put("locationName", baseLoc.getLocationName());
1140
		result.put("latitude", baseLoc.getLatitude());
1141
		result.put("longitude", baseLoc.getLongitude());
1142
		result.put("address", baseLoc.getAddress());
36681 ranu 1143
		result.put("isDefault", baseLoc.isDefault());
36618 ranu 1144
		return responseSender.ok(result);
1145
	}
1146
 
36681 ranu 1147
	// Returns ALL BASE locations for a user, default first.
1148
	@GetMapping(value = "/beatPlan/listBaseLocations")
1149
	public ResponseEntity<?> listBaseLocations(@RequestParam int authUserId) {
1150
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1151
		// Default at the top, then by created desc (the repo already returns desc).
1152
		all.sort((a, b) -> {
1153
			if (a.isDefault() && !b.isDefault()) return -1;
1154
			if (!a.isDefault() && b.isDefault()) return 1;
1155
			return 0;
1156
		});
1157
		List<Map<String, Object>> rows = new ArrayList<>();
1158
		for (AuthUserLocation l : all) {
1159
			Map<String, Object> row = new HashMap<>();
1160
			row.put("id", l.getId());
1161
			row.put("locationName", l.getLocationName());
1162
			row.put("latitude", l.getLatitude());
1163
			row.put("longitude", l.getLongitude());
1164
			row.put("address", l.getAddress());
1165
			row.put("isDefault", l.isDefault());
1166
			row.put("createdTimestamp", l.getCreatedTimestamp() != null ? l.getCreatedTimestamp().toString() : null);
1167
			rows.add(row);
1168
		}
1169
		Map<String, Object> result = new HashMap<>();
1170
		result.put("authUserId", authUserId);
1171
		result.put("locations", rows);
1172
		return responseSender.ok(result);
1173
	}
1174
 
1175
	// Flip the default flag — set this id default, clear all others.
1176
	@PostMapping(value = "/beatPlan/setDefaultBaseLocation")
1177
	public ResponseEntity<?> setDefaultBaseLocation(
1178
			HttpServletRequest request,
1179
			@RequestParam int id) throws ProfitMandiBusinessException {
1180
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1181
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1182
		if (me == null) return responseSender.unauthorized("Not logged in");
1183
		if (!isBaseLocationManager(me)) {
1184
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
1185
		}
1186
 
1187
		AuthUserLocation target = authUserLocationRepository.selectById(id);
1188
		if (target == null) return responseSender.badRequest("Location not found");
1189
 
1190
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(target.getAuthUserId(), "BASE");
1191
		for (AuthUserLocation l : all) {
1192
			boolean shouldBeDefault = (l.getId() == id);
1193
			if (l.isDefault() != shouldBeDefault) {
1194
				l.setDefault(shouldBeDefault);
1195
				authUserLocationRepository.persist(l); // saveOrUpdate
1196
			}
1197
		}
1198
 
1199
		Map<String, Object> result = new HashMap<>();
1200
		result.put("status", true);
1201
		result.put("id", id);
1202
		result.put("message", "Default base location updated");
1203
		return responseSender.ok(result);
1204
	}
1205
 
1206
	// Delete a base location. The DEFAULT one cannot be deleted — user must
1207
	// first pick another row as default.
1208
	@PostMapping(value = "/beatPlan/deleteBaseLocation")
1209
	public ResponseEntity<?> deleteBaseLocation(
1210
			HttpServletRequest request,
1211
			@RequestParam int id) throws ProfitMandiBusinessException {
1212
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1213
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1214
		if (me == null) return responseSender.unauthorized("Not logged in");
1215
		if (!isBaseLocationManager(me)) {
1216
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
1217
		}
1218
 
1219
		AuthUserLocation target = authUserLocationRepository.selectById(id);
1220
		if (target == null) return responseSender.badRequest("Location not found");
1221
		if (target.isDefault()) {
1222
			return responseSender.badRequest("Default base location cannot be removed. Set another location as default first.");
1223
		}
1224
 
1225
		authUserLocationRepository.delete(target);
1226
 
1227
		Map<String, Object> result = new HashMap<>();
1228
		result.put("status", true);
1229
		result.put("message", "Base location removed");
1230
		return responseSender.ok(result);
1231
	}
1232
 
36686 ranu 1233
	@GetMapping(value = "/beatPlan/getAuthUsers")
1234
	public ResponseEntity<?> getAuthUsers(
1235
			HttpServletRequest request,
1236
			@RequestParam int categoryId,
1237
			@RequestParam EscalationType escalationType) throws ProfitMandiBusinessException {
1238
 
1239
		// Hierarchy filter: a manager only sees users in their downline
1240
		// (themselves + every reportee under them, recursively). Super-admin
1241
		// emails bypass the filter and see everyone. Downline is computed by
1242
		// AuthService.getAllReportees (existing recursive walker).
1243
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1244
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1245
 
1246
		final Set<Integer> visible;
1247
		if (me == null || isSuperAdmin(me)) {
1248
			visible = null; // null = no filter
1249
		} else {
1250
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
1251
			visible.add(me.getId()); // include self
1252
		}
1253
 
1254
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
1255
		List<Map<String, Object>> result = authUsers.stream()
1256
				.filter(au -> au.getActive())
1257
				.filter(au -> visible == null || visible.contains(au.getId()))
1258
				.map(au -> {
1259
					Map<String, Object> map = new HashMap<>();
1260
					map.put("id", au.getId());
1261
					map.put("name", au.getFirstName() + " " + au.getLastName());
1262
					return map;
1263
				})
1264
				.collect(Collectors.toList());
1265
		return responseSender.ok(result);
1266
	}
1267
 
1268
	private boolean isSuperAdmin(AuthUser me) {
36681 ranu 1269
		String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";
36686 ranu 1270
		return SUPER_ADMIN_EMAILS.contains(myEmail);
1271
	}
36681 ranu 1272
 
36686 ranu 1273
	// Returns the user's highest escalation level across all positions.
1274
	// Mirrors OrderController.getSalesEscalationLevel but category-agnostic.
1275
	private EscalationType getHighestEscalation(int authUserId) {
1276
		EscalationType highest = null;
1277
		List<com.spice.profitmandi.dao.entity.cs.Position> positions = positionRepository.selectPositionByAuthId(authUserId);
1278
		for (com.spice.profitmandi.dao.entity.cs.Position p : positions) {
1279
			if (highest == null || p.getEscalationType().isGreaterThanEqualTo(highest)) {
1280
				highest = p.getEscalationType();
1281
			}
1282
		}
1283
		return highest;
1284
	}
1285
 
1286
	// Returns the escalation levels a user can manage — strictly below their own.
1287
	// L3 → [L1, L2]; L4 → [L1, L2, L3]; Final → all levels. Super-admin → all levels.
1288
	private List<EscalationType> getVisibleEscalationLevels(AuthUser me) {
1289
		if (isSuperAdmin(me)) return EscalationType.escalations;
1290
		EscalationType mine = getHighestEscalation(me.getId());
1291
		if (mine == null) return java.util.Collections.emptyList();
1292
		List<EscalationType> below = new ArrayList<>();
1293
		for (EscalationType e : EscalationType.escalations) {
1294
			if (mine.isGreaterThanEqualTo(e) && !e.equals(mine)) below.add(e);
1295
		}
1296
		return below;
1297
	}
1298
 
1299
	private List<EscalationType> visibleLevelsFor(HttpServletRequest request) throws ProfitMandiBusinessException {
1300
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1301
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1302
		return me == null ? java.util.Collections.emptyList() : getVisibleEscalationLevels(me);
1303
	}
1304
 
1305
	// Shared permission check for base-location admin actions: Sales L3+ OR super-admin.
1306
	private boolean isBaseLocationManager(AuthUser me) {
1307
		if (isSuperAdmin(me)) return true;
36681 ranu 1308
		return csService.getAuthUserIds(
1309
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
1310
						Arrays.asList(EscalationType.L3, EscalationType.L4))
1311
				.stream().anyMatch(u -> u.getId() == me.getId());
1312
	}
1313
 
36618 ranu 1314
	@PostMapping(value = "/beatPlan/saveBaseLocation")
1315
	public ResponseEntity<?> saveBaseLocation(
1316
			@RequestParam int authUserId,
1317
			@RequestParam String locationName,
1318
			@RequestParam String latitude,
1319
			@RequestParam String longitude,
1320
			@RequestParam(required = false) String address) {
1321
		AuthUserLocation loc = new AuthUserLocation();
1322
		loc.setAuthUserId(authUserId);
1323
		loc.setLocationType("BASE");
1324
		loc.setLocationName(locationName);
1325
		loc.setLatitude(latitude);
1326
		loc.setLongitude(longitude);
1327
		loc.setAddress(address);
1328
		loc.setCreatedTimestamp(LocalDateTime.now());
36681 ranu 1329
 
1330
		// First BASE for this user → auto-default so every user always has one.
1331
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1332
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1333
		loc.setDefault(existing.isEmpty() || noExistingDefault);
36618 ranu 1334
		authUserLocationRepository.persist(loc);
1335
 
1336
		Map<String, Object> result = new HashMap<>();
1337
		result.put("status", true);
1338
		result.put("id", loc.getId());
36681 ranu 1339
		result.put("isDefault", loc.isDefault());
36618 ranu 1340
		return responseSender.ok(result);
1341
	}
1342
 
1343
	@GetMapping(value = "/beatPlan/getPartners")
1344
	public ResponseEntity<?> getPartners(
1345
			@RequestParam int authUserId,
1346
			@RequestParam int categoryId,
1347
			@RequestParam(required = false) String startLat,
1348
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
1349
 
1350
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdPartnerIdMapping();
1351
		List<Integer> fofoIds = pp.get(authUserId);
1352
 
36644 ranu 1353
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 1354
			Map<String, Object> empty = new HashMap<>();
1355
			empty.put("partners", new ArrayList<>());
1356
			return responseSender.ok(empty);
1357
		}
1358
 
1359
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
1360
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
1361
 
1362
		List<Map<String, Object>> partners = new ArrayList<>();
1363
 
1364
		for (FofoStore store : fofoStores) {
1365
			if (!store.isActive() || store.isClosed()) continue;
1366
			CustomRetailer retailer = retailerMap.get(store.getId());
1367
 
1368
			Map<String, Object> partnerData = new HashMap<>();
1369
			partnerData.put("fofoId", store.getId());
1370
			partnerData.put("code", store.getCode());
1371
			partnerData.put("outletName", store.getOutletName());
1372
			partnerData.put("type", "partner");
1373
 
36655 ranu 1374
			// Use FofoStore lat/lng directly (migrated from address geocode)
1375
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
1376
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
1377
				partnerData.put("latitude", store.getLatitude());
1378
				partnerData.put("longitude", store.getLongitude());
1379
			}
1380
 
36618 ranu 1381
			if (retailer != null) {
1382
				partnerData.put("businessName", retailer.getBusinessName());
1383
				if (retailer.getAddress() != null) {
36644 ranu 1384
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 1385
				}
1386
			}
1387
			partners.add(partnerData);
1388
		}
1389
 
1390
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
1391
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
1392
		} else {
1393
			partners = sortByNearestNeighbor(partners);
1394
		}
1395
 
1396
		Map<String, Object> response = new HashMap<>();
1397
		response.put("partners", partners);
1398
		return responseSender.ok(response);
1399
	}
1400
 
1401
	@PostMapping(value = "/beatPlan/submitPlan")
1402
	public ResponseEntity<?> submitPlan(
1403
			HttpServletRequest request,
1404
			@RequestParam int authUserId,
1405
			@RequestParam String planData) throws Exception {
1406
 
1407
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1408
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1409
 
1410
		Gson gson = new Gson();
1411
		Type type = new TypeToken<Map<String, Object>>() {
1412
		}.getType();
1413
		Map<String, Object> plan = gson.fromJson(planData, type);
1414
 
1415
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
1416
		List<String> dates = (List<String>) plan.get("dates");
1417
 
36644 ranu 1418
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 1419
 
36698 ranu 1420
		// Duplicate check — same name + same authUserId among ACTIVE beats only.
1421
		// Soft-deleted beats keep the name in the table; we don't want them to
1422
		// block the user from reusing a name they "deleted".
1423
		List<Beat> existingBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36644 ranu 1424
		for (Beat existing : existingBeats) {
1425
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
1426
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 1427
				Map<String, Object> response = new HashMap<>();
1428
				response.put("status", true);
36644 ranu 1429
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 1430
				response.put("duplicate", true);
36644 ranu 1431
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 1432
				return responseSender.ok(response);
1433
			}
1434
		}
1435
 
36644 ranu 1436
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
1437
		int totalDays = days.size();
36618 ranu 1438
 
36644 ranu 1439
		// Create Beat master
1440
		Beat beat = new Beat();
1441
		beat.setName(beatName);
1442
		beat.setAuthUserId(authUserId);
1443
		beat.setBeatColor(beatColor);
1444
		beat.setTotalDays(totalDays);
1445
		beat.setActive(true);
1446
		beat.setCreatedBy(currentUser.getId());
1447
		beat.setCreatedTimestamp(LocalDateTime.now());
1448
 
1449
		// Set start location from first day
1450
		if (!days.isEmpty()) {
1451
			Map<String, Object> firstDay = days.get(0);
1452
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1453
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
1454
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
1455
		}
1456
		beatRepository.persist(beat);
1457
 
1458
		// End date of the whole beat = last scheduled day's date
1459
		LocalDate beatEndDate = null;
1460
		if (dates != null) {
1461
			for (int d = dates.size() - 1; d >= 0; d--) {
1462
				if (dates.get(d) != null) {
1463
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
1464
					break;
1465
				}
1466
			}
1467
		}
1468
 
1469
		// Create routes and schedules for each day
36618 ranu 1470
		for (int d = 0; d < days.size(); d++) {
1471
			Map<String, Object> day = days.get(d);
1472
			int dayNumber = d + 1;
1473
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 1474
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 1475
 
36644 ranu 1476
			// Auto-determine end action: last day = HOME, others = DAYBREAK
1477
			String endAction = (String) day.get("endAction");
1478
			if (endAction == null || endAction.isEmpty()) {
1479
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 1480
			}
1481
 
36644 ranu 1482
			// Always create schedule (even if planDate is null — unscheduled beat)
1483
			BeatSchedule schedule = new BeatSchedule();
1484
			schedule.setBeatId(beat.getId());
1485
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
1486
			schedule.setEndDate(beatEndDate);
1487
			schedule.setDayNumber(dayNumber);
1488
			schedule.setEndAction(endAction);
1489
			schedule.setStayLocationName((String) day.get("stayLocationName"));
1490
			schedule.setStayLatitude((String) day.get("stayLatitude"));
1491
			schedule.setStayLongitude((String) day.get("stayLongitude"));
1492
			if (day.get("totalDistanceKm") != null)
1493
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1494
			if (day.get("totalTimeMins") != null)
1495
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1496
			schedule.setCreatedTimestamp(LocalDateTime.now());
1497
			beatScheduleRepository.persist(schedule);
1498
 
36711 ranu 1499
            // Routes (stops) — also persist per-leg distance/time supplied by the
1500
            // client so reports/dashboards don't have to recompute from lat/lng.
36618 ranu 1501
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1502
			if (visits != null) {
1503
				for (int i = 0; i < visits.size(); i++) {
1504
					Map<String, Object> visit = visits.get(i);
36644 ranu 1505
					BeatRoute route = new BeatRoute();
1506
					route.setBeatId(beat.getId());
1507
					route.setFofoId(((Number) visit.get("id")).intValue());
1508
					route.setSequenceOrder(i);
1509
					route.setDayNumber(dayNumber);
1510
					route.setActive(true);
36711 ranu 1511
                    if (visit.get("distanceFromPrevKm") != null)
1512
                        route.setDistanceFromPrevKm(((Number) visit.get("distanceFromPrevKm")).doubleValue());
1513
                    if (visit.get("timeFromPrevMins") != null)
1514
                        route.setTimeFromPrevMins(((Number) visit.get("timeFromPrevMins")).intValue());
36644 ranu 1515
					beatRouteRepository.persist(route);
36618 ranu 1516
				}
1517
			}
1518
		}
1519
 
1520
		Map<String, Object> response = new HashMap<>();
1521
		response.put("status", true);
36644 ranu 1522
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 1523
		response.put("message", "Beat plan submitted successfully");
1524
		return responseSender.ok(response);
1525
	}
1526
 
36632 ranu 1527
	// ============ BULK UPLOAD ============
1528
 
1529
	@GetMapping(value = "/beatPlan/bulkUpload")
1530
	public String bulkUploadPage(HttpServletRequest request, Model model) {
1531
		return "beat-plan-bulk";
1532
	}
1533
 
36681 ranu 1534
	// Adds a new base location for the user. Caller can request this new row
1535
	// becomes the default. If the user has NO base locations yet, the new row
1536
	// is auto-defaulted (so every user always has exactly one default).
36668 ranu 1537
	@PostMapping(value = "/beatPlan/updateBaseLocation")
1538
	public ResponseEntity<?> updateBaseLocation(
1539
			HttpServletRequest request,
1540
			@RequestParam int authUserId,
1541
			@RequestParam String locationName,
1542
			@RequestParam String latitude,
1543
			@RequestParam String longitude,
36681 ranu 1544
			@RequestParam(required = false) String address,
1545
			@RequestParam(required = false, defaultValue = "false") boolean isDefault) throws Exception {
36668 ranu 1546
 
1547
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1548
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1549
		if (me == null) return responseSender.unauthorized("Not logged in");
36681 ranu 1550
		if (!isBaseLocationManager(me)) {
1551
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");
1552
		}
36668 ranu 1553
 
36681 ranu 1554
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1555
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1556
		boolean makeDefault = isDefault || existing.isEmpty() || noExistingDefault;
36668 ranu 1557
 
36681 ranu 1558
		// If this new row becomes the default, clear any existing default.
1559
		if (makeDefault) {
1560
			for (AuthUserLocation e : existing) {
1561
				if (e.isDefault()) {
1562
					e.setDefault(false);
1563
					authUserLocationRepository.persist(e);
1564
				}
1565
			}
36668 ranu 1566
		}
1567
 
1568
		AuthUserLocation loc = new AuthUserLocation();
1569
		loc.setAuthUserId(authUserId);
1570
		loc.setLocationType("BASE");
1571
		loc.setLocationName(locationName);
1572
		loc.setLatitude(latitude);
1573
		loc.setLongitude(longitude);
1574
		loc.setAddress(address);
36681 ranu 1575
		loc.setDefault(makeDefault);
36668 ranu 1576
		loc.setCreatedTimestamp(LocalDateTime.now());
1577
		authUserLocationRepository.persist(loc);
1578
 
1579
		Map<String, Object> result = new HashMap<>();
1580
		result.put("status", true);
1581
		result.put("id", loc.getId());
36681 ranu 1582
		result.put("isDefault", loc.isDefault());
1583
		result.put("message", makeDefault ? "Base location added and set as default" : "Base location added");
36668 ranu 1584
		return responseSender.ok(result);
1585
	}
1586
 
36632 ranu 1587
	@GetMapping(value = "/beatPlan/downloadTemplate")
36668 ranu 1588
	public ResponseEntity<?> downloadTemplate() throws java.io.IOException {
1589
		org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
1590
		org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");
36632 ranu 1591
 
36668 ranu 1592
		String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};
1593
 
1594
		// Header style
1595
		org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();
1596
		org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();
1597
		headerFont.setBold(true);
1598
		headerStyle.setFont(headerFont);
1599
		headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));
1600
		headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
1601
 
1602
		org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);
1603
		for (int i = 0; i < cols.length; i++) {
1604
			org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);
1605
			c.setCellValue(cols[i]);
1606
			c.setCellStyle(headerStyle);
1607
		}
1608
 
1609
		// Example rows — one partner per row. Inheritable columns blank after first row of a beat.
1610
		Object[][] sample = {
1611
				{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},
1612
				{"", "", "", "1", "2", "RJBUN1449"},
1613
				{"", "", "", "1", "3", "RJDEG1443"},
1614
				{"", "", "", "2", "1", "RJALR1362"},
1615
				{"", "", "", "2", "2", "RJBTR1388"},
1616
				{"", "", "", "3", "1", "RJRSD1518"},
1617
				{"", "", "", "3", "2", "RJSML356"},
1618
				{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},
1619
				{"", "", "", "1", "2", "UPAGR102"},
1620
		};
1621
		for (int r = 0; r < sample.length; r++) {
1622
			org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);
1623
			for (int c = 0; c < cols.length; c++) {
1624
				row.createCell(c).setCellValue(sample[r][c].toString());
1625
			}
1626
		}
1627
		for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);
1628
 
1629
		java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1630
		wb.write(out);
1631
		wb.close();
1632
 
36632 ranu 1633
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
36668 ranu 1634
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");
1635
		headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
1636
		return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
36632 ranu 1637
	}
1638
 
1639
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
1640
	public ResponseEntity<?> bulkUploadProcess(
1641
			HttpServletRequest request,
1642
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
1643
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
1644
 
1645
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1646
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1647
 
36668 ranu 1648
		// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date
1649
		// appear ONLY on the first row of a beat; subsequent rows inherit them.
1650
		org.apache.poi.ss.usermodel.Workbook workbook =
1651
				new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());
1652
		org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
36632 ranu 1653
 
36668 ranu 1654
		// Header → column index map
1655
		org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);
1656
		if (headerRow == null) {
1657
			workbook.close();
1658
			return responseSender.badRequest("Empty file");
1659
		}
1660
		Map<String, Integer> colIdx = new HashMap<>();
1661
		for (int i = 0; i < headerRow.getLastCellNum(); i++) {
1662
			String h = readCell(headerRow.getCell(i));
1663
			if (h != null) colIdx.put(h.trim().toLowerCase(), i);
1664
		}
1665
		for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {
1666
			if (!colIdx.containsKey(required)) {
1667
				workbook.close();
1668
				return responseSender.badRequest("Missing required column: " + required);
1669
			}
1670
		}
36632 ranu 1671
 
36668 ranu 1672
		// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_order
1673
		Map<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();
1674
		String currentKey = null;
1675
		String currentBeatName = null;
1676
		String currentAuthId = null;
1677
		String currentStartDate = null;
1678
 
1679
		for (int r = 1; r <= sheet.getLastRowNum(); r++) {
1680
			org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
1681
			if (row == null) continue;
1682
 
1683
			String beatName = readCell(row.getCell(colIdx.get("beat_name")));
1684
			String authId = readCell(row.getCell(colIdx.get("auth_user_id")));
1685
			String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;
1686
			String dayNumber = readCell(row.getCell(colIdx.get("day_number")));
1687
			String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;
1688
			String code = readCell(row.getCell(colIdx.get("partner_code")));
1689
 
1690
			if (beatName != null && !beatName.trim().isEmpty()) {
1691
				// Start of a new beat — capture inheritable fields
1692
				currentBeatName = beatName.trim().replaceAll("\\s+", " ");
1693
				currentAuthId = authId != null ? authId.trim() : null;
1694
				currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;
1695
				currentKey = currentBeatName + "|" + currentAuthId;
1696
			}
1697
			if (currentKey == null) continue; // partner row before any beat header — skip
1698
			if (code == null || code.trim().isEmpty()) continue;
1699
 
1700
			final String beatNameF = currentBeatName;
1701
			final String authIdF = currentAuthId;
1702
			final String startDateF = currentStartDate;
1703
			BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));
1704
 
1705
			int day;
1706
			try {
1707
				day = Integer.parseInt(dayNumber.trim());
1708
			} catch (Exception e) {
1709
				continue;
1710
			} // bad day → skip row
1711
 
1712
			int seq = -1;
1713
			if (seqOrder != null && !seqOrder.trim().isEmpty()) {
1714
				try {
1715
					seq = Integer.parseInt(seqOrder.trim());
1716
				} catch (Exception ignore) {
1717
				}
1718
			}
1719
			g.addPartner(day, seq, code.trim(), r + 1);
36632 ranu 1720
		}
36668 ranu 1721
		workbook.close();
36632 ranu 1722
 
1723
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
1724
		Map<String, Integer> codeToId = new HashMap<>();
36644 ranu 1725
		for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());
36632 ranu 1726
 
1727
		LocalDate holidayStart = LocalDate.now();
36644 ranu 1728
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 1729
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
1730
 
36644 ranu 1731
		int beatsCreated = 0, errors = 0;
36632 ranu 1732
		List<String> errorMessages = new ArrayList<>();
1733
 
36668 ranu 1734
		for (BulkBeatGroup g : beatGroups.values()) {
36632 ranu 1735
			try {
36668 ranu 1736
				String beatName = g.beatName;
1737
				int authUserId;
1738
				try {
1739
					authUserId = Integer.parseInt(g.authUserId);
1740
				} catch (Exception e) {
1741
					errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'. Skipped.");
1742
					errors++;
1743
					continue;
1744
				}
36632 ranu 1745
 
36668 ranu 1746
				LocalDate startDate = (g.startDate == null) ? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
36632 ranu 1747
				if (startDate != null && startDate.isBefore(LocalDate.now())) {
36644 ranu 1748
					errorMessages.add("Beat '" + beatName + "': start_date in past. Skipped.");
36632 ranu 1749
					errors++;
1750
					continue;
1751
				}
1752
 
36668 ranu 1753
				List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
1754
				Collections.sort(sortedDays);
1755
 
36632 ranu 1756
				List<LocalDate> scheduleDates = new ArrayList<>();
1757
				if (startDate != null) {
1758
					LocalDate d = startDate;
36668 ranu 1759
					while (scheduleDates.size() < sortedDays.size()) {
1760
						if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
36644 ranu 1761
							d = d.plusDays(1);
1762
							continue;
36632 ranu 1763
						}
36644 ranu 1764
						scheduleDates.add(d);
36632 ranu 1765
						d = d.plusDays(1);
1766
					}
1767
				}
1768
 
36698 ranu 1769
				// Duplicate check — ACTIVE beats only (soft-deleted names are reusable)
1770
				boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
36644 ranu 1771
						.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
1772
				if (isDuplicate) {
1773
					errorMessages.add("Beat '" + beatName + "' already exists for user " + authUserId + ". Skipped.");
1774
					errors++;
1775
					continue;
1776
				}
36632 ranu 1777
 
36644 ranu 1778
				String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
36681 ranu 1779
				AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36632 ranu 1780
 
36644 ranu 1781
				Beat beat = new Beat();
1782
				beat.setName(beatName);
1783
				beat.setAuthUserId(authUserId);
1784
				beat.setBeatColor(beatColor);
36668 ranu 1785
				beat.setTotalDays(sortedDays.size());
36644 ranu 1786
				beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
1787
				beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
1788
				beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
1789
				beat.setActive(true);
1790
				beat.setCreatedBy(currentUser.getId());
1791
				beat.setCreatedTimestamp(LocalDateTime.now());
1792
				beatRepository.persist(beat);
36632 ranu 1793
 
36668 ranu 1794
				LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
36632 ranu 1795
 
36668 ranu 1796
				for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
1797
					int dayNumber = sortedDays.get(dayIdx);
1798
					LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
1799
 
36644 ranu 1800
					BeatSchedule schedule = new BeatSchedule();
1801
					schedule.setBeatId(beat.getId());
1802
					schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
1803
					schedule.setEndDate(bulkEndDate);
1804
					schedule.setDayNumber(dayNumber);
36668 ranu 1805
					schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
36644 ranu 1806
					schedule.setCreatedTimestamp(LocalDateTime.now());
1807
					beatScheduleRepository.persist(schedule);
36632 ranu 1808
 
36668 ranu 1809
					List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
1810
					// Sort by explicit sequence_order when present, else by row order
1811
					partners.sort((a, b) -> {
1812
						if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
1813
						if (a.seq >= 0) return -1;
1814
						if (b.seq >= 0) return 1;
1815
						return Integer.compare(a.rowNum, b.rowNum);
1816
					});
1817
 
1818
					int autoSeq = 0;
1819
					for (BulkPartner p : partners) {
1820
						Integer fofoId = codeToId.get(p.code);
36632 ranu 1821
						if (fofoId == null) {
36668 ranu 1822
							errorMessages.add("Row " + p.rowNum + ": code not found '" + p.code + "'");
36632 ranu 1823
							errors++;
1824
							continue;
1825
						}
36644 ranu 1826
						BeatRoute route = new BeatRoute();
1827
						route.setBeatId(beat.getId());
1828
						route.setFofoId(fofoId);
36668 ranu 1829
						route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
36644 ranu 1830
						route.setDayNumber(dayNumber);
1831
						route.setActive(true);
1832
						beatRouteRepository.persist(route);
36668 ranu 1833
						autoSeq++;
36632 ranu 1834
					}
1835
				}
1836
				beatsCreated++;
1837
			} catch (Exception e) {
1838
				errors++;
36668 ranu 1839
				errorMessages.add("Error: " + g.beatName + " - " + e.getMessage());
36632 ranu 1840
			}
1841
		}
1842
 
1843
		Map<String, Object> response = new HashMap<>();
1844
		response.put("status", true);
1845
		response.put("beatsCreated", beatsCreated);
1846
		response.put("errors", errors);
1847
		response.put("errorMessages", errorMessages);
1848
		return responseSender.ok(response);
1849
	}
1850
 
36668 ranu 1851
	private static class BulkBeatGroup {
1852
		final String beatName;
1853
		final String authUserId;
1854
		final String startDate;
1855
		final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();
1856
 
1857
		BulkBeatGroup(String beatName, String authUserId, String startDate) {
1858
			this.beatName = beatName;
1859
			this.authUserId = authUserId;
1860
			this.startDate = startDate;
1861
		}
1862
 
1863
		void addPartner(int day, int seq, String code, int rowNum) {
1864
			dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));
1865
		}
1866
	}
1867
 
1868
	private static class BulkPartner {
1869
		final int seq;
1870
		final String code;
1871
		final int rowNum;
1872
 
1873
		BulkPartner(int seq, String code, int rowNum) {
1874
			this.seq = seq;
1875
			this.code = code;
1876
			this.rowNum = rowNum;
1877
		}
1878
	}
1879
 
36644 ranu 1880
	// ============ CALENDAR ============
36618 ranu 1881
 
1882
	@PostMapping(value = "/beatPlan/delete")
1883
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 1884
		int beatId = Integer.parseInt(planGroupId);
36698 ranu 1885
		// Hard delete — wipe all child rows first, then the beat itself.
1886
		// The name slot is freed naturally because the row is gone.
36644 ranu 1887
		beatRouteRepository.deleteByBeatId(beatId);
1888
		beatScheduleRepository.deleteByBeatId(beatId);
36698 ranu 1889
		leadRouteRepository.deleteByBeatId(beatId);
36644 ranu 1890
		Beat beat = beatRepository.selectById(beatId);
1891
		if (beat != null) {
36698 ranu 1892
			beatRepository.delete(beat);
36644 ranu 1893
		}
36618 ranu 1894
 
1895
		Map<String, Object> response = new HashMap<>();
1896
		response.put("status", true);
1897
		response.put("message", "Beat deleted");
1898
		return responseSender.ok(response);
1899
	}
1900
 
36670 ranu 1901
	// Unschedule the beat from ONE specific date — does NOT delete the beat.
1902
	// The beat (and its route template) stays; only the matching beat_schedule
1903
	// row is removed. If no real-date schedules remain, a placeholder
1904
	// (9999-12-31) row is added so the beat still shows up as "unscheduled".
1905
	@PostMapping(value = "/beatPlan/unscheduleDate")
1906
	public ResponseEntity<?> unscheduleDate(
1907
			@RequestParam String planGroupId,
1908
			@RequestParam String date) {
1909
		int beatId = Integer.parseInt(planGroupId);
1910
		LocalDate target = LocalDate.parse(date);
1911
 
1912
		Beat beat = beatRepository.selectById(beatId);
1913
		if (beat == null) return responseSender.badRequest("Beat not found");
1914
 
1915
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
1916
		int removed = 0;
1917
		for (BeatSchedule s : schedules) {
1918
			if (s.getStartDate() != null && s.getStartDate().equals(target)) {
1919
				beatScheduleRepository.delete(s);
1920
				removed++;
1921
			}
1922
		}
1923
		if (removed == 0) return responseSender.badRequest("No schedule found for that date");
1924
 
1925
		// If no real-date schedules left, drop in a placeholder so the beat
1926
		// remains visible in the unscheduled bucket.
1927
		boolean hasReal = schedules.stream()
1928
				.filter(s -> !s.getStartDate().equals(target))
1929
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);
1930
		if (!hasReal) {
1931
			boolean hasPlaceholder = schedules.stream()
1932
					.filter(s -> !s.getStartDate().equals(target))
1933
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);
1934
			if (!hasPlaceholder) {
1935
				BeatSchedule ph = new BeatSchedule();
1936
				ph.setBeatId(beatId);
1937
				ph.setStartDate(LocalDate.of(9999, 12, 31));
1938
				ph.setDayNumber(1);
1939
				ph.setEndAction("HOME");
1940
				ph.setCreatedTimestamp(LocalDateTime.now());
1941
				beatScheduleRepository.persist(ph);
1942
			}
1943
		}
1944
 
1945
		Map<String, Object> response = new HashMap<>();
1946
		response.put("status", true);
1947
		response.put("message", "Unscheduled from " + date);
1948
		return responseSender.ok(response);
1949
	}
1950
 
1951
	// Move a beat from one date to another — used by calendar drag-and-drop.
1952
	// Just updates the start_date of the matching beat_schedule row.
1953
	@PostMapping(value = "/beatPlan/moveScheduleDate")
1954
	public ResponseEntity<?> moveScheduleDate(
1955
			@RequestParam String planGroupId,
1956
			@RequestParam String fromDate,
1957
			@RequestParam String toDate) {
1958
		int beatId = Integer.parseInt(planGroupId);
1959
		LocalDate from = LocalDate.parse(fromDate);
1960
		LocalDate to = LocalDate.parse(toDate);
1961
 
1962
		if (from.equals(to)) {
1963
			Map<String, Object> ok = new HashMap<>();
1964
			ok.put("status", true);
1965
			ok.put("message", "Same date — no change");
1966
			return responseSender.ok(ok);
1967
		}
1968
 
1969
		Beat beat = beatRepository.selectById(beatId);
1970
		if (beat == null) return responseSender.badRequest("Beat not found");
1971
 
1972
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
1973
 
1974
		// Reject if the beat is already on the target date
1975
		boolean conflict = schedules.stream()
1976
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
1977
		if (conflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
1978
 
1979
		BeatSchedule match = schedules.stream()
1980
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
1981
				.findFirst().orElse(null);
1982
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
1983
 
1984
		match.setStartDate(to);
1985
		// Recompute endDate as the max across all (post-update) schedules
1986
		LocalDate newEnd = schedules.stream()
1987
				.map(s -> s == match ? to : s.getStartDate())
1988
				.filter(d -> d != null && d.getYear() != 9999)
1989
				.max(LocalDate::compareTo).orElse(to);
1990
		for (BeatSchedule s : schedules) {
1991
			if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {
1992
				s.setEndDate(newEnd);
1993
			}
1994
		}
1995
 
1996
		Map<String, Object> response = new HashMap<>();
1997
		response.put("status", true);
1998
		response.put("message", "Moved from " + fromDate + " to " + toDate);
1999
		return responseSender.ok(response);
2000
	}
2001
 
36618 ranu 2002
	@GetMapping(value = "/beatPlan/calendar")
2003
	public ResponseEntity<?> getCalendar(
2004
			@RequestParam int authUserId,
2005
			@RequestParam String month) {
2006
 
2007
		YearMonth ym = YearMonth.parse(month);
2008
		LocalDate startDate = ym.atDay(1);
2009
		LocalDate endDate = ym.atEndOfMonth();
2010
 
2011
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
2012
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
2013
			Map<String, String> m = new HashMap<>();
2014
			m.put("date", h.getDate().toString());
2015
			m.put("occasion", h.getOccasion());
2016
			return m;
2017
		}).collect(Collectors.toList());
2018
 
36644 ranu 2019
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 2020
		LocalDate today = LocalDate.now();
2021
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
2022
 
36644 ranu 2023
		for (Beat beat : allBeats) {
2024
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
2025
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 2026
 
36644 ranu 2027
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
2028
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
2029
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
2030
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 2031
 
2032
			String status;
2033
			if (allNullDates) status = "unscheduled";
2034
			else if (hasToday) status = "running";
2035
			else if (allPast) status = "completed";
36644 ranu 2036
			else status = "scheduled";
36618 ranu 2037
 
36644 ranu 2038
			Map<String, Object> beatInfo = new HashMap<>();
2039
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
2040
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
2041
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
2042
			beatInfo.put("status", status);
36618 ranu 2043
 
2044
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 2045
			for (BeatSchedule s : schedules) {
36618 ranu 2046
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 2047
				dayInfo.put("dayNumber", s.getDayNumber());
2048
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
2049
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
2050
				dayInfo.put("totalKm", s.getTotalDistanceKm());
2051
				dayInfo.put("totalMins", s.getTotalTimeMins());
36711 ranu 2052
                // endAction tells the planner whether to draw the return-to-home line
2053
                // for this day (HOME) or end at the last stop (DAYBREAK).
2054
                dayInfo.put("endAction", s.getEndAction());
36644 ranu 2055
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
2056
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 2057
				dayInfoList.add(dayInfo);
2058
			}
36644 ranu 2059
			if (schedules.isEmpty()) {
2060
				// No schedule at all — show from routes
2061
				Map<Integer, Long> dayCounts = routes.stream()
2062
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
2063
				for (int d = 1; d <= beat.getTotalDays(); d++) {
2064
					Map<String, Object> dayInfo = new HashMap<>();
2065
					dayInfo.put("dayNumber", d);
2066
					dayInfo.put("planDate", null);
2067
					dayInfo.put("totalKm", null);
2068
					dayInfo.put("totalMins", null);
2069
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
2070
					dayInfoList.add(dayInfo);
2071
				}
2072
			}
2073
			beatInfo.put("days", dayInfoList);
2074
			scheduledBeats.add(beatInfo);
36618 ranu 2075
		}
2076
 
2077
		Set<String> blockedDates = new HashSet<>();
2078
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 2079
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 2080
		}
36644 ranu 2081
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 2082
 
2083
		Map<String, Object> response = new HashMap<>();
2084
		response.put("holidays", holidayList);
2085
		response.put("scheduledBeats", scheduledBeats);
2086
		response.put("blockedDates", blockedDates);
2087
		return responseSender.ok(response);
2088
	}
2089
 
2090
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
2091
	public ResponseEntity<?> scheduleOnCalendar(
2092
			HttpServletRequest request,
2093
			@RequestParam String planGroupId,
2094
			@RequestParam String dates,
2095
			@RequestParam(required = false) String beatName,
2096
			@RequestParam(required = false) String beatColor) throws Exception {
2097
 
36644 ranu 2098
		int beatId = Integer.parseInt(planGroupId);
36618 ranu 2099
		Gson gson = new Gson();
2100
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2101
		}.getType());
2102
 
36644 ranu 2103
		Beat beat = beatRepository.selectById(beatId);
2104
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 2105
 
36644 ranu 2106
		if (beatName != null) beat.setName(beatName);
2107
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36618 ranu 2108
 
36644 ranu 2109
		// Delete old schedules and create new
2110
		beatScheduleRepository.deleteByBeatId(beatId);
2111
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
2112
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
36711 ranu 2113
            int dayNumber = i + 1;
2114
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
36644 ranu 2115
			BeatSchedule schedule = new BeatSchedule();
2116
			schedule.setBeatId(beatId);
2117
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
2118
			schedule.setEndDate(schEndDate);
36711 ranu 2119
            schedule.setDayNumber(dayNumber);
2120
            schedule.setEndAction(endAction);
2121
            // Fill total_distance_km / total_time_mins from beat_route so the new
2122
            // schedule row isn't NULL (this was the bug — these were left unset).
2123
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
2124
            schedule.setTotalDistanceKm(totals[0]);
2125
            schedule.setTotalTimeMins((int) totals[1]);
36644 ranu 2126
			schedule.setCreatedTimestamp(LocalDateTime.now());
2127
			beatScheduleRepository.persist(schedule);
36618 ranu 2128
		}
2129
 
2130
		Map<String, Object> response = new HashMap<>();
2131
		response.put("status", true);
2132
		response.put("message", "Beat scheduled successfully");
2133
		return responseSender.ok(response);
2134
	}
2135
 
36644 ranu 2136
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 2137
	@PostMapping(value = "/beatPlan/repeatBeat")
2138
	public ResponseEntity<?> repeatBeat(
2139
			HttpServletRequest request,
2140
			@RequestParam String sourcePlanGroupId,
2141
			@RequestParam int authUserId,
2142
			@RequestParam String dates) throws Exception {
2143
 
36644 ranu 2144
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 2145
		Gson gson = new Gson();
2146
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2147
		}.getType());
2148
 
36644 ranu 2149
		Beat beat = beatRepository.selectById(beatId);
2150
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 2151
 
36644 ranu 2152
		// Remove placeholder (unscheduled) schedule rows
2153
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
2154
		for (BeatSchedule s : existing) {
2155
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
2156
				beatScheduleRepository.delete(s);
2157
			}
36618 ranu 2158
		}
2159
 
36711 ranu 2160
        // Add new real-date schedule rows for the existing beat — fill totals
2161
        // from beat_route so total_distance_km / total_time_mins aren't NULL.
36644 ranu 2162
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
2163
		for (int i = 0; i < dateList.size(); i++) {
36711 ranu 2164
            int dayNumber = i + 1;
2165
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
36644 ranu 2166
			BeatSchedule schedule = new BeatSchedule();
2167
			schedule.setBeatId(beatId);
2168
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
2169
			schedule.setEndDate(repeatEndDate);
36711 ranu 2170
            schedule.setDayNumber(dayNumber);
2171
            schedule.setEndAction(endAction);
2172
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
2173
            schedule.setTotalDistanceKm(totals[0]);
2174
            schedule.setTotalTimeMins((int) totals[1]);
36644 ranu 2175
			schedule.setCreatedTimestamp(LocalDateTime.now());
2176
			beatScheduleRepository.persist(schedule);
36618 ranu 2177
		}
2178
 
2179
		Map<String, Object> response = new HashMap<>();
2180
		response.put("status", true);
36644 ranu 2181
		response.put("planGroupId", String.valueOf(beatId));
2182
		response.put("message", "Beat scheduled successfully");
36618 ranu 2183
		return responseSender.ok(response);
2184
	}
2185
 
2186
	@GetMapping(value = "/beatPlan/availableSlots")
2187
	public ResponseEntity<?> getAvailableSlots(
2188
			@RequestParam int authUserId,
2189
			@RequestParam String month,
2190
			@RequestParam int daysNeeded) {
2191
 
2192
		YearMonth ym = YearMonth.parse(month);
2193
		LocalDate startDate = ym.atDay(1);
2194
		LocalDate endDate = ym.atEndOfMonth();
2195
		LocalDate today = LocalDate.now();
2196
 
2197
		Set<LocalDate> blocked = new HashSet<>();
2198
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
2199
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 2200
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 2201
		}
2202
 
2203
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
2204
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
2205
 
36644 ranu 2206
		// Get all scheduled dates for this user
2207
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
2208
		for (Beat b : userBeats) {
2209
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
2210
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 2211
		}
2212
 
2213
		List<String> available = new ArrayList<>();
2214
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
2215
			 !d.isAfter(endDate) && available.size() < daysNeeded;
2216
			 d = d.plusDays(1)) {
36644 ranu 2217
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 2218
		}
2219
 
2220
		Map<String, Object> response = new HashMap<>();
2221
		response.put("suggestedDates", available);
2222
		response.put("totalAvailable", available.size());
2223
		return responseSender.ok(response);
2224
	}
2225
 
2226
	// --- Sorting helpers ---
2227
 
2228
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
2229
			List<Map<String, Object>> partners, double startLat, double startLng) {
2230
		List<Map<String, Object>> withCoords = new ArrayList<>();
2231
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2232
		for (Map<String, Object> p : partners) {
36644 ranu 2233
			if (hasValidCoords(p)) withCoords.add(p);
2234
			else withoutCoords.add(p);
36618 ranu 2235
		}
2236
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 2237
		double currentLat = startLat, currentLng = startLng;
36618 ranu 2238
		while (!withCoords.isEmpty()) {
2239
			int nearestIdx = 0;
2240
			double nearestDist = Double.MAX_VALUE;
2241
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2242
				double dist = haversine(currentLat, currentLng,
2243
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
2244
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 2245
				if (dist < nearestDist) {
2246
					nearestDist = dist;
2247
					nearestIdx = i;
2248
				}
2249
			}
2250
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
2251
			sorted.add(nearest);
2252
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
2253
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
2254
		}
2255
		sorted.addAll(withoutCoords);
2256
		return sorted;
2257
	}
2258
 
2259
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
2260
		List<Map<String, Object>> withCoords = new ArrayList<>();
2261
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2262
		for (Map<String, Object> p : partners) {
36644 ranu 2263
			if (hasValidCoords(p)) withCoords.add(p);
2264
			else withoutCoords.add(p);
36618 ranu 2265
		}
2266
		List<Map<String, Object>> sorted = new ArrayList<>();
2267
		if (!withCoords.isEmpty()) {
2268
			sorted.add(withCoords.remove(0));
2269
			while (!withCoords.isEmpty()) {
2270
				Map<String, Object> last = sorted.get(sorted.size() - 1);
2271
				double lastLat = Double.parseDouble(last.get("latitude").toString());
2272
				double lastLng = Double.parseDouble(last.get("longitude").toString());
2273
				int nearestIdx = 0;
2274
				double nearestDist = Double.MAX_VALUE;
2275
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2276
					double dist = haversine(lastLat, lastLng,
2277
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
2278
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 2279
					if (dist < nearestDist) {
2280
						nearestDist = dist;
2281
						nearestIdx = i;
2282
					}
2283
				}
2284
				sorted.add(withCoords.remove(nearestIdx));
2285
			}
2286
		}
2287
		sorted.addAll(withoutCoords);
2288
		return sorted;
2289
	}
2290
 
2291
	private boolean hasValidCoords(Map<String, Object> p) {
2292
		Object lat = p.get("latitude");
2293
		Object lng = p.get("longitude");
36644 ranu 2294
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 2295
	}
2296
 
2297
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
2298
		double R = 6371;
2299
		double dLat = Math.toRadians(lat2 - lat1);
2300
		double dLng = Math.toRadians(lng2 - lng1);
2301
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
2302
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
2303
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
2304
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
2305
		return R * c;
2306
	}
2307
}