Subversion Repositories SmartDukaan

Rev

Rev 36785 | Rev 36802 | 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);
36792 ranu 579
			// Optional cancel reason — overlay onto the reason column. Original
580
			// deferred-reason is preserved as a suffix so we keep the audit trail.
581
			String cancelReason = body.get("reason") != null ? String.valueOf(body.get("reason")).trim() : "";
582
			if (!cancelReason.isEmpty()) {
583
				String prev = d.getReason() != null ? d.getReason() : "";
584
				d.setReason("Cancelled: " + cancelReason + (prev.isEmpty() ? "" : " | Original: " + prev));
585
			}
36740 ranu 586
			beatDeferredVisitRepository.persist(d);
587
			Map<String, Object> ok = new HashMap<>();
588
			ok.put("status", true);
589
			ok.put("message", "Deferred visit cancelled");
590
			return responseSender.ok(ok);
591
		}
592
 
593
		// reschedule_oneoff | reschedule_beat
594
		if (toDateStr == null || toDateStr.isEmpty())
595
			return responseSender.badRequest("toDate is required to reschedule");
596
		LocalDate toDate;
597
		try {
598
			toDate = LocalDate.parse(toDateStr);
599
		} catch (Exception e) {
600
			return responseSender.badRequest("Invalid toDate (yyyy-MM-dd)");
601
		}
602
		if (toDate.isBefore(LocalDate.now())) return responseSender.badRequest("Reschedule date cannot be in the past");
603
 
604
		if ("reschedule_beat".equalsIgnoreCase(action)) {
605
			boolean hasBeat = beatRepository.selectActiveByAuthUserId(d.getAuthUserId()).stream()
606
					.flatMap(b -> beatScheduleRepository.selectByBeatId(b.getId()).stream())
607
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(toDate));
608
			if (!hasBeat)
609
				return responseSender.badRequest("No beat is scheduled for this user on " + toDateStr + ". Pick another date or use a one-off visit.");
610
		}
611
 
612
		// Resolve dtr user, then create a PENDING task on toDate. Reuse the
613
		// denormalized name + type (works for both partner visits and leads — for
614
		// leads, looking up fofo_store would be the wrong id space). For visits we
615
		// still try to pull lat/lng for the visit location.
616
		Integer dtrId = resolveDtrId(d.getAuthUserId(), new HashMap<>());
617
		if (dtrId == null) return responseSender.badRequest("No dtr.users record for this sales person");
618
		boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());
619
		String visitLocation = "0.0000,0.0000";
620
		if (!isLead) {
621
			try {
622
				List<FofoStore> ss = fofoStoreRepository.selectByRetailerIds(java.util.Collections.singletonList(d.getFofoId()));
623
				if (!ss.isEmpty()) {
624
					FofoStore fs = ss.get(0);
625
					if (fs.getLatitude() != null && fs.getLongitude() != null
626
							&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
627
						visitLocation = fs.getLatitude() + "," + fs.getLongitude();
628
					}
629
				}
630
			} catch (Exception ignored) {
631
			}
632
		}
633
		String taskName = d.getDisplayName() != null ? d.getDisplayName()
634
				: ((d.getReason() != null ? d.getReason() : "Rescheduled") + " | #" + d.getFofoId());
635
 
636
		com.spice.profitmandi.dao.entity.auth.LocationTracking row = new com.spice.profitmandi.dao.entity.auth.LocationTracking();
637
		row.setUserId(dtrId);
638
		row.setDeviceId("0");
639
		row.setTaskId(d.getFofoId());
640
		row.setTaskDate(toDate);
641
		row.setTaskName(taskName);
642
		row.setTaskDescription("Rescheduled from " + d.getDeferredDate());
643
		row.setTaskType(d.getTaskType() != null ? d.getTaskType() : "franchisee-visit");
36763 ranu 644
		row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));
36740 ranu 645
		row.setAddress("");
646
		row.setVisitLocation(visitLocation);
647
		row.setCheckInLatLng("0.0000,0.0000");
648
		row.setCheckOutLatLng("0.0000,0.0000");
649
		row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
650
		row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
651
		row.setTransitTime(java.time.LocalTime.MIDNIGHT);
652
		row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
653
		row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
654
		row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
655
		row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
656
		row.setTotalDistance("0.0");
657
		row.setStatus(false);
658
		row.setCreatedTimestamp(now);
659
		row.setUpdatedTimestamp(now);
660
		locationTrackingRepositoryAuto.persist(row);
661
 
36763 ranu 662
		d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));
36740 ranu 663
		d.setRescheduledToDate(toDate);
664
		d.setActionBy(me.getId());
665
		d.setUpdatedTimestamp(now);
666
		beatDeferredVisitRepository.persist(d);
667
 
668
		Map<String, Object> ok = new HashMap<>();
669
		ok.put("status", true);
670
		ok.put("message", "Visit rescheduled to " + toDateStr);
671
		return responseSender.ok(ok);
672
	}
673
 
674
	// Drop a deferred item into a specific upcoming BEAT run (chosen from the beat
675
	// calendar). Lead → a lead_route row on that beat/date (renders as a lead stop).
676
	// Partner visit → appended to that beat's route for the date's day_number.
677
	// The beat plan calendar then shows it. Marks the deferral RESCHEDULED.
678
	@PostMapping(value = "/beatPlan/deferred/assignToBeat")
679
	public ResponseEntity<?> deferredAssignToBeat(
680
			HttpServletRequest request,
681
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
682
 
683
		Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;
684
		Integer beatId = body.get("beatId") != null ? ((Number) body.get("beatId")).intValue() : null;
685
		String dateStr = (String) body.get("date");
686
		if (deferredId == null || beatId == null || dateStr == null)
687
			return responseSender.badRequest("deferredId, beatId and date are required");
688
 
689
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
690
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
691
		if (me == null) return responseSender.unauthorized("Not logged in");
692
 
693
		LocalDate date;
694
		try {
695
			date = LocalDate.parse(dateStr);
696
		} catch (Exception e) {
697
			return responseSender.badRequest("Invalid date (yyyy-MM-dd)");
698
		}
699
		if (date.isBefore(LocalDate.now())) return responseSender.badRequest("Pick an upcoming date");
700
 
701
		BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);
702
		if (d == null) return responseSender.badRequest("Deferred record not found");
703
 
704
		// A deferral can only move FORWARD — never onto the day it was deferred or earlier.
705
		if (d.getDeferredDate() != null && !date.isAfter(d.getDeferredDate())) {
706
			return responseSender.badRequest("A deferred item can only be moved to a date after "
707
					+ d.getDeferredDate() + " (it was deferred that day).");
708
		}
709
 
710
		Beat beat = beatRepository.selectById(beatId);
711
		if (beat == null) return responseSender.badRequest("Beat not found");
712
 
713
		// The beat must actually run on the chosen date — get that run's day number.
714
		BeatSchedule sched = beatScheduleRepository.selectByBeatId(beatId).stream()
715
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(date))
716
				.findFirst().orElse(null);
717
		if (sched == null) return responseSender.badRequest("That beat is not scheduled on " + dateStr);
718
 
719
		LocalDateTime now = LocalDateTime.now();
720
		boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());
721
 
722
		if (isLead) {
723
			// Avoid duplicating the same lead on the same beat/date
724
			boolean exists = leadRouteRepository.selectByBeatId(beatId).stream()
725
					.anyMatch(lr -> lr.getLeadId() == d.getFofoId()
726
							&& date.equals(lr.getScheduleDate())
727
							&& !"CANCELLED".equals(lr.getStatus()));
728
			if (!exists) {
729
				LeadRoute lr = new LeadRoute();
730
				lr.setBeatId(beatId);
731
				lr.setLeadId(d.getFofoId());
732
				lr.setScheduleDate(date);
733
				lr.setSequenceOrder(9999); // append; planner can reorder
734
				lr.setStatus("APPROVED");
735
				lr.setApprovedBy(me.getId());
736
				lr.setApprovedTimestamp(now);
737
				lr.setCreatedTimestamp(now);
738
				lr.setUpdatedTimestamp(now);
739
				leadRouteRepository.persist(lr);
740
			}
741
		} else {
742
			// Partner visit → append to that beat's route for the date's day number,
743
			// if not already present on that day.
744
			boolean exists = beatRouteRepository.selectByBeatId(beatId).stream()
745
					.anyMatch(r -> r.getFofoId() == d.getFofoId() && r.getDayNumber() == sched.getDayNumber() && r.isActive());
746
			if (!exists) {
747
				int nextSeq = beatRouteRepository.selectByBeatId(beatId).stream()
748
						.filter(r -> r.getDayNumber() == sched.getDayNumber())
749
						.mapToInt(BeatRoute::getSequenceOrder).max().orElse(-1) + 1;
750
				BeatRoute br = new BeatRoute();
751
				br.setBeatId(beatId);
752
				br.setFofoId(d.getFofoId());
753
				br.setDayNumber(sched.getDayNumber());
754
				br.setSequenceOrder(nextSeq);
755
				br.setActive(true);
756
				beatRouteRepository.persist(br);
757
			}
758
		}
759
 
36763 ranu 760
		d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));
36740 ranu 761
		d.setRescheduledToDate(date);
762
		d.setActionBy(me.getId());
763
		d.setUpdatedTimestamp(now);
764
		beatDeferredVisitRepository.persist(d);
765
 
766
		Map<String, Object> ok = new HashMap<>();
767
		ok.put("status", true);
768
		ok.put("message", (isLead ? "Lead" : "Partner") + " added to beat '" + beat.getName() + "' on " + dateStr);
769
		return responseSender.ok(ok);
770
	}
771
 
772
	// authUserId -> dtr.users id (via shared email), memoized in the passed cache.
773
	// Used by the reschedule action to create the new PENDING location_tracking row.
774
	private Integer resolveDtrId(int authUserId, Map<Integer, Integer> cache) {
775
		if (cache.containsKey(authUserId)) return cache.get(authUserId);
776
		Integer dtrId = null;
777
		try {
778
			AuthUser au = authRepository.selectById(authUserId);
779
			if (au != null && au.getEmailId() != null) {
780
				com.spice.profitmandi.dao.entity.dtr.User u = userRepositoryAuto.selectByEmailId(au.getEmailId());
781
				if (u != null) dtrId = u.getId();
782
			}
783
		} catch (Exception ignored) {
784
		}
785
		cache.put(authUserId, dtrId);
786
		return dtrId;
787
	}
788
 
789
	// For an auth user: fofoId -> earliest upcoming (>= today) scheduled date where
790
	// an active beat's route still includes that partner (the "Next Scheduled" hint).
791
	private Map<Integer, LocalDate> computeFutureCover(int authUserId) {
792
		LocalDate today = LocalDate.now();
793
		Map<Integer, LocalDate> cover = new HashMap<>();
794
		for (Beat b : beatRepository.selectActiveByAuthUserId(authUserId)) {
795
			LocalDate earliest = null;
796
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(b.getId())) {
797
				LocalDate dt = s.getStartDate();
798
				if (dt != null && dt.getYear() != 9999 && !dt.isBefore(today)) {
799
					if (earliest == null || dt.isBefore(earliest)) earliest = dt;
800
				}
801
			}
802
			if (earliest == null) continue;
803
			for (BeatRoute rt : beatRouteRepository.selectByBeatId(b.getId())) {
804
				if (!rt.isActive()) continue;
805
				LocalDate cur = cover.get(rt.getFofoId());
806
				if (cur == null || earliest.isBefore(cur)) cover.put(rt.getFofoId(), earliest);
807
			}
808
		}
809
		return cover;
810
	}
811
 
36686 ranu 812
	@GetMapping(value = "/beatPlanWindow")
813
	public String beatPlanWindow(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
814
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
815
		return "beat-plan-window";
36655 ranu 816
	}
817
 
36668 ranu 818
	// Helpers for XLSX bulk upload
819
	private static String readCell(org.apache.poi.ss.usermodel.Cell cell) {
820
		if (cell == null) return null;
821
		switch (cell.getCellType()) {
822
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:
823
				return cell.getStringCellValue();
824
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:
825
				if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
826
					return cell.getDateCellValue().toInstant()
827
							.atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();
828
				}
829
				double n = cell.getNumericCellValue();
830
				return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);
