Subversion Repositories SmartDukaan

Rev

Rev 37060 | 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;
36811 ranu 88
	@Autowired
89
	private com.spice.profitmandi.dao.repository.logistics.CompanyOfficeRepository companyOfficeRepository;
36618 ranu 90
 
91
	@GetMapping(value = "/beatPlan")
36686 ranu 92
	public String beatPlan(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
93
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36618 ranu 94
		return "beat-plan";
95
	}
96
 
36650 ranu 97
	@Autowired
98
	private com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository leadLiveLocationRepositoryAuto;
36651 ranu 99
	@Autowired
100
	private com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository leadActivityRepositoryAuto;
36663 ranu 101
	@Autowired
102
	private com.spice.profitmandi.dao.repository.dtr.UserRepository userRepositoryAuto;
103
	@Autowired
104
	private com.spice.profitmandi.common.web.client.RestClient restClientAuto;
105
	@Autowired
106
	private com.spice.profitmandi.dao.repository.auth.LocationTrackingRepository locationTrackingRepositoryAuto;
36740 ranu 107
	@Autowired
108
	private com.spice.profitmandi.dao.repository.dtr.BeatDeferredVisitRepository beatDeferredVisitRepository;
36650 ranu 109
 
36655 ranu 110
	private static Double parseDoubleOrNull(String s) {
111
		if (s == null || s.trim().isEmpty()) return null;
112
		try {
113
			return Double.parseDouble(s.trim());
114
		} catch (NumberFormatException e) {
115
			return null;
116
		}
117
	}
118
 
119
	private static double haversineKm(double lat1, double lng1, double lat2, double lng2) {
120
		double R = 6371;
121
		double dLat = Math.toRadians(lat2 - lat1);
122
		double dLng = Math.toRadians(lng2 - lng1);
123
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
124
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
125
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
126
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
127
		return R * c;
128
	}
129
 
37060 vikas 130
	// Reorder a day's bulk-upload stops in place into a greedy nearest-neighbor
131
	// route from the given start point (the beat's start location). Codes with no
132
	// known coordinates (offices, or partners missing lat/lng) are parked at the
133
	// end preserving their relative order rather than dropped. Sequence is taken
134
	// from the resulting list position by the caller.
135
	private static void sortPartnersByNearestNeighbor(List<BulkPartner> partners, Double startLat, Double startLng, Map<String, double[]> codeToLatLng) {
136
		if (partners == null || partners.size() < 2) return;
137
 
138
		List<BulkPartner> routable = new ArrayList<>();
139
		List<BulkPartner> parked = new ArrayList<>();
140
		for (BulkPartner p : partners) {
141
			if (codeToLatLng.containsKey(p.code)) routable.add(p);
142
			else parked.add(p);
143
		}
144
 
145
		List<BulkPartner> ordered = new ArrayList<>();
146
		double curLat, curLng;
147
		if (startLat != null && startLng != null) {
148
			curLat = startLat;
149
			curLng = startLng;
150
		} else if (!routable.isEmpty()) {
151
			BulkPartner first = routable.remove(0);
152
			ordered.add(first);
153
			double[] c = codeToLatLng.get(first.code);
154
			curLat = c[0];
155
			curLng = c[1];
156
		} else {
157
			return;
158
		}
159
 
160
		while (!routable.isEmpty()) {
161
			int bestIdx = 0;
162
			double bestD = Double.MAX_VALUE;
163
			for (int i = 0; i < routable.size(); i++) {
164
				double[] c = codeToLatLng.get(routable.get(i).code);
165
				double d = haversineKm(curLat, curLng, c[0], c[1]);
166
				if (d < bestD) {
167
					bestD = d;
168
					bestIdx = i;
169
				}
170
			}
171
			BulkPartner next = routable.remove(bestIdx);
172
			ordered.add(next);
173
			double[] c = codeToLatLng.get(next.code);
174
			curLat = c[0];
175
			curLng = c[1];
176
		}
177
 
178
		partners.clear();
179
		partners.addAll(ordered);
180
		partners.addAll(parked);
181
	}
182
 
36711 ranu 183
    // Mirrors the JS recalcDay() formula. Used by schedule/repeat endpoints
184
    // which create fresh BeatSchedule rows — they need to fill totals from the
185
    // existing beat_route table, not from anything the client posted.
186
    // Returns {totalKm, totalMins}.
187
    private double[] computeDayTotals(int beatId, int dayNumber, String endAction) {
188
        Beat beat = beatRepository.selectById(beatId);
189
        if (beat == null) return new double[]{0d, 0d};
190
 
191
        List<BeatRoute> dayRoutes = beatRouteRepository.selectByBeatId(beatId).stream()
192
                .filter(r -> r.getDayNumber() == dayNumber)
193
                .sorted(java.util.Comparator.comparingInt(BeatRoute::getSequenceOrder))
194
                .collect(Collectors.toList());
195
        if (dayRoutes.isEmpty()) return new double[]{0d, 0d};
196
 
197
        List<Integer> fofoIds = dayRoutes.stream().map(BeatRoute::getFofoId).distinct().collect(Collectors.toList());
198
        Map<Integer, FofoStore> storeMap = new HashMap<>();
199
        try {
200
            for (FofoStore fs : fofoStoreRepository.selectByRetailerIds(fofoIds)) {
201
                storeMap.put(fs.getId(), fs);
202
            }
203
        } catch (Exception ignored) { /* fall through with empty map */ }
204
 
205
        double ROAD_FACTOR = 1.3;
206
        double AVG_SPEED = 30.0; // km/h
207
        int VISIT_MINS = 30;
208
 
209
        Double prevLat = parseDoubleOrNull(beat.getStartLatitude());
210
        Double prevLng = parseDoubleOrNull(beat.getStartLongitude());
211
 
212
        double totalKm = 0d;
213
        for (BeatRoute r : dayRoutes) {
214
            FofoStore fs = storeMap.get(r.getFofoId());
215
            if (fs == null) continue;
216
            Double curLat = parseDoubleOrNull(fs.getLatitude());
217
            Double curLng = parseDoubleOrNull(fs.getLongitude());
218
            if (prevLat != null && prevLng != null && curLat != null && curLng != null) {
219
                totalKm += haversineKm(prevLat, prevLng, curLat, curLng) * ROAD_FACTOR;
220
            }
221
            if (curLat != null && curLng != null) {
222
                prevLat = curLat;
223
                prevLng = curLng;
224
            }
225
        }
226
 
227
        // Return-home leg only when end_action='HOME'
228
        if ("HOME".equalsIgnoreCase(endAction)) {
229
            Double homeLat = parseDoubleOrNull(beat.getStartLatitude());
230
            Double homeLng = parseDoubleOrNull(beat.getStartLongitude());
231
            if (prevLat != null && prevLng != null && homeLat != null && homeLng != null) {
232
                totalKm += haversineKm(prevLat, prevLng, homeLat, homeLng) * ROAD_FACTOR;
233
            }
234
        }
235
 
236
        double totalMins = (totalKm / AVG_SPEED) * 60.0 + dayRoutes.size() * VISIT_MINS;
237
        return new double[]{Math.round(totalKm * 1000d) / 1000d, Math.round(totalMins)};
238
    }
239
 
36663 ranu 240
	// ====================== ASSIGN VISIT ======================
241
	// Day View "Assign Visit" — lets an admin pick parties (stores) for a specific
242
	// auth user on a specific date and pushes them as visit tasks to the v2
243
	// /profitmandi-web/v2/beat-tracking/batch endpoint.
244
 
36716 ranu 245
	// List of parties (stores) assigned to this auth user + their dtr.users.id.
246
	// When date+beatId are passed, each party is also tagged with:
247
	//   inBeat        = is this store part of the scheduled beat's route on that date
248
	//   existingAgendas[] = agendas already saved for this store on that date (so the
249
	//                       modal can pre-fill them and let the user refill rather than re-assign)
36663 ranu 250
	@GetMapping(value = "/beatPlan/assignVisit/parties")
36716 ranu 251
	public ResponseEntity<?> assignVisitParties(
252
			@RequestParam int authUserId,
253
			@RequestParam(required = false) String date,
254
			@RequestParam(required = false) Integer beatId) throws Exception {
36663 ranu 255
		AuthUser au = authRepository.selectById(authUserId);
256
		if (au == null) return responseSender.badRequest("Auth user not found");
257
 
258
		// Map auth_user → dtr.users via email
259
		Integer dtrUserId = null;
260
		try {
261
			com.spice.profitmandi.dao.entity.dtr.User dtrUser =
262
					userRepositoryAuto.selectByEmailId(au.getEmailId());
263
			if (dtrUser != null) dtrUserId = dtrUser.getId();
264
		} catch (Exception ignored) {
265
		}
266
 
36716 ranu 267
		// Parse optional date
268
		LocalDate parsedDate = null;
269
		if (date != null && !date.isEmpty()) {
270
			try {
271
				parsedDate = LocalDate.parse(date);
272
			} catch (Exception ignored) {
273
			}
274
		}
275
 
276
		// Build (fofoId → dayNumber) of partners already in the beat's scheduled route for this date
277
		Set<Integer> inBeatFofoIds = new HashSet<>();
278
		if (beatId != null && parsedDate != null) {
279
			final LocalDate dateF = parsedDate; // capture for lambda (parsedDate is reassigned earlier so not effectively final)
280
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
281
			BeatSchedule match = schedules.stream()
282
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(dateF))
283
					.findFirst().orElse(null);
284
			if (match != null) {
285
				List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
286
				routes.stream()
287
						.filter(r -> r.getDayNumber() == match.getDayNumber() && r.isActive())
288
						.forEach(r -> inBeatFofoIds.add(r.getFofoId()));
289
			}
290
		}
291
 
292
		// Build (fofoId → existingAgendas) and (fofoId → existingDescription) from
293
		// any already-saved location_tracking rows for this user on this date.
294
		// Agenda is stored as task_name = "agenda1, agenda2 | OutletName"
295
		// so we split on " | " to peel the outlet suffix off, then split agendas by ", ".
296
		// Description is stored on task_description (free text).
297
		Map<Integer, List<String>> existingAgendaByFofo = new HashMap<>();
298
		Map<Integer, String> existingDescByFofo = new HashMap<>();
299
		Map<Integer, Integer> existingTrackingIdByFofo = new HashMap<>();
300
		if (dtrUserId != null && parsedDate != null) {
301
			List<com.spice.profitmandi.dao.entity.auth.LocationTracking> existing =
302
					locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, parsedDate);
303
			for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt : existing) {
304
				if (!"franchisee-visit".equals(lt.getTaskType())) continue;
305
				if (existingAgendaByFofo.containsKey(lt.getTaskId())) continue; // first wins
306
				String taskName = lt.getTaskName() == null ? "" : lt.getTaskName();
307
				String agendaPart = taskName;
308
				int pipeIdx = taskName.lastIndexOf(" | ");
309
				if (pipeIdx > 0) agendaPart = taskName.substring(0, pipeIdx);
310
				List<String> agendas = new ArrayList<>();
311
				for (String a : agendaPart.split(",")) {
312
					String trimmed = a.trim();
313
					if (!trimmed.isEmpty()) agendas.add(trimmed);
314
				}
315
				existingAgendaByFofo.put(lt.getTaskId(), agendas);
316
				existingDescByFofo.put(lt.getTaskId(), lt.getTaskDescription() != null ? lt.getTaskDescription() : "");
317
				existingTrackingIdByFofo.put(lt.getTaskId(), lt.getId());
318
			}
319
		}
320
 
36663 ranu 321
		Map<Integer, List<Integer>> mapping = csService.getAuthUserIdPartnerIdMapping();
322
		List<Integer> fofoIds = mapping.get(authUserId);
323
 
324
		List<Map<String, Object>> parties = new ArrayList<>();
325
		if (fofoIds != null && !fofoIds.isEmpty()) {
326
			List<FofoStore> stores = fofoStoreRepository.selectByRetailerIds(fofoIds);
327
			Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
328
			for (FofoStore store : stores) {
329
				if (!store.isActive() || store.isClosed()) continue;
330
				CustomRetailer retailer = retailerMap.get(store.getId());
331
				Map<String, Object> p = new HashMap<>();
332
				p.put("fofoStoreId", store.getId());
333
				p.put("code", store.getCode());
334
				p.put("outletName", store.getOutletName() != null ? store.getOutletName()
335
						: (retailer != null ? retailer.getBusinessName() : "Store #" + store.getId()));
336
				p.put("latitude", store.getLatitude());
337
				p.put("longitude", store.getLongitude());
338
				p.put("city", retailer != null && retailer.getAddress() != null ? retailer.getAddress().getCity() : null);
36716 ranu 339
				p.put("inBeat", inBeatFofoIds.contains(store.getId()));
340
				p.put("existingAgendas", existingAgendaByFofo.getOrDefault(store.getId(), new ArrayList<>()));
341
				p.put("existingDescription", existingDescByFofo.getOrDefault(store.getId(), ""));
342
				p.put("existingTrackingId", existingTrackingIdByFofo.get(store.getId()));
36663 ranu 343
				parties.add(p);
344
			}
36716 ranu 345
			// In-beat first, then by code
346
			parties.sort((a, b) -> {
347
				boolean ai = Boolean.TRUE.equals(a.get("inBeat"));
348
				boolean bi = Boolean.TRUE.equals(b.get("inBeat"));
349
				if (ai != bi) return ai ? -1 : 1;
350
				return String.valueOf(a.get("code")).compareToIgnoreCase(String.valueOf(b.get("code")));
351
			});
36663 ranu 352
		}
353
 
354
		Map<String, Object> result = new HashMap<>();
355
		result.put("dtrUserId", dtrUserId);
356
		result.put("authUserId", authUserId);
357
		result.put("userName", au.getFirstName() + " " + au.getLastName());
358
		result.put("parties", parties);
36716 ranu 359
		result.put("agendaOptions", com.spice.profitmandi.dao.enumuration.dtr.VisitAgenda.labels());
36663 ranu 360
		return responseSender.ok(result);
361
	}
362
 
363
	// Submit assignment — accepts a JSON body, builds the v2 payload, posts it
364
	@PostMapping(value = "/beatPlan/assignVisit/submit")
365
	public ResponseEntity<?> assignVisitSubmit(
366
			HttpServletRequest request,
367
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
368
 
369
		Integer authUserId = body.get("authUserId") != null ? ((Number) body.get("authUserId")).intValue() : null;
370
		String planDate = (String) body.get("planDate");
371
		List<Map<String, Object>> selected = (List<Map<String, Object>>) body.get("parties");
372
		if (authUserId == null || planDate == null || selected == null || selected.isEmpty()) {
373
			return responseSender.badRequest("authUserId, planDate and parties are required");
374
		}
375
 
36962 vikas 376
		// Defence-in-depth: Assign Visit is L2-and-above only (mirrors the hidden UI).
377
		if (!canEditBeat(currentUser(request))) {
378
			return responseSender.badRequest("Assigning a visit is restricted to L2 and above.");
379
		}
380
 
36663 ranu 381
		AuthUser au = authRepository.selectById(authUserId);
382
		if (au == null) return responseSender.badRequest("Auth user not found");
383
 
384
		// Map auth → dtr.users.id (this is the userId the v2 endpoint expects)
385
		com.spice.profitmandi.dao.entity.dtr.User dtrUser;
386
		try {
387
			dtrUser = userRepositoryAuto.selectByEmailId(au.getEmailId());
388
		} catch (Exception e) {
389
			return responseSender.badRequest("Failed to look up dtr.users for this auth user");
390
		}
391
		if (dtrUser == null) {
392
			return responseSender.badRequest("No dtr.users record found for auth user " + authUserId);
393
		}
394
		int dtrUserId = dtrUser.getId();
395
 
396
		// Persist directly via the shared DAO — mirrors BeatTrackingController.createBatch
397
		// in profitmandi-web. We can't autowire a controller across WARs, but
398
		// LocationTrackingRepository lives in profitmandi-dao and is shared.
399
		LocalDate taskDate;
400
		try {
401
			taskDate = LocalDate.parse(planDate);
402
		} catch (Exception e) {
403
			return responseSender.badRequest("Invalid planDate (expected yyyy-MM-dd): " + planDate);
404
		}
405
 
36716 ranu 406
		// Existing rows for this user on this date — keyed by fofoStoreId.
407
		// If a row already exists for a party, we UPDATE its agenda instead of
408
		// creating a duplicate (this is the "refill agenda" path for already-
409
		// assigned parties).
410
		Map<Integer, com.spice.profitmandi.dao.entity.auth.LocationTracking> existingByFofo = new HashMap<>();
411
		for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt :
412
				locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, taskDate)) {
413
			if (!"franchisee-visit".equals(lt.getTaskType())) continue;
414
			existingByFofo.putIfAbsent(lt.getTaskId(), lt);
415
		}
416
 
417
		// Defence-in-depth: Assign Visit is only valid for today's run. Hiding the
418
		// button on the UI isn't enough — block at the API too.
419
		if (!taskDate.equals(LocalDate.now())) {
420
			return responseSender.badRequest("Visits can only be assigned for today's date (" + LocalDate.now() + ")");
421
		}
422
 
36663 ranu 423
		LocalDateTime now = LocalDateTime.now();
36716 ranu 424
		int createdCount = 0, updatedCount = 0;
36663 ranu 425
 
426
		for (Map<String, Object> p : selected) {
427
			Integer fofoStoreId = ((Number) p.get("fofoStoreId")).intValue();
428
			String outletName = (String) p.get("outletName");
429
			String lat = (String) p.get("latitude");
430
			String lng = (String) p.get("longitude");
36716 ranu 431
			String description = (String) p.get("description");
432
			if (description != null) description = description.trim();
433
			if (description == null) description = "";
36663 ranu 434
 
36716 ranu 435
			// Multi-agenda: accept agendas[] (new format) or fall back to agenda (legacy single)
436
			List<String> agendas = new ArrayList<>();
437
			Object rawAgendas = p.get("agendas");
438
			if (rawAgendas instanceof List) {
439
				for (Object o : (List<?>) rawAgendas) {
440
					if (o != null) {
441
						String s = String.valueOf(o).trim();
442
						if (!s.isEmpty()) agendas.add(s);
443
					}
444
				}
445
			}
446
			if (agendas.isEmpty()) {
447
				String single = (String) p.get("agenda");
448
				if (single != null && !single.trim().isEmpty()) agendas.add(single.trim());
449
			}
450
			if (agendas.isEmpty()) agendas.add("Visit");
451
			String agendaJoined = String.join(", ", agendas);
452
 
36663 ranu 453
			String visitLocation = (lat != null && lng != null && !lat.isEmpty() && !lng.isEmpty())
454
					? (lat + "," + lng) : "0.0000,0.0000";
455
 
456
			String displayName = (outletName != null && !outletName.isEmpty()) ? outletName : ("Store #" + fofoStoreId);
36716 ranu 457
			String newTaskName = agendaJoined + " | " + displayName;
36663 ranu 458
 
36716 ranu 459
			com.spice.profitmandi.dao.entity.auth.LocationTracking existing = existingByFofo.get(fofoStoreId);
460
			if (existing != null) {
461
				// Refill — agenda (task_name), description, and visit location change
462
				existing.setTaskName(newTaskName);
463
				existing.setTaskDescription(description);
464
				existing.setVisitLocation(visitLocation);
465
				existing.setUpdatedTimestamp(now);
466
				locationTrackingRepositoryAuto.persist(existing);
467
				updatedCount++;
468
				continue;
469
			}
470
 
36663 ranu 471
			com.spice.profitmandi.dao.entity.auth.LocationTracking row =
472
					new com.spice.profitmandi.dao.entity.auth.LocationTracking();
473
			row.setUserId(dtrUserId);
474
			row.setDeviceId("0");
475
			row.setTaskId(fofoStoreId);
476
			row.setTaskDate(taskDate);
36716 ranu 477
			row.setTaskName(newTaskName);
478
			row.setTaskDescription(description);
36663 ranu 479
			row.setTaskType("franchisee-visit");
36764 ranu 480
			row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));
