Subversion Repositories SmartDukaan

Rev

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