831
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:
832
				return String.valueOf(cell.getBooleanCellValue());
833
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:
834
				return cell.getCellFormula();
835
			default:
836
				return null;
36655 ranu 837
		}
838
	}
839
 
840
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
841
	// For each active fofo_store, compare its stored lat/lng with the geocoded
842
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
843
	// OR the store has no lat/lng yet, update the store with the geocoded
844
	// coordinates. Otherwise keep the existing values.
845
	//
846
	// Usage:
847
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
848
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
849
	//   ?thresholdKm=3      -> use a different threshold
850
	//   ?limit=100          -> process only N stores (for staged runs)
851
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
852
	public ResponseEntity<?> migrateStoreLatLng(
853
			@RequestParam(required = false, defaultValue = "false") boolean apply,
854
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
36660 ranu 855
			@RequestParam(required = false, defaultValue = "0") int limit,
36727 ranu 856
			@RequestParam(required = false, defaultValue = "0") int offset,
857
			@RequestParam(required = false, defaultValue = "40") int maxSeconds) throws ProfitMandiBusinessException {
36655 ranu 858
 
36660 ranu 859
		List<FofoStore> all = fofoStoreRepository.selectActiveStores();
860
		int totalAvailable = all.size();
861
		int from = Math.max(0, Math.min(offset, totalAvailable));
36655 ranu 862
 
36727 ranu 863
		// Hard cap (if limit given), else go to the end of the list.
864
		int hardTo = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;
865
 
866
		// Time budget: stop processing once we approach the gateway timeout and
867
		// return nextOffset so the caller can resume. Geocoding is the slow part
868
		// (network/cache), so a fixed batch size could still time out on a cache-miss
869
		// run — a wall-clock budget is safer. maxSeconds defaults to 40 (< typical 60s gateway).
870
		long deadlineMs = System.currentTimeMillis() + Math.max(5, maxSeconds) * 1000L;
871
 
872
		List<FofoStore> stores = all.subList(from, hardTo);
36655 ranu 873
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
874
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
875
 
36727 ranu 876
		int total = 0;            // stores actually processed this call
36655 ranu 877
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
36727 ranu 878
		boolean stoppedOnTime = false;
879
		int nextIndex = from;     // absolute index of next unprocessed store
36655 ranu 880
		List<Map<String, Object>> changes = new ArrayList<>();
881
 
882
		for (FofoStore store : stores) {
36727 ranu 883
			// Stop before doing more slow geocoding work if we've spent our budget.
884
			if (System.currentTimeMillis() >= deadlineMs) {
885
				stoppedOnTime = true;
886
				break;
887
			}
888
			total++;
889
			nextIndex++;
36655 ranu 890
			try {
891
				CustomRetailer retailer = retailerMap.get(store.getId());
892
				if (retailer == null || retailer.getAddress() == null) {
893
					noAddress++;
894
					continue;
895
				}
896
 
897
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
898
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
899
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
900
				if (geoAddr == null || geoAddr.isEmpty()) {
901
					noAddress++;
902
					continue;
903
				}
904
 
905
				double[] coords = geocodingService.geocodeAddress(geoAddr);
906
				if (coords == null) {
907
					noGeocode++;
908
					continue;
909
				}
910
 
911
				Double existingLat = parseDoubleOrNull(store.getLatitude());
912
				Double existingLng = parseDoubleOrNull(store.getLongitude());
913
 
914
				boolean shouldUpdate;
915
				double distKm = -1;
916
				String reason;
917
				if (existingLat == null || existingLng == null) {
918
					shouldUpdate = true;
919
					reason = "missing existing lat/lng";
920
				} else {
921
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
922
					shouldUpdate = distKm > thresholdKm;
923
					reason = shouldUpdate
924
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
925
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
926
				}
927
 
928
				if (shouldUpdate) {
929
					if (apply) {
930
						store.setLatitude(String.valueOf(coords[0]));
931
						store.setLongitude(String.valueOf(coords[1]));
36727 ranu 932
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
36655 ranu 933
						fofoStoreRepository.persist(store);
934
					}
935
					updated++;
936
					Map<String, Object> ch = new HashMap<>();
937
					ch.put("storeId", store.getId());
938
					ch.put("code", store.getCode());
939
					ch.put("oldLat", existingLat);
940
					ch.put("oldLng", existingLng);
941
					ch.put("newLat", coords[0]);
942
					ch.put("newLng", coords[1]);
943
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
944
					ch.put("reason", reason);
945
					changes.add(ch);
946
				} else {
36727 ranu 947
					// Verified-kept: lat/lng was already within threshold. Still stamp it
948
					// so "processed vs pending" can be told from lat_lng_updated_timestamp.
949
					if (apply) {
950
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
951
						fofoStoreRepository.persist(store);
952
					}
36655 ranu 953
					kept++;
954
				}
955
			} catch (Exception e) {
956
				errored++;
957
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
958
			}
959
		}
960
 
961
		Map<String, Object> result = new HashMap<>();
962
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
963
		result.put("thresholdKm", thresholdKm);
36727 ranu 964
		result.put("totalAvailable", totalAvailable);   // total active stores in DB
36660 ranu 965
		result.put("offset", from);
36727 ranu 966
		result.put("processed", total);                  // stores processed this call
967
		result.put("nextOffset", nextIndex);             // resume here next call
968
		result.put("done", nextIndex >= totalAvailable); // true when nothing left
969
		result.put("stoppedOnTimeBudget", stoppedOnTime);// true if we paused for time, not because we finished
36655 ranu 970
		result.put("updated", updated);
971
		result.put("kept", kept);
972
		result.put("noAddress", noAddress);
973
		result.put("noGeocode", noGeocode);
974
		result.put("errored", errored);
975
		// Limit changes preview to avoid huge responses
976
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
977
		result.put("changesShownCount", Math.min(changes.size(), 200));
978
		return responseSender.ok(result);
979
	}
980
 
36651 ranu 981
	// ====================== EDIT BEAT ======================
982
	// Update an existing beat — name + partner stops (routes).
983
	// Schedules are NOT touched here; manage them via calendar drag-drop.
984
	@PostMapping(value = "/beatPlan/updateBeat")
985
	public ResponseEntity<?> updateBeat(
986
			HttpServletRequest request,
987
			@RequestParam int beatId,
988
			@RequestParam String planData) throws Exception {
989
 
990
		Beat beat = beatRepository.selectById(beatId);
991
		if (beat == null) return responseSender.badRequest("Beat not found");
992
 
993
		Gson gson = new Gson();
994
		Type type = new TypeToken<Map<String, Object>>() {
995
		}.getType();
996
		Map<String, Object> plan = gson.fromJson(planData, type);
997
 
998
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
999
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
1000
 
1001
		// Update name if changed (and not colliding with another beat)
1002
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
1003
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
36698 ranu 1004
			// Make sure no other ACTIVE beat for this user already uses this name.
1005
			// Soft-deleted beats keep their name in the table; we don't want them
1006
			// to block a legitimate rename.
1007
			boolean collides = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId()).stream()
36651 ranu 1008
					.anyMatch(b -> b.getId() != beat.getId()
1009
							&& b.getName() != null
1010
							&& newName.equalsIgnoreCase(b.getName().trim()));
1011
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
1012
			beat.setName(newName);
1013
		}
1014
 
1015
		// Update start location from first day if present
1016
		Map<String, Object> firstDay = days.get(0);
1017
		if (firstDay.get("startLocationName") != null)
1018
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1019
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
1020
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
1021
 
36681 ranu 1022
		int oldTotalDays = beat.getTotalDays();
1023
		int newTotalDays = days.size();
1024
 
1025
		// Hard rule: you cannot grow the number of days on an existing beat.
1026
		// If you need more days, create a new beat. (Shrinking is allowed and
1027
		// the schedules for dropped day numbers are cleaned below.)
1028
		if (newTotalDays > oldTotalDays) {
1029
			return responseSender.badRequest(
1030
					"Cannot increase the number of days on an existing beat. "
1031
							+ "Original: " + oldTotalDays + " day(s), tried: " + newTotalDays + " day(s). "
1032
							+ "Please create a new beat for additional days.");
1033
		}
1034
		beat.setTotalDays(newTotalDays);
1035
 
1036
		// Replace routes (partner stops). Schedules stay intact (except for
36711 ranu 1037
        // dayNumber > newTotalDays cleanup + total km/min refresh below).
36651 ranu 1038
		beatRouteRepository.deleteByBeatId(beatId);
36681 ranu 1039
		// Collect lead IDs the user kept on the plan
36651 ranu 1040
		Set<Integer> keptLeadIds = new HashSet<>();
1041
		for (int d = 0; d < days.size(); d++) {
1042
			Map<String, Object> day = days.get(d);
1043
			int dayNumber = d + 1;
1044
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1045
			if (visits == null) continue;
1046
			int partnerSeq = 0;
1047
			for (int i = 0; i < visits.size(); i++) {
1048
				Map<String, Object> v = visits.get(i);
1049
				if ("lead".equals(v.get("type"))) {
1050
					keptLeadIds.add(((Number) v.get("id")).intValue());
1051
					continue; // leads live in lead_route, handled below
1052
				}
1053
				BeatRoute route = new BeatRoute();
1054
				route.setBeatId(beatId);
1055
				route.setFofoId(((Number) v.get("id")).intValue());
1056
				route.setSequenceOrder(partnerSeq++);
1057
				route.setDayNumber(dayNumber);
1058
				route.setActive(true);
36711 ranu 1059
                if (v.get("distanceFromPrevKm") != null)
1060
                    route.setDistanceFromPrevKm(((Number) v.get("distanceFromPrevKm")).doubleValue());
1061
                if (v.get("timeFromPrevMins") != null)
1062
                    route.setTimeFromPrevMins(((Number) v.get("timeFromPrevMins")).intValue());
36651 ranu 1063
				beatRouteRepository.persist(route);
1064
			}
1065
		}
1066
 
36681 ranu 1067
		// If the beat shrank, drop schedule rows for day numbers that no longer exist
1068
		if (newTotalDays < oldTotalDays) {
1069
			List<BeatSchedule> currentSchedules = beatScheduleRepository.selectByBeatId(beatId);
1070
			for (BeatSchedule s : currentSchedules) {
1071
				if (s.getDayNumber() > newTotalDays) beatScheduleRepository.delete(s);
1072
			}
1073
		}
1074
 
36711 ranu 1075
        // Refresh the day-level totals on every remaining schedule row so they
1076
        // reflect the post-edit route. Previously updateBeat left these stale
1077
        // (or NULL, for beats created before this fix), which is what the user
1078
        // reported. Keyed by dayNumber so multi-instance beats all get updated.
1079
        Map<Integer, Map<String, Object>> dayByNumber = new HashMap<>();
1080
        for (int d = 0; d < days.size(); d++) {
1081
            dayByNumber.put(d + 1, days.get(d));
1082
        }
1083
        List<BeatSchedule> allSchedules = beatScheduleRepository.selectByBeatId(beatId);
1084
        for (BeatSchedule s : allSchedules) {
1085
            Map<String, Object> day = dayByNumber.get(s.getDayNumber());
1086
            if (day == null) continue;
1087
            if (day.get("totalDistanceKm") != null)
1088
                s.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1089
            if (day.get("totalTimeMins") != null)
1090
                s.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1091
        }
1092
 
36681 ranu 1093
		// Process per-lead actions sent from the editor's removed-leads popup.
1094
		// Each entry: {leadId, action: "cancel"|"reschedule", toDate?: "yyyy-MM-dd"}.
1095
		// - cancel: mark the lead's current APPROVED row for this beat as CANCELLED.
1096
		// - reschedule: cancel here, then create a fresh APPROVED LeadRoute on
1097
		//   whichever beat this user has scheduled on toDate. If no beat exists
1098
		//   on toDate, the whole update fails (so the caller can prompt again).
1099
		int leadsCancelled = 0, leadsRescheduled = 0;
1100
		List<String> leadFailures = new ArrayList<>();
1101
		String removedLeadActionsJson = (String) plan.get("removedLeadActions");