36663 ranu 481
			row.setAddress("");
482
			row.setVisitLocation(visitLocation);
483
			row.setCheckInLatLng("0.0000,0.0000");
484
			row.setCheckOutLatLng("0.0000,0.0000");
485
			row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
486
			row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
487
			row.setTransitTime(java.time.LocalTime.MIDNIGHT);
488
			row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
489
			row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
490
			row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
491
			row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
492
			row.setTotalDistance("0.0");
493
			row.setStatus(false);
494
			row.setCreatedTimestamp(now);
495
			row.setUpdatedTimestamp(now);
496
 
497
			// Do NOT try/catch this — if persist throws, let it propagate so
498
			// @Transactional(rollbackFor = Throwable.class) rolls back cleanly.
499
			locationTrackingRepositoryAuto.persist(row);
36716 ranu 500
			createdCount++;
36663 ranu 501
		}
36716 ranu 502
		LOGGER.info("assignVisit dtrUserId={} created={} updated={}", dtrUserId, createdCount, updatedCount);
36663 ranu 503
 
504
		Map<String, Object> result = new HashMap<>();
505
		result.put("status", true);
36716 ranu 506
		result.put("createdCount", createdCount);
507
		result.put("updatedCount", updatedCount);
36663 ranu 508
		result.put("dtrUserId", dtrUserId);
36716 ranu 509
		StringBuilder msg = new StringBuilder();
510
		if (createdCount > 0)
511
			msg.append(createdCount).append(" new visit").append(createdCount == 1 ? "" : "s").append(" assigned");
512
		if (updatedCount > 0) {
513
			if (msg.length() > 0) msg.append(", ");
514
			msg.append(updatedCount).append(" existing agenda").append(updatedCount == 1 ? "" : "s").append(" refilled");
515
		}
516
		msg.append(" for ").append(au.getFirstName()).append(" ").append(au.getLastName());
517
		result.put("message", msg.toString());
36663 ranu 518
		return responseSender.ok(result);
519
	}
520
 
36740 ranu 521
	// ====================== DEFERRED PARTNERS ======================
522
	// Heads review partners that weren't visited on their planned day and act on
523
	// them. The deferral lifecycle lives in user.beat_deferred_visit (separate
524
	// from the raw location_tracking event log). Detection = explicit
525
	// (mark_type='DEFERRED') + derived (planned beat_route minus completed visits).
526
 
527
	// Page
528
	@GetMapping(value = "/beatPlan/deferredView")
529
	public String deferredView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
530
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
531
		return "beat-plan-deferred";
532
	}
533
 
534
	// List (syncs the table first, then returns the head's downline deferrals).
535
	@GetMapping(value = "/beatPlan/deferred")
536
	public ResponseEntity<?> deferredList(
537
			HttpServletRequest request,
538
			@RequestParam(required = false) String startDate,
539
			@RequestParam(required = false) String endDate) throws Exception {
540
 
541
		LocalDate start, end;
542
		try {
543
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now().minusDays(7) : LocalDate.parse(startDate);
544
			end = (endDate == null || endDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(endDate);
545
		} catch (Exception e) {
546
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
547
		}
548
 
549
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
550
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
551
		if (me == null) return responseSender.unauthorized("Not logged in");
552
 
553
		// PURE READ. Deferrals are persisted at the write point (BeatTrackingController,
554
		// when mark_type='DEFERRED' is recorded) — this endpoint never writes.
555
		List<BeatDeferredVisit> source;
556
		if (isSuperAdmin(me)) {
557
			source = beatDeferredVisitRepository.selectByDateRange(start, end);
558
		} else {
559
			Set<Integer> downline = new HashSet<>(authService.getAllReportees(me.getId()));
560
			downline.add(me.getId());
561
			source = beatDeferredVisitRepository.selectByAuthUserIdsAndDateRange(new ArrayList<>(downline), start, end);
562
		}
563
		List<BeatDeferredVisit> rows = source.stream()
564
				.filter(r -> "DEFERRED".equals(r.getStatus()))
565
				.collect(Collectors.toList());
566
 
567
		// ---- nextScheduledDate (info only: does a future run already cover it?) ----
568
		// Only meaningful for partner visits (leads aren't on beat_route). Purely a
569
		// hint — the row stays actionable even when auto-covered, since the next run
570
		// could be far off.
571
		Map<Integer, Map<Integer, LocalDate>> coverCache = new HashMap<>();
572
		Map<Integer, LocalDate> nextByRowId = new HashMap<>();
573
		for (BeatDeferredVisit r : rows) {
574
			if (!"franchisee-visit".equals(r.getTaskType())) continue;
575
			Map<Integer, LocalDate> cover = coverCache.computeIfAbsent(r.getAuthUserId(), this::computeFutureCover);
576
			LocalDate next = cover.get(r.getFofoId());
577
			if (next != null) nextByRowId.put(r.getId(), next);
578
		}
579
 
580
		// ---- resolve user names (display name + type already denormalized on the row) ----
581
		Set<Integer> authIds = rows.stream().map(BeatDeferredVisit::getAuthUserId).collect(Collectors.toSet());
582
		Map<Integer, AuthUser> userMap = new HashMap<>();
583
		if (!authIds.isEmpty())
584
			authRepository.selectByIds(new ArrayList<>(authIds)).forEach(u -> userMap.put(u.getId(), u));
585
 
586
		List<Map<String, Object>> out = new ArrayList<>();
587
		for (BeatDeferredVisit r : rows) {
588
			AuthUser u = userMap.get(r.getAuthUserId());
589
			boolean isLead = "lead".equalsIgnoreCase(r.getTaskType());
36811 ranu 590
			boolean isOffice = "office-visit".equalsIgnoreCase(r.getTaskType());
36740 ranu 591
			Map<String, Object> row = new HashMap<>();
592
			row.put("id", r.getId());
593
			row.put("authUserId", r.getAuthUserId());
594
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : ("User #" + r.getAuthUserId()));
595
			row.put("fofoStoreId", r.getFofoId());
596
			row.put("name", r.getDisplayName() != null ? r.getDisplayName() : ("#" + r.getFofoId()));
36811 ranu 597
			row.put("type", isLead ? "Lead" : (isOffice ? "Office" : "Visit"));
36740 ranu 598
			row.put("deferredDate", r.getDeferredDate() != null ? r.getDeferredDate().toString() : null);
599
			row.put("reason", r.getReason());
600
			row.put("status", r.getStatus());
601
			LocalDate next = nextByRowId.get(r.getId());
602
			row.put("nextScheduledDate", next != null ? next.toString() : null);
603
			out.add(row);
604
		}
605
		out.sort((a, c) -> String.valueOf(a.get("deferredDate")).compareTo(String.valueOf(c.get("deferredDate"))));
606
 
607
		Map<String, Object> result = new HashMap<>();
608
		result.put("rows", out);
609
		result.put("startDate", start.toString());
610
		result.put("endDate", end.toString());
611
		return responseSender.ok(result);
612
	}
613
 
614
	// Head action on a deferral: reschedule (one-off visit, or into an existing
615
	// beat-day) or cancel. Never edits the beat template.
616
	@PostMapping(value = "/beatPlan/deferred/action")
617
	public ResponseEntity<?> deferredAction(
618
			HttpServletRequest request,
619
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
620
 
621
		Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;
622
		String action = (String) body.get("action");
623
		String toDateStr = (String) body.get("toDate");
624
		if (deferredId == null || action == null)
625
			return responseSender.badRequest("deferredId and action are required");
626
 
627
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
628
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
629
		if (me == null) return responseSender.unauthorized("Not logged in");
630
 
631
		BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);
632
		if (d == null) return responseSender.badRequest("Deferred record not found");
633
 
634
		LocalDateTime now = LocalDateTime.now();
635
 
636
		if ("cancel".equalsIgnoreCase(action)) {
36763 ranu 637
			d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.CANCELLED));
36740 ranu 638
			d.setActionBy(me.getId());
639
			d.setUpdatedTimestamp(now);
36792 ranu 640
			// Optional cancel reason — overlay onto the reason column. Original
641
			// deferred-reason is preserved as a suffix so we keep the audit trail.
642
			String cancelReason = body.get("reason") != null ? String.valueOf(body.get("reason")).trim() : "";
643
			if (!cancelReason.isEmpty()) {
644
				String prev = d.getReason() != null ? d.getReason() : "";
645
				d.setReason("Cancelled: " + cancelReason + (prev.isEmpty() ? "" : " | Original: " + prev));
646
			}
36740 ranu 647
			beatDeferredVisitRepository.persist(d);
648
			Map<String, Object> ok = new HashMap<>();
649
			ok.put("status", true);
650
			ok.put("message", "Deferred visit cancelled");
651
			return responseSender.ok(ok);
652
		}
653
 
654
		// reschedule_oneoff | reschedule_beat
655
		if (toDateStr == null || toDateStr.isEmpty())
656
			return responseSender.badRequest("toDate is required to reschedule");
657
		LocalDate toDate;
658
		try {
659
			toDate = LocalDate.parse(toDateStr);
660
		} catch (Exception e) {
661
			return responseSender.badRequest("Invalid toDate (yyyy-MM-dd)");
662
		}
36821 ranu 663
		// Reschedule must land on a STRICTLY future date — today is already in progress.
664
		if (!toDate.isAfter(LocalDate.now()))
665
			return responseSender.badRequest("Reschedule date must be in the future (today is already in progress).");
36740 ranu 666
 
667
		if ("reschedule_beat".equalsIgnoreCase(action)) {
668
			boolean hasBeat = beatRepository.selectActiveByAuthUserId(d.getAuthUserId()).stream()
669
					.flatMap(b -> beatScheduleRepository.selectByBeatId(b.getId()).stream())
670
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(toDate));
671
			if (!hasBeat)
672
				return responseSender.badRequest("No beat is scheduled for this user on " + toDateStr + ". Pick another date or use a one-off visit.");
673
		}
674
 
675
		// Resolve dtr user, then create a PENDING task on toDate. Reuse the
676
		// denormalized name + type (works for both partner visits and leads — for
677
		// leads, looking up fofo_store would be the wrong id space). For visits we
678
		// still try to pull lat/lng for the visit location.
679
		Integer dtrId = resolveDtrId(d.getAuthUserId(), new HashMap<>());
680
		if (dtrId == null) return responseSender.badRequest("No dtr.users record for this sales person");
681
		boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());
36811 ranu 682
		boolean isOffice = "office-visit".equalsIgnoreCase(d.getTaskType());
36740 ranu 683
		String visitLocation = "0.0000,0.0000";
36811 ranu 684
		if (isOffice) {
685
			// Office stops resolve lat/lng from logistics.company_office.
36740 ranu 686
			try {
36811 ranu 687
				com.spice.profitmandi.dao.entity.logistics.CompanyOffice o = companyOfficeRepository.selectById(d.getFofoId());
688
				if (o != null) visitLocation = o.getLat() + "," + o.getLng();
689
			} catch (Exception ignored) {
690
			}
691
		} else if (!isLead) {
692
			try {
36740 ranu 693
				List<FofoStore> ss = fofoStoreRepository.selectByRetailerIds(java.util.Collections.singletonList(d.getFofoId()));
694
				if (!ss.isEmpty()) {
695
					FofoStore fs = ss.get(0);
696
					if (fs.getLatitude() != null && fs.getLongitude() != null
697
							&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
698
						visitLocation = fs.getLatitude() + "," + fs.getLongitude();
699
					}
700
				}
701
			} catch (Exception ignored) {
702
			}
703
		}
704
		String taskName = d.getDisplayName() != null ? d.getDisplayName()
705
				: ((d.getReason() != null ? d.getReason() : "Rescheduled") + " | #" + d.getFofoId());
706
 
707
		com.spice.profitmandi.dao.entity.auth.LocationTracking row = new com.spice.profitmandi.dao.entity.auth.LocationTracking();
708
		row.setUserId(dtrId);
709
		row.setDeviceId("0");
710
		row.setTaskId(d.getFofoId());
711
		row.setTaskDate(toDate);
712
		row.setTaskName(taskName);
713
		row.setTaskDescription("Rescheduled from " + d.getDeferredDate());
714
		row.setTaskType(d.getTaskType() != null ? d.getTaskType() : "franchisee-visit");
36763 ranu 715
		row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));
36740 ranu 716
		row.setAddress("");
717
		row.setVisitLocation(visitLocation);
718
		row.setCheckInLatLng("0.0000,0.0000");
719
		row.setCheckOutLatLng("0.0000,0.0000");
720
		row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
721
		row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
722
		row.setTransitTime(java.time.LocalTime.MIDNIGHT);
723
		row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
724
		row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
725
		row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
726
		row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
727
		row.setTotalDistance("0.0");
728
		row.setStatus(false);
729
		row.setCreatedTimestamp(now);
730
		row.setUpdatedTimestamp(now);
731
		locationTrackingRepositoryAuto.persist(row);
732
 
36763 ranu 733
		d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));
36740 ranu 734
		d.setRescheduledToDate(toDate);
735
		d.setActionBy(me.getId());
736
		d.setUpdatedTimestamp(now);
737
		beatDeferredVisitRepository.persist(d);
738
 
739
		Map<String, Object> ok = new HashMap<>();
740
		ok.put("status", true);
741
		ok.put("message", "Visit rescheduled to " + toDateStr);
742
		return responseSender.ok(ok);
743
	}
744
 
745
	// Drop a deferred item into a specific upcoming BEAT run (chosen from the beat
746
	// calendar). Lead → a lead_route row on that beat/date (renders as a lead stop).
747
	// Partner visit → appended to that beat's route for the date's day_number.
748
	// The beat plan calendar then shows it. Marks the deferral RESCHEDULED.
749
	@PostMapping(value = "/beatPlan/deferred/assignToBeat")
750
	public ResponseEntity<?> deferredAssignToBeat(
751
			HttpServletRequest request,
752
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
753
 
754
		Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;
755
		Integer beatId = body.get("beatId") != null ? ((Number) body.get("beatId")).intValue() : null;
756
		String dateStr = (String) body.get("date");
757
		if (deferredId == null || beatId == null || dateStr == null)
758
			return responseSender.badRequest("deferredId, beatId and date are required");
759
 
760
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
761
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
762
		if (me == null) return responseSender.unauthorized("Not logged in");
763
 
764
		LocalDate date;
765
		try {
766
			date = LocalDate.parse(dateStr);
767
		} catch (Exception e) {
768
			return responseSender.badRequest("Invalid date (yyyy-MM-dd)");
769
		}
36821 ranu 770
		// Drop-onto-beat must land on a STRICTLY future date — today is already in progress.
771
		if (!date.isAfter(LocalDate.now()))
772
			return responseSender.badRequest("Pick a future date — today is already in progress.");
36740 ranu 773
 
774
		BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);
775
		if (d == null) return responseSender.badRequest("Deferred record not found");
776
 
777
		// A deferral can only move FORWARD — never onto the day it was deferred or earlier.
778
		if (d.getDeferredDate() != null && !date.isAfter(d.getDeferredDate())) {
779
			return responseSender.badRequest("A deferred item can only be moved to a date after "
780
					+ d.getDeferredDate() + " (it was deferred that day).");
781
		}
782
 
783
		Beat beat = beatRepository.selectById(beatId);
784
		if (beat == null) return responseSender.badRequest("Beat not found");
785
 
786
		// The beat must actually run on the chosen date — get that run's day number.
787
		BeatSchedule sched = beatScheduleRepository.selectByBeatId(beatId).stream()
788
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(date))
789
				.findFirst().orElse(null);
790
		if (sched == null) return responseSender.badRequest("That beat is not scheduled on " + dateStr);
791
 
792
		LocalDateTime now = LocalDateTime.now();
793
		boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());
794
 
795
		if (isLead) {
796
			// Avoid duplicating the same lead on the same beat/date
797
			boolean exists = leadRouteRepository.selectByBeatId(beatId).stream()
798
					.anyMatch(lr -> lr.getLeadId() == d.getFofoId()
799
							&& date.equals(lr.getScheduleDate())
800
							&& !"CANCELLED".equals(lr.getStatus()));
801
			if (!exists) {
802
				LeadRoute lr = new LeadRoute();
803
				lr.setBeatId(beatId);
804
				lr.setLeadId(d.getFofoId());
805
				lr.setScheduleDate(date);
806
				lr.setSequenceOrder(9999); // append; planner can reorder
807
				lr.setStatus("APPROVED");
808
				lr.setApprovedBy(me.getId());
809
				lr.setApprovedTimestamp(now);
810
				lr.setCreatedTimestamp(now);
811
				lr.setUpdatedTimestamp(now);
812
				leadRouteRepository.persist(lr);
813
			}
814
		} else {
815
			// Partner visit → append to that beat's route for the date's day number,
816
			// if not already present on that day.
817
			boolean exists = beatRouteRepository.selectByBeatId(beatId).stream()
818
					.anyMatch(r -> r.getFofoId() == d.getFofoId() && r.getDayNumber() == sched.getDayNumber() && r.isActive());
819
			if (!exists) {
820
				int nextSeq = beatRouteRepository.selectByBeatId(beatId).stream()
821
						.filter(r -> r.getDayNumber() == sched.getDayNumber())
822
						.mapToInt(BeatRoute::getSequenceOrder).max().orElse(-1) + 1;
823
				BeatRoute br = new BeatRoute();
824
				br.setBeatId(beatId);
825
				br.setFofoId(d.getFofoId());
36811 ranu 826
				br.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
36740 ranu 827
				br.setDayNumber(sched.getDayNumber());
828
				br.setSequenceOrder(nextSeq);
829
				br.setActive(true);
830
				beatRouteRepository.persist(br);
831
			}
832
		}
833
 
36763 ranu 834
		d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));
36740 ranu 835
		d.setRescheduledToDate(date);
836
		d.setActionBy(me.getId());
837
		d.setUpdatedTimestamp(now);
838
		beatDeferredVisitRepository.persist(d);
839
 
840
		Map<String, Object> ok = new HashMap<>();
841
		ok.put("status", true);
842
		ok.put("message", (isLead ? "Lead" : "Partner") + " added to beat '" + beat.getName() + "' on " + dateStr);
843
		return responseSender.ok(ok);
844
	}
845
 
37120 vikas 846
	// ==================== ASSIGN LEADS ONTO BEATS ====================
847
	// A rep's assigned leads can be routed onto a beat from two surfaces:
848
	//   • Route Planner "Assigned Leads" list → single lead, calendar-chip picks beat+date
849
	//   • Day View "Add Lead" modal           → bulk write onto a known beat+date
850
	// Both converge on the same dated, APPROVED lead_route write (writeLeadRoute),
851
	// mirroring VisitRequestController.approveSchedule and deferredAssignToBeat's
