Subversion Repositories SmartDukaan

Rev

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