Subversion Repositories SmartDukaan

Rev

Rev 37175 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

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