852
	// lead branch. Nothing downstream changes — getBeatVisits and the
853
	// /visit-approvals/beat-route feed already render lead_route rows for the run.
854
 
855
	// Active/open lead colours a manager can still route (matches getOpenLead's
856
	// All → yellow+green expansion). Red/other are dropped as not-worth-visiting.
857
	private static final Set<String> ROUTABLE_LEAD_COLORS =
858
			new HashSet<>(Arrays.asList("yellow", "green"));
859
 
860
	// Can the caller see the selected user's leads? Super-admin sees everyone;
861
	// otherwise the user must be the caller or in their downline.
862
	private boolean canViewUserLeads(AuthUser me, int authUserId) {
863
		if (me == null) return false;
864
		if (isSuperAdmin(me)) return true;
865
		if (me.getId() == authUserId) return true;
866
		return new HashSet<>(authService.getAllReportees(me.getId())).contains(authUserId);
867
	}
868
 
869
	// GET /beatPlan/assignedLeads?authUserId=&beatId=&date=
870
	// Lists the selected user's active/open leads (pending + follow-up, yellow/green).
871
	// When beatId+date are supplied (Day View), each lead is tagged alreadyOnBeat if
872
	// it already has a non-cancelled lead_route on that beat+date.
873
	@GetMapping(value = "/beatPlan/assignedLeads")
874
	public ResponseEntity<?> assignedLeads(
875
			HttpServletRequest request,
876
			@RequestParam int authUserId,
877
			@RequestParam(required = false) Integer beatId,
878
			@RequestParam(required = false) String date) throws ProfitMandiBusinessException {
879
 
880
		AuthUser me = currentUser(request);
881
		if (me == null) return responseSender.unauthorized("Not logged in");
882
		if (!canViewUserLeads(me, authUserId)) {
883
			// Not visible to this manager → empty (mirrors VisitRequestController.list).
884
			return responseSender.ok(Collections.singletonMap("rows", new ArrayList<>()));
885
		}
886
 
887
		// Active/open only — pending + follow-up.
888
		List<Lead> leads = new ArrayList<>();
889
		leads.addAll(leadRepository.selectByAssignAuthIdAndStatus(
890
				authUserId, com.spice.profitmandi.dao.enumuration.dtr.LeadStatus.pending));
891
		leads.addAll(leadRepository.selectByAssignAuthIdAndStatus(
892
				authUserId, com.spice.profitmandi.dao.enumuration.dtr.LeadStatus.followUp));
893
 
894
		// Leads already on this beat+date (Day View only), to flag dupes in the picker.
895
		LocalDate onDate = (date != null && !date.isEmpty()) ? LocalDate.parse(date) : null;
896
		Set<Integer> alreadyOnBeat = new HashSet<>();
897
		if (beatId != null && onDate != null) {
898
			for (LeadRoute lr : leadRouteRepository.selectByBeatId(beatId)) {
899
				if (onDate.equals(lr.getScheduleDate()) && !"CANCELLED".equals(lr.getStatus())) {
900
					alreadyOnBeat.add(lr.getLeadId());
901
				}
902
			}
903
		}
904
 
905
		// Colour-filter + dedupe, then batch-resolve approved geo (avoids N+1).
906
		List<Lead> routable = new ArrayList<>();
907
		Set<Integer> seen = new HashSet<>();
908
		for (Lead lead : leads) {
909
			if (!seen.add(lead.getId())) continue;
910
			String color = lead.getColor() != null ? lead.getColor().toLowerCase() : "";
911
			if (!ROUTABLE_LEAD_COLORS.contains(color)) continue;
912
			routable.add(lead);
913
		}
914
		Set<Integer> withGeo = new HashSet<>();
915
		if (!routable.isEmpty()) {
916
			List<Integer> ids = routable.stream().map(Lead::getId).collect(Collectors.toList());
917
			for (com.spice.profitmandi.dao.entity.user.LeadLiveLocation loc :
918
					leadLiveLocationRepositoryAuto.selectByLeadIds(ids)) {
919
				if (loc.getStatus() != null && "APPROVED".equals(loc.getStatus().name())) {
920
					withGeo.add(loc.getLeadId());
921
				}
922
			}
923
		}
924
 
925
		List<Map<String, Object>> rows = new ArrayList<>();
926
		for (Lead lead : routable) {
927
			Map<String, Object> m = new HashMap<>();
928
			m.put("leadId", lead.getId());
929
			String name = ((lead.getFirstName() != null ? lead.getFirstName() : "") + " "
930
					+ (lead.getLastName() != null ? lead.getLastName() : "")).trim();
931
			m.put("leadName", name.isEmpty() ? ("Lead #" + lead.getId()) : name);
932
			m.put("outletName", lead.getOutLetName());
933
			m.put("mobile", lead.getLeadMobile());
934
			m.put("city", lead.getCity());
935
			m.put("state", lead.getState());
936
			m.put("stage", lead.getEffectiveStage() != null ? lead.getEffectiveStage().name() : "");
937
			m.put("color", lead.getColor());
938
			m.put("hasGeo", withGeo.contains(lead.getId()));
939
			m.put("alreadyOnBeat", alreadyOnBeat.contains(lead.getId()));
940
			rows.add(m);
941
		}
942
 
943
		return responseSender.ok(Collections.singletonMap("rows", rows));
944
	}
945
 
946
	// Shared, idempotent lead_route write. Returns true when a new row is created,
947
	// false when the lead is already on that (beat, date). Best-effort nudges the
948
	// lead forward to BEAT_PLANNED. Callers are responsible for the beat-runs-on-date
949
	// and future-date guards. Mirrors deferredAssignToBeat's lead branch.
950
	private boolean writeLeadRoute(int beatId, int leadId, LocalDate date, int approverAuthId) {
951
		boolean exists = leadRouteRepository.selectByBeatId(beatId).stream()
952
				.anyMatch(lr -> lr.getLeadId() == leadId
953
						&& date.equals(lr.getScheduleDate())
954
						&& !"CANCELLED".equals(lr.getStatus()));
955
		if (exists) return false;
956
 
957
		LocalDateTime now = LocalDateTime.now();
958
		LeadRoute lr = new LeadRoute();
959
		lr.setBeatId(beatId);
960
		lr.setLeadId(leadId);
961
		lr.setScheduleDate(date);
962
		lr.setSequenceOrder(9999); // append; planner can reorder
963
		lr.setStatus("APPROVED");
964
		lr.setApprovedBy(approverAuthId);
965
		lr.setApprovedTimestamp(now);
966
		lr.setCreatedTimestamp(now);
967
		lr.setUpdatedTimestamp(now);
968
		leadRouteRepository.persist(lr);
969
 
970
		advanceLeadToBeatPlanned(leadId);
971
		return true;
972
	}
973
 
974
	// Best-effort: move a lead forward to BEAT_PLANNED when it's still in an earlier
975
	// happy-path stage. Never regresses a VISITED+ lead and never aborts the caller's
976
	// write on failure (the legacy status is kept in sync via toLegacyStatus()).
977
	private void advanceLeadToBeatPlanned(int leadId) {
978
		try {
979
			Lead lead = leadRepository.selectById(leadId);
980
			if (lead == null) return;
981
			com.spice.profitmandi.dao.enumuration.dtr.LeadStage cur = lead.getEffectiveStage();
982
			boolean beforeBeatPlanned =
983
					cur == com.spice.profitmandi.dao.enumuration.dtr.LeadStage.NEW
984
							|| cur == com.spice.profitmandi.dao.enumuration.dtr.LeadStage.ASSIGNED
985
							|| cur == com.spice.profitmandi.dao.enumuration.dtr.LeadStage.CONTACTED
986
							|| cur == com.spice.profitmandi.dao.enumuration.dtr.LeadStage.QUALIFIED;
987
			if (!beforeBeatPlanned) return;
988
			com.spice.profitmandi.dao.enumuration.dtr.LeadStage target =
989
					com.spice.profitmandi.dao.enumuration.dtr.LeadStage.BEAT_PLANNED;
990
			lead.setStage(target);
991
			lead.setStatus(target.toLegacyStatus());
992
			lead.setUpdatedTimestamp(LocalDateTime.now());
993
			leadRepository.persist(lead);
994
		} catch (Exception e) {
995
			LOGGER.warn("Could not advance lead {} to BEAT_PLANNED", leadId, e);
996
		}
997
	}
998
 
999
	// Confirm a beat actually runs on the given date.
1000
	private boolean beatRunsOnDate(int beatId, LocalDate date) {
1001
		return beatScheduleRepository.selectByBeatId(beatId).stream()
1002
				.anyMatch(s -> date.equals(s.getStartDate()));
1003
	}
1004
 
1005
	// POST /beatPlan/lead/scheduleOnBeat  {leadId, beatId, date}
1006
	// Single-lead write for the Route Planner calendar-chip handoff. Strictly-future
1007
	// date only (matches the visit-request / deferred chip flow).
1008
	@PostMapping(value = "/beatPlan/lead/scheduleOnBeat")
1009
	public ResponseEntity<?> leadScheduleOnBeat(
1010
			HttpServletRequest request,
1011
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
1012
		AuthUser me = currentUser(request);
1013
		if (me == null) return responseSender.unauthorized("Not logged in");
1014
		if (!canEditBeat(me)) return responseSender.badRequest("Scheduling a lead is restricted to L2 and above.");
1015
 
1016
		Integer beatId = body.get("beatId") != null ? ((Number) body.get("beatId")).intValue() : null;
1017
		Integer leadId = body.get("leadId") != null ? ((Number) body.get("leadId")).intValue() : null;
1018
		String dateStr = (String) body.get("date");
1019
		if (beatId == null || leadId == null || dateStr == null)
1020
			return responseSender.badRequest("leadId, beatId and date are required");
1021
 
1022
		LocalDate date;
1023
		try {
1024
			date = LocalDate.parse(dateStr);
1025
		} catch (Exception e) {
1026
			return responseSender.badRequest("Invalid date (yyyy-MM-dd)");
1027
		}
1028
		if (!date.isAfter(LocalDate.now()))
1029
			return responseSender.badRequest("Pick a future date — today is already in progress.");
1030
 
1031
		Beat beat = beatRepository.selectById(beatId);
1032
		if (beat == null) return responseSender.badRequest("Beat not found");
1033
		if (!beatRunsOnDate(beatId, date))
1034
			return responseSender.badRequest("That beat is not scheduled on " + dateStr);
1035
		if (leadRepository.selectById(leadId) == null)
1036
			return responseSender.badRequest("Lead not found");
1037
 
1038
		boolean created = writeLeadRoute(beatId, leadId, date, me.getId());
1039
 
1040
		Map<String, Object> ok = new HashMap<>();
1041
		ok.put("status", true);
1042
		ok.put("created", created);
1043
		ok.put("beatId", beatId);
1044
		ok.put("scheduleDate", date.toString());
1045
		ok.put("message", created
1046
				? "Lead added to beat '" + beat.getName() + "' on " + dateStr
1047
				: "Lead is already on that beat for " + dateStr);
1048
		return responseSender.ok(ok);
1049
	}
1050
 
1051
	// POST /beatPlan/leadsForBeat/submit  {beatId, date, leadIds:[...]}
1052
	// Bulk write for the Day View "Add Lead" modal onto a known beat+date. Allows
1053
	// today or a future date (the row's scheduled run may be today).
1054
	@PostMapping(value = "/beatPlan/leadsForBeat/submit")
1055
	public ResponseEntity<?> leadsForBeatSubmit(
1056
			HttpServletRequest request,
1057
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
1058
		AuthUser me = currentUser(request);
1059
		if (me == null) return responseSender.unauthorized("Not logged in");
1060
		if (!canEditBeat(me)) return responseSender.badRequest("Adding a lead is restricted to L2 and above.");
1061
 
1062
		Integer beatId = body.get("beatId") != null ? ((Number) body.get("beatId")).intValue() : null;
1063
		String dateStr = (String) body.get("date");
1064
		List<?> leadIdsRaw = (List<?>) body.get("leadIds");
1065
		if (beatId == null || dateStr == null || leadIdsRaw == null || leadIdsRaw.isEmpty())
1066
			return responseSender.badRequest("beatId, date and leadIds are required");
1067
 
1068
		LocalDate date;
1069
		try {
1070
			date = LocalDate.parse(dateStr);
1071
		} catch (Exception e) {
1072
			return responseSender.badRequest("Invalid date (yyyy-MM-dd)");
1073
		}
1074
		if (date.isBefore(LocalDate.now()))
1075
			return responseSender.badRequest("Pick today or a future date.");
1076
 
1077
		Beat beat = beatRepository.selectById(beatId);
1078
		if (beat == null) return responseSender.badRequest("Beat not found");
1079
		if (!beatRunsOnDate(beatId, date))
1080
			return responseSender.badRequest("That beat is not scheduled on " + dateStr);
1081
 
1082
		int added = 0, skipped = 0;
1083
		for (Object idRaw : leadIdsRaw) {
1084
			int leadId = ((Number) idRaw).intValue();
1085
			if (leadRepository.selectById(leadId) == null) {
1086
				skipped++;
1087
				continue;
1088
			}
1089
			if (writeLeadRoute(beatId, leadId, date, me.getId())) added++;
1090
			else skipped++;
1091
		}
1092
 
1093
		Map<String, Object> ok = new HashMap<>();
1094
		ok.put("status", true);
1095
		ok.put("added", added);
1096
		ok.put("skipped", skipped);
1097
		ok.put("message", added + " lead(s) added to '" + beat.getName() + "' on " + dateStr
1098
				+ (skipped > 0 ? " (" + skipped + " already present or invalid)" : ""));
1099
		return responseSender.ok(ok);
1100
	}
1101
 
1102
	// POST /beatPlan/lead/createBeatAndSchedule  {authUserId, leadId, date, beatName?}
1103
	// The "+ New beat" action — a 1-day beat with this lead as the only Day-1 stop,
1104
	// for a rep who has no beat to land on. Mirrors VisitRequestController.createBeatAndSchedule.
1105
	@PostMapping(value = "/beatPlan/lead/createBeatAndSchedule")
1106
	public ResponseEntity<?> leadCreateBeatAndSchedule(
1107
			HttpServletRequest request,
1108
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
1109
		AuthUser me = currentUser(request);
1110
		if (me == null) return responseSender.unauthorized("Not logged in");
1111
		if (!canEditBeat(me)) return responseSender.badRequest("Creating a beat is restricted to L2 and above.");
1112
 
1113
		Integer authUserId = body.get("authUserId") != null ? ((Number) body.get("authUserId")).intValue() : null;
1114
		Integer leadId = body.get("leadId") != null ? ((Number) body.get("leadId")).intValue() : null;
1115
		String dateStr = (String) body.get("date");
1116
		if (authUserId == null || leadId == null || dateStr == null)
1117
			return responseSender.badRequest("authUserId, leadId and date are required");
1118
 
1119
		LocalDate date;
1120
		try {
1121
			date = LocalDate.parse(dateStr);
1122
		} catch (Exception e) {
1123
			return responseSender.badRequest("Invalid date (yyyy-MM-dd)");
1124
		}
1125
		if (!date.isAfter(LocalDate.now()))
1126
			return responseSender.badRequest("Pick a future date — today is already in progress.");
1127
 
1128
		Lead lead = leadRepository.selectById(leadId);
1129
		if (lead == null) return responseSender.badRequest("Lead not found");
1130
 
1131
		// One-beat-per-day guard for the rep.
1132
		for (Beat existing : beatRepository.selectActiveByAuthUserId(authUserId)) {
1133
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(existing.getId())) {
1134
				if (date.equals(s.getStartDate())) {
1135
					return responseSender.badRequest("This user already has '"
1136
							+ (existing.getName() != null ? existing.getName() : "Beat #" + existing.getId())
1137
							+ "' scheduled on " + date + " — use Schedule on a beat instead.");
1138
				}
1139
			}
1140
		}
1141
 
1142
		String leadLabel = ((lead.getFirstName() != null ? lead.getFirstName() : "") + " "
1143
				+ (lead.getLastName() != null ? lead.getLastName() : "")).trim();
1144
		if (leadLabel.isEmpty()) leadLabel = "Lead #" + leadId;
1145
		Object nameRaw = body.get("beatName");
1146
		String beatName = (nameRaw != null && !nameRaw.toString().trim().isEmpty())
1147
				? nameRaw.toString().trim()
1148
				: ("Lead - " + leadLabel + " - " + date);
1149
		if (beatName.length() > 100) beatName = beatName.substring(0, 100);
1150
 
1151
		LocalDateTime now = LocalDateTime.now();
1152
		Beat beat = new Beat();
1153
		beat.setName(beatName);
1154
		beat.setAuthUserId(authUserId);
1155
		beat.setBeatColor(BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length]);
1156
		beat.setTotalDays(1);
1157
		beat.setActive(true);
1158
		beat.setCreatedBy(me.getId());
1159
		beat.setCreatedTimestamp(now);
1160
		beatRepository.persist(beat);
1161
 
1162
		BeatSchedule sched = new BeatSchedule();
1163
		sched.setBeatId(beat.getId());
1164
		sched.setStartDate(date);
1165
		sched.setEndDate(date);
1166
		sched.setDayNumber(1);
1167
		sched.setEndAction("HOME");
1168
		sched.setCreatedTimestamp(now);
1169
		beatScheduleRepository.persist(sched);
1170
 
1171
		writeLeadRoute(beat.getId(), leadId, date, me.getId());
1172
 
1173
		Map<String, Object> ok = new HashMap<>();
1174
		ok.put("status", true);
1175
		ok.put("beatId", beat.getId());
1176
		ok.put("beatName", beatName);
1177
		ok.put("scheduleDate", date.toString());
1178
		ok.put("message", "Beat '" + beatName + "' created on " + dateStr);
1179
		return responseSender.ok(ok);
1180
	}
1181
 
36740 ranu 1182
	// authUserId -> dtr.users id (via shared email), memoized in the passed cache.
1183
	// Used by the reschedule action to create the new PENDING location_tracking row.
1184
	private Integer resolveDtrId(int authUserId, Map<Integer, Integer> cache) {
1185
		if (cache.containsKey(authUserId)) return cache.get(authUserId);
1186
		Integer dtrId = null;
1187
		try {
1188
			AuthUser au = authRepository.selectById(authUserId);
1189
			if (au != null && au.getEmailId() != null) {
1190
				com.spice.profitmandi.dao.entity.dtr.User u = userRepositoryAuto.selectByEmailId(au.getEmailId());
1191
				if (u != null) dtrId = u.getId();
1192
			}
1193
		} catch (Exception ignored) {
1194
		}
1195
		cache.put(authUserId, dtrId);
1196
		return dtrId;
1197
	}
1198
 
1199
	// For an auth user: fofoId -> earliest upcoming (>= today) scheduled date where
1200
	// an active beat's route still includes that partner (the "Next Scheduled" hint).
1201
	private Map<Integer, LocalDate> computeFutureCover(int authUserId) {
1202
		LocalDate today = LocalDate.now();
1203
		Map<Integer, LocalDate> cover = new HashMap<>();
1204
		for (Beat b : beatRepository.selectActiveByAuthUserId(authUserId)) {
1205
			LocalDate earliest = null;
1206
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(b.getId())) {
1207
				LocalDate dt = s.getStartDate();
1208
				if (dt != null && dt.getYear() != 9999 && !dt.isBefore(today)) {
1209
					if (earliest == null || dt.isBefore(earliest)) earliest = dt;
1210
				}
1211
			}
1212
			if (earliest == null) continue;
1213
			for (BeatRoute rt : beatRouteRepository.selectByBeatId(b.getId())) {
1214
				if (!rt.isActive()) continue;
1215
				LocalDate cur = cover.get(rt.getFofoId());
1216
				if (cur == null || earliest.isBefore(cur)) cover.put(rt.getFofoId(), earliest);
1217
			}
1218
		}