1102
		if (removedLeadActionsJson != null && !removedLeadActionsJson.isEmpty()) {
1103
			Type listType = new TypeToken<List<Map<String, Object>>>() {
1104
			}.getType();
1105
			List<Map<String, Object>> actions = gson.fromJson(removedLeadActionsJson, listType);
1106
 
1107
			List<LeadRoute> beatLeads = leadRouteRepository.selectByBeatId(beatId);
1108
 
1109
			for (Map<String, Object> act : actions) {
1110
				int leadId = ((Number) act.get("leadId")).intValue();
1111
				String mode = (String) act.get("action");
1112
 
1113
				// Find this lead's most-recent APPROVED row on this beat
1114
				LeadRoute current = beatLeads.stream()
1115
						.filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus()))
1116
						.findFirst().orElse(null);
1117
				if (current == null) continue; // already removed/cancelled; nothing to do
1118
 
1119
				if ("reschedule".equalsIgnoreCase(mode)) {
1120
					String toDateStr = (String) act.get("toDate");
1121
					if (toDateStr == null || toDateStr.isEmpty()) {
1122
						leadFailures.add("Lead " + leadId + ": reschedule date missing");
1123
						continue;
1124
					}
1125
					LocalDate toDate = LocalDate.parse(toDateStr);
1126
 
1127
					// Find ANY beat this user has scheduled on toDate
1128
					Beat targetBeat = null;
1129
					Integer targetDayNumber = null;
1130
					List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId());
1131
					for (Beat b : userBeats) {
1132
						List<BeatSchedule> sl = beatScheduleRepository.selectByBeatId(b.getId());
1133
						for (BeatSchedule s : sl) {
1134
							if (s.getStartDate() != null && s.getStartDate().equals(toDate)) {
1135
								targetBeat = b;
1136
								targetDayNumber = s.getDayNumber();
1137
								break;
1138
							}
1139
						}
1140
						if (targetBeat != null) break;
1141
					}
1142
					if (targetBeat == null) {
1143
						return responseSender.badRequest(
1144
								"No beat is scheduled for this user on " + toDateStr
1145
										+ ". Pick a different date for lead " + leadId
1146
										+ ", or choose Cancel for it.");
1147
					}
1148
 
1149
					// Cancel the current attachment to this beat
1150
					current.setStatus("CANCELLED");
1151
					current.setUpdatedTimestamp(LocalDateTime.now());
1152
 
1153
					// Create the new attachment on the target beat/date
1154
					LeadRoute fresh = new LeadRoute();
1155
					fresh.setBeatId(targetBeat.getId());
1156
					fresh.setLeadId(leadId);
1157
					fresh.setNearestStoreId(current.getNearestStoreId());
1158
					fresh.setScheduleDate(toDate);
1159
					fresh.setSequenceOrder(9999); // append; the planner can reorder
1160
					fresh.setStatus("APPROVED");
1161
					fresh.setRequestedBy(current.getRequestedBy());
1162
					fresh.setApprovedBy(current.getApprovedBy());
1163
					fresh.setApprovedTimestamp(LocalDateTime.now());
1164
					fresh.setCreatedTimestamp(LocalDateTime.now());
1165
					fresh.setUpdatedTimestamp(LocalDateTime.now());
1166
					leadRouteRepository.persist(fresh);
1167
 
1168
					LeadActivity la = new LeadActivity();
1169
					la.setLeadId(leadId);
1170
					la.setRemark("Rescheduled from beat '" + beat.getName() + "' to '"
1171
							+ targetBeat.getName() + "' on " + toDateStr + " (day " + targetDayNumber + ")");
1172
					la.setAuthId(0);
1173
					la.setCreatedTimestamp(LocalDateTime.now());
1174
					leadActivityRepositoryAuto.persist(la);
1175
					leadsRescheduled++;
1176
				} else {
1177
					// cancel (default)
1178
					current.setStatus("CANCELLED");
1179
					current.setUpdatedTimestamp(LocalDateTime.now());
1180
 
1181
					LeadActivity la = new LeadActivity();
1182
					la.setLeadId(leadId);
1183
					la.setRemark("Cancelled from beat '" + beat.getName() + "' during edit");
1184
					la.setAuthId(0);
1185
					la.setCreatedTimestamp(LocalDateTime.now());
1186
					leadActivityRepositoryAuto.persist(la);
1187
					leadsCancelled++;
36651 ranu 1188
				}
1189
			}
1190
		}
1191
 
1192
		Map<String, Object> response = new HashMap<>();
1193
		response.put("status", true);
1194
		response.put("planGroupId", String.valueOf(beat.getId()));
36681 ranu 1195
		response.put("leadsCancelled", leadsCancelled);
1196
		response.put("leadsRescheduled", leadsRescheduled);
1197
		response.put("leadFailures", leadFailures);
1198
		response.put("message", "Beat updated successfully"
1199
				+ (leadsCancelled > 0 ? " (" + leadsCancelled + " lead(s) cancelled)" : "")
1200
				+ (leadsRescheduled > 0 ? " (" + leadsRescheduled + " lead(s) rescheduled)" : ""));
36651 ranu 1201
		return responseSender.ok(response);
1202
	}
1203
 
36681 ranu 1204
	// Used by the edit-mode "removed leads" popup so the date picker can warn
1205
	// upfront when the user picks a date that has no beat for them.
1206
	@GetMapping(value = "/beatPlan/userBeatsOnDate")
1207
	public ResponseEntity<?> userBeatsOnDate(
1208
			@RequestParam int authUserId,
1209
			@RequestParam String date) {
1210
		LocalDate target;
1211
		try {
1212
			target = LocalDate.parse(date);
1213
		} catch (Exception e) {
1214
			return responseSender.badRequest("Invalid date");
1215
		}
1216
 
1217
		List<Map<String, Object>> hits = new ArrayList<>();
1218
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
1219
		for (Beat b : userBeats) {
1220
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
1221
			for (BeatSchedule s : schedules) {
1222
				if (s.getStartDate() != null && s.getStartDate().equals(target)) {
1223
					Map<String, Object> m = new HashMap<>();
1224
					m.put("beatId", b.getId());
1225
					m.put("beatName", b.getName());
1226
					m.put("dayNumber", s.getDayNumber());
1227
					hits.add(m);
1228
				}
1229
			}
1230
		}
1231
		Map<String, Object> result = new HashMap<>();
1232
		result.put("date", date);
1233
		result.put("authUserId", authUserId);
1234
		result.put("beats", hits);
1235
		return responseSender.ok(result);
1236
	}
1237
 
36686 ranu 1238
	// ====================== BASE LOCATION MANAGEMENT ======================
1239
	// Inline page that lets Sales L3+ pick a user and set their base (home)
1240
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
1241
	// go through the L3+-guarded endpoint below.
1242
	@GetMapping(value = "/beatPlan/baseLocationPage")
1243
	public String baseLocationPage(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1244
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
1245
		return "beat-plan-base-location";
36650 ranu 1246
	}
1247
 
1248
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
1249
	@GetMapping(value = "/beatPlan/scheduledList")
1250
	public ResponseEntity<?> scheduledList(
1251
			@RequestParam(required = false) String startDate,
1252
			@RequestParam(required = false) String endDate) {
1253
 
1254
		LocalDate start, end;
1255
		try {
1256
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
1257
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
1258
		} catch (Exception e) {
1259
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
1260
		}
1261
 
1262
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
1263
				beatPlanQueryService.getAllScheduledBeats(start, end);
1264
 
1265
		// Resolve user names in bulk
1266
		Set<Integer> userIds = beats.stream()
1267
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
1268
				.collect(java.util.stream.Collectors.toSet());
1269
		Map<Integer, AuthUser> userMap = new HashMap<>();
1270
		if (!userIds.isEmpty()) {
1271
			authRepository.selectByIds(new ArrayList<>(userIds))
1272
					.forEach(u -> userMap.put(u.getId(), u));
1273
		}
1274
 
1275
		List<Map<String, Object>> rows = new ArrayList<>();
1276
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1277
			AuthUser u = userMap.get(b.getAuthUserId());
1278
			Map<String, Object> row = new HashMap<>();
1279
			row.put("authUserId", b.getAuthUserId());
1280
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
1281
			row.put("scheduleDate", b.getScheduleDate().toString());
1282
			row.put("dayNumber", b.getDayNumber());
1283
			row.put("beatId", b.getBeatId());
1284
			row.put("beatName", b.getBeatName());
1285
			row.put("beatColor", b.getBeatColor());
1286
			row.put("partnerCount", b.getPartnerStops().size());
1287
			row.put("leadCount", b.getLeadStops().size());
1288
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
1289
			rows.add(row);
1290
		}
1291
 
1292
		Map<String, Object> result = new HashMap<>();
1293
		result.put("rows", rows);
1294
		result.put("startDate", start.toString());
1295
		result.put("endDate", end.toString());
1296
		return responseSender.ok(result);
1297
	}
1298
 
1299
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
1300
	@GetMapping(value = "/beatPlan/dayViewData")
1301
	public ResponseEntity<?> beatPlanDayViewData(
1302
			@RequestParam int authUserId,
1303
			@RequestParam String date) throws ProfitMandiBusinessException {
1304
 
1305
		LocalDate localDate;
1306
		try {
1307
			localDate = LocalDate.parse(date);
1308
		} catch (Exception e) {
1309
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
1310
		}
1311
 
1312
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
1313
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
1314
 
1315
		// Collect all partner & lead IDs to fetch metadata in bulk
1316
		Set<Integer> partnerIds = new HashSet<>();
1317
		Set<Integer> leadIds = new HashSet<>();
1318
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1319
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
1320
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
1321
		}
1322
 
1323
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
1324
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
1325
				? new HashMap<>()
1326
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
1327
		Map<Integer, FofoStore> storeMap = new HashMap<>();
1328
		if (!partnerIds.isEmpty()) {
1329
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
1330
					.forEach(fs -> storeMap.put(fs.getId(), fs));
1331
		}
1332
 
1333
		// Leads: name + geo
1334
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
1335
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
1336
		for (int leadId : leadIds) {
1337
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
1338
			if (l != null) leadMap.put(leadId, l);
1339
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
1340
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
1341
			if (lg != null) leadGeoMap.put(leadId, lg);
1342
		}
1343
 
1344
		// Enrich each stop
1345
		List<Map<String, Object>> out = new ArrayList<>();
1346
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1347
			Map<String, Object> beatJson = new HashMap<>();
1348
			beatJson.put("beatId", b.getBeatId());
1349
			beatJson.put("beatName", b.getBeatName());
1350
			beatJson.put("beatColor", b.getBeatColor());
1351
			beatJson.put("dayNumber", b.getDayNumber());
1352
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
1353
			beatJson.put("endAction", b.getEndAction());
1354
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
1355
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
1356
			beatJson.put("startLocationName", b.getStartLocationName());
1357
			beatJson.put("startLatitude", b.getStartLatitude());
1358
			beatJson.put("startLongitude", b.getStartLongitude());
1359
 
1360
			List<Map<String, Object>> stops = new ArrayList<>();
1361
			// Partners
1362
			for (Map<String, Object> ps : b.getPartnerStops()) {
1363
				int fofoId = (Integer) ps.get("fofoId");
1364
				Map<String, Object> stop = new HashMap<>();
1365
				stop.put("type", "partner");
1366
				stop.put("id", fofoId);
1367
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
1368
				FofoStore fs = storeMap.get(fofoId);
1369
				CustomRetailer cr = retailerMap.get(fofoId);
1370
				stop.put("code", fs != null ? fs.getCode() : null);
1371
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
1372
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 1373
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
1374
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
1375
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 1376
					try {
36655 ranu 1377
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
1378
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
1379
					} catch (NumberFormatException ignored) {
36650 ranu 1380
					}
1381
				}
36655 ranu 1382
				if (cr != null && cr.getAddress() != null) {
1383
					stop.put("address", cr.getAddress().getAddressString());
1384
				}
36650 ranu 1385
				stops.add(stop);
1386
			}
1387
			// Leads
