Subversion Repositories SmartDukaan

Rev

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