1219
		return cover;
1220
	}
1221
 
36686 ranu 1222
	@GetMapping(value = "/beatPlanWindow")
1223
	public String beatPlanWindow(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1224
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36962 vikas 1225
		model.addAttribute("canScheduleToday", canScheduleToday(currentUser(request)));
36686 ranu 1226
		return "beat-plan-window";
36655 ranu 1227
	}
1228
 
36668 ranu 1229
	// Helpers for XLSX bulk upload
1230
	private static String readCell(org.apache.poi.ss.usermodel.Cell cell) {
1231
		if (cell == null) return null;
1232
		switch (cell.getCellType()) {
1233
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:
1234
				return cell.getStringCellValue();
1235
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:
1236
				if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
1237
					return cell.getDateCellValue().toInstant()
1238
							.atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();
1239
				}
1240
				double n = cell.getNumericCellValue();
1241
				return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);
1242
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:
1243
				return String.valueOf(cell.getBooleanCellValue());
1244
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:
1245
				return cell.getCellFormula();
1246
			default:
1247
				return null;
36655 ranu 1248
		}
1249
	}
1250
 
1251
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
1252
	// For each active fofo_store, compare its stored lat/lng with the geocoded
1253
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
1254
	// OR the store has no lat/lng yet, update the store with the geocoded
1255
	// coordinates. Otherwise keep the existing values.
1256
	//
1257
	// Usage:
1258
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
1259
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
1260
	//   ?thresholdKm=3      -> use a different threshold
1261
	//   ?limit=100          -> process only N stores (for staged runs)
1262
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
1263
	public ResponseEntity<?> migrateStoreLatLng(
1264
			@RequestParam(required = false, defaultValue = "false") boolean apply,
1265
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
36660 ranu 1266
			@RequestParam(required = false, defaultValue = "0") int limit,
36727 ranu 1267
			@RequestParam(required = false, defaultValue = "0") int offset,
1268
			@RequestParam(required = false, defaultValue = "40") int maxSeconds) throws ProfitMandiBusinessException {
36655 ranu 1269
 
36660 ranu 1270
		List<FofoStore> all = fofoStoreRepository.selectActiveStores();
1271
		int totalAvailable = all.size();
1272
		int from = Math.max(0, Math.min(offset, totalAvailable));
36655 ranu 1273
 
36727 ranu 1274
		// Hard cap (if limit given), else go to the end of the list.
1275
		int hardTo = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;
1276
 
1277
		// Time budget: stop processing once we approach the gateway timeout and
1278
		// return nextOffset so the caller can resume. Geocoding is the slow part
1279
		// (network/cache), so a fixed batch size could still time out on a cache-miss
1280
		// run — a wall-clock budget is safer. maxSeconds defaults to 40 (< typical 60s gateway).
1281
		long deadlineMs = System.currentTimeMillis() + Math.max(5, maxSeconds) * 1000L;
1282
 
1283
		List<FofoStore> stores = all.subList(from, hardTo);
36655 ranu 1284
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
1285
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
1286
 
36727 ranu 1287
		int total = 0;            // stores actually processed this call
36655 ranu 1288
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
36727 ranu 1289
		boolean stoppedOnTime = false;
1290
		int nextIndex = from;     // absolute index of next unprocessed store
36655 ranu 1291
		List<Map<String, Object>> changes = new ArrayList<>();
1292
 
1293
		for (FofoStore store : stores) {
36727 ranu 1294
			// Stop before doing more slow geocoding work if we've spent our budget.
1295
			if (System.currentTimeMillis() >= deadlineMs) {
1296
				stoppedOnTime = true;
1297
				break;
1298
			}
1299
			total++;
1300
			nextIndex++;
36655 ranu 1301
			try {
1302
				CustomRetailer retailer = retailerMap.get(store.getId());
1303
				if (retailer == null || retailer.getAddress() == null) {
1304
					noAddress++;
1305
					continue;
1306
				}
1307
 
1308
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
1309
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
1310
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
1311
				if (geoAddr == null || geoAddr.isEmpty()) {
1312
					noAddress++;
1313
					continue;
1314
				}
1315
 
1316
				double[] coords = geocodingService.geocodeAddress(geoAddr);
1317
				if (coords == null) {
1318
					noGeocode++;
1319
					continue;
1320
				}
1321
 
1322
				Double existingLat = parseDoubleOrNull(store.getLatitude());
1323
				Double existingLng = parseDoubleOrNull(store.getLongitude());
1324
 
1325
				boolean shouldUpdate;
1326
				double distKm = -1;
1327
				String reason;
1328
				if (existingLat == null || existingLng == null) {
1329
					shouldUpdate = true;
1330
					reason = "missing existing lat/lng";
1331
				} else {
1332
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
1333
					shouldUpdate = distKm > thresholdKm;
1334
					reason = shouldUpdate
1335
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
1336
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
1337
				}
1338
 
1339
				if (shouldUpdate) {
1340
					if (apply) {
1341
						store.setLatitude(String.valueOf(coords[0]));
1342
						store.setLongitude(String.valueOf(coords[1]));
36727 ranu 1343
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
36655 ranu 1344
						fofoStoreRepository.persist(store);
1345
					}
1346
					updated++;
1347
					Map<String, Object> ch = new HashMap<>();
1348
					ch.put("storeId", store.getId());
1349
					ch.put("code", store.getCode());
1350
					ch.put("oldLat", existingLat);
1351
					ch.put("oldLng", existingLng);
1352
					ch.put("newLat", coords[0]);
1353
					ch.put("newLng", coords[1]);
1354
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
1355
					ch.put("reason", reason);
1356
					changes.add(ch);
1357
				} else {
36727 ranu 1358
					// Verified-kept: lat/lng was already within threshold. Still stamp it
1359
					// so "processed vs pending" can be told from lat_lng_updated_timestamp.
1360
					if (apply) {
1361
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
1362
						fofoStoreRepository.persist(store);
1363
					}
36655 ranu 1364
					kept++;
1365
				}
1366
			} catch (Exception e) {
1367
				errored++;
1368
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
1369
			}
1370
		}
1371
 
1372
		Map<String, Object> result = new HashMap<>();
1373
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
1374
		result.put("thresholdKm", thresholdKm);
36727 ranu 1375
		result.put("totalAvailable", totalAvailable);   // total active stores in DB
36660 ranu 1376
		result.put("offset", from);
36727 ranu 1377
		result.put("processed", total);                  // stores processed this call
1378
		result.put("nextOffset", nextIndex);             // resume here next call
1379
		result.put("done", nextIndex >= totalAvailable); // true when nothing left
1380
		result.put("stoppedOnTimeBudget", stoppedOnTime);// true if we paused for time, not because we finished
36655 ranu 1381
		result.put("updated", updated);
1382
		result.put("kept", kept);
1383
		result.put("noAddress", noAddress);
1384
		result.put("noGeocode", noGeocode);
1385
		result.put("errored", errored);
1386
		// Limit changes preview to avoid huge responses
1387
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
1388
		result.put("changesShownCount", Math.min(changes.size(), 200));
1389
		return responseSender.ok(result);
1390
	}
1391
 
36651 ranu 1392
	// ====================== EDIT BEAT ======================
1393
	// Update an existing beat — name + partner stops (routes).
1394
	// Schedules are NOT touched here; manage them via calendar drag-drop.
1395
	@PostMapping(value = "/beatPlan/updateBeat")
1396
	public ResponseEntity<?> updateBeat(
1397
			HttpServletRequest request,
1398
			@RequestParam int beatId,
1399
			@RequestParam String planData) throws Exception {
1400
 
1401
		Beat beat = beatRepository.selectById(beatId);
1402
		if (beat == null) return responseSender.badRequest("Beat not found");
1403
 
36821 ranu 1404
		// Refuse edits while the beat is "live" — i.e., a schedule row covers today.
1405
		// The salesperson is already on-route; mutating the plan mid-day would
1406
		// break their location_tracking timeline. Edits resume tomorrow.
1407
		LocalDate today = LocalDate.now();
1408
		boolean runningToday = beatScheduleRepository.selectByBeatId(beatId).stream()
1409
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(today));
1410
		if (runningToday) {
1411
			return responseSender.badRequest(
1412
					"This beat is scheduled to run today — editing is locked until tomorrow. "
1413
							+ "Use the Deferred panel for today's adjustments.");
1414
		}
1415
 
36651 ranu 1416
		Gson gson = new Gson();
1417
		Type type = new TypeToken<Map<String, Object>>() {
1418
		}.getType();
1419
		Map<String, Object> plan = gson.fromJson(planData, type);
1420
 
1421
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
1422
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
1423
 
1424
		// Update name if changed (and not colliding with another beat)
1425
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
1426
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
36698 ranu 1427
			// Make sure no other ACTIVE beat for this user already uses this name.
1428
			// Soft-deleted beats keep their name in the table; we don't want them
1429
			// to block a legitimate rename.
1430
			boolean collides = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId()).stream()
36651 ranu 1431
					.anyMatch(b -> b.getId() != beat.getId()
1432
							&& b.getName() != null
1433
							&& newName.equalsIgnoreCase(b.getName().trim()));
1434
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
1435
			beat.setName(newName);
1436
		}
1437
 
1438
		// Update start location from first day if present
1439
		Map<String, Object> firstDay = days.get(0);
1440
		if (firstDay.get("startLocationName") != null)
1441
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1442
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
1443
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
1444
 
36681 ranu 1445
		int oldTotalDays = beat.getTotalDays();
1446
		int newTotalDays = days.size();
1447
 
1448
		// Hard rule: you cannot grow the number of days on an existing beat.
1449
		// If you need more days, create a new beat. (Shrinking is allowed and
1450
		// the schedules for dropped day numbers are cleaned below.)
1451
		if (newTotalDays > oldTotalDays) {
1452
			return responseSender.badRequest(
1453
					"Cannot increase the number of days on an existing beat. "
1454
							+ "Original: " + oldTotalDays + " day(s), tried: " + newTotalDays + " day(s). "
1455
							+ "Please create a new beat for additional days.");
1456
		}
1457
		beat.setTotalDays(newTotalDays);
1458
 
1459
		// Replace routes (partner stops). Schedules stay intact (except for
36711 ranu 1460
        // dayNumber > newTotalDays cleanup + total km/min refresh below).
36651 ranu 1461
		beatRouteRepository.deleteByBeatId(beatId);
36681 ranu 1462
		// Collect lead IDs the user kept on the plan
36651 ranu 1463
		Set<Integer> keptLeadIds = new HashSet<>();
1464
		for (int d = 0; d < days.size(); d++) {
1465
			Map<String, Object> day = days.get(d);
1466
			int dayNumber = d + 1;
1467
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1468
			if (visits == null) continue;
1469
			int partnerSeq = 0;
1470
			for (int i = 0; i < visits.size(); i++) {
1471
				Map<String, Object> v = visits.get(i);
1472
				if ("lead".equals(v.get("type"))) {
1473
					keptLeadIds.add(((Number) v.get("id")).intValue());
1474
					continue; // leads live in lead_route, handled below
1475
				}
1476
				BeatRoute route = new BeatRoute();
1477
				route.setBeatId(beatId);
1478
				route.setFofoId(((Number) v.get("id")).intValue());
36811 ranu 1479
				route.setVisitType("office".equals(v.get("type"))
1480
						? com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE
1481
						: com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
36651 ranu 1482
				route.setSequenceOrder(partnerSeq++);
1483
				route.setDayNumber(dayNumber);
1484
				route.setActive(true);
36711 ranu 1485
                if (v.get("distanceFromPrevKm") != null)
1486
                    route.setDistanceFromPrevKm(((Number) v.get("distanceFromPrevKm")).doubleValue());
1487
                if (v.get("timeFromPrevMins") != null)
1488
                    route.setTimeFromPrevMins(((Number) v.get("timeFromPrevMins")).intValue());
36651 ranu 1489
				beatRouteRepository.persist(route);
1490
			}
1491
		}
1492
 
36681 ranu 1493
		// If the beat shrank, drop schedule rows for day numbers that no longer exist
1494
		if (newTotalDays < oldTotalDays) {
1495
			List<BeatSchedule> currentSchedules = beatScheduleRepository.selectByBeatId(beatId);
1496
			for (BeatSchedule s : currentSchedules) {
1497
				if (s.getDayNumber() > newTotalDays) beatScheduleRepository.delete(s);
1498
			}
1499
		}
1500
 
36711 ranu 1501
        // Refresh the day-level totals on every remaining schedule row so they
1502
        // reflect the post-edit route. Previously updateBeat left these stale
1503
        // (or NULL, for beats created before this fix), which is what the user
1504
        // reported. Keyed by dayNumber so multi-instance beats all get updated.
1505
        Map<Integer, Map<String, Object>> dayByNumber = new HashMap<>();
1506
        for (int d = 0; d < days.size(); d++) {
1507
            dayByNumber.put(d + 1, days.get(d));
1508
        }
1509
        List<BeatSchedule> allSchedules = beatScheduleRepository.selectByBeatId(beatId);
1510
        for (BeatSchedule s : allSchedules) {
1511
            Map<String, Object> day = dayByNumber.get(s.getDayNumber());
1512
            if (day == null) continue;
1513
            if (day.get("totalDistanceKm") != null)
1514
                s.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1515
            if (day.get("totalTimeMins") != null)
1516
                s.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1517
        }
1518
 
36681 ranu 1519
		// Process per-lead actions sent from the editor's removed-leads popup.
1520
		// Each entry: {leadId, action: "cancel"|"reschedule", toDate?: "yyyy-MM-dd"}.
1521
		// - cancel: mark the lead's current APPROVED row for this beat as CANCELLED.
1522
		// - reschedule: cancel here, then create a fresh APPROVED LeadRoute on
1523
		//   whichever beat this user has scheduled on toDate. If no beat exists
1524
		//   on toDate, the whole update fails (so the caller can prompt again).
1525
		int leadsCancelled = 0, leadsRescheduled = 0;
1526
		List<String> leadFailures = new ArrayList<>();
1527
		String removedLeadActionsJson = (String) plan.get("removedLeadActions");
1528
		if (removedLeadActionsJson != null && !removedLeadActionsJson.isEmpty()) {
1529
			Type listType = new TypeToken<List<Map<String, Object>>>() {
1530
			}.getType();
1531
			List<Map<String, Object>> actions = gson.fromJson(removedLeadActionsJson, listType);
1532
 
1533
			List<LeadRoute> beatLeads = leadRouteRepository.selectByBeatId(beatId);
1534
 
1535
			for (Map<String, Object> act : actions) {
1536
				int leadId = ((Number) act.get("leadId")).intValue();
1537
				String mode = (String) act.get("action");
1538
 
1539
				// Find this lead's most-recent APPROVED row on this beat
1540
				LeadRoute current = beatLeads.stream()
1541
						.filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus()))
1542
						.findFirst().orElse(null);
1543
				if (current == null) continue; // already removed/cancelled; nothing to do
1544
 
1545
				if ("reschedule".equalsIgnoreCase(mode)) {
1546
					String toDateStr = (String) act.get("toDate");
1547
					if (toDateStr == null || toDateStr.isEmpty()) {
1548
						leadFailures.add("Lead " + leadId + ": reschedule date missing");
1549
						continue;
1550
					}
1551
					LocalDate toDate = LocalDate.parse(toDateStr);
1552
 
1553
					// Find ANY beat this user has scheduled on toDate
1554
					Beat targetBeat = null;
1555
					Integer targetDayNumber = null;
1556
					List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId());
1557
					for (Beat b : userBeats) {
1558
						List<BeatSchedule> sl = beatScheduleRepository.selectByBeatId(b.getId());
1559
						for (BeatSchedule s : sl) {
1560
							if (s.getStartDate() != null && s.getStartDate().equals(toDate)) {
1561
								targetBeat = b;
1562
								targetDayNumber = s.getDayNumber();
1563
								break;
1564
							}
1565
						}
1566
						if (targetBeat != null) break;
1567
					}
1568
					if (targetBeat == null) {
1569
						return responseSender.badRequest(
1570
								"No beat is scheduled for this user on " + toDateStr
1571
										+ ". Pick a different date for lead " + leadId
1572
										+ ", or choose Cancel for it.");
1573
					}
1574
 
1575
					// Cancel the current attachment to this beat
1576
					current.setStatus("CANCELLED");
1577
					current.setUpdatedTimestamp(LocalDateTime.now());
1578
 
1579
					// Create the new attachment on the target beat/date
1580
					LeadRoute fresh = new LeadRoute();
1581
					fresh.setBeatId(targetBeat.getId());
1582
					fresh.setLeadId(leadId);
1583
					fresh.setNearestStoreId(current.getNearestStoreId());
1584
					fresh.setScheduleDate(toDate);
1585
					fresh.setSequenceOrder(9999); // append; the planner can reorder
1586
					fresh.setStatus("APPROVED");
1587
					fresh.setRequestedBy(current.getRequestedBy());
1588
					fresh.setApprovedBy(current.getApprovedBy());
1589
					fresh.setApprovedTimestamp(LocalDateTime.now());
1590
					fresh.setCreatedTimestamp(LocalDateTime.now());
1591
					fresh.setUpdatedTimestamp(LocalDateTime.now());
1592
					leadRouteRepository.persist(fresh);
1593
 
1594
					LeadActivity la = new LeadActivity();
1595
					la.setLeadId(leadId);
1596
					la.setRemark("Rescheduled from beat '" + beat.getName() + "' to '"
1597
							+ targetBeat.getName() + "' on " + toDateStr + " (day " + targetDayNumber + ")");
1598
					la.setAuthId(0);
1599
					la.setCreatedTimestamp(LocalDateTime.now());
1600
					leadActivityRepositoryAuto.persist(la);
1601
					leadsRescheduled++;
1602
				} else {
1603
					// cancel (default)
1604
					current.setStatus("CANCELLED");
1605
					current.setUpdatedTimestamp(LocalDateTime.now());
1606
 
1607
					LeadActivity la = new LeadActivity();
1608
					la.setLeadId(leadId);
1609
					la.setRemark("Cancelled from beat '" + beat.getName() + "' during edit");
1610
					la.setAuthId(0);
1611
					la.setCreatedTimestamp(LocalDateTime.now());
1612
					leadActivityRepositoryAuto.persist(la);
1613
					leadsCancelled++;
36651 ranu 1614
				}
1615
			}
1616
		}
1617
 
1618
		Map<String, Object> response = new HashMap<>();
1619
		response.put("status", true);
1620
		response.put("planGroupId", String.valueOf(beat.getId()));
36681 ranu 1621
		response.put("leadsCancelled", leadsCancelled);
1622
		response.put("leadsRescheduled", leadsRescheduled);
1623
		response.put("leadFailures", leadFailures);
1624
		response.put("message", "Beat updated successfully"
1625
				+ (leadsCancelled > 0 ? " (" + leadsCancelled + " lead(s) cancelled)" : "")
1626
				+ (leadsRescheduled > 0 ? " (" + leadsRescheduled + " lead(s) rescheduled)" : ""));