1388
			for (Map<String, Object> ls : b.getLeadStops()) {
1389
				int leadId = (Integer) ls.get("leadId");
1390
				Map<String, Object> stop = new HashMap<>();
1391
				stop.put("type", "lead");
1392
				stop.put("id", leadId);
1393
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
1394
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
1395
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
1396
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
1397
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
1398
				stop.put("city", l != null ? l.getCity() : null);
1399
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
1400
				if (lg != null) {
1401
					stop.put("lat", lg.getLatitude());
1402
					stop.put("lng", lg.getLongitude());
1403
				}
1404
				stops.add(stop);
1405
			}
1406
			beatJson.put("stops", stops);
1407
			beatJson.put("partnerCount", b.getPartnerStops().size());
1408
			beatJson.put("leadCount", b.getLeadStops().size());
1409
			out.add(beatJson);
1410
		}
1411
 
1412
		Map<String, Object> result = new HashMap<>();
1413
		result.put("beats", out);
1414
		return responseSender.ok(result);
1415
	}
1416
 
36686 ranu 1417
	// ====================== DAY VIEW ======================
1418
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
1419
	// scheduled in a date range across all users. Each row has a View button that
1420
	// opens that user's calendar in a modal.
1421
	@GetMapping(value = "/beatPlan/dayView")
1422
	public String beatPlanDayView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1423
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
1424
		return "beat-plan-day-view";
36618 ranu 1425
	}
1426
 
36644 ranu 1427
	// Returns visits for a beat.
1428
	// - Partner stops (beat_route) belong to the beat template — always returned.
1429
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
1430
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 1431
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 1432
	public ResponseEntity<?> getBeatVisits(
1433
			@RequestParam String planGroupId,
1434
			@RequestParam(required = false) String planDate) {
1435
 
1436
		int beatId;
1437
		try {
1438
			beatId = Integer.parseInt(planGroupId);
1439
		} catch (NumberFormatException e) {
1440
			return responseSender.ok(new ArrayList<>());
1441
		}
1442
 
1443
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
1444
		List<Map<String, Object>> result = new ArrayList<>();
1445
 
1446
		// Partner stops — always (they belong to the beat template)
1447
		for (BeatRoute r : routes) {
36632 ranu 1448
			Map<String, Object> map = new HashMap<>();
36644 ranu 1449
			map.put("fofoId", r.getFofoId());
1450
			map.put("dayNumber", r.getDayNumber());
1451
			map.put("sequenceOrder", r.getSequenceOrder());
1452
			map.put("visitType", "partner");
1453
			result.add(map);
1454
		}
1455
 
1456
		// Lead stops — only for the requested run date
1457
		if (planDate != null && !planDate.isEmpty()) {
1458
			LocalDate date = LocalDate.parse(planDate);
1459
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
1460
			for (LeadRoute lr : leads) {
1461
				if ("APPROVED".equals(lr.getStatus())
1462
						&& lr.getScheduleDate() != null
1463
						&& lr.getScheduleDate().equals(date)) {
1464
					Map<String, Object> map = new HashMap<>();
1465
					map.put("fofoId", lr.getLeadId());
1466
					map.put("dayNumber", 1);
1467
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
1468
					map.put("visitType", "lead");
1469
					result.add(map);
1470
				}
1471
			}
1472
		}
1473
 
1474
		// Sort by dayNumber then sequenceOrder
1475
		result.sort((a, b) -> {
1476
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
1477
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
1478
		});
1479
 
36632 ranu 1480
		return responseSender.ok(result);
1481
	}
1482
 
36681 ranu 1483
	// Returns the user's DEFAULT base location. Falls back to most-recent for
1484
	// legacy users who pre-date the is_default column.
36618 ranu 1485
	@GetMapping(value = "/beatPlan/getBaseLocation")
1486
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
36681 ranu 1487
		AuthUserLocation baseLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36618 ranu 1488
		if (baseLoc == null) {
1489
			return responseSender.ok(new HashMap<>());
1490
		}
1491
		Map<String, Object> result = new HashMap<>();
1492
		result.put("id", baseLoc.getId());
1493
		result.put("locationName", baseLoc.getLocationName());
1494
		result.put("latitude", baseLoc.getLatitude());
1495
		result.put("longitude", baseLoc.getLongitude());
1496
		result.put("address", baseLoc.getAddress());
36681 ranu 1497
		result.put("isDefault", baseLoc.isDefault());
36618 ranu 1498
		return responseSender.ok(result);
1499
	}
1500
 
36681 ranu 1501
	// Returns ALL BASE locations for a user, default first.
1502
	@GetMapping(value = "/beatPlan/listBaseLocations")
1503
	public ResponseEntity<?> listBaseLocations(@RequestParam int authUserId) {
1504
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1505
		// Default at the top, then by created desc (the repo already returns desc).
1506
		all.sort((a, b) -> {
1507
			if (a.isDefault() && !b.isDefault()) return -1;
1508
			if (!a.isDefault() && b.isDefault()) return 1;
1509
			return 0;
1510
		});
1511
		List<Map<String, Object>> rows = new ArrayList<>();
1512
		for (AuthUserLocation l : all) {
1513
			Map<String, Object> row = new HashMap<>();
1514
			row.put("id", l.getId());
1515
			row.put("locationName", l.getLocationName());
1516
			row.put("latitude", l.getLatitude());
1517
			row.put("longitude", l.getLongitude());
1518
			row.put("address", l.getAddress());
1519
			row.put("isDefault", l.isDefault());
1520
			row.put("createdTimestamp", l.getCreatedTimestamp() != null ? l.getCreatedTimestamp().toString() : null);
1521
			rows.add(row);
1522
		}
1523
		Map<String, Object> result = new HashMap<>();
1524
		result.put("authUserId", authUserId);
1525
		result.put("locations", rows);
1526
		return responseSender.ok(result);
1527
	}
1528
 
1529
	// Flip the default flag — set this id default, clear all others.
1530
	@PostMapping(value = "/beatPlan/setDefaultBaseLocation")
1531
	public ResponseEntity<?> setDefaultBaseLocation(
1532
			HttpServletRequest request,
1533
			@RequestParam int id) throws ProfitMandiBusinessException {
1534
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1535
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1536
		if (me == null) return responseSender.unauthorized("Not logged in");
1537
		if (!isBaseLocationManager(me)) {
1538
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
1539
		}
1540
 
1541
		AuthUserLocation target = authUserLocationRepository.selectById(id);
1542
		if (target == null) return responseSender.badRequest("Location not found");
1543
 
1544
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(target.getAuthUserId(), "BASE");
1545
		for (AuthUserLocation l : all) {
1546
			boolean shouldBeDefault = (l.getId() == id);
1547
			if (l.isDefault() != shouldBeDefault) {
1548
				l.setDefault(shouldBeDefault);
1549
				authUserLocationRepository.persist(l); // saveOrUpdate
1550
			}
1551
		}
1552
 
1553
		Map<String, Object> result = new HashMap<>();
1554
		result.put("status", true);
1555
		result.put("id", id);
1556
		result.put("message", "Default base location updated");
1557
		return responseSender.ok(result);
1558
	}
1559
 
1560
	// Delete a base location. The DEFAULT one cannot be deleted — user must
1561
	// first pick another row as default.
1562
	@PostMapping(value = "/beatPlan/deleteBaseLocation")
1563
	public ResponseEntity<?> deleteBaseLocation(
1564
			HttpServletRequest request,
1565
			@RequestParam int id) throws ProfitMandiBusinessException {
1566
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1567
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1568
		if (me == null) return responseSender.unauthorized("Not logged in");
1569
		if (!isBaseLocationManager(me)) {
1570
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
1571
		}
1572
 
1573
		AuthUserLocation target = authUserLocationRepository.selectById(id);
1574
		if (target == null) return responseSender.badRequest("Location not found");
1575
		if (target.isDefault()) {
1576
			return responseSender.badRequest("Default base location cannot be removed. Set another location as default first.");
1577
		}
1578
 
1579
		authUserLocationRepository.delete(target);
1580
 
1581
		Map<String, Object> result = new HashMap<>();
1582
		result.put("status", true);
1583
		result.put("message", "Base location removed");
1584
		return responseSender.ok(result);
1585
	}
1586
 
36686 ranu 1587
	@GetMapping(value = "/beatPlan/getAuthUsers")
1588
	public ResponseEntity<?> getAuthUsers(
1589
			HttpServletRequest request,
1590
			@RequestParam int categoryId,
1591
			@RequestParam EscalationType escalationType) throws ProfitMandiBusinessException {
1592
 
1593
		// Hierarchy filter: a manager only sees users in their downline
1594
		// (themselves + every reportee under them, recursively). Super-admin
1595
		// emails bypass the filter and see everyone. Downline is computed by
1596
		// AuthService.getAllReportees (existing recursive walker).
1597
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1598
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1599
 
1600
		final Set<Integer> visible;
1601
		if (me == null || isSuperAdmin(me)) {
1602
			visible = null; // null = no filter
1603
		} else {
1604
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
1605
			visible.add(me.getId()); // include self
1606
		}
1607
 
1608
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
1609
		List<Map<String, Object>> result = authUsers.stream()
1610
				.filter(au -> au.getActive())
1611
				.filter(au -> visible == null || visible.contains(au.getId()))
1612
				.map(au -> {
1613
					Map<String, Object> map = new HashMap<>();
1614
					map.put("id", au.getId());
1615
					map.put("name", au.getFirstName() + " " + au.getLastName());
1616
					return map;
1617
				})
1618
				.collect(Collectors.toList());
1619
		return responseSender.ok(result);
1620
	}
1621
 
1622
	private boolean isSuperAdmin(AuthUser me) {
36681 ranu 1623
		String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";
36686 ranu 1624
		return SUPER_ADMIN_EMAILS.contains(myEmail);
1625
	}
36681 ranu 1626
 
36686 ranu 1627
	// Returns the user's highest escalation level across all positions.
1628
	// Mirrors OrderController.getSalesEscalationLevel but category-agnostic.
1629
	private EscalationType getHighestEscalation(int authUserId) {
1630
		EscalationType highest = null;
1631
		List<com.spice.profitmandi.dao.entity.cs.Position> positions = positionRepository.selectPositionByAuthId(authUserId);
1632
		for (com.spice.profitmandi.dao.entity.cs.Position p : positions) {
1633
			if (highest == null || p.getEscalationType().isGreaterThanEqualTo(highest)) {
1634
				highest = p.getEscalationType();
1635
			}
1636
		}
1637
		return highest;
1638
	}
1639
 
1640
	// Returns the escalation levels a user can manage — strictly below their own.
1641
	// L3 → [L1, L2]; L4 → [L1, L2, L3]; Final → all levels. Super-admin → all levels.
1642
	private List<EscalationType> getVisibleEscalationLevels(AuthUser me) {
1643
		if (isSuperAdmin(me)) return EscalationType.escalations;
1644
		EscalationType mine = getHighestEscalation(me.getId());
1645
		if (mine == null) return java.util.Collections.emptyList();
1646
		List<EscalationType> below = new ArrayList<>();
1647
		for (EscalationType e : EscalationType.escalations) {
1648
			if (mine.isGreaterThanEqualTo(e) && !e.equals(mine)) below.add(e);
1649
		}
1650
		return below;
1651
	}
1652
 
1653
	private List<EscalationType> visibleLevelsFor(HttpServletRequest request) throws ProfitMandiBusinessException {
1654
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1655
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1656
		return me == null ? java.util.Collections.emptyList() : getVisibleEscalationLevels(me);
1657
	}
1658
 
1659
	// Shared permission check for base-location admin actions: Sales L3+ OR super-admin.
1660
	private boolean isBaseLocationManager(AuthUser me) {
1661
		if (isSuperAdmin(me)) return true;
36681 ranu 1662
		return csService.getAuthUserIds(
1663
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
1664
						Arrays.asList(EscalationType.L3, EscalationType.L4))
1665
				.stream().anyMatch(u -> u.getId() == me.getId());
1666
	}
1667
 
36618 ranu 1668
	@PostMapping(value = "/beatPlan/saveBaseLocation")
