Subversion Repositories SmartDukaan

Rev

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

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