36651 ranu 1627
		return responseSender.ok(response);
1628
	}
1629
 
36681 ranu 1630
	// Used by the edit-mode "removed leads" popup so the date picker can warn
1631
	// upfront when the user picks a date that has no beat for them.
1632
	@GetMapping(value = "/beatPlan/userBeatsOnDate")
1633
	public ResponseEntity<?> userBeatsOnDate(
1634
			@RequestParam int authUserId,
1635
			@RequestParam String date) {
1636
		LocalDate target;
1637
		try {
1638
			target = LocalDate.parse(date);
1639
		} catch (Exception e) {
1640
			return responseSender.badRequest("Invalid date");
1641
		}
1642
 
1643
		List<Map<String, Object>> hits = new ArrayList<>();
1644
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
1645
		for (Beat b : userBeats) {
1646
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
1647
			for (BeatSchedule s : schedules) {
1648
				if (s.getStartDate() != null && s.getStartDate().equals(target)) {
1649
					Map<String, Object> m = new HashMap<>();
1650
					m.put("beatId", b.getId());
1651
					m.put("beatName", b.getName());
1652
					m.put("dayNumber", s.getDayNumber());
1653
					hits.add(m);
1654
				}
1655
			}
1656
		}
1657
		Map<String, Object> result = new HashMap<>();
1658
		result.put("date", date);
1659
		result.put("authUserId", authUserId);
1660
		result.put("beats", hits);
1661
		return responseSender.ok(result);
1662
	}
1663
 
36686 ranu 1664
	// ====================== BASE LOCATION MANAGEMENT ======================
1665
	// Inline page that lets Sales L3+ pick a user and set their base (home)
1666
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
1667
	// go through the L3+-guarded endpoint below.
1668
	@GetMapping(value = "/beatPlan/baseLocationPage")
1669
	public String baseLocationPage(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1670
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
1671
		return "beat-plan-base-location";
36650 ranu 1672
	}
1673
 
1674
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
36821 ranu 1675
	// Hierarchy-scoped: a manager sees only their downline + self; super-admins see all.
1676
	// Optional categoryId + escalationType further narrow the listing to users at
1677
	// a specific level (e.g. all Sales L1 in scope).
36650 ranu 1678
	@GetMapping(value = "/beatPlan/scheduledList")
1679
	public ResponseEntity<?> scheduledList(
36821 ranu 1680
			HttpServletRequest request,
36650 ranu 1681
			@RequestParam(required = false) String startDate,
36821 ranu 1682
			@RequestParam(required = false) String endDate,
1683
			@RequestParam(required = false) Integer categoryId,
1684
			@RequestParam(required = false) com.spice.profitmandi.dao.enumuration.cs.EscalationType escalationType) throws ProfitMandiBusinessException {
36650 ranu 1685
 
1686
		LocalDate start, end;
1687
		try {
1688
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
1689
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
1690
		} catch (Exception e) {
1691
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
1692
		}
1693
 
36821 ranu 1694
		// ---- Scope: which auth users does the caller get to see ----
1695
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1696
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1697
		Set<Integer> visible = null;
1698
		if (me != null && !isSuperAdmin(me)) {
1699
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
1700
			visible.add(me.getId());
1701
		}
1702
		// If a (category, level) was picked, further restrict to that population.
1703
		Set<Integer> levelFilter = null;
1704
		if (categoryId != null && escalationType != null) {
1705
			levelFilter = csService.getAuthUserByCategoryId(categoryId, escalationType).stream()
1706
					.filter(AuthUser::getActive)
1707
					.map(AuthUser::getId)
1708
					.collect(java.util.stream.Collectors.toSet());
1709
		}
1710
 
36650 ranu 1711
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
1712
				beatPlanQueryService.getAllScheduledBeats(start, end);
1713
 
36821 ranu 1714
		final Set<Integer> visibleF = visible;
1715
		final Set<Integer> levelF = levelFilter;
1716
		beats = beats.stream()
1717
				.filter(b -> visibleF == null || visibleF.contains(b.getAuthUserId()))
1718
				.filter(b -> levelF == null || levelF.contains(b.getAuthUserId()))
1719
				.collect(java.util.stream.Collectors.toList());
1720
 
36650 ranu 1721
		// Resolve user names in bulk
1722
		Set<Integer> userIds = beats.stream()
1723
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
1724
				.collect(java.util.stream.Collectors.toSet());
1725
		Map<Integer, AuthUser> userMap = new HashMap<>();
1726
		if (!userIds.isEmpty()) {
1727
			authRepository.selectByIds(new ArrayList<>(userIds))
1728
					.forEach(u -> userMap.put(u.getId(), u));
1729
		}
1730
 
1731
		List<Map<String, Object>> rows = new ArrayList<>();
1732
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1733
			AuthUser u = userMap.get(b.getAuthUserId());
1734
			Map<String, Object> row = new HashMap<>();
1735
			row.put("authUserId", b.getAuthUserId());
1736
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
1737
			row.put("scheduleDate", b.getScheduleDate().toString());
1738
			row.put("dayNumber", b.getDayNumber());
1739
			row.put("beatId", b.getBeatId());
1740
			row.put("beatName", b.getBeatName());
1741
			row.put("beatColor", b.getBeatColor());
1742
			row.put("partnerCount", b.getPartnerStops().size());
1743
			row.put("leadCount", b.getLeadStops().size());
1744
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
1745
			rows.add(row);
1746
		}
1747
 
1748
		Map<String, Object> result = new HashMap<>();
1749
		result.put("rows", rows);
1750
		result.put("startDate", start.toString());
1751
		result.put("endDate", end.toString());
1752
		return responseSender.ok(result);
1753
	}
1754
 
1755
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
1756
	@GetMapping(value = "/beatPlan/dayViewData")
1757
	public ResponseEntity<?> beatPlanDayViewData(
1758
			@RequestParam int authUserId,
1759
			@RequestParam String date) throws ProfitMandiBusinessException {
1760
 
1761
		LocalDate localDate;
1762
		try {
1763
			localDate = LocalDate.parse(date);
1764
		} catch (Exception e) {
1765
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
1766
		}
1767
 
1768
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
1769
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
1770
 
1771
		// Collect all partner & lead IDs to fetch metadata in bulk
1772
		Set<Integer> partnerIds = new HashSet<>();
1773
		Set<Integer> leadIds = new HashSet<>();
1774
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1775
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
1776
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
1777
		}
1778
 
1779
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
1780
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
1781
				? new HashMap<>()
1782
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
1783
		Map<Integer, FofoStore> storeMap = new HashMap<>();
1784
		if (!partnerIds.isEmpty()) {
1785
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
1786
					.forEach(fs -> storeMap.put(fs.getId(), fs));
1787
		}
1788
 
1789
		// Leads: name + geo
1790
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
1791
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
1792
		for (int leadId : leadIds) {
1793
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
1794
			if (l != null) leadMap.put(leadId, l);
1795
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
1796
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
1797
			if (lg != null) leadGeoMap.put(leadId, lg);
1798
		}
1799
 
1800
		// Enrich each stop
1801
		List<Map<String, Object>> out = new ArrayList<>();
1802
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1803
			Map<String, Object> beatJson = new HashMap<>();
1804
			beatJson.put("beatId", b.getBeatId());
1805
			beatJson.put("beatName", b.getBeatName());
1806
			beatJson.put("beatColor", b.getBeatColor());
1807
			beatJson.put("dayNumber", b.getDayNumber());
1808
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
1809
			beatJson.put("endAction", b.getEndAction());
1810
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
1811
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
1812
			beatJson.put("startLocationName", b.getStartLocationName());
1813
			beatJson.put("startLatitude", b.getStartLatitude());
1814
			beatJson.put("startLongitude", b.getStartLongitude());
1815
 
1816
			List<Map<String, Object>> stops = new ArrayList<>();
1817
			// Partners
1818
			for (Map<String, Object> ps : b.getPartnerStops()) {
1819
				int fofoId = (Integer) ps.get("fofoId");
1820
				Map<String, Object> stop = new HashMap<>();
1821
				stop.put("type", "partner");
1822
				stop.put("id", fofoId);
1823
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
1824
				FofoStore fs = storeMap.get(fofoId);
1825
				CustomRetailer cr = retailerMap.get(fofoId);
1826
				stop.put("code", fs != null ? fs.getCode() : null);
1827
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
1828
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 1829
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
1830
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
1831
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 1832
					try {
36655 ranu 1833
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
1834
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
1835
					} catch (NumberFormatException ignored) {
36650 ranu 1836
					}
1837
				}
36655 ranu 1838
				if (cr != null && cr.getAddress() != null) {
1839
					stop.put("address", cr.getAddress().getAddressString());
1840
				}
36650 ranu 1841
				stops.add(stop);
1842
			}
1843
			// Leads
1844
			for (Map<String, Object> ls : b.getLeadStops()) {
1845
				int leadId = (Integer) ls.get("leadId");
1846
				Map<String, Object> stop = new HashMap<>();
1847
				stop.put("type", "lead");
1848
				stop.put("id", leadId);
1849
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
1850
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
1851
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
1852
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
1853
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
1854
				stop.put("city", l != null ? l.getCity() : null);
1855
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
1856
				if (lg != null) {
1857
					stop.put("lat", lg.getLatitude());
1858
					stop.put("lng", lg.getLongitude());
1859
				}
1860
				stops.add(stop);
1861
			}
1862
			beatJson.put("stops", stops);
1863
			beatJson.put("partnerCount", b.getPartnerStops().size());
1864
			beatJson.put("leadCount", b.getLeadStops().size());
1865
			out.add(beatJson);
1866
		}
1867
 
1868
		Map<String, Object> result = new HashMap<>();
1869
		result.put("beats", out);
1870
		return responseSender.ok(result);
1871
	}
1872
 
36686 ranu 1873
	// ====================== DAY VIEW ======================
1874
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
1875
	// scheduled in a date range across all users. Each row has a View button that
1876
	// opens that user's calendar in a modal.
1877
	@GetMapping(value = "/beatPlan/dayView")
1878
	public String beatPlanDayView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1879
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36821 ranu 1880
		// Matches /beatPlan/getAuthUsers and the Beat Report — default Sales category.
1881
		model.addAttribute("categoryId", com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES);
36962 vikas 1882
		model.addAttribute("canEditBeat", canEditBeat(currentUser(request)));
36686 ranu 1883
		return "beat-plan-day-view";
36618 ranu 1884
	}
1885
 
36644 ranu 1886
	// Returns visits for a beat.
1887
	// - Partner stops (beat_route) belong to the beat template — always returned.
1888
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
1889
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 1890
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 1891
	public ResponseEntity<?> getBeatVisits(
1892
			@RequestParam String planGroupId,
1893
			@RequestParam(required = false) String planDate) {
1894
 
1895
		int beatId;
1896
		try {
1897
			beatId = Integer.parseInt(planGroupId);
1898
		} catch (NumberFormatException e) {
1899
			return responseSender.ok(new ArrayList<>());
1900
		}
1901
 
1902
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
1903
		List<Map<String, Object>> result = new ArrayList<>();
1904
 
36811 ranu 1905
		// Stops — partner OR office, dispatched by visit_type. Partners are
1906
		// enriched on the client from the partner map (already in scope);
1907
		// offices are enriched here because the client has no office map.
36644 ranu 1908
		for (BeatRoute r : routes) {
36632 ranu 1909
			Map<String, Object> map = new HashMap<>();
36644 ranu 1910
			map.put("fofoId", r.getFofoId());
1911
			map.put("dayNumber", r.getDayNumber());
1912
			map.put("sequenceOrder", r.getSequenceOrder());
36811 ranu 1913
			if (r.getVisitType() == com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE) {
1914
				map.put("visitType", "office");
1915
				try {
1916
					com.spice.profitmandi.dao.entity.logistics.CompanyOffice o =
1917
							companyOfficeRepository.selectById(r.getFofoId());
1918
					if (o != null) {
1919
						map.put("code", o.getCode());
1920
						map.put("name", o.getName());
1921
						map.put("latitude", String.valueOf(o.getLat()));
1922
						map.put("longitude", String.valueOf(o.getLng()));
1923
					}
1924
				} catch (Exception ignored) {
1925
				}
1926
			} else {
1927
				map.put("visitType", "partner");
1928
			}
36644 ranu 1929
			result.add(map);
1930
		}
1931
 
1932
		// Lead stops — only for the requested run date
1933
		if (planDate != null && !planDate.isEmpty()) {
1934
			LocalDate date = LocalDate.parse(planDate);
1935
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
1936
			for (LeadRoute lr : leads) {
1937
				if ("APPROVED".equals(lr.getStatus())
1938
						&& lr.getScheduleDate() != null
1939
						&& lr.getScheduleDate().equals(date)) {
1940
					Map<String, Object> map = new HashMap<>();
1941
					map.put("fofoId", lr.getLeadId());
1942
					map.put("dayNumber", 1);
1943
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
1944
					map.put("visitType", "lead");
1945
					result.add(map);
1946
				}
1947
			}
1948
		}
1949
 
1950
		// Sort by dayNumber then sequenceOrder
1951
		result.sort((a, b) -> {
1952
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
1953
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
1954
		});
1955
 
36632 ranu 1956
		return responseSender.ok(result);
1957
	}
1958
 
36681 ranu 1959
	// Returns the user's DEFAULT base location. Falls back to most-recent for
1960
	// legacy users who pre-date the is_default column.
36618 ranu 1961
	@GetMapping(value = "/beatPlan/getBaseLocation")
1962
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
36681 ranu 1963
		AuthUserLocation baseLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36618 ranu 1964
		if (baseLoc == null) {
1965
			return responseSender.ok(new HashMap<>());
1966
		}
1967
		Map<String, Object> result = new HashMap<>();
1968
		result.put("id", baseLoc.getId());
1969
		result.put("locationName", baseLoc.getLocationName());
1970
		result.put("latitude", baseLoc.getLatitude());
1971
		result.put("longitude", baseLoc.getLongitude());
1972
		result.put("address", baseLoc.getAddress());
36681 ranu 1973
		result.put("isDefault", baseLoc.isDefault());
36618 ranu 1974
		return responseSender.ok(result);
1975
	}
1976
 
36681 ranu 1977
	// Returns ALL BASE locations for a user, default first.
1978
	@GetMapping(value = "/beatPlan/listBaseLocations")
1979
	public ResponseEntity<?> listBaseLocations(@RequestParam int authUserId) {
1980
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1981
		// Default at the top, then by created desc (the repo already returns desc).
1982
		all.sort((a, b) -> {
1983
			if (a.isDefault() && !b.isDefault()) return -1;
1984
			if (!a.isDefault() && b.isDefault()) return 1;
1985
			return 0;
1986
		});
1987
		List<Map<String, Object>> rows = new ArrayList<>();
1988
		for (AuthUserLocation l : all) {
1989
			Map<String, Object> row = new HashMap<>();
1990
			row.put("id", l.getId());
1991
			row.put("locationName", l.getLocationName());
1992
			row.put("latitude", l.getLatitude());
1993
			row.put("longitude", l.getLongitude());
1994
			row.put("address", l.getAddress());
1995
			row.put("isDefault", l.isDefault());
1996
			row.put("createdTimestamp", l.getCreatedTimestamp() != null ? l.getCreatedTimestamp().toString() : null);
1997
			rows.add(row);
1998
		}
1999
		Map<String, Object> result = new HashMap<>();
2000
		result.put("authUserId", authUserId);
2001
		result.put("locations", rows);
2002
		return responseSender.ok(result);
2003
	}
2004
 
2005
	// Flip the default flag — set this id default, clear all others.
2006
	@PostMapping(value = "/beatPlan/setDefaultBaseLocation")
2007
	public ResponseEntity<?> setDefaultBaseLocation(
2008
			HttpServletRequest request,
2009
			@RequestParam int id) throws ProfitMandiBusinessException {
2010
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
2011
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
2012
		if (me == null) return responseSender.unauthorized("Not logged in");
2013
		if (!isBaseLocationManager(me)) {
2014
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
2015
		}
2016
 
2017
		AuthUserLocation target = authUserLocationRepository.selectById(id);
2018
		if (target == null) return responseSender.badRequest("Location not found");
2019
 
2020
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(target.getAuthUserId(), "BASE");
2021
		for (AuthUserLocation l : all) {
2022
			boolean shouldBeDefault = (l.getId() == id);
2023
			if (l.isDefault() != shouldBeDefault) {
2024
				l.setDefault(shouldBeDefault);
2025
				authUserLocationRepository.persist(l); // saveOrUpdate
2026
			}
2027
		}
2028
 
2029
		Map<String, Object> result = new HashMap<>();
2030
		result.put("status", true);
2031
		result.put("id", id);
2032
		result.put("message", "Default base location updated");
2033
		return responseSender.ok(result);
2034
	}
2035
 
2036
	// Delete a base location. The DEFAULT one cannot be deleted — user must
2037
	// first pick another row as default.
2038
	@PostMapping(value = "/beatPlan/deleteBaseLocation")
2039
	public ResponseEntity<?> deleteBaseLocation(
2040
			HttpServletRequest request,
2041
			@RequestParam int id) throws ProfitMandiBusinessException {
2042
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
2043
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
2044
		if (me == null) return responseSender.unauthorized("Not logged in");
2045
		if (!isBaseLocationManager(me)) {
2046
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
2047
		}
2048
 
2049
		AuthUserLocation target = authUserLocationRepository.selectById(id);
2050
		if (target == null) return responseSender.badRequest("Location not found");
2051
		if (target.isDefault()) {
2052
			return responseSender.badRequest("Default base location cannot be removed. Set another location as default first.");
2053
		}
2054
 
2055
		authUserLocationRepository.delete(target);
2056
 
2057
		Map<String, Object> result = new HashMap<>();
2058
		result.put("status", true);
2059
		result.put("message", "Base location removed");
2060
		return responseSender.ok(result);
2061
	}
2062
 
36686 ranu 2063
	@GetMapping(value = "/beatPlan/getAuthUsers")
2064
	public ResponseEntity<?> getAuthUsers(
2065
			HttpServletRequest request,
2066
			@RequestParam int categoryId,
2067
			@RequestParam EscalationType escalationType) throws ProfitMandiBusinessException {
2068
 
2069
		// Hierarchy filter: a manager only sees users in their downline
2070
		// (themselves + every reportee under them, recursively). Super-admin
2071
		// emails bypass the filter and see everyone. Downline is computed by
2072
		// AuthService.getAllReportees (existing recursive walker).
2073
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
2074
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
2075
 
2076
		final Set<Integer> visible;
2077
		if (me == null || isSuperAdmin(me)) {
2078
			visible = null; // null = no filter
2079
		} else {
2080
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
2081
			visible.add(me.getId()); // include self
2082
		}
2083
 
2084
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
2085
		List<Map<String, Object>> result = authUsers.stream()
2086
				.filter(au -> au.getActive())
2087
				.filter(au -> visible == null || visible.contains(au.getId()))
2088
				.map(au -> {
2089
					Map<String, Object> map = new HashMap<>();
2090
					map.put("id", au.getId());
2091
					map.put("name", au.getFirstName() + " " + au.getLastName());
2092
					return map;
2093
				})
2094
				.collect(Collectors.toList());
2095
		return responseSender.ok(result);
2096
	}