1669
	public ResponseEntity<?> saveBaseLocation(
1670
			@RequestParam int authUserId,
1671
			@RequestParam String locationName,
1672
			@RequestParam String latitude,
1673
			@RequestParam String longitude,
1674
			@RequestParam(required = false) String address) {
1675
		AuthUserLocation loc = new AuthUserLocation();
1676
		loc.setAuthUserId(authUserId);
1677
		loc.setLocationType("BASE");
1678
		loc.setLocationName(locationName);
1679
		loc.setLatitude(latitude);
1680
		loc.setLongitude(longitude);
1681
		loc.setAddress(address);
1682
		loc.setCreatedTimestamp(LocalDateTime.now());
36681 ranu 1683
 
1684
		// First BASE for this user → auto-default so every user always has one.
1685
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1686
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1687
		loc.setDefault(existing.isEmpty() || noExistingDefault);
36618 ranu 1688
		authUserLocationRepository.persist(loc);
1689
 
1690
		Map<String, Object> result = new HashMap<>();
1691
		result.put("status", true);
1692
		result.put("id", loc.getId());
36681 ranu 1693
		result.put("isDefault", loc.isDefault());
36618 ranu 1694
		return responseSender.ok(result);
1695
	}
1696
 
1697
	@GetMapping(value = "/beatPlan/getPartners")
1698
	public ResponseEntity<?> getPartners(
1699
			@RequestParam int authUserId,
1700
			@RequestParam int categoryId,
1701
			@RequestParam(required = false) String startLat,
1702
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
1703
 
1704
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdPartnerIdMapping();
1705
		List<Integer> fofoIds = pp.get(authUserId);
1706
 
36644 ranu 1707
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 1708
			Map<String, Object> empty = new HashMap<>();
1709
			empty.put("partners", new ArrayList<>());
1710
			return responseSender.ok(empty);
1711
		}
1712
 
1713
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
1714
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
1715
 
1716
		List<Map<String, Object>> partners = new ArrayList<>();
1717
 
1718
		for (FofoStore store : fofoStores) {
1719
			if (!store.isActive() || store.isClosed()) continue;
1720
			CustomRetailer retailer = retailerMap.get(store.getId());
1721
 
1722
			Map<String, Object> partnerData = new HashMap<>();
1723
			partnerData.put("fofoId", store.getId());
1724
			partnerData.put("code", store.getCode());
1725
			partnerData.put("outletName", store.getOutletName());
1726
			partnerData.put("type", "partner");
1727
 
36655 ranu 1728
			// Use FofoStore lat/lng directly (migrated from address geocode)
1729
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
1730
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
1731
				partnerData.put("latitude", store.getLatitude());
1732
				partnerData.put("longitude", store.getLongitude());
1733
			}
1734
 
36618 ranu 1735
			if (retailer != null) {
1736
				partnerData.put("businessName", retailer.getBusinessName());
1737
				if (retailer.getAddress() != null) {
36644 ranu 1738
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 1739
				}
1740
			}
1741
			partners.add(partnerData);
1742
		}
1743
 
1744
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
1745
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
1746
		} else {
1747
			partners = sortByNearestNeighbor(partners);
1748
		}
1749
 
1750
		Map<String, Object> response = new HashMap<>();
1751
		response.put("partners", partners);
1752
		return responseSender.ok(response);
1753
	}
1754
 
1755
	@PostMapping(value = "/beatPlan/submitPlan")
1756
	public ResponseEntity<?> submitPlan(
1757
			HttpServletRequest request,
1758
			@RequestParam int authUserId,
1759
			@RequestParam String planData) throws Exception {
1760
 
1761
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1762
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1763
 
1764
		Gson gson = new Gson();
1765
		Type type = new TypeToken<Map<String, Object>>() {
1766
		}.getType();
1767
		Map<String, Object> plan = gson.fromJson(planData, type);
1768
 
1769
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
1770
		List<String> dates = (List<String>) plan.get("dates");
1771
 
36644 ranu 1772
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 1773
 
36698 ranu 1774
		// Duplicate check — same name + same authUserId among ACTIVE beats only.
1775
		// Soft-deleted beats keep the name in the table; we don't want them to
1776
		// block the user from reusing a name they "deleted".
1777
		List<Beat> existingBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36644 ranu 1778
		for (Beat existing : existingBeats) {
1779
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
1780
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 1781
				Map<String, Object> response = new HashMap<>();
1782
				response.put("status", true);
36644 ranu 1783
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 1784
				response.put("duplicate", true);
36644 ranu 1785
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 1786
				return responseSender.ok(response);
1787
			}
1788
		}
1789
 
36644 ranu 1790
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
1791
		int totalDays = days.size();
36618 ranu 1792
 
36785 ranu 1793
		// One-beat-per-day guard: reject if any of the requested dates already
1794
		// has a beat scheduled for this user.
1795
		if (dates != null) {
1796
			List<LocalDate> candidateDates = new ArrayList<>();
1797
			for (String dStr : dates) {
1798
				if (dStr != null && !dStr.isEmpty()) {
1799
					try {
1800
						candidateDates.add(LocalDate.parse(dStr, DateTimeFormatter.ISO_DATE));
1801
					} catch (Exception ignored) {
1802
					}
1803
				}
1804
			}
1805
			Map<String, Object> conflict = findScheduleConflict(authUserId, candidateDates, 0);
1806
			if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
1807
		}
1808
 
36644 ranu 1809
		// Create Beat master
1810
		Beat beat = new Beat();
1811
		beat.setName(beatName);
1812
		beat.setAuthUserId(authUserId);
1813
		beat.setBeatColor(beatColor);
1814
		beat.setTotalDays(totalDays);
1815
		beat.setActive(true);
1816
		beat.setCreatedBy(currentUser.getId());
1817
		beat.setCreatedTimestamp(LocalDateTime.now());
1818
 
1819
		// Set start location from first day
1820
		if (!days.isEmpty()) {
1821
			Map<String, Object> firstDay = days.get(0);
1822
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1823
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
1824
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
1825
		}
1826
		beatRepository.persist(beat);
1827
 
1828
		// End date of the whole beat = last scheduled day's date
1829
		LocalDate beatEndDate = null;
1830
		if (dates != null) {
1831
			for (int d = dates.size() - 1; d >= 0; d--) {
1832
				if (dates.get(d) != null) {
1833
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
1834
					break;
1835
				}
1836
			}
1837
		}
1838
 
1839
		// Create routes and schedules for each day
36618 ranu 1840
		for (int d = 0; d < days.size(); d++) {
1841
			Map<String, Object> day = days.get(d);
1842
			int dayNumber = d + 1;
1843
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 1844
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 1845
 
36644 ranu 1846
			// Auto-determine end action: last day = HOME, others = DAYBREAK
1847
			String endAction = (String) day.get("endAction");
1848
			if (endAction == null || endAction.isEmpty()) {
1849
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 1850
			}
1851
 
36644 ranu 1852
			// Always create schedule (even if planDate is null — unscheduled beat)
1853
			BeatSchedule schedule = new BeatSchedule();
1854
			schedule.setBeatId(beat.getId());
1855
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
1856
			schedule.setEndDate(beatEndDate);
1857
			schedule.setDayNumber(dayNumber);
1858
			schedule.setEndAction(endAction);
1859
			schedule.setStayLocationName((String) day.get("stayLocationName"));
1860
			schedule.setStayLatitude((String) day.get("stayLatitude"));
1861
			schedule.setStayLongitude((String) day.get("stayLongitude"));
1862
			if (day.get("totalDistanceKm") != null)
1863
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1864
			if (day.get("totalTimeMins") != null)
1865
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1866
			schedule.setCreatedTimestamp(LocalDateTime.now());
1867
			beatScheduleRepository.persist(schedule);
1868
 
36711 ranu 1869
            // Routes (stops) — also persist per-leg distance/time supplied by the
1870
            // client so reports/dashboards don't have to recompute from lat/lng.
36618 ranu 1871
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1872
			if (visits != null) {
1873
				for (int i = 0; i < visits.size(); i++) {
1874
					Map<String, Object> visit = visits.get(i);
36644 ranu 1875
					BeatRoute route = new BeatRoute();
1876
					route.setBeatId(beat.getId());
1877
					route.setFofoId(((Number) visit.get("id")).intValue());
1878
					route.setSequenceOrder(i);
1879
					route.setDayNumber(dayNumber);
1880
					route.setActive(true);
36711 ranu 1881
                    if (visit.get("distanceFromPrevKm") != null)
1882
                        route.setDistanceFromPrevKm(((Number) visit.get("distanceFromPrevKm")).doubleValue());
1883
                    if (visit.get("timeFromPrevMins") != null)
1884
                        route.setTimeFromPrevMins(((Number) visit.get("timeFromPrevMins")).intValue());
36644 ranu 1885
					beatRouteRepository.persist(route);
36618 ranu 1886
				}
1887
			}
1888
		}
1889
 
1890
		Map<String, Object> response = new HashMap<>();
1891
		response.put("status", true);
36644 ranu 1892
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 1893
		response.put("message", "Beat plan submitted successfully");
1894
		return responseSender.ok(response);
1895
	}
1896
 
36632 ranu 1897
	// ============ BULK UPLOAD ============
1898
 
1899
	@GetMapping(value = "/beatPlan/bulkUpload")
1900
	public String bulkUploadPage(HttpServletRequest request, Model model) {
1901
		return "beat-plan-bulk";
1902
	}
1903
 
36681 ranu 1904
	// Adds a new base location for the user. Caller can request this new row
1905
	// becomes the default. If the user has NO base locations yet, the new row
1906
	// is auto-defaulted (so every user always has exactly one default).
36668 ranu 1907
	@PostMapping(value = "/beatPlan/updateBaseLocation")
1908
	public ResponseEntity<?> updateBaseLocation(
1909
			HttpServletRequest request,
1910
			@RequestParam int authUserId,
1911
			@RequestParam String locationName,
1912
			@RequestParam String latitude,
1913
			@RequestParam String longitude,
36681 ranu 1914
			@RequestParam(required = false) String address,
1915
			@RequestParam(required = false, defaultValue = "false") boolean isDefault) throws Exception {
36668 ranu 1916
 
1917
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1918
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1919
		if (me == null) return responseSender.unauthorized("Not logged in");
36681 ranu 1920
		if (!isBaseLocationManager(me)) {
1921
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");
1922
		}
36668 ranu 1923
 
36681 ranu 1924
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1925
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1926
		boolean makeDefault = isDefault || existing.isEmpty() || noExistingDefault;
36668 ranu 1927
 
36681 ranu 1928
		// If this new row becomes the default, clear any existing default.
1929
		if (makeDefault) {
1930
			for (AuthUserLocation e : existing) {
1931
				if (e.isDefault()) {
1932
					e.setDefault(false);
1933
					authUserLocationRepository.persist(e);
1934
				}
1935
			}
36668 ranu 1936
		}
1937
 
1938
		AuthUserLocation loc = new AuthUserLocation();
1939
		loc.setAuthUserId(authUserId);
1940
		loc.setLocationType("BASE");
1941
		loc.setLocationName(locationName);
1942
		loc.setLatitude(latitude);
1943
		loc.setLongitude(longitude);
1944
		loc.setAddress(address);
36681 ranu 1945
		loc.setDefault(makeDefault);
36668 ranu 1946
		loc.setCreatedTimestamp(LocalDateTime.now());
1947
		authUserLocationRepository.persist(loc);
1948
 
1949
		Map<String, Object> result = new HashMap<>();
1950
		result.put("status", true);
1951
		result.put("id", loc.getId());
36681 ranu 1952
		result.put("isDefault", loc.isDefault());
1953
		result.put("message", makeDefault ? "Base location added and set as default" : "Base location added");
36668 ranu 1954
		return responseSender.ok(result);
1955
	}
1956
 
36632 ranu 1957
	@GetMapping(value = "/beatPlan/downloadTemplate")
36668 ranu 1958
	public ResponseEntity<?> downloadTemplate() throws java.io.IOException {
1959
		org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
1960
		org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");
36632 ranu 1961
 
36668 ranu 1962
		String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};
1963
 
1964
		// Header style
1965
		org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();
1966
		org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();
1967
		headerFont.setBold(true);
1968
		headerStyle.setFont(headerFont);
1969
		headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));
1970
		headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
1971
 
1972
		org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);
1973
		for (int i = 0; i < cols.length; i++) {
1974
			org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);
1975
			c.setCellValue(cols[i]);
1976
			c.setCellStyle(headerStyle);
1977
		}
1978
 
