Subversion Repositories SmartDukaan

Rev

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