2097
 
2098
	private boolean isSuperAdmin(AuthUser me) {
36681 ranu 2099
		String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";
36686 ranu 2100
		return SUPER_ADMIN_EMAILS.contains(myEmail);
2101
	}
36681 ranu 2102
 
36686 ranu 2103
	// Returns the user's highest escalation level across all positions.
2104
	// Mirrors OrderController.getSalesEscalationLevel but category-agnostic.
2105
	private EscalationType getHighestEscalation(int authUserId) {
2106
		EscalationType highest = null;
2107
		List<com.spice.profitmandi.dao.entity.cs.Position> positions = positionRepository.selectPositionByAuthId(authUserId);
2108
		for (com.spice.profitmandi.dao.entity.cs.Position p : positions) {
2109
			if (highest == null || p.getEscalationType().isGreaterThanEqualTo(highest)) {
2110
				highest = p.getEscalationType();
2111
			}
2112
		}
2113
		return highest;
2114
	}
2115
 
2116
	// Returns the escalation levels a user can manage — strictly below their own.
2117
	// L3 → [L1, L2]; L4 → [L1, L2, L3]; Final → all levels. Super-admin → all levels.
2118
	private List<EscalationType> getVisibleEscalationLevels(AuthUser me) {
2119
		if (isSuperAdmin(me)) return EscalationType.escalations;
2120
		EscalationType mine = getHighestEscalation(me.getId());
2121
		if (mine == null) return java.util.Collections.emptyList();
2122
		List<EscalationType> below = new ArrayList<>();
2123
		for (EscalationType e : EscalationType.escalations) {
2124
			if (mine.isGreaterThanEqualTo(e) && !e.equals(mine)) below.add(e);
2125
		}
2126
		return below;
2127
	}
2128
 
36962 vikas 2129
	private AuthUser currentUser(HttpServletRequest request) throws ProfitMandiBusinessException {
2130
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
2131
		return (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
2132
	}
2133
 
36686 ranu 2134
	private List<EscalationType> visibleLevelsFor(HttpServletRequest request) throws ProfitMandiBusinessException {
36962 vikas 2135
		AuthUser me = currentUser(request);
36686 ranu 2136
		return me == null ? java.util.Collections.emptyList() : getVisibleEscalationLevels(me);
2137
	}
2138
 
36962 vikas 2139
	// Same-day scheduling is restricted to L4+ operators (or super-admin). Everyone
2140
	// else can only schedule beats for future dates.
2141
	private boolean canScheduleToday(AuthUser me) {
2142
		if (me == null) return false;
2143
		if (isSuperAdmin(me)) return true;
2144
		EscalationType lvl = getHighestEscalation(me.getId());
2145
		return lvl != null && lvl.isGreaterThanEqualTo(EscalationType.L4);
2146
	}
2147
 
2148
	// Editing a beat / assigning a visit from the day-view is restricted to L2 and
2149
	// above (or super-admin). L1 executives can view but not edit.
2150
	private boolean canEditBeat(AuthUser me) {
2151
		if (me == null) return false;
2152
		if (isSuperAdmin(me)) return true;
2153
		EscalationType lvl = getHighestEscalation(me.getId());
2154
		return lvl != null && lvl.isGreaterThanEqualTo(EscalationType.L2);
2155
	}
2156
 
36686 ranu 2157
	// Shared permission check for base-location admin actions: Sales L3+ OR super-admin.
2158
	private boolean isBaseLocationManager(AuthUser me) {
2159
		if (isSuperAdmin(me)) return true;
36681 ranu 2160
		return csService.getAuthUserIds(
2161
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
2162
						Arrays.asList(EscalationType.L3, EscalationType.L4))
2163
				.stream().anyMatch(u -> u.getId() == me.getId());
2164
	}
2165
 
36618 ranu 2166
	@PostMapping(value = "/beatPlan/saveBaseLocation")
2167
	public ResponseEntity<?> saveBaseLocation(
2168
			@RequestParam int authUserId,
2169
			@RequestParam String locationName,
2170
			@RequestParam String latitude,
2171
			@RequestParam String longitude,
2172
			@RequestParam(required = false) String address) {
2173
		AuthUserLocation loc = new AuthUserLocation();
2174
		loc.setAuthUserId(authUserId);
2175
		loc.setLocationType("BASE");
2176
		loc.setLocationName(locationName);
2177
		loc.setLatitude(latitude);
2178
		loc.setLongitude(longitude);
2179
		loc.setAddress(address);
2180
		loc.setCreatedTimestamp(LocalDateTime.now());
36681 ranu 2181
 
2182
		// First BASE for this user → auto-default so every user always has one.
2183
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
2184
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
2185
		loc.setDefault(existing.isEmpty() || noExistingDefault);
36618 ranu 2186
		authUserLocationRepository.persist(loc);
2187
 
2188
		Map<String, Object> result = new HashMap<>();
2189
		result.put("status", true);
2190
		result.put("id", loc.getId());
36681 ranu 2191
		result.put("isDefault", loc.isDefault());
36618 ranu 2192
		return responseSender.ok(result);
2193
	}
2194
 
2195
	@GetMapping(value = "/beatPlan/getPartners")
2196
	public ResponseEntity<?> getPartners(
2197
			@RequestParam int authUserId,
2198
			@RequestParam int categoryId,
2199
			@RequestParam(required = false) String startLat,
2200
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
2201
 
36802 ranu 2202
		// Beat planning needs every partner ever assigned — inactive ones included —
2203
		// so the planner can keep building beats around a partner that was paused
2204
		// after the assignment was made. The closed-store skip happens below per row.
2205
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdAllPartnerIdMapping();
36618 ranu 2206
		List<Integer> fofoIds = pp.get(authUserId);
2207
 
36644 ranu 2208
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 2209
			Map<String, Object> empty = new HashMap<>();
2210
			empty.put("partners", new ArrayList<>());
2211
			return responseSender.ok(empty);
2212
		}
2213
 
2214
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
2215
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
2216
 
2217
		List<Map<String, Object>> partners = new ArrayList<>();
2218
 
2219
		for (FofoStore store : fofoStores) {
36802 ranu 2220
			// Closed partners are gone for good — skip. Inactive ones are kept
2221
			// so the planner can still drop a beat onto them (the assignment
2222
			// pre-dates the deactivation); the UI tags them visually.
2223
			if (store.isClosed()) continue;
36618 ranu 2224
			CustomRetailer retailer = retailerMap.get(store.getId());
2225
 
2226
			Map<String, Object> partnerData = new HashMap<>();
2227
			partnerData.put("fofoId", store.getId());
2228
			partnerData.put("code", store.getCode());
2229
			partnerData.put("outletName", store.getOutletName());
36802 ranu 2230
			partnerData.put("active", store.isActive());
36618 ranu 2231
			partnerData.put("type", "partner");
2232
 
36655 ranu 2233
			// Use FofoStore lat/lng directly (migrated from address geocode)
2234
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
2235
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
2236
				partnerData.put("latitude", store.getLatitude());
2237
				partnerData.put("longitude", store.getLongitude());
2238
			}
2239
 
36618 ranu 2240
			if (retailer != null) {
2241
				partnerData.put("businessName", retailer.getBusinessName());
2242
				if (retailer.getAddress() != null) {
36644 ranu 2243
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 2244
				}
2245
			}
2246
			partners.add(partnerData);
2247
		}
2248
 
2249
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
2250
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
2251
		} else {
2252
			partners = sortByNearestNeighbor(partners);
2253
		}
2254
 
2255
		Map<String, Object> response = new HashMap<>();
2256
		response.put("partners", partners);
2257
		return responseSender.ok(response);
2258
	}
2259
 
2260
	@PostMapping(value = "/beatPlan/submitPlan")
2261
	public ResponseEntity<?> submitPlan(
2262
			HttpServletRequest request,
2263
			@RequestParam int authUserId,
2264
			@RequestParam String planData) throws Exception {
2265
 
2266
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
2267
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
2268
 
2269
		Gson gson = new Gson();
2270
		Type type = new TypeToken<Map<String, Object>>() {
2271
		}.getType();
2272
		Map<String, Object> plan = gson.fromJson(planData, type);
2273
 
2274
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
2275
		List<String> dates = (List<String>) plan.get("dates");
2276
 
36644 ranu 2277
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 2278
 
36698 ranu 2279
		// Duplicate check — same name + same authUserId among ACTIVE beats only.
2280
		// Soft-deleted beats keep the name in the table; we don't want them to
2281
		// block the user from reusing a name they "deleted".
2282
		List<Beat> existingBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36644 ranu 2283
		for (Beat existing : existingBeats) {
2284
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
2285
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 2286
				Map<String, Object> response = new HashMap<>();
2287
				response.put("status", true);
36644 ranu 2288
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 2289
				response.put("duplicate", true);
36644 ranu 2290
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 2291
				return responseSender.ok(response);
2292
			}
2293
		}
2294
 
36644 ranu 2295
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
2296
		int totalDays = days.size();
36618 ranu 2297
 
36785 ranu 2298
		// One-beat-per-day guard: reject if any of the requested dates already
2299
		// has a beat scheduled for this user.
2300
		if (dates != null) {
2301
			List<LocalDate> candidateDates = new ArrayList<>();
2302
			for (String dStr : dates) {
2303
				if (dStr != null && !dStr.isEmpty()) {
2304
					try {
2305
						candidateDates.add(LocalDate.parse(dStr, DateTimeFormatter.ISO_DATE));
2306
					} catch (Exception ignored) {
2307
					}
2308
				}
2309
			}
2310
			Map<String, Object> conflict = findScheduleConflict(authUserId, candidateDates, 0);
2311
			if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
2312
		}
2313
 
36644 ranu 2314
		// Create Beat master
2315
		Beat beat = new Beat();
2316
		beat.setName(beatName);
2317
		beat.setAuthUserId(authUserId);
2318
		beat.setBeatColor(beatColor);
2319
		beat.setTotalDays(totalDays);
2320
		beat.setActive(true);
2321
		beat.setCreatedBy(currentUser.getId());
2322
		beat.setCreatedTimestamp(LocalDateTime.now());
2323
 
2324
		// Set start location from first day
2325
		if (!days.isEmpty()) {
2326
			Map<String, Object> firstDay = days.get(0);
2327
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
2328
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
2329
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
2330
		}
2331
		beatRepository.persist(beat);
2332
 
2333
		// End date of the whole beat = last scheduled day's date
2334
		LocalDate beatEndDate = null;
2335
		if (dates != null) {
2336
			for (int d = dates.size() - 1; d >= 0; d--) {
2337
				if (dates.get(d) != null) {
2338
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
2339
					break;
2340
				}
2341
			}
2342
		}
2343
 
2344
		// Create routes and schedules for each day
36618 ranu 2345
		for (int d = 0; d < days.size(); d++) {
2346
			Map<String, Object> day = days.get(d);
2347
			int dayNumber = d + 1;
2348
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 2349
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 2350
 
36644 ranu 2351
			// Auto-determine end action: last day = HOME, others = DAYBREAK
2352
			String endAction = (String) day.get("endAction");
2353
			if (endAction == null || endAction.isEmpty()) {
2354
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 2355
			}
2356
 
36644 ranu 2357
			// Always create schedule (even if planDate is null — unscheduled beat)
2358
			BeatSchedule schedule = new BeatSchedule();
2359
			schedule.setBeatId(beat.getId());
2360
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
2361
			schedule.setEndDate(beatEndDate);
2362
			schedule.setDayNumber(dayNumber);
2363
			schedule.setEndAction(endAction);
2364
			schedule.setStayLocationName((String) day.get("stayLocationName"));
2365
			schedule.setStayLatitude((String) day.get("stayLatitude"));
2366
			schedule.setStayLongitude((String) day.get("stayLongitude"));
2367
			if (day.get("totalDistanceKm") != null)
2368
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
2369
			if (day.get("totalTimeMins") != null)
2370
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
2371
			schedule.setCreatedTimestamp(LocalDateTime.now());
2372
			beatScheduleRepository.persist(schedule);
2373
 
36711 ranu 2374
            // Routes (stops) — also persist per-leg distance/time supplied by the
2375
            // client so reports/dashboards don't have to recompute from lat/lng.
36618 ranu 2376
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
2377
			if (visits != null) {
2378
				for (int i = 0; i < visits.size(); i++) {
2379
					Map<String, Object> visit = visits.get(i);
36644 ranu 2380
					BeatRoute route = new BeatRoute();
2381
					route.setBeatId(beat.getId());
2382
					route.setFofoId(((Number) visit.get("id")).intValue());
36811 ranu 2383
					route.setVisitType("office".equals(visit.get("type"))
2384
							? com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE
2385
							: com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
36644 ranu 2386
					route.setSequenceOrder(i);
2387
					route.setDayNumber(dayNumber);
2388
					route.setActive(true);
36711 ranu 2389
                    if (visit.get("distanceFromPrevKm") != null)
2390
                        route.setDistanceFromPrevKm(((Number) visit.get("distanceFromPrevKm")).doubleValue());
2391
                    if (visit.get("timeFromPrevMins") != null)
2392
                        route.setTimeFromPrevMins(((Number) visit.get("timeFromPrevMins")).intValue());
36644 ranu 2393
					beatRouteRepository.persist(route);
36618 ranu 2394
				}
2395
			}
2396
		}
2397
 
2398
		Map<String, Object> response = new HashMap<>();
2399
		response.put("status", true);
36644 ranu 2400
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 2401
		response.put("message", "Beat plan submitted successfully");
2402
		return responseSender.ok(response);
2403
	}
2404
 
36632 ranu 2405
	// ============ BULK UPLOAD ============
2406
 
2407
	@GetMapping(value = "/beatPlan/bulkUpload")
2408
	public String bulkUploadPage(HttpServletRequest request, Model model) {
2409
		return "beat-plan-bulk";
2410
	}
2411
 
36681 ranu 2412
	// Adds a new base location for the user. Caller can request this new row
2413
	// becomes the default. If the user has NO base locations yet, the new row
2414
	// is auto-defaulted (so every user always has exactly one default).
36668 ranu 2415
	@PostMapping(value = "/beatPlan/updateBaseLocation")
2416
	public ResponseEntity<?> updateBaseLocation(
2417
			HttpServletRequest request,
2418
			@RequestParam int authUserId,
2419
			@RequestParam String locationName,
2420
			@RequestParam String latitude,
2421
			@RequestParam String longitude,
36681 ranu 2422
			@RequestParam(required = false) String address,
2423
			@RequestParam(required = false, defaultValue = "false") boolean isDefault) throws Exception {
36668 ranu 2424
 
2425
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
2426
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
2427
		if (me == null) return responseSender.unauthorized("Not logged in");
36681 ranu 2428
		if (!isBaseLocationManager(me)) {
2429
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");
2430
		}
36668 ranu 2431
 
36681 ranu 2432
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
2433
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
2434
		boolean makeDefault = isDefault || existing.isEmpty() || noExistingDefault;
36668 ranu 2435
 
36681 ranu 2436
		// If this new row becomes the default, clear any existing default.
2437
		if (makeDefault) {
2438
			for (AuthUserLocation e : existing) {
2439
				if (e.isDefault()) {
2440
					e.setDefault(false);
2441
					authUserLocationRepository.persist(e);
2442
				}
2443
			}
36668 ranu 2444
		}
2445
 
2446
		AuthUserLocation loc = new AuthUserLocation();
2447
		loc.setAuthUserId(authUserId);
2448
		loc.setLocationType("BASE");
2449
		loc.setLocationName(locationName);
2450
		loc.setLatitude(latitude);
2451
		loc.setLongitude(longitude);
2452
		loc.setAddress(address);
36681 ranu 2453
		loc.setDefault(makeDefault);
36668 ranu 2454
		loc.setCreatedTimestamp(LocalDateTime.now());
2455
		authUserLocationRepository.persist(loc);
2456
 
2457
		Map<String, Object> result = new HashMap<>();
2458
		result.put("status", true);
2459
		result.put("id", loc.getId());
36681 ranu 2460
		result.put("isDefault", loc.isDefault());
2461
		result.put("message", makeDefault ? "Base location added and set as default" : "Base location added");
36668 ranu 2462
		return responseSender.ok(result);
2463
	}
2464
 
36814 ranu 2465
	// Read-only list of company offices — gives BMs a quick lookup of the codes
2466
	// they'll need to drop into the bulk-upload sheet for OFFICE stops.
2467
	// Two URL mappings: /companyOffice/list (canonical) + /company-office-list
2468
	// (matches the menu's action_class so the sidebar link works without an extra
2469
	// auth.menu update).
2470
	@GetMapping(value = {"/companyOffice/list", "/company-office-list"})
2471
	public String companyOfficeList(Model model) {
2472
		List<com.spice.profitmandi.dao.entity.logistics.CompanyOffice> offices = companyOfficeRepository.selectAll();
2473
		// Active first, then sort by code so the bulk-upload reference is stable across page loads.
2474
		offices.sort((a, b) -> {
2475
			int aa = a.isActive() ? 0 : 1;
2476
			int bb = b.isActive() ? 0 : 1;
2477
			if (aa != bb) return Integer.compare(aa, bb);
2478
			String ac = a.getCode() != null ? a.getCode() : "";
2479
			String bc = b.getCode() != null ? b.getCode() : "";
2480
			return ac.compareToIgnoreCase(bc);
2481
		});
2482
		model.addAttribute("offices", offices);
2483
		return "company-office-list";
2484
	}
2485
 
36632 ranu 2486
	@GetMapping(value = "/beatPlan/downloadTemplate")
36668 ranu 2487
	public ResponseEntity<?> downloadTemplate() throws java.io.IOException {
2488
		org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
2489
		org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");
36632 ranu 2490
 
36668 ranu 2491
		String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};
2492
 
2493
		// Header style
2494
		org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();
2495
		org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();
2496
		headerFont.setBold(true);
2497
		headerStyle.setFont(headerFont);
2498
		headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));
2499
		headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
2500
 
2501
		org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);
2502
		for (int i = 0; i < cols.length; i++) {
2503
			org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);
2504
			c.setCellValue(cols[i]);
2505
			c.setCellStyle(headerStyle);
2506
		}
2507
 
2508
		// Example rows — one partner per row. Inheritable columns blank after first row of a beat.
2509
		Object[][] sample = {
2510
				{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},
2511
				{"", "", "", "1", "2", "RJBUN1449"},
2512
				{"", "", "", "1", "3", "RJDEG1443"},
2513
				{"", "", "", "2", "1", "RJALR1362"},
2514
				{"", "", "", "2", "2", "RJBTR1388"},
2515
				{"", "", "", "3", "1", "RJRSD1518"},
2516
				{"", "", "", "3", "2", "RJSML356"},
2517
				{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},
2518
				{"", "", "", "1", "2", "UPAGR102"},
2519
		};
2520
		for (int r = 0; r < sample.length; r++) {
2521
			org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);
2522
			for (int c = 0; c < cols.length; c++) {
2523
				row.createCell(c).setCellValue(sample[r][c].toString());
2524
			}
2525
		}
2526
		for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);
2527
 
2528
		java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
2529
		wb.write(out);
2530
		wb.close();
2531
 
36632 ranu 2532
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
36668 ranu 2533
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");
2534
		headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
2535
		return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
36632 ranu 2536
	}
2537
 