1979
		// Example rows — one partner per row. Inheritable columns blank after first row of a beat.
1980
		Object[][] sample = {
1981
				{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},
1982
				{"", "", "", "1", "2", "RJBUN1449"},
1983
				{"", "", "", "1", "3", "RJDEG1443"},
1984
				{"", "", "", "2", "1", "RJALR1362"},
1985
				{"", "", "", "2", "2", "RJBTR1388"},
1986
				{"", "", "", "3", "1", "RJRSD1518"},
1987
				{"", "", "", "3", "2", "RJSML356"},
1988
				{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},
1989
				{"", "", "", "1", "2", "UPAGR102"},
1990
		};
1991
		for (int r = 0; r < sample.length; r++) {
1992
			org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);
1993
			for (int c = 0; c < cols.length; c++) {
1994
				row.createCell(c).setCellValue(sample[r][c].toString());
1995
			}
1996
		}
1997
		for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);
1998
 
1999
		java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
2000
		wb.write(out);
2001
		wb.close();
2002
 
36632 ranu 2003
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
36668 ranu 2004
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");
2005
		headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
2006
		return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
36632 ranu 2007
	}
2008
 
2009
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
2010
	public ResponseEntity<?> bulkUploadProcess(
2011
			HttpServletRequest request,
2012
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
2013
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
2014
 
2015
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
2016
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
2017
 
36668 ranu 2018
		// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date
2019
		// appear ONLY on the first row of a beat; subsequent rows inherit them.
2020
		org.apache.poi.ss.usermodel.Workbook workbook =
2021
				new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());
2022
		org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
36632 ranu 2023
 
36668 ranu 2024
		// Header → column index map
2025
		org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);
2026
		if (headerRow == null) {
2027
			workbook.close();
2028
			return responseSender.badRequest("Empty file");
2029
		}
2030
		Map<String, Integer> colIdx = new HashMap<>();
2031
		for (int i = 0; i < headerRow.getLastCellNum(); i++) {
2032
			String h = readCell(headerRow.getCell(i));
2033
			if (h != null) colIdx.put(h.trim().toLowerCase(), i);
2034
		}
2035
		for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {
2036
			if (!colIdx.containsKey(required)) {
2037
				workbook.close();
2038
				return responseSender.badRequest("Missing required column: " + required);
2039
			}
2040
		}
36632 ranu 2041
 
36668 ranu 2042
		// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_order
2043
		Map<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();
2044
		String currentKey = null;
2045
		String currentBeatName = null;
2046
		String currentAuthId = null;
2047
		String currentStartDate = null;
2048
 
2049
		for (int r = 1; r <= sheet.getLastRowNum(); r++) {
2050
			org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
2051
			if (row == null) continue;
2052
 
2053
			String beatName = readCell(row.getCell(colIdx.get("beat_name")));
2054
			String authId = readCell(row.getCell(colIdx.get("auth_user_id")));
2055
			String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;
2056
			String dayNumber = readCell(row.getCell(colIdx.get("day_number")));
2057
			String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;
2058
			String code = readCell(row.getCell(colIdx.get("partner_code")));
2059
 
2060
			if (beatName != null && !beatName.trim().isEmpty()) {
2061
				// Start of a new beat — capture inheritable fields
2062
				currentBeatName = beatName.trim().replaceAll("\\s+", " ");
2063
				currentAuthId = authId != null ? authId.trim() : null;
2064
				currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;
2065
				currentKey = currentBeatName + "|" + currentAuthId;
2066
			}
2067
			if (currentKey == null) continue; // partner row before any beat header — skip
2068
			if (code == null || code.trim().isEmpty()) continue;
2069
 
2070
			final String beatNameF = currentBeatName;
2071
			final String authIdF = currentAuthId;
2072
			final String startDateF = currentStartDate;
2073
			BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));
2074
 
2075
			int day;
2076
			try {
2077
				day = Integer.parseInt(dayNumber.trim());
2078
			} catch (Exception e) {
2079
				continue;
2080
			} // bad day → skip row
2081
 
2082
			int seq = -1;
2083
			if (seqOrder != null && !seqOrder.trim().isEmpty()) {
2084
				try {
2085
					seq = Integer.parseInt(seqOrder.trim());
2086
				} catch (Exception ignore) {
2087
				}
2088
			}
2089
			g.addPartner(day, seq, code.trim(), r + 1);
36632 ranu 2090
		}
36668 ranu 2091
		workbook.close();
36632 ranu 2092
 
2093
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
2094
		Map<String, Integer> codeToId = new HashMap<>();
36644 ranu 2095
		for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());
36632 ranu 2096
 
2097
		LocalDate holidayStart = LocalDate.now();
36644 ranu 2098
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 2099
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
2100
 
36785 ranu 2101
		// =====================================================================
2102
		// All-or-nothing import: validate every group first; only persist if
2103
		// the entire file passes. A single bad row blocks the whole upload
2104
		// so the user can fix and re-upload without partial creations.
2105
		// =====================================================================
2106
 
36632 ranu 2107
		List<String> errorMessages = new ArrayList<>();
36785 ranu 2108
		List<ValidatedBulkBeat> ready = new ArrayList<>();
36632 ranu 2109
 
36785 ranu 2110
		// ----- Phase 1: validate every group, collect ALL errors -----
36668 ranu 2111
		for (BulkBeatGroup g : beatGroups.values()) {
36785 ranu 2112
			String beatName = g.beatName;
2113
 
2114
			int authUserId;
36632 ranu 2115
			try {
36785 ranu 2116
				authUserId = Integer.parseInt(g.authUserId);
2117
			} catch (Exception e) {
2118
				errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'.");
2119
				continue;
2120
			}
36632 ranu 2121
 
36785 ranu 2122
			LocalDate startDate;
2123
			try {
2124
				startDate = (g.startDate == null || g.startDate.isEmpty())
2125
						? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
2126
			} catch (Exception e) {
2127
				errorMessages.add("Beat '" + beatName + "': invalid start_date '" + g.startDate + "'.");
2128
				continue;
2129
			}
2130
			if (startDate != null && startDate.isBefore(LocalDate.now())) {
2131
				errorMessages.add("Beat '" + beatName + "': start_date in past.");
2132
				continue;
2133
			}
36632 ranu 2134
 
36785 ranu 2135
			List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
2136
			Collections.sort(sortedDays);
36668 ranu 2137
 
36785 ranu 2138
			List<LocalDate> scheduleDates = new ArrayList<>();
2139
			if (startDate != null) {
2140
				LocalDate d = startDate;
2141
				while (scheduleDates.size() < sortedDays.size()) {
2142
					if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
36632 ranu 2143
						d = d.plusDays(1);
36785 ranu 2144
						continue;
36632 ranu 2145
					}
36785 ranu 2146
					scheduleDates.add(d);
2147
					d = d.plusDays(1);
36632 ranu 2148
				}
36785 ranu 2149
			}
36632 ranu 2150
 
36785 ranu 2151
			// Duplicate beat-name check (ACTIVE only; soft-deleted names are reusable).
2152
			boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
2153
					.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
2154
			if (isDuplicate) {
2155
				errorMessages.add("Beat '" + beatName + "': already exists for user " + authUserId + ".");
2156
				continue;
2157
			}
2158
 
2159
			// One-beat-per-day guard (against existing beats).
2160
			Map<String, Object> bulkConflict = findScheduleConflict(authUserId, scheduleDates, 0);
2161
			if (bulkConflict != null) {
2162
				errorMessages.add("Beat '" + beatName + "': " + scheduleConflictMessage(bulkConflict));
2163
				continue;
2164
			}
2165
 
2166
			// Validate partner codes upfront so we don't half-persist.
2167
			List<String> badCodes = new ArrayList<>();
2168
			for (List<BulkPartner> ps : g.dayToPartners.values()) {
2169
				for (BulkPartner p : ps) {
2170
					if (!codeToId.containsKey(p.code)) {
2171
						badCodes.add(p.code + " (row " + p.rowNum + ")");
2172
					}
36644 ranu 2173
				}
36785 ranu 2174
			}
2175
			if (!badCodes.isEmpty()) {
2176
				errorMessages.add("Beat '" + beatName + "': unknown partner code(s) — " + String.join(", ", badCodes) + ".");
2177
				continue;
2178
			}
36632 ranu 2179
 
36785 ranu 2180
			ready.add(new ValidatedBulkBeat(g, authUserId, sortedDays, scheduleDates));
2181
		}
36632 ranu 2182
 
36785 ranu 2183
		// Intra-file conflict: two beats in the same upload requesting the same
2184
		// user + date. Caught here so the user fixes the file before re-uploading.
2185
		Set<String> seenUserDates = new HashSet<>();
2186
		for (ValidatedBulkBeat v : ready) {
2187
			for (LocalDate sd : v.scheduleDates) {
2188
				String key = v.authUserId + "|" + sd;
2189
				if (!seenUserDates.add(key)) {
2190
					errorMessages.add("Beat '" + v.g.beatName + "': date " + sd + " is also claimed by another beat in this file for the same user.");
2191
					break;
2192
				}
2193
			}
2194
		}
36632 ranu 2195
 
36785 ranu 2196
		// All-or-nothing: any error → return without persisting anything.
2197
		if (!errorMessages.isEmpty()) {
2198
			Map<String, Object> response = new HashMap<>();
2199
			response.put("status", false);
2200
			response.put("beatsCreated", 0);
2201
			response.put("errors", errorMessages.size());
2202
			response.put("errorMessages", errorMessages);
2203
			response.put("message", "No beats created. Fix the issues below and re-upload the file.");
2204
			return responseSender.ok(response);
2205
		}
36632 ranu 2206
 
36785 ranu 2207
		// ----- Phase 2: persist (only reached when every row was clean) -----
2208
		int beatsCreated = 0;
2209
		for (ValidatedBulkBeat v : ready) {
2210
			BulkBeatGroup g = v.g;
2211
			String beatName = g.beatName;
2212
			int authUserId = v.authUserId;
2213
			List<Integer> sortedDays = v.sortedDays;
2214
			List<LocalDate> scheduleDates = v.scheduleDates;
36668 ranu 2215
 
36785 ranu 2216
			String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
2217
			AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36632 ranu 2218
 
36785 ranu 2219
			Beat beat = new Beat();
2220
			beat.setName(beatName);
2221
			beat.setAuthUserId(authUserId);
2222
			beat.setBeatColor(beatColor);
2223
			beat.setTotalDays(sortedDays.size());
2224
			beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
2225
			beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
2226
			beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
2227
			beat.setActive(true);
2228
			beat.setCreatedBy(currentUser.getId());
2229
			beat.setCreatedTimestamp(LocalDateTime.now());
2230
			beatRepository.persist(beat);
36668 ranu 2231
 
36785 ranu 2232
			LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
2233
 
2234
			for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
2235
				int dayNumber = sortedDays.get(dayIdx);
2236
				LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
2237
 
2238
				BeatSchedule schedule = new BeatSchedule();
2239
				schedule.setBeatId(beat.getId());
2240
				schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
2241
				schedule.setEndDate(bulkEndDate);
2242
				schedule.setDayNumber(dayNumber);
2243
				schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
2244
				schedule.setCreatedTimestamp(LocalDateTime.now());
2245
				beatScheduleRepository.persist(schedule);
2246
 
2247
				List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
2248
				partners.sort((a, b) -> {
2249
					if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
2250
					if (a.seq >= 0) return -1;
2251
					if (b.seq >= 0) return 1;
2252
					return Integer.compare(a.rowNum, b.rowNum);
2253
				});
2254
 
2255
				int autoSeq = 0;
2256
				for (BulkPartner p : partners) {
2257
					Integer fofoId = codeToId.get(p.code);
2258
					// Codes were validated in Phase 1, this is just a safety net.
2259
					if (fofoId == null) continue;
2260
					BeatRoute route = new BeatRoute();
2261
					route.setBeatId(beat.getId());
2262
					route.setFofoId(fofoId);
2263
					route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
2264
					route.setDayNumber(dayNumber);
2265
					route.setActive(true);
2266
					beatRouteRepository.persist(route);
2267
					autoSeq++;
36632 ranu 2268
				}
2269
			}
36785 ranu 2270
			beatsCreated++;
36632 ranu 2271
		}
2272
 
2273
		Map<String, Object> response = new HashMap<>();
2274
		response.put("status", true);
2275
		response.put("beatsCreated", beatsCreated);
36785 ranu 2276
		response.put("errors", 0);
36632 ranu 2277
		response.put("errorMessages", errorMessages);
36785 ranu 2278
		response.put("message", beatsCreated + " beat(s) created.");
36632 ranu 2279
		return responseSender.ok(response);
2280
	}
2281
 
36785 ranu 2282
	// Move a beat from one date to another — used by calendar drag-and-drop.
2283
	// Behaviour: if the target date already has ANOTHER beat scheduled (for the
2284
	// same sales user), the two schedules swap — the other beat slides onto
2285
	// the source date. If the target date is empty, the source date becomes empty.
2286
	@PostMapping(value = "/beatPlan/moveScheduleDate")
2287
	public ResponseEntity<?> moveScheduleDate(
2288
			@RequestParam String planGroupId,
2289
			@RequestParam String fromDate,
2290
			@RequestParam String toDate) {
2291
		int beatId = Integer.parseInt(planGroupId);
2292
		LocalDate from = LocalDate.parse(fromDate);
2293
		LocalDate to = LocalDate.parse(toDate);
2294
 
2295
		if (from.equals(to)) {
2296
			Map<String, Object> ok = new HashMap<>();
2297
			ok.put("status", true);
2298
			ok.put("message", "Same date — no change");
2299
			return responseSender.ok(ok);
2300
		}
2301
 
2302
		// Today is the live/running slot — a beat already running today can't be
2303
		// bumped, and a future beat can't be moved onto today.
2304
		LocalDate today = LocalDate.now();
2305
		if (to.equals(today) || from.equals(today) || from.isBefore(today) || to.isBefore(today)) {
2306
			return responseSender.badRequest("Cannot move to or from today / a past date — today's beat is live. Use future dates only.");
2307
		}
2308
 
2309
		Beat beat = beatRepository.selectById(beatId);
2310
		if (beat == null) return responseSender.badRequest("Beat not found");
2311
 
2312
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
2313
 
2314
		// Reject if THIS beat already has a different schedule row on the target date
2315
		// (it would create two schedule rows of the same beat on one day).
2316
		boolean selfConflict = schedules.stream()
2317
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
2318
		if (selfConflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
2319
 
2320
		BeatSchedule match = schedules.stream()
2321
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
2322
				.findFirst().orElse(null);
2323
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
2324
 
2325
		// Look for ANY OTHER beat (same sales user) whose schedule sits on the target
2326
		// date — if found we'll swap it onto the source date.
2327
		BeatSchedule otherSchedule = null;
2328
		List<BeatSchedule> otherSchedules = null;
2329
		Beat otherBeat = null;
2330
		for (Beat ub : beatRepository.selectActiveByAuthUserId(beat.getAuthUserId())) {
2331
			if (ub.getId() == beatId) continue;
2332
			List<BeatSchedule> ubSchedules = beatScheduleRepository.selectByBeatId(ub.getId());
2333
			BeatSchedule hit = ubSchedules.stream()
2334
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(to))
2335
					.findFirst().orElse(null);
2336
			if (hit != null) {
2337
				otherSchedule = hit;
2338
				otherSchedules = ubSchedules;
2339
				otherBeat = ub;
2340
				break;
2341
			}
2342
		}
2343
 
2344
		// Move the dragged beat onto the target date.
2345
		match.setStartDate(to);
2346
		// If another beat occupied the target, slide it onto the source date (swap).
2347
		if (otherSchedule != null) {
2348
			otherSchedule.setStartDate(from);
2349
		}
2350
 
2351
		// Recompute endDate as the max across each affected beat's schedules so
2352
		// the [startDate, endDate] envelope stays consistent.
2353
		recomputeEndDate(schedules);
2354
		if (otherSchedules != null) {
2355
			recomputeEndDate(otherSchedules);
2356
		}
2357
 
2358
		Map<String, Object> response = new HashMap<>();
2359
		response.put("status", true);
2360
		response.put("message", otherBeat != null
2361
				? "Swapped with \"" + (otherBeat.getName() != null ? otherBeat.getName() : "beat") + "\" on " + toDate
2362
				: "Moved from " + fromDate + " to " + toDate);
2363
		response.put("swapped", otherBeat != null);
2364
		return responseSender.ok(response);
2365
	}
2366
 
36668 ranu 2367
	private static class BulkBeatGroup {
2368
		final String beatName;
2369
		final String authUserId;
2370
		final String startDate;
2371
		final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();
2372
 
2373
		BulkBeatGroup(String beatName, String authUserId, String startDate) {
2374
			this.beatName = beatName;
2375
			this.authUserId = authUserId;
2376
			this.startDate = startDate;
2377
		}
2378
 
2379
		void addPartner(int day, int seq, String code, int rowNum) {
2380
			dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));
2381
		}
2382
	}
2383
 
2384
	private static class BulkPartner {
2385
		final int seq;
2386
		final String code;
2387
		final int rowNum;
2388
 
2389
		BulkPartner(int seq, String code, int rowNum) {
2390
			this.seq = seq;
2391
			this.code = code;
2392
			this.rowNum = rowNum;
2393
		}
2394
	}
2395
 
36644 ranu 2396
	// ============ CALENDAR ============
36618 ranu 2397
 
2398
	@PostMapping(value = "/beatPlan/delete")
2399
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 2400
		int beatId = Integer.parseInt(planGroupId);
36698 ranu 2401
		// Hard delete — wipe all child rows first, then the beat itself.
2402
		// The name slot is freed naturally because the row is gone.
36644 ranu 2403
		beatRouteRepository.deleteByBeatId(beatId);
2404
		beatScheduleRepository.deleteByBeatId(beatId);
36698 ranu 2405
		leadRouteRepository.deleteByBeatId(beatId);
36644 ranu 2406
		Beat beat = beatRepository.selectById(beatId);
2407
		if (beat != null) {
36698 ranu 2408
			beatRepository.delete(beat);
36644 ranu 2409
		}
36618 ranu 2410
 
2411
		Map<String, Object> response = new HashMap<>();
2412
		response.put("status", true);
2413
		response.put("message", "Beat deleted");
2414
		return responseSender.ok(response);
2415
	}
2416
 
36670 ranu 2417
	// Unschedule the beat from ONE specific date — does NOT delete the beat.
2418
	// The beat (and its route template) stays; only the matching beat_schedule
2419
	// row is removed. If no real-date schedules remain, a placeholder
2420
	// (9999-12-31) row is added so the beat still shows up as "unscheduled".
2421
	@PostMapping(value = "/beatPlan/unscheduleDate")
2422
	public ResponseEntity<?> unscheduleDate(
2423
			@RequestParam String planGroupId,
2424
			@RequestParam String date) {
2425
		int beatId = Integer.parseInt(planGroupId);
2426
		LocalDate target = LocalDate.parse(date);
2427
 
2428
		Beat beat = beatRepository.selectById(beatId);
2429
		if (beat == null) return responseSender.badRequest("Beat not found");
2430
 
2431
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
2432
		int removed = 0;
2433
		for (BeatSchedule s : schedules) {
2434
			if (s.getStartDate() != null && s.getStartDate().equals(target)) {
2435
				beatScheduleRepository.delete(s);
2436
				removed++;
2437
			}
2438
		}
2439
		if (removed == 0) return responseSender.badRequest("No schedule found for that date");
2440
 
2441
		// If no real-date schedules left, drop in a placeholder so the beat
2442
		// remains visible in the unscheduled bucket.
2443
		boolean hasReal = schedules.stream()
2444
				.filter(s -> !s.getStartDate().equals(target))
2445
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);
2446
		if (!hasReal) {
2447
			boolean hasPlaceholder = schedules.stream()
2448
					.filter(s -> !s.getStartDate().equals(target))
2449
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);
2450
			if (!hasPlaceholder) {
2451
				BeatSchedule ph = new BeatSchedule();
2452
				ph.setBeatId(beatId);
2453
				ph.setStartDate(LocalDate.of(9999, 12, 31));
2454
				ph.setDayNumber(1);
2455
				ph.setEndAction("HOME");
2456
				ph.setCreatedTimestamp(LocalDateTime.now());
2457
				beatScheduleRepository.persist(ph);
2458
			}
2459
		}
2460
 
2461
		Map<String, Object> response = new HashMap<>();
2462
		response.put("status", true);
2463
		response.put("message", "Unscheduled from " + date);
2464
		return responseSender.ok(response);
2465
	}
2466
 
36785 ranu 2467
	private void recomputeEndDate(List<BeatSchedule> schedules) {
2468
		LocalDate newEnd = schedules.stream()
2469
				.map(BeatSchedule::getStartDate)
2470
				.filter(d -> d != null && d.getYear() != 9999)
2471
				.max(LocalDate::compareTo).orElse(null);
2472
		if (newEnd == null) return;
2473
		for (BeatSchedule s : schedules) {
2474
			if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {
2475
				s.setEndDate(newEnd);
2476
			}
2477
		}
2478
	}
2479
 
2480
	/**
2481
	 * Per-user one-beat-per-day guard. Walks every active beat the sales user
2482
	 * already has and looks for a schedule row on any of the candidate dates.
2483
	 * Returns null if the candidate dates are clear, otherwise a {date,beatName}
2484
	 * map describing the first collision so the caller can surface a clean error.
2485
	 * Pass `excludeBeatId` so callers that are re-scheduling an existing beat
2486
	 * don't trip on their own pre-existing schedule rows; pass 0 for new beats.
2487
	 */
2488
	private Map<String, Object> findScheduleConflict(int authUserId, java.util.Collection<LocalDate> candidates, int excludeBeatId) {
2489
		if (candidates == null || candidates.isEmpty()) return null;
2490
		Set<LocalDate> ds = new HashSet<>();
2491
		for (LocalDate d : candidates) {
2492
			if (d != null && d.getYear() != 9999) ds.add(d);
2493
		}
2494
		if (ds.isEmpty()) return null;
2495
		for (Beat ub : beatRepository.selectActiveByAuthUserId(authUserId)) {
2496
			if (ub.getId() == excludeBeatId) continue;
2497
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(ub.getId())) {
2498
				if (s.getStartDate() != null && ds.contains(s.getStartDate())) {
2499
					Map<String, Object> conflict = new HashMap<>();
2500
					conflict.put("date", s.getStartDate().toString());
2501
					conflict.put("beatName", ub.getName() != null ? ub.getName() : "Beat #" + ub.getId());
2502
					return conflict;
2503
				}
2504
			}
2505
		}
2506
		return null;
2507
	}
2508
 
2509
	private String scheduleConflictMessage(Map<String, Object> conflict) {
2510
		return "Cannot schedule on " + conflict.get("date")
2511
				+ " — \"" + conflict.get("beatName") + "\" is already scheduled for this user on that day.";
2512
	}
2513
 
2514
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
2515
	public ResponseEntity<?> scheduleOnCalendar(
2516
			HttpServletRequest request,
36670 ranu 2517
			@RequestParam String planGroupId,
36785 ranu 2518
			@RequestParam String dates,
2519
			@RequestParam(required = false) String beatName,
2520
			@RequestParam(required = false) String beatColor) throws Exception {
2521
 
36670 ranu 2522
		int beatId = Integer.parseInt(planGroupId);
36785 ranu 2523
		Gson gson = new Gson();
2524
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2525
		}.getType());
36670 ranu 2526
 
2527
		Beat beat = beatRepository.selectById(beatId);
2528
		if (beat == null) return responseSender.badRequest("Beat not found");
2529
 
36785 ranu 2530
		if (beatName != null) beat.setName(beatName);
2531
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36670 ranu 2532
 
36785 ranu 2533
		// One-beat-per-day guard: reject if any of the requested dates already
2534
		// has another beat scheduled for this user (excluding this beat itself).
2535
		List<LocalDate> requested = new ArrayList<>();
2536
		for (String s : dateList) {
2537
			try {
2538
				requested.add(LocalDate.parse(s));
2539
			} catch (Exception ignored) {
36670 ranu 2540
			}
2541
		}