2538
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
2539
	public ResponseEntity<?> bulkUploadProcess(
2540
			HttpServletRequest request,
2541
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
2542
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
2543
 
2544
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
2545
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
2546
 
36668 ranu 2547
		// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date
2548
		// appear ONLY on the first row of a beat; subsequent rows inherit them.
2549
		org.apache.poi.ss.usermodel.Workbook workbook =
2550
				new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());
2551
		org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
36632 ranu 2552
 
36668 ranu 2553
		// Header → column index map
2554
		org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);
2555
		if (headerRow == null) {
2556
			workbook.close();
2557
			return responseSender.badRequest("Empty file");
2558
		}
2559
		Map<String, Integer> colIdx = new HashMap<>();
2560
		for (int i = 0; i < headerRow.getLastCellNum(); i++) {
2561
			String h = readCell(headerRow.getCell(i));
2562
			if (h != null) colIdx.put(h.trim().toLowerCase(), i);
2563
		}
2564
		for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {
2565
			if (!colIdx.containsKey(required)) {
2566
				workbook.close();
2567
				return responseSender.badRequest("Missing required column: " + required);
2568
			}
2569
		}
36632 ranu 2570
 
36668 ranu 2571
		// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_order
2572
		Map<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();
2573
		String currentKey = null;
2574
		String currentBeatName = null;
2575
		String currentAuthId = null;
2576
		String currentStartDate = null;
2577
 
2578
		for (int r = 1; r <= sheet.getLastRowNum(); r++) {
2579
			org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
2580
			if (row == null) continue;
2581
 
2582
			String beatName = readCell(row.getCell(colIdx.get("beat_name")));
2583
			String authId = readCell(row.getCell(colIdx.get("auth_user_id")));
2584
			String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;
2585
			String dayNumber = readCell(row.getCell(colIdx.get("day_number")));
2586
			String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;
2587
			String code = readCell(row.getCell(colIdx.get("partner_code")));
2588
 
2589
			if (beatName != null && !beatName.trim().isEmpty()) {
2590
				// Start of a new beat — capture inheritable fields
2591
				currentBeatName = beatName.trim().replaceAll("\\s+", " ");
2592
				currentAuthId = authId != null ? authId.trim() : null;
2593
				currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;
2594
				currentKey = currentBeatName + "|" + currentAuthId;
2595
			}
2596
			if (currentKey == null) continue; // partner row before any beat header — skip
2597
			if (code == null || code.trim().isEmpty()) continue;
2598
 
2599
			final String beatNameF = currentBeatName;
2600
			final String authIdF = currentAuthId;
2601
			final String startDateF = currentStartDate;
2602
			BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));
2603
 
2604
			int day;
2605
			try {
2606
				day = Integer.parseInt(dayNumber.trim());
2607
			} catch (Exception e) {
2608
				continue;
2609
			} // bad day → skip row
2610
 
2611
			int seq = -1;
2612
			if (seqOrder != null && !seqOrder.trim().isEmpty()) {
2613
				try {
2614
					seq = Integer.parseInt(seqOrder.trim());
2615
				} catch (Exception ignore) {
2616
				}
2617
			}
2618
			g.addPartner(day, seq, code.trim(), r + 1);
36632 ranu 2619
		}
36668 ranu 2620
		workbook.close();
36632 ranu 2621
 
36811 ranu 2622
		// Partner-code lookup (legacy).
36632 ranu 2623
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
2624
		Map<String, Integer> codeToId = new HashMap<>();
37060 vikas 2625
		Map<String, double[]> codeToLatLng = new HashMap<>();
2626
		for (FofoStore store : allStores) {
2627
			codeToId.put(store.getCode(), store.getId());
2628
			Double la = parseDoubleOrNull(store.getLatitude());
2629
			Double lo = parseDoubleOrNull(store.getLongitude());
2630
			if (la != null && lo != null) codeToLatLng.put(store.getCode(), new double[]{la, lo});
2631
		}
36632 ranu 2632
 
36811 ranu 2633
		// Office-code lookup — office stops share the same `partner_code` column in the bulk
2634
		// sheet; resolution dispatches by which catalogue the code belongs to. A code present
2635
		// in BOTH catalogues is treated as an error so the planner fixes the collision.
2636
		Map<String, Integer> officeCodeToId = new HashMap<>();
2637
		for (com.spice.profitmandi.dao.entity.logistics.CompanyOffice o : companyOfficeRepository.selectAll()) {
2638
			if (o.getCode() != null && !o.getCode().isEmpty()) officeCodeToId.put(o.getCode(), o.getId());
2639
		}
2640
 
36632 ranu 2641
		LocalDate holidayStart = LocalDate.now();
36644 ranu 2642
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 2643
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
2644
 
36785 ranu 2645
		// =====================================================================
2646
		// All-or-nothing import: validate every group first; only persist if
2647
		// the entire file passes. A single bad row blocks the whole upload
2648
		// so the user can fix and re-upload without partial creations.
2649
		// =====================================================================
2650
 
36632 ranu 2651
		List<String> errorMessages = new ArrayList<>();
36785 ranu 2652
		List<ValidatedBulkBeat> ready = new ArrayList<>();
36632 ranu 2653
 
36785 ranu 2654
		// ----- Phase 1: validate every group, collect ALL errors -----
36668 ranu 2655
		for (BulkBeatGroup g : beatGroups.values()) {
36785 ranu 2656
			String beatName = g.beatName;
2657
 
2658
			int authUserId;
36632 ranu 2659
			try {
36785 ranu 2660
				authUserId = Integer.parseInt(g.authUserId);
2661
			} catch (Exception e) {
2662
				errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'.");
2663
				continue;
2664
			}
36632 ranu 2665
 
36785 ranu 2666
			LocalDate startDate;
2667
			try {
2668
				startDate = (g.startDate == null || g.startDate.isEmpty())
2669
						? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
2670
			} catch (Exception e) {
2671
				errorMessages.add("Beat '" + beatName + "': invalid start_date '" + g.startDate + "'.");
2672
				continue;
2673
			}
2674
			if (startDate != null && startDate.isBefore(LocalDate.now())) {
2675
				errorMessages.add("Beat '" + beatName + "': start_date in past.");
2676
				continue;
2677
			}
36632 ranu 2678
 
36785 ranu 2679
			List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
2680
			Collections.sort(sortedDays);
36668 ranu 2681
 
36785 ranu 2682
			List<LocalDate> scheduleDates = new ArrayList<>();
2683
			if (startDate != null) {
2684
				LocalDate d = startDate;
2685
				while (scheduleDates.size() < sortedDays.size()) {
2686
					if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
36632 ranu 2687
						d = d.plusDays(1);
36785 ranu 2688
						continue;
36632 ranu 2689
					}
36785 ranu 2690
					scheduleDates.add(d);
2691
					d = d.plusDays(1);
36632 ranu 2692
				}
36785 ranu 2693
			}
36632 ranu 2694
 
36785 ranu 2695
			// Duplicate beat-name check (ACTIVE only; soft-deleted names are reusable).
2696
			boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
2697
					.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
2698
			if (isDuplicate) {
2699
				errorMessages.add("Beat '" + beatName + "': already exists for user " + authUserId + ".");
2700
				continue;
2701
			}
2702
 
2703
			// One-beat-per-day guard (against existing beats).
2704
			Map<String, Object> bulkConflict = findScheduleConflict(authUserId, scheduleDates, 0);
2705
			if (bulkConflict != null) {
2706
				errorMessages.add("Beat '" + beatName + "': " + scheduleConflictMessage(bulkConflict));
2707
				continue;
2708
			}
2709
 
36811 ranu 2710
			// Validate codes upfront so we don't half-persist. A code may belong to
2711
			// fofo_store (PARTNER) or company_office (OFFICE) but not both.
36785 ranu 2712
			List<String> badCodes = new ArrayList<>();
36811 ranu 2713
			List<String> ambiguousCodes = new ArrayList<>();
36785 ranu 2714
			for (List<BulkPartner> ps : g.dayToPartners.values()) {
2715
				for (BulkPartner p : ps) {
36811 ranu 2716
					boolean inPartner = codeToId.containsKey(p.code);
2717
					boolean inOffice = officeCodeToId.containsKey(p.code);
2718
					if (inPartner && inOffice) {
2719
						ambiguousCodes.add(p.code + " (row " + p.rowNum + ")");
2720
					} else if (!inPartner && !inOffice) {
36785 ranu 2721
						badCodes.add(p.code + " (row " + p.rowNum + ")");
2722
					}
36644 ranu 2723
				}
36785 ranu 2724
			}
2725
			if (!badCodes.isEmpty()) {
36811 ranu 2726
				errorMessages.add("Beat '" + beatName + "': unknown code(s) — " + String.join(", ", badCodes) + ".");
36785 ranu 2727
				continue;
2728
			}
36811 ranu 2729
			if (!ambiguousCodes.isEmpty()) {
2730
				errorMessages.add("Beat '" + beatName + "': code(s) exist in both partner and office catalogues — " + String.join(", ", ambiguousCodes) + ".");
2731
				continue;
2732
			}
36632 ranu 2733
 
36785 ranu 2734
			ready.add(new ValidatedBulkBeat(g, authUserId, sortedDays, scheduleDates));
2735
		}
36632 ranu 2736
 
36785 ranu 2737
		// Intra-file conflict: two beats in the same upload requesting the same
2738
		// user + date. Caught here so the user fixes the file before re-uploading.
2739
		Set<String> seenUserDates = new HashSet<>();
2740
		for (ValidatedBulkBeat v : ready) {
2741
			for (LocalDate sd : v.scheduleDates) {
2742
				String key = v.authUserId + "|" + sd;
2743
				if (!seenUserDates.add(key)) {
2744
					errorMessages.add("Beat '" + v.g.beatName + "': date " + sd + " is also claimed by another beat in this file for the same user.");
2745
					break;
2746
				}
2747
			}
2748
		}
36632 ranu 2749
 
36785 ranu 2750
		// All-or-nothing: any error → return without persisting anything.
2751
		if (!errorMessages.isEmpty()) {
2752
			Map<String, Object> response = new HashMap<>();
2753
			response.put("status", false);
2754
			response.put("beatsCreated", 0);
2755
			response.put("errors", errorMessages.size());
2756
			response.put("errorMessages", errorMessages);
2757
			response.put("message", "No beats created. Fix the issues below and re-upload the file.");
2758
			return responseSender.ok(response);
2759
		}
36632 ranu 2760
 
36785 ranu 2761
		// ----- Phase 2: persist (only reached when every row was clean) -----
2762
		int beatsCreated = 0;
2763
		for (ValidatedBulkBeat v : ready) {
2764
			BulkBeatGroup g = v.g;
2765
			String beatName = g.beatName;
2766
			int authUserId = v.authUserId;
2767
			List<Integer> sortedDays = v.sortedDays;
2768
			List<LocalDate> scheduleDates = v.scheduleDates;
36668 ranu 2769
 
36785 ranu 2770
			String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
2771
			AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36632 ranu 2772
 
36785 ranu 2773
			Beat beat = new Beat();
2774
			beat.setName(beatName);
2775
			beat.setAuthUserId(authUserId);
2776
			beat.setBeatColor(beatColor);
2777
			beat.setTotalDays(sortedDays.size());
2778
			beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
2779
			beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
2780
			beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
2781
			beat.setActive(true);
2782
			beat.setCreatedBy(currentUser.getId());
2783
			beat.setCreatedTimestamp(LocalDateTime.now());
2784
			beatRepository.persist(beat);
36668 ranu 2785
 
36785 ranu 2786
			LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
2787
 
2788
			for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
2789
				int dayNumber = sortedDays.get(dayIdx);
2790
				LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
2791
 
2792
				BeatSchedule schedule = new BeatSchedule();
2793
				schedule.setBeatId(beat.getId());
2794
				schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
2795
				schedule.setEndDate(bulkEndDate);
2796
				schedule.setDayNumber(dayNumber);
2797
				schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
2798
				schedule.setCreatedTimestamp(LocalDateTime.now());
2799
				beatScheduleRepository.persist(schedule);
2800
 
2801
				List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
37060 vikas 2802
				// Auto-order the day into a nearest-neighbor route from the beat's
2803
				// start location, matching the interactive planner. The sheet's
2804
				// sequence_order column is intentionally ignored.
2805
				sortPartnersByNearestNeighbor(partners,
2806
						parseDoubleOrNull(beat.getStartLatitude()),
2807
						parseDoubleOrNull(beat.getStartLongitude()),
2808
						codeToLatLng);
36785 ranu 2809
 
2810
				int autoSeq = 0;
2811
				for (BulkPartner p : partners) {
36811 ranu 2812
					Integer partnerId = codeToId.get(p.code);
2813
					Integer officeId = officeCodeToId.get(p.code);
36785 ranu 2814
					// Codes were validated in Phase 1, this is just a safety net.
36811 ranu 2815
					if (partnerId == null && officeId == null) continue;
36785 ranu 2816
					BeatRoute route = new BeatRoute();
2817
					route.setBeatId(beat.getId());
36811 ranu 2818
					if (partnerId != null) {
2819
						route.setFofoId(partnerId);
2820
						route.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
2821
					} else {
2822
						route.setFofoId(officeId);
2823
						route.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE);
2824
					}
37060 vikas 2825
					// Sequence follows the nearest-neighbor ordering above, not the sheet.
2826
					route.setSequenceOrder(autoSeq);
36785 ranu 2827
					route.setDayNumber(dayNumber);
2828
					route.setActive(true);
2829
					beatRouteRepository.persist(route);
2830
					autoSeq++;
36632 ranu 2831
				}
2832
			}
36785 ranu 2833
			beatsCreated++;
36632 ranu 2834
		}
2835
 
2836
		Map<String, Object> response = new HashMap<>();
2837
		response.put("status", true);
2838
		response.put("beatsCreated", beatsCreated);
36785 ranu 2839
		response.put("errors", 0);
36632 ranu 2840
		response.put("errorMessages", errorMessages);
36785 ranu 2841
		response.put("message", beatsCreated + " beat(s) created.");
36632 ranu 2842
		return responseSender.ok(response);
2843
	}
2844
 
36785 ranu 2845
	// Move a beat from one date to another — used by calendar drag-and-drop.
2846
	// Behaviour: if the target date already has ANOTHER beat scheduled (for the
2847
	// same sales user), the two schedules swap — the other beat slides onto
2848
	// the source date. If the target date is empty, the source date becomes empty.
2849
	@PostMapping(value = "/beatPlan/moveScheduleDate")
2850
	public ResponseEntity<?> moveScheduleDate(
2851
			@RequestParam String planGroupId,
2852
			@RequestParam String fromDate,
2853
			@RequestParam String toDate) {
2854
		int beatId = Integer.parseInt(planGroupId);
2855
		LocalDate from = LocalDate.parse(fromDate);
2856
		LocalDate to = LocalDate.parse(toDate);
2857
 
2858
		if (from.equals(to)) {
2859
			Map<String, Object> ok = new HashMap<>();
2860
			ok.put("status", true);
2861
			ok.put("message", "Same date — no change");
2862
			return responseSender.ok(ok);
2863
		}
2864
 
2865
		// Today is the live/running slot — a beat already running today can't be
2866
		// bumped, and a future beat can't be moved onto today.
2867
		LocalDate today = LocalDate.now();
2868
		if (to.equals(today) || from.equals(today) || from.isBefore(today) || to.isBefore(today)) {
2869
			return responseSender.badRequest("Cannot move to or from today / a past date — today's beat is live. Use future dates only.");
2870
		}
2871
 
2872
		Beat beat = beatRepository.selectById(beatId);
2873
		if (beat == null) return responseSender.badRequest("Beat not found");
2874
 
2875
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
2876
 
2877
		// Reject if THIS beat already has a different schedule row on the target date
2878
		// (it would create two schedule rows of the same beat on one day).
2879
		boolean selfConflict = schedules.stream()
2880
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
2881
		if (selfConflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
2882
 
2883
		BeatSchedule match = schedules.stream()
2884
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
2885
				.findFirst().orElse(null);
2886
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
2887
 
2888
		// Look for ANY OTHER beat (same sales user) whose schedule sits on the target
2889
		// date — if found we'll swap it onto the source date.
2890
		BeatSchedule otherSchedule = null;
2891
		List<BeatSchedule> otherSchedules = null;
2892
		Beat otherBeat = null;
2893
		for (Beat ub : beatRepository.selectActiveByAuthUserId(beat.getAuthUserId())) {
2894
			if (ub.getId() == beatId) continue;
2895
			List<BeatSchedule> ubSchedules = beatScheduleRepository.selectByBeatId(ub.getId());
2896
			BeatSchedule hit = ubSchedules.stream()
2897
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(to))
2898
					.findFirst().orElse(null);
2899
			if (hit != null) {
2900
				otherSchedule = hit;
2901
				otherSchedules = ubSchedules;
2902
				otherBeat = ub;
2903
				break;
2904
			}
2905
		}
2906
 
36840 ranu 2907
		// Multi-day beats can't be drag-swapped — moving one of their day_numbers
2908
		// in isolation would leave the other days behind and break the
2909
		// Day 1 → Day 2 → … chronological contiguity of the plan. Block both
2910
		// sides of the swap, point the BM at the Reschedule flow which moves
2911
		// every day together.
2912
		if (beat.getTotalDays() > 1) {
2913
			return responseSender.badRequest(
2914
					"\"" + (beat.getName() != null ? beat.getName() : "Beat #" + beat.getId())
2915
							+ "\" is a multi-day beat (" + beat.getTotalDays()
2916
							+ " days). Drag-swap is only allowed for single-day beats. Use Reschedule to move the whole plan together.");
2917
		}
2918
		if (otherBeat != null && otherBeat.getTotalDays() > 1) {
2919
			return responseSender.badRequest(
2920
					"Target date already has \"" + (otherBeat.getName() != null ? otherBeat.getName() : "Beat #" + otherBeat.getId())
2921
							+ "\", a multi-day beat (" + otherBeat.getTotalDays()
2922
							+ " days). Drag-swap is only allowed when both beats are single-day. Use Reschedule on one of them first.");
2923
		}
2924
 
2925
		// Move the dragged beat onto the target date. Per-row invariant:
2926
		// each schedule row's end_date == its own start_date (single-day shape).
2927
		// We touch ONLY the two rows being swapped — other schedule rows for the
2928
		// same beat (e.g. older dates) are left exactly as they were.
36785 ranu 2929
		match.setStartDate(to);
36840 ranu 2930
		match.setEndDate(to);
36785 ranu 2931
		if (otherSchedule != null) {
2932
			otherSchedule.setStartDate(from);
36840 ranu 2933
			otherSchedule.setEndDate(from);
36785 ranu 2934
		}
2935
 
2936
		Map<String, Object> response = new HashMap<>();
2937
		response.put("status", true);
2938
		response.put("message", otherBeat != null
2939
				? "Swapped with \"" + (otherBeat.getName() != null ? otherBeat.getName() : "beat") + "\" on " + toDate
2940
				: "Moved from " + fromDate + " to " + toDate);
2941
		response.put("swapped", otherBeat != null);
2942
		return responseSender.ok(response);
2943
	}
2944
 
36668 ranu 2945
	private static class BulkBeatGroup {
2946
		final String beatName;
2947
		final String authUserId;
2948
		final String startDate;
2949
		final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();
2950
 
2951
		BulkBeatGroup(String beatName, String authUserId, String startDate) {
2952
			this.beatName = beatName;
2953
			this.authUserId = authUserId;
2954
			this.startDate = startDate;
2955
		}
2956
 
2957
		void addPartner(int day, int seq, String code, int rowNum) {
2958
			dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));