36785 ranu 2542
		Map<String, Object> conflict = findScheduleConflict(beat.getAuthUserId(), requested, beatId);
2543
		if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
36670 ranu 2544
 
36785 ranu 2545
		// Delete old schedules and create new
2546
		beatScheduleRepository.deleteByBeatId(beatId);
2547
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
2548
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
2549
            int dayNumber = i + 1;
2550
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
2551
			BeatSchedule schedule = new BeatSchedule();
2552
			schedule.setBeatId(beatId);
2553
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
2554
			schedule.setEndDate(schEndDate);
2555
            schedule.setDayNumber(dayNumber);
2556
            schedule.setEndAction(endAction);
2557
            // Fill total_distance_km / total_time_mins from beat_route so the new
2558
            // schedule row isn't NULL (this was the bug — these were left unset).
2559
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
2560
            schedule.setTotalDistanceKm(totals[0]);
2561
            schedule.setTotalTimeMins((int) totals[1]);
2562
			schedule.setCreatedTimestamp(LocalDateTime.now());
2563
			beatScheduleRepository.persist(schedule);
2564
		}
2565
 
36670 ranu 2566
		Map<String, Object> response = new HashMap<>();
2567
		response.put("status", true);
36785 ranu 2568
		response.put("message", "Beat scheduled successfully");
36670 ranu 2569
		return responseSender.ok(response);
2570
	}
2571
 
36618 ranu 2572
	@GetMapping(value = "/beatPlan/calendar")
2573
	public ResponseEntity<?> getCalendar(
2574
			@RequestParam int authUserId,
2575
			@RequestParam String month) {
2576
 
2577
		YearMonth ym = YearMonth.parse(month);
2578
		LocalDate startDate = ym.atDay(1);
2579
		LocalDate endDate = ym.atEndOfMonth();
2580
 
2581
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
2582
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
2583
			Map<String, String> m = new HashMap<>();
2584
			m.put("date", h.getDate().toString());
2585
			m.put("occasion", h.getOccasion());
2586
			return m;
2587
		}).collect(Collectors.toList());
2588
 
36644 ranu 2589
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 2590
		LocalDate today = LocalDate.now();
2591
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
2592
 
36644 ranu 2593
		for (Beat beat : allBeats) {
2594
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
2595
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 2596
 
36644 ranu 2597
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
2598
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
2599
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
2600
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 2601
 
2602
			String status;
2603
			if (allNullDates) status = "unscheduled";
2604
			else if (hasToday) status = "running";
2605
			else if (allPast) status = "completed";
36644 ranu 2606
			else status = "scheduled";
36618 ranu 2607
 
36644 ranu 2608
			Map<String, Object> beatInfo = new HashMap<>();
2609
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
2610
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
2611
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
2612
			beatInfo.put("status", status);
36728 vikas 2613
			beatInfo.put("totalDays", beat.getTotalDays());
36618 ranu 2614
 
2615
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 2616
			for (BeatSchedule s : schedules) {
36618 ranu 2617
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 2618
				dayInfo.put("dayNumber", s.getDayNumber());
2619
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
2620
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
2621
				dayInfo.put("totalKm", s.getTotalDistanceKm());
2622
				dayInfo.put("totalMins", s.getTotalTimeMins());
36711 ranu 2623
                // endAction tells the planner whether to draw the return-to-home line
2624
                // for this day (HOME) or end at the last stop (DAYBREAK).
2625
                dayInfo.put("endAction", s.getEndAction());
36644 ranu 2626
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
2627
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 2628
				dayInfoList.add(dayInfo);
2629
			}
36644 ranu 2630
			if (schedules.isEmpty()) {
2631
				// No schedule at all — show from routes
2632
				Map<Integer, Long> dayCounts = routes.stream()
2633
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
2634
				for (int d = 1; d <= beat.getTotalDays(); d++) {
2635
					Map<String, Object> dayInfo = new HashMap<>();
2636
					dayInfo.put("dayNumber", d);
2637
					dayInfo.put("planDate", null);
2638
					dayInfo.put("totalKm", null);
2639
					dayInfo.put("totalMins", null);
2640
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
2641
					dayInfoList.add(dayInfo);
2642
				}
2643
			}
2644
			beatInfo.put("days", dayInfoList);
2645
			scheduledBeats.add(beatInfo);
36618 ranu 2646
		}
2647
 
2648
		Set<String> blockedDates = new HashSet<>();
2649
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 2650
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 2651
		}
36644 ranu 2652
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 2653
 
2654
		Map<String, Object> response = new HashMap<>();
2655
		response.put("holidays", holidayList);
2656
		response.put("scheduledBeats", scheduledBeats);
2657
		response.put("blockedDates", blockedDates);
2658
		return responseSender.ok(response);
2659
	}
2660
 
36644 ranu 2661
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 2662
	@PostMapping(value = "/beatPlan/repeatBeat")
2663
	public ResponseEntity<?> repeatBeat(
2664
			HttpServletRequest request,
2665
			@RequestParam String sourcePlanGroupId,
2666
			@RequestParam int authUserId,
2667
			@RequestParam String dates) throws Exception {
2668
 
36644 ranu 2669
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 2670
		Gson gson = new Gson();
2671
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2672
		}.getType());
2673
 
36644 ranu 2674
		Beat beat = beatRepository.selectById(beatId);
2675
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 2676
 
36785 ranu 2677
		// One-beat-per-day guard: reject if any of the new dates already has
2678
		// another beat scheduled for this user (excluding this beat itself).
2679
		List<LocalDate> repeatDates = new ArrayList<>();
2680
		for (String s : dateList) {
2681
			try {
2682
				repeatDates.add(LocalDate.parse(s));
2683
			} catch (Exception ignored) {
2684
			}
2685
		}
2686
		Map<String, Object> repeatConflict = findScheduleConflict(beat.getAuthUserId(), repeatDates, beatId);
2687
		if (repeatConflict != null) return responseSender.badRequest(scheduleConflictMessage(repeatConflict));
2688
 
36644 ranu 2689
		// Remove placeholder (unscheduled) schedule rows
2690
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
2691
		for (BeatSchedule s : existing) {
2692
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
2693
				beatScheduleRepository.delete(s);
2694
			}
36618 ranu 2695
		}
2696
 
36711 ranu 2697
        // Add new real-date schedule rows for the existing beat — fill totals
2698
        // from beat_route so total_distance_km / total_time_mins aren't NULL.
36644 ranu 2699
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
2700
		for (int i = 0; i < dateList.size(); i++) {
36711 ranu 2701
            int dayNumber = i + 1;
2702
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
36644 ranu 2703
			BeatSchedule schedule = new BeatSchedule();
2704
			schedule.setBeatId(beatId);
2705
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
2706
			schedule.setEndDate(repeatEndDate);
36711 ranu 2707
            schedule.setDayNumber(dayNumber);
2708
            schedule.setEndAction(endAction);
2709
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
2710
            schedule.setTotalDistanceKm(totals[0]);
2711
            schedule.setTotalTimeMins((int) totals[1]);
36644 ranu 2712
			schedule.setCreatedTimestamp(LocalDateTime.now());
2713
			beatScheduleRepository.persist(schedule);
36618 ranu 2714
		}
2715
 
2716
		Map<String, Object> response = new HashMap<>();
2717
		response.put("status", true);
36644 ranu 2718
		response.put("planGroupId", String.valueOf(beatId));
2719
		response.put("message", "Beat scheduled successfully");
36618 ranu 2720
		return responseSender.ok(response);
2721
	}
2722
 
36785 ranu 2723
	private static class ValidatedBulkBeat {
2724
		final BulkBeatGroup g;
2725
		final int authUserId;
2726
		final List<Integer> sortedDays;
2727
		final List<LocalDate> scheduleDates;
2728
 
2729
		ValidatedBulkBeat(BulkBeatGroup g, int authUserId, List<Integer> sortedDays, List<LocalDate> scheduleDates) {
2730
			this.g = g;
2731
			this.authUserId = authUserId;
2732
			this.sortedDays = sortedDays;
2733
			this.scheduleDates = scheduleDates;
2734
		}
2735
	}
2736
 
36618 ranu 2737
	@GetMapping(value = "/beatPlan/availableSlots")
2738
	public ResponseEntity<?> getAvailableSlots(
2739
			@RequestParam int authUserId,
2740
			@RequestParam String month,
2741
			@RequestParam int daysNeeded) {
2742
 
2743
		YearMonth ym = YearMonth.parse(month);
2744
		LocalDate startDate = ym.atDay(1);
2745
		LocalDate endDate = ym.atEndOfMonth();
2746
		LocalDate today = LocalDate.now();
2747
 
2748
		Set<LocalDate> blocked = new HashSet<>();
2749
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
2750
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 2751
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 2752
		}
2753
 
2754
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
2755
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
2756
 
36644 ranu 2757
		// Get all scheduled dates for this user
2758
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
2759
		for (Beat b : userBeats) {
2760
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
2761
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 2762
		}
2763
 
2764
		List<String> available = new ArrayList<>();
2765
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
2766
			 !d.isAfter(endDate) && available.size() < daysNeeded;
2767
			 d = d.plusDays(1)) {
36644 ranu 2768
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 2769
		}
2770
 
2771
		Map<String, Object> response = new HashMap<>();
2772
		response.put("suggestedDates", available);
2773
		response.put("totalAvailable", available.size());
2774
		return responseSender.ok(response);
2775
	}
2776
 
2777
	// --- Sorting helpers ---
2778
 
2779
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
2780
			List<Map<String, Object>> partners, double startLat, double startLng) {
2781
		List<Map<String, Object>> withCoords = new ArrayList<>();
2782
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2783
		for (Map<String, Object> p : partners) {
36644 ranu 2784
			if (hasValidCoords(p)) withCoords.add(p);
2785
			else withoutCoords.add(p);
36618 ranu 2786
		}
2787
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 2788
		double currentLat = startLat, currentLng = startLng;
36618 ranu 2789
		while (!withCoords.isEmpty()) {
2790
			int nearestIdx = 0;
2791
			double nearestDist = Double.MAX_VALUE;
2792
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2793
				double dist = haversine(currentLat, currentLng,
2794
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
2795
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 2796
				if (dist < nearestDist) {
2797
					nearestDist = dist;
2798
					nearestIdx = i;
2799
				}
2800
			}
2801
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
2802
			sorted.add(nearest);
2803
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
2804
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
2805
		}
2806
		sorted.addAll(withoutCoords);
2807
		return sorted;
2808
	}
2809
 
2810
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
2811
		List<Map<String, Object>> withCoords = new ArrayList<>();
2812
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2813
		for (Map<String, Object> p : partners) {
36644 ranu 2814
			if (hasValidCoords(p)) withCoords.add(p);
2815
			else withoutCoords.add(p);
36618 ranu 2816
		}
2817
		List<Map<String, Object>> sorted = new ArrayList<>();
2818
		if (!withCoords.isEmpty()) {
2819
			sorted.add(withCoords.remove(0));
2820
			while (!withCoords.isEmpty()) {
2821
				Map<String, Object> last = sorted.get(sorted.size() - 1);
2822
				double lastLat = Double.parseDouble(last.get("latitude").toString());
2823
				double lastLng = Double.parseDouble(last.get("longitude").toString());
2824
				int nearestIdx = 0;
2825
				double nearestDist = Double.MAX_VALUE;
2826
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2827
					double dist = haversine(lastLat, lastLng,
2828
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
2829
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 2830
					if (dist < nearestDist) {
2831
						nearestDist = dist;
2832
						nearestIdx = i;
2833
					}
2834
				}
2835
				sorted.add(withCoords.remove(nearestIdx));
2836
			}
2837
		}
2838
		sorted.addAll(withoutCoords);
2839
		return sorted;
2840
	}
2841
 
2842
	private boolean hasValidCoords(Map<String, Object> p) {
2843
		Object lat = p.get("latitude");
2844
		Object lng = p.get("longitude");
36644 ranu 2845
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 2846
	}
2847
 
2848
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
2849
		double R = 6371;
2850
		double dLat = Math.toRadians(lat2 - lat1);
2851
		double dLng = Math.toRadians(lng2 - lng1);
2852
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
2853
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
2854
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
2855
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
2856
		return R * c;
2857
	}
2858
}