2959
		}
2960
	}
2961
 
2962
	private static class BulkPartner {
2963
		final int seq;
2964
		final String code;
2965
		final int rowNum;
2966
 
2967
		BulkPartner(int seq, String code, int rowNum) {
2968
			this.seq = seq;
2969
			this.code = code;
2970
			this.rowNum = rowNum;
2971
		}
2972
	}
2973
 
36644 ranu 2974
	// ============ CALENDAR ============
36618 ranu 2975
 
2976
	@PostMapping(value = "/beatPlan/delete")
2977
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 2978
		int beatId = Integer.parseInt(planGroupId);
36698 ranu 2979
		// Hard delete — wipe all child rows first, then the beat itself.
2980
		// The name slot is freed naturally because the row is gone.
36644 ranu 2981
		beatRouteRepository.deleteByBeatId(beatId);
2982
		beatScheduleRepository.deleteByBeatId(beatId);
36698 ranu 2983
		leadRouteRepository.deleteByBeatId(beatId);
36644 ranu 2984
		Beat beat = beatRepository.selectById(beatId);
2985
		if (beat != null) {
36698 ranu 2986
			beatRepository.delete(beat);
36644 ranu 2987
		}
36618 ranu 2988
 
2989
		Map<String, Object> response = new HashMap<>();
2990
		response.put("status", true);
2991
		response.put("message", "Beat deleted");
2992
		return responseSender.ok(response);
2993
	}
2994
 
36670 ranu 2995
	// Unschedule the beat from ONE specific date — does NOT delete the beat.
2996
	// The beat (and its route template) stays; only the matching beat_schedule
2997
	// row is removed. If no real-date schedules remain, a placeholder
2998
	// (9999-12-31) row is added so the beat still shows up as "unscheduled".
2999
	@PostMapping(value = "/beatPlan/unscheduleDate")
3000
	public ResponseEntity<?> unscheduleDate(
3001
			@RequestParam String planGroupId,
3002
			@RequestParam String date) {
3003
		int beatId = Integer.parseInt(planGroupId);
3004
		LocalDate target = LocalDate.parse(date);
3005
 
3006
		Beat beat = beatRepository.selectById(beatId);
3007
		if (beat == null) return responseSender.badRequest("Beat not found");
3008
 
3009
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
3010
		int removed = 0;
3011
		for (BeatSchedule s : schedules) {
3012
			if (s.getStartDate() != null && s.getStartDate().equals(target)) {
3013
				beatScheduleRepository.delete(s);
3014
				removed++;
3015
			}
3016
		}
3017
		if (removed == 0) return responseSender.badRequest("No schedule found for that date");
3018
 
3019
		// If no real-date schedules left, drop in a placeholder so the beat
3020
		// remains visible in the unscheduled bucket.
3021
		boolean hasReal = schedules.stream()
3022
				.filter(s -> !s.getStartDate().equals(target))
3023
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);
3024
		if (!hasReal) {
3025
			boolean hasPlaceholder = schedules.stream()
3026
					.filter(s -> !s.getStartDate().equals(target))
3027
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);
3028
			if (!hasPlaceholder) {
3029
				BeatSchedule ph = new BeatSchedule();
3030
				ph.setBeatId(beatId);
3031
				ph.setStartDate(LocalDate.of(9999, 12, 31));
3032
				ph.setDayNumber(1);
3033
				ph.setEndAction("HOME");
3034
				ph.setCreatedTimestamp(LocalDateTime.now());
3035
				beatScheduleRepository.persist(ph);
3036
			}
3037
		}
3038
 
3039
		Map<String, Object> response = new HashMap<>();
3040
		response.put("status", true);
3041
		response.put("message", "Unscheduled from " + date);
3042
		return responseSender.ok(response);
3043
	}
3044
 
36785 ranu 3045
	/**
3046
	 * Per-user one-beat-per-day guard. Walks every active beat the sales user
3047
	 * already has and looks for a schedule row on any of the candidate dates.
3048
	 * Returns null if the candidate dates are clear, otherwise a {date,beatName}
3049
	 * map describing the first collision so the caller can surface a clean error.
3050
	 * Pass `excludeBeatId` so callers that are re-scheduling an existing beat
3051
	 * don't trip on their own pre-existing schedule rows; pass 0 for new beats.
3052
	 */
3053
	private Map<String, Object> findScheduleConflict(int authUserId, java.util.Collection<LocalDate> candidates, int excludeBeatId) {
3054
		if (candidates == null || candidates.isEmpty()) return null;
3055
		Set<LocalDate> ds = new HashSet<>();
3056
		for (LocalDate d : candidates) {
3057
			if (d != null && d.getYear() != 9999) ds.add(d);
3058
		}
3059
		if (ds.isEmpty()) return null;
3060
		for (Beat ub : beatRepository.selectActiveByAuthUserId(authUserId)) {
3061
			if (ub.getId() == excludeBeatId) continue;
3062
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(ub.getId())) {
3063
				if (s.getStartDate() != null && ds.contains(s.getStartDate())) {
3064
					Map<String, Object> conflict = new HashMap<>();
3065
					conflict.put("date", s.getStartDate().toString());
3066
					conflict.put("beatName", ub.getName() != null ? ub.getName() : "Beat #" + ub.getId());
3067
					return conflict;
3068
				}
3069
			}
3070
		}
3071
		return null;
3072
	}
3073
 
3074
	private String scheduleConflictMessage(Map<String, Object> conflict) {
3075
		return "Cannot schedule on " + conflict.get("date")
3076
				+ " — \"" + conflict.get("beatName") + "\" is already scheduled for this user on that day.";
3077
	}
3078
 
3079
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
3080
	public ResponseEntity<?> scheduleOnCalendar(
3081
			HttpServletRequest request,
36670 ranu 3082
			@RequestParam String planGroupId,
36785 ranu 3083
			@RequestParam String dates,
3084
			@RequestParam(required = false) String beatName,
3085
			@RequestParam(required = false) String beatColor) throws Exception {
3086
 
36670 ranu 3087
		int beatId = Integer.parseInt(planGroupId);
36785 ranu 3088
		Gson gson = new Gson();
3089
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
3090
		}.getType());
36670 ranu 3091
 
3092
		Beat beat = beatRepository.selectById(beatId);
3093
		if (beat == null) return responseSender.badRequest("Beat not found");
3094
 
36785 ranu 3095
		if (beatName != null) beat.setName(beatName);
3096
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36670 ranu 3097
 
36785 ranu 3098
		// One-beat-per-day guard: reject if any of the requested dates already
3099
		// has another beat scheduled for this user (excluding this beat itself).
3100
		List<LocalDate> requested = new ArrayList<>();
3101
		for (String s : dateList) {
3102
			try {
3103
				requested.add(LocalDate.parse(s));
3104
			} catch (Exception ignored) {
36670 ranu 3105
			}
3106
		}
36962 vikas 3107
		// Same-day scheduling is restricted to L4+ operators.
3108
		if (requested.contains(LocalDate.now()) && !canScheduleToday(currentUser(request))) {
3109
			return responseSender.badRequest("Scheduling a beat for today is restricted to L4 and above.");
3110
		}
36785 ranu 3111
		Map<String, Object> conflict = findScheduleConflict(beat.getAuthUserId(), requested, beatId);
3112
		if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
36670 ranu 3113
 
36785 ranu 3114
		// Delete old schedules and create new
3115
		beatScheduleRepository.deleteByBeatId(beatId);
3116
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
3117
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
3118
            int dayNumber = i + 1;
3119
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
3120
			BeatSchedule schedule = new BeatSchedule();
3121
			schedule.setBeatId(beatId);
3122
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
3123
			schedule.setEndDate(schEndDate);
3124
            schedule.setDayNumber(dayNumber);
3125
            schedule.setEndAction(endAction);
3126
            // Fill total_distance_km / total_time_mins from beat_route so the new
3127
            // schedule row isn't NULL (this was the bug — these were left unset).
3128
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
3129
            schedule.setTotalDistanceKm(totals[0]);
3130
            schedule.setTotalTimeMins((int) totals[1]);
3131
			schedule.setCreatedTimestamp(LocalDateTime.now());
3132
			beatScheduleRepository.persist(schedule);
3133
		}
3134
 
36670 ranu 3135
		Map<String, Object> response = new HashMap<>();
3136
		response.put("status", true);
36785 ranu 3137
		response.put("message", "Beat scheduled successfully");
36670 ranu 3138
		return responseSender.ok(response);
3139
	}
3140
 
36618 ranu 3141
	@GetMapping(value = "/beatPlan/calendar")
3142
	public ResponseEntity<?> getCalendar(
3143
			@RequestParam int authUserId,
3144
			@RequestParam String month) {
3145
 
3146
		YearMonth ym = YearMonth.parse(month);
3147
		LocalDate startDate = ym.atDay(1);
3148
		LocalDate endDate = ym.atEndOfMonth();
3149
 
3150
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
3151
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
3152
			Map<String, String> m = new HashMap<>();
3153
			m.put("date", h.getDate().toString());
3154
			m.put("occasion", h.getOccasion());
3155
			return m;
3156
		}).collect(Collectors.toList());
3157
 
36644 ranu 3158
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 3159
		LocalDate today = LocalDate.now();
3160
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
3161
 
36644 ranu 3162
		for (Beat beat : allBeats) {
3163
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
3164
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 3165
 
36644 ranu 3166
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
3167
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
3168
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
3169
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 3170
 
3171
			String status;
3172
			if (allNullDates) status = "unscheduled";
3173
			else if (hasToday) status = "running";
3174
			else if (allPast) status = "completed";
36644 ranu 3175
			else status = "scheduled";
36618 ranu 3176
 
36644 ranu 3177
			Map<String, Object> beatInfo = new HashMap<>();
3178
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
3179
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
3180
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
3181
			beatInfo.put("status", status);
36728 vikas 3182
			beatInfo.put("totalDays", beat.getTotalDays());
36618 ranu 3183
 
3184
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 3185
			for (BeatSchedule s : schedules) {
36618 ranu 3186
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 3187
				dayInfo.put("dayNumber", s.getDayNumber());
3188
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
3189
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
3190
				dayInfo.put("totalKm", s.getTotalDistanceKm());
3191
				dayInfo.put("totalMins", s.getTotalTimeMins());
36711 ranu 3192
                // endAction tells the planner whether to draw the return-to-home line
3193
                // for this day (HOME) or end at the last stop (DAYBREAK).
3194
                dayInfo.put("endAction", s.getEndAction());
36644 ranu 3195
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
3196
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 3197
				dayInfoList.add(dayInfo);
3198
			}
36644 ranu 3199
			if (schedules.isEmpty()) {
3200
				// No schedule at all — show from routes
3201
				Map<Integer, Long> dayCounts = routes.stream()
3202
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
3203
				for (int d = 1; d <= beat.getTotalDays(); d++) {
3204
					Map<String, Object> dayInfo = new HashMap<>();
3205
					dayInfo.put("dayNumber", d);
3206
					dayInfo.put("planDate", null);
3207
					dayInfo.put("totalKm", null);
3208
					dayInfo.put("totalMins", null);
3209
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
3210
					dayInfoList.add(dayInfo);
3211
				}
3212
			}
3213
			beatInfo.put("days", dayInfoList);
3214
			scheduledBeats.add(beatInfo);
36618 ranu 3215
		}
3216
 
3217
		Set<String> blockedDates = new HashSet<>();
3218
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 3219
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 3220
		}
36644 ranu 3221
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 3222
 
3223
		Map<String, Object> response = new HashMap<>();
3224
		response.put("holidays", holidayList);
3225
		response.put("scheduledBeats", scheduledBeats);
3226
		response.put("blockedDates", blockedDates);
3227
		return responseSender.ok(response);
3228
	}
3229
 
36644 ranu 3230
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 3231
	@PostMapping(value = "/beatPlan/repeatBeat")
3232
	public ResponseEntity<?> repeatBeat(
3233
			HttpServletRequest request,
3234
			@RequestParam String sourcePlanGroupId,
3235
			@RequestParam int authUserId,
3236
			@RequestParam String dates) throws Exception {
3237
 
36644 ranu 3238
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 3239
		Gson gson = new Gson();
3240
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
3241
		}.getType());
3242
 
36644 ranu 3243
		Beat beat = beatRepository.selectById(beatId);
3244
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 3245
 
36785 ranu 3246
		// One-beat-per-day guard: reject if any of the new dates already has
3247
		// another beat scheduled for this user (excluding this beat itself).
3248
		List<LocalDate> repeatDates = new ArrayList<>();
3249
		for (String s : dateList) {
3250
			try {
3251
				repeatDates.add(LocalDate.parse(s));
3252
			} catch (Exception ignored) {
3253
			}
3254
		}
3255
		Map<String, Object> repeatConflict = findScheduleConflict(beat.getAuthUserId(), repeatDates, beatId);
3256
		if (repeatConflict != null) return responseSender.badRequest(scheduleConflictMessage(repeatConflict));
3257
 
36644 ranu 3258
		// Remove placeholder (unscheduled) schedule rows
3259
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
3260
		for (BeatSchedule s : existing) {
3261
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
3262
				beatScheduleRepository.delete(s);
3263
			}
36618 ranu 3264
		}
3265
 
36711 ranu 3266
        // Add new real-date schedule rows for the existing beat — fill totals
3267
        // from beat_route so total_distance_km / total_time_mins aren't NULL.
36644 ranu 3268
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
3269
		for (int i = 0; i < dateList.size(); i++) {
36711 ranu 3270
            int dayNumber = i + 1;
3271
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
36644 ranu 3272
			BeatSchedule schedule = new BeatSchedule();
3273
			schedule.setBeatId(beatId);
3274
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
3275
			schedule.setEndDate(repeatEndDate);
36711 ranu 3276
            schedule.setDayNumber(dayNumber);
3277
            schedule.setEndAction(endAction);
3278
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
3279
            schedule.setTotalDistanceKm(totals[0]);
3280
            schedule.setTotalTimeMins((int) totals[1]);
36644 ranu 3281
			schedule.setCreatedTimestamp(LocalDateTime.now());
3282
			beatScheduleRepository.persist(schedule);
36618 ranu 3283
		}
3284
 
3285
		Map<String, Object> response = new HashMap<>();
3286
		response.put("status", true);
36644 ranu 3287
		response.put("planGroupId", String.valueOf(beatId));
3288
		response.put("message", "Beat scheduled successfully");
36618 ranu 3289
		return responseSender.ok(response);
3290
	}
3291
 
36785 ranu 3292
	private static class ValidatedBulkBeat {
3293
		final BulkBeatGroup g;
3294
		final int authUserId;
3295
		final List<Integer> sortedDays;
3296
		final List<LocalDate> scheduleDates;
3297
 
3298
		ValidatedBulkBeat(BulkBeatGroup g, int authUserId, List<Integer> sortedDays, List<LocalDate> scheduleDates) {
3299
			this.g = g;
3300
			this.authUserId = authUserId;
3301
			this.sortedDays = sortedDays;
3302
			this.scheduleDates = scheduleDates;
3303
		}
3304
	}
3305
 
36618 ranu 3306
	@GetMapping(value = "/beatPlan/availableSlots")
3307
	public ResponseEntity<?> getAvailableSlots(
3308
			@RequestParam int authUserId,
3309
			@RequestParam String month,
3310
			@RequestParam int daysNeeded) {
3311
 
3312
		YearMonth ym = YearMonth.parse(month);
3313
		LocalDate startDate = ym.atDay(1);
3314
		LocalDate endDate = ym.atEndOfMonth();
3315
		LocalDate today = LocalDate.now();
3316
 
3317
		Set<LocalDate> blocked = new HashSet<>();
3318
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
3319
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 3320
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 3321
		}
3322
 
3323
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
3324
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
3325
 
36644 ranu 3326
		// Get all scheduled dates for this user
3327
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
3328
		for (Beat b : userBeats) {
3329
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
3330
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 3331
		}
3332
 
3333
		List<String> available = new ArrayList<>();
3334
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
3335
			 !d.isAfter(endDate) && available.size() < daysNeeded;
3336
			 d = d.plusDays(1)) {
36644 ranu 3337
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 3338
		}
3339
 
3340
		Map<String, Object> response = new HashMap<>();
3341
		response.put("suggestedDates", available);
3342
		response.put("totalAvailable", available.size());
3343
		return responseSender.ok(response);
3344
	}
3345
 
3346
	// --- Sorting helpers ---
3347
 
3348
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
3349
			List<Map<String, Object>> partners, double startLat, double startLng) {
3350
		List<Map<String, Object>> withCoords = new ArrayList<>();
3351
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
3352
		for (Map<String, Object> p : partners) {
36644 ranu 3353
			if (hasValidCoords(p)) withCoords.add(p);
3354
			else withoutCoords.add(p);
36618 ranu 3355
		}
3356
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 3357
		double currentLat = startLat, currentLng = startLng;
36618 ranu 3358
		while (!withCoords.isEmpty()) {
3359
			int nearestIdx = 0;
3360
			double nearestDist = Double.MAX_VALUE;
3361
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 3362
				double dist = haversine(currentLat, currentLng,
3363
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
3364
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 3365
				if (dist < nearestDist) {
3366
					nearestDist = dist;
3367
					nearestIdx = i;
3368
				}
3369
			}
3370
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
3371
			sorted.add(nearest);
3372
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
3373
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
3374
		}
3375
		sorted.addAll(withoutCoords);
3376
		return sorted;
3377
	}
3378
 
3379
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
3380
		List<Map<String, Object>> withCoords = new ArrayList<>();
3381
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
3382
		for (Map<String, Object> p : partners) {
36644 ranu 3383
			if (hasValidCoords(p)) withCoords.add(p);
3384
			else withoutCoords.add(p);
36618 ranu 3385
		}
3386
		List<Map<String, Object>> sorted = new ArrayList<>();
3387
		if (!withCoords.isEmpty()) {
3388
			sorted.add(withCoords.remove(0));
3389
			while (!withCoords.isEmpty()) {
3390
				Map<String, Object> last = sorted.get(sorted.size() - 1);
3391
				double lastLat = Double.parseDouble(last.get("latitude").toString());
3392
				double lastLng = Double.parseDouble(last.get("longitude").toString());
3393
				int nearestIdx = 0;
3394
				double nearestDist = Double.MAX_VALUE;
3395
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 3396
					double dist = haversine(lastLat, lastLng,
3397
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
3398
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 3399
					if (dist < nearestDist) {
3400
						nearestDist = dist;
3401
						nearestIdx = i;
3402
					}
3403
				}
3404
				sorted.add(withCoords.remove(nearestIdx));
3405
			}
3406
		}
3407
		sorted.addAll(withoutCoords);
3408
		return sorted;
3409
	}
3410
 
3411
	private boolean hasValidCoords(Map<String, Object> p) {
3412
		Object lat = p.get("latitude");
3413
		Object lng = p.get("longitude");
36644 ranu 3414
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 3415
	}
3416
 
3417
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
3418
		double R = 6371;
3419
		double dLat = Math.toRadians(lat2 - lat1);
3420
		double dLng = Math.toRadians(lng2 - lng1);
3421
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
3422
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
3423
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
3424
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
3425
		return R * c;
3426
	}
3427
}