Subversion Repositories SmartDukaan

Rev

Rev 36651 | Rev 36660 | 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;
7
import com.spice.profitmandi.common.web.util.ResponseSender;
8
import com.spice.profitmandi.dao.entity.auth.AuthUser;
9
import com.spice.profitmandi.dao.entity.fofo.FofoStore;
10
import com.spice.profitmandi.dao.entity.logistics.PublicHolidays;
36644 ranu 11
import com.spice.profitmandi.dao.entity.user.*;
36618 ranu 12
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
13
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
14
import com.spice.profitmandi.dao.repository.cs.CsService;
15
import com.spice.profitmandi.dao.repository.dtr.*;
16
import com.spice.profitmandi.dao.repository.logistics.PublicHolidaysRepository;
17
import com.spice.profitmandi.service.user.RetailerService;
18
import com.spice.profitmandi.web.model.LoginDetails;
19
import com.spice.profitmandi.web.util.CookiesProcessor;
20
import org.apache.logging.log4j.LogManager;
21
import org.apache.logging.log4j.Logger;
22
import org.springframework.beans.factory.annotation.Autowired;
23
import org.springframework.http.ResponseEntity;
24
import org.springframework.stereotype.Controller;
25
import org.springframework.transaction.annotation.Transactional;
26
import org.springframework.ui.Model;
27
import org.springframework.web.bind.annotation.GetMapping;
28
import org.springframework.web.bind.annotation.PostMapping;
29
import org.springframework.web.bind.annotation.RequestParam;
30
 
31
import javax.servlet.http.HttpServletRequest;
32
import java.lang.reflect.Type;
33
import java.time.DayOfWeek;
34
import java.time.LocalDate;
35
import java.time.LocalDateTime;
36
import java.time.YearMonth;
37
import java.time.format.DateTimeFormatter;
38
import java.util.*;
39
import java.util.stream.Collectors;
40
 
41
@Controller
42
@Transactional(rollbackFor = Throwable.class)
43
public class BeatPlanController {
44
	private static final Logger LOGGER = LogManager.getLogger(BeatPlanController.class);
45
	private static final String[] BEAT_COLORS = {
46
			"#3498DB", "#E74C3C", "#2ECC71", "#9B59B6", "#F39C12",
47
			"#1ABC9C", "#E67E22", "#34495E", "#16A085", "#C0392B"
48
	};
49
	@Autowired
50
	private CsService csService;
51
	@Autowired
52
	private AuthRepository authRepository;
53
	@Autowired
54
	private FofoStoreRepository fofoStoreRepository;
55
	@Autowired
56
	private RetailerService retailerService;
57
	@Autowired
36644 ranu 58
	private BeatRepository beatRepository;
36618 ranu 59
	@Autowired
36644 ranu 60
	private BeatRouteRepository beatRouteRepository;
36618 ranu 61
	@Autowired
36644 ranu 62
	private BeatScheduleRepository beatScheduleRepository;
63
	@Autowired
64
	private LeadRouteRepository leadRouteRepository;
65
	@Autowired
36650 ranu 66
	private com.spice.profitmandi.dao.service.BeatPlanQueryService beatPlanQueryService;
67
	@Autowired
36618 ranu 68
	private AuthUserLocationRepository authUserLocationRepository;
69
	@Autowired
70
	private LeadRepository leadRepository;
71
	@Autowired
72
	private PublicHolidaysRepository publicHolidaysRepository;
73
	@Autowired
74
	private com.spice.profitmandi.service.GeocodingService geocodingService;
75
	@Autowired
76
	private CookiesProcessor cookiesProcessor;
77
	@Autowired
78
	private ResponseSender responseSender;
79
 
80
	@GetMapping(value = "/beatPlan")
81
	public String beatPlan(HttpServletRequest request, Model model) {
82
		EscalationType[] escalationTypes = EscalationType.values();
83
		model.addAttribute("escalationTypes", escalationTypes);
84
		return "beat-plan";
85
	}
86
 
87
	@GetMapping(value = "/beatPlanWindow")
88
	public String beatPlanWindow(HttpServletRequest request, Model model) {
89
		EscalationType[] escalationTypes = EscalationType.values();
90
		model.addAttribute("escalationTypes", escalationTypes);
91
		return "beat-plan-window";
92
	}
93
 
36650 ranu 94
	@Autowired
95
	private com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository leadLiveLocationRepositoryAuto;
36651 ranu 96
	@Autowired
97
	private com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository leadActivityRepositoryAuto;
36650 ranu 98
 
36655 ranu 99
	private static Double parseDoubleOrNull(String s) {
100
		if (s == null || s.trim().isEmpty()) return null;
101
		try {
102
			return Double.parseDouble(s.trim());
103
		} catch (NumberFormatException e) {
104
			return null;
105
		}
106
	}
107
 
108
	private static double haversineKm(double lat1, double lng1, double lat2, double lng2) {
109
		double R = 6371;
110
		double dLat = Math.toRadians(lat2 - lat1);
111
		double dLng = Math.toRadians(lng2 - lng1);
112
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
113
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
114
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
115
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
116
		return R * c;
117
	}
118
 
119
	// ====================== BASE LOCATION MANAGEMENT ======================
120
	// Inline page that lets Sales L3+ pick a user and set their base (home)
121
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
122
	// go through the L3+-guarded endpoint below.
123
	@GetMapping(value = "/beatPlan/baseLocationPage")
124
	public String baseLocationPage(HttpServletRequest request, Model model) {
125
		EscalationType[] escalationTypes = EscalationType.values();
126
		model.addAttribute("escalationTypes", escalationTypes);
127
		return "beat-plan-base-location";
128
	}
129
 
130
	@PostMapping(value = "/beatPlan/updateBaseLocation")
131
	public ResponseEntity<?> updateBaseLocation(
132
			HttpServletRequest request,
133
			@RequestParam int authUserId,
134
			@RequestParam String locationName,
135
			@RequestParam String latitude,
136
			@RequestParam String longitude,
137
			@RequestParam(required = false) String address) throws Exception {
138
 
139
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
140
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
141
		if (me == null) return responseSender.badRequest("Not logged in");
142
 
143
		// Permission gate: only Sales L3 and above
144
		boolean isSalesL3Plus = csService.getAuthUserIds(
145
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
146
						Arrays.asList(EscalationType.L3, EscalationType.L4))
147
				.stream().anyMatch(u -> u.getId() == me.getId());
148
		if (!isSalesL3Plus) {
149
			return responseSender.badRequest("Only Sales L3 and above can update base location");
150
		}
151
 
152
		AuthUserLocation loc = new AuthUserLocation();
153
		loc.setAuthUserId(authUserId);
154
		loc.setLocationType("BASE");
155
		loc.setLocationName(locationName);
156
		loc.setLatitude(latitude);
157
		loc.setLongitude(longitude);
158
		loc.setAddress(address);
159
		loc.setCreatedTimestamp(LocalDateTime.now());
160
		authUserLocationRepository.persist(loc);
161
 
162
		Map<String, Object> result = new HashMap<>();
163
		result.put("status", true);
164
		result.put("id", loc.getId());
165
		result.put("message", "Base location updated");
166
		return responseSender.ok(result);
167
	}
168
 
169
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
170
	// For each active fofo_store, compare its stored lat/lng with the geocoded
171
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
172
	// OR the store has no lat/lng yet, update the store with the geocoded
173
	// coordinates. Otherwise keep the existing values.
174
	//
175
	// Usage:
176
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
177
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
178
	//   ?thresholdKm=3      -> use a different threshold
179
	//   ?limit=100          -> process only N stores (for staged runs)
180
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
181
	public ResponseEntity<?> migrateStoreLatLng(
182
			@RequestParam(required = false, defaultValue = "false") boolean apply,
183
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
184
			@RequestParam(required = false, defaultValue = "0") int limit) throws ProfitMandiBusinessException {
185
 
186
		List<FofoStore> stores = fofoStoreRepository.selectActiveStores();
187
		if (limit > 0 && stores.size() > limit) stores = stores.subList(0, limit);
188
 
189
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
190
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
191
 
192
		int total = stores.size();
193
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
194
		List<Map<String, Object>> changes = new ArrayList<>();
195
 
196
		for (FofoStore store : stores) {
197
			try {
198
				CustomRetailer retailer = retailerMap.get(store.getId());
199
				if (retailer == null || retailer.getAddress() == null) {
200
					noAddress++;
201
					continue;
202
				}
203
 
204
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
205
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
206
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
207
				if (geoAddr == null || geoAddr.isEmpty()) {
208
					noAddress++;
209
					continue;
210
				}
211
 
212
				double[] coords = geocodingService.geocodeAddress(geoAddr);
213
				if (coords == null) {
214
					noGeocode++;
215
					continue;
216
				}
217
 
218
				Double existingLat = parseDoubleOrNull(store.getLatitude());
219
				Double existingLng = parseDoubleOrNull(store.getLongitude());
220
 
221
				boolean shouldUpdate;
222
				double distKm = -1;
223
				String reason;
224
				if (existingLat == null || existingLng == null) {
225
					shouldUpdate = true;
226
					reason = "missing existing lat/lng";
227
				} else {
228
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
229
					shouldUpdate = distKm > thresholdKm;
230
					reason = shouldUpdate
231
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
232
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
233
				}
234
 
235
				if (shouldUpdate) {
236
					if (apply) {
237
						store.setLatitude(String.valueOf(coords[0]));
238
						store.setLongitude(String.valueOf(coords[1]));
239
						fofoStoreRepository.persist(store);
240
					}
241
					updated++;
242
					Map<String, Object> ch = new HashMap<>();
243
					ch.put("storeId", store.getId());
244
					ch.put("code", store.getCode());
245
					ch.put("oldLat", existingLat);
246
					ch.put("oldLng", existingLng);
247
					ch.put("newLat", coords[0]);
248
					ch.put("newLng", coords[1]);
249
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
250
					ch.put("reason", reason);
251
					changes.add(ch);
252
				} else {
253
					kept++;
254
				}
255
			} catch (Exception e) {
256
				errored++;
257
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
258
			}
259
		}
260
 
261
		Map<String, Object> result = new HashMap<>();
262
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
263
		result.put("thresholdKm", thresholdKm);
264
		result.put("total", total);
265
		result.put("updated", updated);
266
		result.put("kept", kept);
267
		result.put("noAddress", noAddress);
268
		result.put("noGeocode", noGeocode);
269
		result.put("errored", errored);
270
		// Limit changes preview to avoid huge responses
271
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
272
		result.put("changesShownCount", Math.min(changes.size(), 200));
273
		return responseSender.ok(result);
274
	}
275
 
36651 ranu 276
	// ====================== EDIT BEAT ======================
277
	// Update an existing beat — name + partner stops (routes).
278
	// Schedules are NOT touched here; manage them via calendar drag-drop.
279
	@PostMapping(value = "/beatPlan/updateBeat")
280
	public ResponseEntity<?> updateBeat(
281
			HttpServletRequest request,
282
			@RequestParam int beatId,
283
			@RequestParam String planData) throws Exception {
284
 
285
		Beat beat = beatRepository.selectById(beatId);
286
		if (beat == null) return responseSender.badRequest("Beat not found");
287
 
288
		Gson gson = new Gson();
289
		Type type = new TypeToken<Map<String, Object>>() {
290
		}.getType();
291
		Map<String, Object> plan = gson.fromJson(planData, type);
292
 
293
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
294
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
295
 
296
		// Update name if changed (and not colliding with another beat)
297
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
298
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
299
			// Make sure no other beat for this user already uses this name
300
			boolean collides = beatRepository.selectByAuthUserId(beat.getAuthUserId()).stream()
301
					.anyMatch(b -> b.getId() != beat.getId()
302
							&& b.getName() != null
303
							&& newName.equalsIgnoreCase(b.getName().trim()));
304
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
305
			beat.setName(newName);
306
		}
307
 
308
		// Update start location from first day if present
309
		Map<String, Object> firstDay = days.get(0);
310
		if (firstDay.get("startLocationName") != null)
311
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
312
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
313
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
314
		beat.setTotalDays(days.size());
315
 
316
		// Replace routes (partner stops). Schedules stay intact.
317
		beatRouteRepository.deleteByBeatId(beatId);
318
		// Collect lead IDs the user kept on the plan (for date-aware edit below)
319
		Set<Integer> keptLeadIds = new HashSet<>();
320
		for (int d = 0; d < days.size(); d++) {
321
			Map<String, Object> day = days.get(d);
322
			int dayNumber = d + 1;
323
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
324
			if (visits == null) continue;
325
			int partnerSeq = 0;
326
			for (int i = 0; i < visits.size(); i++) {
327
				Map<String, Object> v = visits.get(i);
328
				if ("lead".equals(v.get("type"))) {
329
					keptLeadIds.add(((Number) v.get("id")).intValue());
330
					continue; // leads live in lead_route, handled below
331
				}
332
				BeatRoute route = new BeatRoute();
333
				route.setBeatId(beatId);
334
				route.setFofoId(((Number) v.get("id")).intValue());
335
				route.setSequenceOrder(partnerSeq++);
336
				route.setDayNumber(dayNumber);
337
				route.setActive(true);
338
				beatRouteRepository.persist(route);
339
			}
340
		}
341
 
342
		// Date-aware lead handling: if planDate is provided, remove lead_route rows
343
		// for that (beat, date) that the user removed in the editor.
344
		String planDateStr = (String) plan.get("planDate");
345
		if (planDateStr != null && !planDateStr.isEmpty()) {
346
			try {
347
				LocalDate planDate = LocalDate.parse(planDateStr);
348
				List<LeadRoute> existing = leadRouteRepository.selectByBeatId(beatId);
349
				for (LeadRoute lr : existing) {
350
					if (!"APPROVED".equals(lr.getStatus())) continue;
351
					if (lr.getScheduleDate() == null || !lr.getScheduleDate().equals(planDate)) continue;
352
					if (keptLeadIds.contains(lr.getLeadId())) continue;
353
					// User removed this lead from the date — mark cancelled
354
					lr.setStatus("CANCELLED");
355
					lr.setUpdatedTimestamp(LocalDateTime.now());
356
					// activity log
357
					LeadActivity a = new LeadActivity();
358
					a.setLeadId(lr.getLeadId());
359
					a.setRemark("Removed from beat '" + beat.getName() + "' on " + planDate);
360
					a.setAuthId(0);
361
					a.setCreatedTimestamp(LocalDateTime.now());
362
					leadActivityRepositoryAuto.persist(a);
363
				}
364
			} catch (Exception e) {
365
				LOGGER.warn("Could not parse planDate '{}' — leads not adjusted", planDateStr);
366
			}
367
		}
368
 
369
		Map<String, Object> response = new HashMap<>();
370
		response.put("status", true);
371
		response.put("planGroupId", String.valueOf(beat.getId()));
372
		response.put("message", "Beat updated successfully");
373
		return responseSender.ok(response);
374
	}
375
 
36650 ranu 376
	// ====================== DAY VIEW ======================
377
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
378
	// scheduled in a date range across all users. Each row has a View button that
379
	// opens that user's calendar in a modal.
380
	@GetMapping(value = "/beatPlan/dayView")
381
	public String beatPlanDayView(HttpServletRequest request, Model model) {
382
		EscalationType[] escalationTypes = EscalationType.values();
383
		model.addAttribute("escalationTypes", escalationTypes);
384
		return "beat-plan-day-view";
385
	}
386
 
387
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
388
	@GetMapping(value = "/beatPlan/scheduledList")
389
	public ResponseEntity<?> scheduledList(
390
			@RequestParam(required = false) String startDate,
391
			@RequestParam(required = false) String endDate) {
392
 
393
		LocalDate start, end;
394
		try {
395
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
396
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
397
		} catch (Exception e) {
398
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
399
		}
400
 
401
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
402
				beatPlanQueryService.getAllScheduledBeats(start, end);
403
 
404
		// Resolve user names in bulk
405
		Set<Integer> userIds = beats.stream()
406
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
407
				.collect(java.util.stream.Collectors.toSet());
408
		Map<Integer, AuthUser> userMap = new HashMap<>();
409
		if (!userIds.isEmpty()) {
410
			authRepository.selectByIds(new ArrayList<>(userIds))
411
					.forEach(u -> userMap.put(u.getId(), u));
412
		}
413
 
414
		List<Map<String, Object>> rows = new ArrayList<>();
415
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
416
			AuthUser u = userMap.get(b.getAuthUserId());
417
			Map<String, Object> row = new HashMap<>();
418
			row.put("authUserId", b.getAuthUserId());
419
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
420
			row.put("scheduleDate", b.getScheduleDate().toString());
421
			row.put("dayNumber", b.getDayNumber());
422
			row.put("beatId", b.getBeatId());
423
			row.put("beatName", b.getBeatName());
424
			row.put("beatColor", b.getBeatColor());
425
			row.put("partnerCount", b.getPartnerStops().size());
426
			row.put("leadCount", b.getLeadStops().size());
427
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
428
			rows.add(row);
429
		}
430
 
431
		Map<String, Object> result = new HashMap<>();
432
		result.put("rows", rows);
433
		result.put("startDate", start.toString());
434
		result.put("endDate", end.toString());
435
		return responseSender.ok(result);
436
	}
437
 
438
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
439
	@GetMapping(value = "/beatPlan/dayViewData")
440
	public ResponseEntity<?> beatPlanDayViewData(
441
			@RequestParam int authUserId,
442
			@RequestParam String date) throws ProfitMandiBusinessException {
443
 
444
		LocalDate localDate;
445
		try {
446
			localDate = LocalDate.parse(date);
447
		} catch (Exception e) {
448
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
449
		}
450
 
451
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
452
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
453
 
454
		// Collect all partner & lead IDs to fetch metadata in bulk
455
		Set<Integer> partnerIds = new HashSet<>();
456
		Set<Integer> leadIds = new HashSet<>();
457
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
458
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
459
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
460
		}
461
 
462
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
463
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
464
				? new HashMap<>()
465
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
466
		Map<Integer, FofoStore> storeMap = new HashMap<>();
467
		if (!partnerIds.isEmpty()) {
468
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
469
					.forEach(fs -> storeMap.put(fs.getId(), fs));
470
		}
471
 
472
		// Leads: name + geo
473
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
474
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
475
		for (int leadId : leadIds) {
476
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
477
			if (l != null) leadMap.put(leadId, l);
478
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
479
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
480
			if (lg != null) leadGeoMap.put(leadId, lg);
481
		}
482
 
483
		// Enrich each stop
484
		List<Map<String, Object>> out = new ArrayList<>();
485
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
486
			Map<String, Object> beatJson = new HashMap<>();
487
			beatJson.put("beatId", b.getBeatId());
488
			beatJson.put("beatName", b.getBeatName());
489
			beatJson.put("beatColor", b.getBeatColor());
490
			beatJson.put("dayNumber", b.getDayNumber());
491
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
492
			beatJson.put("endAction", b.getEndAction());
493
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
494
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
495
			beatJson.put("startLocationName", b.getStartLocationName());
496
			beatJson.put("startLatitude", b.getStartLatitude());
497
			beatJson.put("startLongitude", b.getStartLongitude());
498
 
499
			List<Map<String, Object>> stops = new ArrayList<>();
500
			// Partners
501
			for (Map<String, Object> ps : b.getPartnerStops()) {
502
				int fofoId = (Integer) ps.get("fofoId");
503
				Map<String, Object> stop = new HashMap<>();
504
				stop.put("type", "partner");
505
				stop.put("id", fofoId);
506
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
507
				FofoStore fs = storeMap.get(fofoId);
508
				CustomRetailer cr = retailerMap.get(fofoId);
509
				stop.put("code", fs != null ? fs.getCode() : null);
510
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
511
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 512
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
513
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
514
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 515
					try {
36655 ranu 516
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
517
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
518
					} catch (NumberFormatException ignored) {
36650 ranu 519
					}
520
				}
36655 ranu 521
				if (cr != null && cr.getAddress() != null) {
522
					stop.put("address", cr.getAddress().getAddressString());
523
				}
36650 ranu 524
				stops.add(stop);
525
			}
526
			// Leads
527
			for (Map<String, Object> ls : b.getLeadStops()) {
528
				int leadId = (Integer) ls.get("leadId");
529
				Map<String, Object> stop = new HashMap<>();
530
				stop.put("type", "lead");
531
				stop.put("id", leadId);
532
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
533
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
534
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
535
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
536
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
537
				stop.put("city", l != null ? l.getCity() : null);
538
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
539
				if (lg != null) {
540
					stop.put("lat", lg.getLatitude());
541
					stop.put("lng", lg.getLongitude());
542
				}
543
				stops.add(stop);
544
			}
545
			beatJson.put("stops", stops);
546
			beatJson.put("partnerCount", b.getPartnerStops().size());
547
			beatJson.put("leadCount", b.getLeadStops().size());
548
			out.add(beatJson);
549
		}
550
 
551
		Map<String, Object> result = new HashMap<>();
552
		result.put("beats", out);
553
		return responseSender.ok(result);
554
	}
555
 
36618 ranu 556
	@GetMapping(value = "/beatPlan/getAuthUsers")
557
	public ResponseEntity<?> getAuthUsers(
558
			@RequestParam int categoryId,
559
			@RequestParam EscalationType escalationType) {
560
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
561
		List<Map<String, Object>> result = authUsers.stream()
562
				.filter(au -> au.getActive())
563
				.map(au -> {
564
					Map<String, Object> map = new HashMap<>();
565
					map.put("id", au.getId());
566
					map.put("name", au.getFirstName() + " " + au.getLastName());
567
					return map;
568
				})
569
				.collect(Collectors.toList());
570
		return responseSender.ok(result);
571
	}
572
 
36644 ranu 573
	// Returns visits for a beat.
574
	// - Partner stops (beat_route) belong to the beat template — always returned.
575
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
576
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 577
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 578
	public ResponseEntity<?> getBeatVisits(
579
			@RequestParam String planGroupId,
580
			@RequestParam(required = false) String planDate) {
581
 
582
		int beatId;
583
		try {
584
			beatId = Integer.parseInt(planGroupId);
585
		} catch (NumberFormatException e) {
586
			return responseSender.ok(new ArrayList<>());
587
		}
588
 
589
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
590
		List<Map<String, Object>> result = new ArrayList<>();
591
 
592
		// Partner stops — always (they belong to the beat template)
593
		for (BeatRoute r : routes) {
36632 ranu 594
			Map<String, Object> map = new HashMap<>();
36644 ranu 595
			map.put("fofoId", r.getFofoId());
596
			map.put("dayNumber", r.getDayNumber());
597
			map.put("sequenceOrder", r.getSequenceOrder());
598
			map.put("visitType", "partner");
599
			result.add(map);
600
		}
601
 
602
		// Lead stops — only for the requested run date
603
		if (planDate != null && !planDate.isEmpty()) {
604
			LocalDate date = LocalDate.parse(planDate);
605
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
606
			for (LeadRoute lr : leads) {
607
				if ("APPROVED".equals(lr.getStatus())
608
						&& lr.getScheduleDate() != null
609
						&& lr.getScheduleDate().equals(date)) {
610
					Map<String, Object> map = new HashMap<>();
611
					map.put("fofoId", lr.getLeadId());
612
					map.put("dayNumber", 1);
613
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
614
					map.put("visitType", "lead");
615
					result.add(map);
616
				}
617
			}
618
		}
619
 
620
		// Sort by dayNumber then sequenceOrder
621
		result.sort((a, b) -> {
622
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
623
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
624
		});
625
 
36632 ranu 626
		return responseSender.ok(result);
627
	}
628
 
36618 ranu 629
	@GetMapping(value = "/beatPlan/getBaseLocation")
630
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
631
		AuthUserLocation baseLoc = authUserLocationRepository.selectLatestByAuthUserIdAndType(authUserId, "BASE");
632
		if (baseLoc == null) {
633
			return responseSender.ok(new HashMap<>());
634
		}
635
		Map<String, Object> result = new HashMap<>();
636
		result.put("id", baseLoc.getId());
637
		result.put("locationName", baseLoc.getLocationName());
638
		result.put("latitude", baseLoc.getLatitude());
639
		result.put("longitude", baseLoc.getLongitude());
640
		result.put("address", baseLoc.getAddress());
641
		return responseSender.ok(result);
642
	}
643
 
644
	@PostMapping(value = "/beatPlan/saveBaseLocation")
645
	public ResponseEntity<?> saveBaseLocation(
646
			@RequestParam int authUserId,
647
			@RequestParam String locationName,
648
			@RequestParam String latitude,
649
			@RequestParam String longitude,
650
			@RequestParam(required = false) String address) {
651
		AuthUserLocation loc = new AuthUserLocation();
652
		loc.setAuthUserId(authUserId);
653
		loc.setLocationType("BASE");
654
		loc.setLocationName(locationName);
655
		loc.setLatitude(latitude);
656
		loc.setLongitude(longitude);
657
		loc.setAddress(address);
658
		loc.setCreatedTimestamp(LocalDateTime.now());
659
		authUserLocationRepository.persist(loc);
660
 
661
		Map<String, Object> result = new HashMap<>();
662
		result.put("status", true);
663
		result.put("id", loc.getId());
664
		return responseSender.ok(result);
665
	}
666
 
667
	@GetMapping(value = "/beatPlan/getPartners")
668
	public ResponseEntity<?> getPartners(
669
			@RequestParam int authUserId,
670
			@RequestParam int categoryId,
671
			@RequestParam(required = false) String startLat,
672
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
673
 
674
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdPartnerIdMapping();
675
		List<Integer> fofoIds = pp.get(authUserId);
676
 
36644 ranu 677
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 678
			Map<String, Object> empty = new HashMap<>();
679
			empty.put("partners", new ArrayList<>());
680
			return responseSender.ok(empty);
681
		}
682
 
683
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
684
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
685
 
686
		List<Map<String, Object>> partners = new ArrayList<>();
687
 
688
		for (FofoStore store : fofoStores) {
689
			if (!store.isActive() || store.isClosed()) continue;
690
			CustomRetailer retailer = retailerMap.get(store.getId());
691
 
692
			Map<String, Object> partnerData = new HashMap<>();
693
			partnerData.put("fofoId", store.getId());
694
			partnerData.put("code", store.getCode());
695
			partnerData.put("outletName", store.getOutletName());
696
			partnerData.put("type", "partner");
697
 
36655 ranu 698
			// Use FofoStore lat/lng directly (migrated from address geocode)
699
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
700
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
701
				partnerData.put("latitude", store.getLatitude());
702
				partnerData.put("longitude", store.getLongitude());
703
			}
704
 
36618 ranu 705
			if (retailer != null) {
706
				partnerData.put("businessName", retailer.getBusinessName());
707
				if (retailer.getAddress() != null) {
36644 ranu 708
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 709
				}
710
			}
711
			partners.add(partnerData);
712
		}
713
 
714
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
715
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
716
		} else {
717
			partners = sortByNearestNeighbor(partners);
718
		}
719
 
720
		Map<String, Object> response = new HashMap<>();
721
		response.put("partners", partners);
722
		return responseSender.ok(response);
723
	}
724
 
725
	@PostMapping(value = "/beatPlan/submitPlan")
726
	public ResponseEntity<?> submitPlan(
727
			HttpServletRequest request,
728
			@RequestParam int authUserId,
729
			@RequestParam String planData) throws Exception {
730
 
731
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
732
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
733
 
734
		Gson gson = new Gson();
735
		Type type = new TypeToken<Map<String, Object>>() {
736
		}.getType();
737
		Map<String, Object> plan = gson.fromJson(planData, type);
738
 
739
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
740
		List<String> dates = (List<String>) plan.get("dates");
741
 
36644 ranu 742
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 743
 
36644 ranu 744
		// Duplicate check — same name + same authUserId = duplicate
745
		List<Beat> existingBeats = beatRepository.selectByAuthUserId(authUserId);
746
		for (Beat existing : existingBeats) {
747
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
748
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 749
				Map<String, Object> response = new HashMap<>();
750
				response.put("status", true);
36644 ranu 751
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 752
				response.put("duplicate", true);
36644 ranu 753
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 754
				return responseSender.ok(response);
755
			}
756
		}
757
 
36644 ranu 758
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
759
		int totalDays = days.size();
36618 ranu 760
 
36644 ranu 761
		// Create Beat master
762
		Beat beat = new Beat();
763
		beat.setName(beatName);
764
		beat.setAuthUserId(authUserId);
765
		beat.setBeatColor(beatColor);
766
		beat.setTotalDays(totalDays);
767
		beat.setActive(true);
768
		beat.setCreatedBy(currentUser.getId());
769
		beat.setCreatedTimestamp(LocalDateTime.now());
770
 
771
		// Set start location from first day
772
		if (!days.isEmpty()) {
773
			Map<String, Object> firstDay = days.get(0);
774
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
775
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
776
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
777
		}
778
		beatRepository.persist(beat);
779
 
780
		// End date of the whole beat = last scheduled day's date
781
		LocalDate beatEndDate = null;
782
		if (dates != null) {
783
			for (int d = dates.size() - 1; d >= 0; d--) {
784
				if (dates.get(d) != null) {
785
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
786
					break;
787
				}
788
			}
789
		}
790
 
791
		// Create routes and schedules for each day
36618 ranu 792
		for (int d = 0; d < days.size(); d++) {
793
			Map<String, Object> day = days.get(d);
794
			int dayNumber = d + 1;
795
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 796
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 797
 
36644 ranu 798
			// Auto-determine end action: last day = HOME, others = DAYBREAK
799
			String endAction = (String) day.get("endAction");
800
			if (endAction == null || endAction.isEmpty()) {
801
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 802
			}
803
 
36644 ranu 804
			// Always create schedule (even if planDate is null — unscheduled beat)
805
			BeatSchedule schedule = new BeatSchedule();
806
			schedule.setBeatId(beat.getId());
807
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
808
			schedule.setEndDate(beatEndDate);
809
			schedule.setDayNumber(dayNumber);
810
			schedule.setEndAction(endAction);
811
			schedule.setStayLocationName((String) day.get("stayLocationName"));
812
			schedule.setStayLatitude((String) day.get("stayLatitude"));
813
			schedule.setStayLongitude((String) day.get("stayLongitude"));
814
			if (day.get("totalDistanceKm") != null)
815
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
816
			if (day.get("totalTimeMins") != null)
817
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
818
			schedule.setCreatedTimestamp(LocalDateTime.now());
819
			beatScheduleRepository.persist(schedule);
820
 
821
			// Routes (stops)
36618 ranu 822
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
823
			if (visits != null) {
824
				for (int i = 0; i < visits.size(); i++) {
825
					Map<String, Object> visit = visits.get(i);
36644 ranu 826
					BeatRoute route = new BeatRoute();
827
					route.setBeatId(beat.getId());
828
					route.setFofoId(((Number) visit.get("id")).intValue());
829
					route.setSequenceOrder(i);
830
					route.setDayNumber(dayNumber);
831
					route.setActive(true);
832
					beatRouteRepository.persist(route);
36618 ranu 833
				}
834
			}
835
		}
836
 
837
		Map<String, Object> response = new HashMap<>();
838
		response.put("status", true);
36644 ranu 839
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 840
		response.put("message", "Beat plan submitted successfully");
841
		return responseSender.ok(response);
842
	}
843
 
36632 ranu 844
	// ============ BULK UPLOAD ============
845
 
846
	@GetMapping(value = "/beatPlan/bulkUpload")
847
	public String bulkUploadPage(HttpServletRequest request, Model model) {
848
		return "beat-plan-bulk";
849
	}
850
 
851
	@GetMapping(value = "/beatPlan/downloadTemplate")
852
	public ResponseEntity<?> downloadTemplate() {
853
		String csv = "beat_name,auth_user_id,start_date,day_number,partner_codes\n";
854
		csv += "Jaipur East Route,280,2026-06-02,1,\"RJKAI1478,RJBUN1449,RJDEG1443\"\n";
855
		csv += ",280,,2,\"RJALR1362,RJBTR1388\"\n";
856
		csv += ",280,,3,\"RJRSD1518,RJSML356\"\n";
857
		csv += "Agra Circuit,145,2026-06-05,1,\"UPAGR101,UPAGR102\"\n";
858
 
859
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
860
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.csv");
861
		headers.add("Content-Type", "text/csv");
862
		return new ResponseEntity<>(csv, headers, org.springframework.http.HttpStatus.OK);
863
	}
864
 
865
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
866
	public ResponseEntity<?> bulkUploadProcess(
867
			HttpServletRequest request,
868
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
869
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
870
 
871
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
872
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
873
 
874
		java.io.Reader reader = new java.io.InputStreamReader(file.getInputStream());
875
		org.apache.commons.csv.CSVParser parser = new org.apache.commons.csv.CSVParser(reader,
876
				org.apache.commons.csv.CSVFormat.DEFAULT.withFirstRecordAsHeader().withTrim());
877
		List<org.apache.commons.csv.CSVRecord> allRecords = parser.getRecords();
878
		parser.close();
879
 
880
		Map<String, String> lastBeatNameByUser = new HashMap<>();
881
		Map<String, List<org.apache.commons.csv.CSVRecord>> beatGroups = new LinkedHashMap<>();
882
		Map<Long, String> resolvedBeatNames = new HashMap<>();
883
 
884
		for (org.apache.commons.csv.CSVRecord record : allRecords) {
885
			String authId = record.get("auth_user_id").trim();
36644 ranu 886
			String rawName = record.get("beat_name").trim().replaceAll("\\s+", " ");
887
			if (rawName.isEmpty()) rawName = lastBeatNameByUser.getOrDefault(authId, "Beat");
888
			else lastBeatNameByUser.put(authId, rawName);
889
			resolvedBeatNames.put(record.getRecordNumber(), rawName);
890
			beatGroups.computeIfAbsent(rawName + "|" + authId, k -> new ArrayList<>()).add(record);
36632 ranu 891
		}
892
 
893
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
894
		Map<String, Integer> codeToId = new HashMap<>();
36644 ranu 895
		for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());
36632 ranu 896
 
897
		LocalDate holidayStart = LocalDate.now();
36644 ranu 898
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 899
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
900
 
36644 ranu 901
		int beatsCreated = 0, errors = 0;
36632 ranu 902
		List<String> errorMessages = new ArrayList<>();
903
 
904
		for (Map.Entry<String, List<org.apache.commons.csv.CSVRecord>> entry : beatGroups.entrySet()) {
905
			try {
906
				String[] keyParts = entry.getKey().split("\\|");
907
				String beatName = keyParts[0];
908
				int authUserId = Integer.parseInt(keyParts[1]);
909
				List<org.apache.commons.csv.CSVRecord> rows = entry.getValue();
910
				rows.sort((a, b) -> Integer.parseInt(a.get("day_number").trim()) - Integer.parseInt(b.get("day_number").trim()));
911
 
912
				String startDateStr = rows.get(0).get("start_date").trim();
36644 ranu 913
				LocalDate startDate = startDateStr.isEmpty() ? null : LocalDate.parse(startDateStr, DateTimeFormatter.ISO_DATE);
36632 ranu 914
 
915
				if (startDate != null && startDate.isBefore(LocalDate.now())) {
36644 ranu 916
					errorMessages.add("Beat '" + beatName + "': start_date in past. Skipped.");
36632 ranu 917
					errors++;
918
					continue;
919
				}
920
 
921
				List<LocalDate> scheduleDates = new ArrayList<>();
922
				if (startDate != null) {
923
					LocalDate d = startDate;
36644 ranu 924
					while (scheduleDates.size() < rows.size()) {
925
						if (holidayDates.contains(d)) {
926
							d = d.plusDays(1);
927
							continue;
36632 ranu 928
						}
36644 ranu 929
						if (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays) {
930
							d = d.plusDays(1);
931
							continue;
932
						}
933
						scheduleDates.add(d);
36632 ranu 934
						d = d.plusDays(1);
935
					}
936
				}
937
 
36644 ranu 938
				// Duplicate check — skip if a beat with same name already exists for this user
939
				boolean isDuplicate = beatRepository.selectByAuthUserId(authUserId).stream()
940
						.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
941
				if (isDuplicate) {
942
					errorMessages.add("Beat '" + beatName + "' already exists for user " + authUserId + ". Skipped.");
943
					errors++;
944
					continue;
945
				}
36632 ranu 946
 
36644 ranu 947
				String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
36632 ranu 948
				AuthUserLocation homeLoc = authUserLocationRepository.selectLatestByAuthUserIdAndType(authUserId, "BASE");
949
 
36644 ranu 950
				// Create Beat master
951
				Beat beat = new Beat();
952
				beat.setName(beatName);
953
				beat.setAuthUserId(authUserId);
954
				beat.setBeatColor(beatColor);
955
				beat.setTotalDays(rows.size());
956
				beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
957
				beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
958
				beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
959
				beat.setActive(true);
960
				beat.setCreatedBy(currentUser.getId());
961
				beat.setCreatedTimestamp(LocalDateTime.now());
962
				beatRepository.persist(beat);
36632 ranu 963
 
36644 ranu 964
				for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) {
36632 ranu 965
					org.apache.commons.csv.CSVRecord row = rows.get(rowIdx);
966
					int dayNumber = Integer.parseInt(row.get("day_number").trim());
967
					LocalDate planDate = (rowIdx < scheduleDates.size()) ? scheduleDates.get(rowIdx) : null;
36644 ranu 968
					LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
36632 ranu 969
 
36644 ranu 970
					// Always create schedule — placeholder date (9999-12-31) when unscheduled
971
					BeatSchedule schedule = new BeatSchedule();
972
					schedule.setBeatId(beat.getId());
973
					schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
974
					schedule.setEndDate(bulkEndDate);
975
					schedule.setDayNumber(dayNumber);
976
					schedule.setEndAction(rowIdx == rows.size() - 1 ? "HOME" : "DAYBREAK");
977
					schedule.setCreatedTimestamp(LocalDateTime.now());
978
					beatScheduleRepository.persist(schedule);
36632 ranu 979
 
36644 ranu 980
					String[] partnerCodes = row.get("partner_codes").trim().split(",");
36632 ranu 981
					for (int i = 0; i < partnerCodes.length; i++) {
36644 ranu 982
						String code = partnerCodes[i].trim();
983
						if (code.isEmpty()) continue;
984
						Integer fofoId = codeToId.get(code);
36632 ranu 985
						if (fofoId == null) {
36644 ranu 986
							errorMessages.add("Code not found: " + code);
36632 ranu 987
							errors++;
988
							continue;
989
						}
990
 
36644 ranu 991
						BeatRoute route = new BeatRoute();
992
						route.setBeatId(beat.getId());
993
						route.setFofoId(fofoId);
994
						route.setSequenceOrder(i);
995
						route.setDayNumber(dayNumber);
996
						route.setActive(true);
997
						beatRouteRepository.persist(route);
36632 ranu 998
					}
999
				}
1000
				beatsCreated++;
1001
			} catch (Exception e) {
1002
				errors++;
36644 ranu 1003
				errorMessages.add("Error: " + entry.getKey() + " - " + e.getMessage());
36632 ranu 1004
			}
1005
		}
1006
 
1007
		Map<String, Object> response = new HashMap<>();
1008
		response.put("status", true);
1009
		response.put("beatsCreated", beatsCreated);
1010
		response.put("errors", errors);
1011
		response.put("errorMessages", errorMessages);
1012
		return responseSender.ok(response);
1013
	}
1014
 
36644 ranu 1015
	// ============ CALENDAR ============
36618 ranu 1016
 
1017
	@PostMapping(value = "/beatPlan/delete")
1018
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 1019
		int beatId = Integer.parseInt(planGroupId);
1020
		beatRouteRepository.deleteByBeatId(beatId);
1021
		beatScheduleRepository.deleteByBeatId(beatId);
1022
		Beat beat = beatRepository.selectById(beatId);
1023
		if (beat != null) {
1024
			beat.setActive(false);
1025
		}
36618 ranu 1026
 
1027
		Map<String, Object> response = new HashMap<>();
1028
		response.put("status", true);
1029
		response.put("message", "Beat deleted");
1030
		return responseSender.ok(response);
1031
	}
1032
 
1033
	@GetMapping(value = "/beatPlan/calendar")
1034
	public ResponseEntity<?> getCalendar(
1035
			@RequestParam int authUserId,
1036
			@RequestParam String month) {
1037
 
1038
		YearMonth ym = YearMonth.parse(month);
1039
		LocalDate startDate = ym.atDay(1);
1040
		LocalDate endDate = ym.atEndOfMonth();
1041
 
1042
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
1043
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
1044
			Map<String, String> m = new HashMap<>();
1045
			m.put("date", h.getDate().toString());
1046
			m.put("occasion", h.getOccasion());
1047
			return m;
1048
		}).collect(Collectors.toList());
1049
 
36644 ranu 1050
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 1051
		LocalDate today = LocalDate.now();
1052
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
1053
 
36644 ranu 1054
		for (Beat beat : allBeats) {
1055
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
1056
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 1057
 
36644 ranu 1058
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
1059
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
1060
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
1061
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 1062
 
1063
			String status;
1064
			if (allNullDates) status = "unscheduled";
1065
			else if (hasToday) status = "running";
1066
			else if (allPast) status = "completed";
36644 ranu 1067
			else status = "scheduled";
36618 ranu 1068
 
36644 ranu 1069
			Map<String, Object> beatInfo = new HashMap<>();
1070
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
1071
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
1072
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
1073
			beatInfo.put("status", status);
36618 ranu 1074
 
1075
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 1076
			for (BeatSchedule s : schedules) {
36618 ranu 1077
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 1078
				dayInfo.put("dayNumber", s.getDayNumber());
1079
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
1080
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
1081
				dayInfo.put("totalKm", s.getTotalDistanceKm());
1082
				dayInfo.put("totalMins", s.getTotalTimeMins());
1083
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
1084
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 1085
				dayInfoList.add(dayInfo);
1086
			}
36644 ranu 1087
			if (schedules.isEmpty()) {
1088
				// No schedule at all — show from routes
1089
				Map<Integer, Long> dayCounts = routes.stream()
1090
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
1091
				for (int d = 1; d <= beat.getTotalDays(); d++) {
1092
					Map<String, Object> dayInfo = new HashMap<>();
1093
					dayInfo.put("dayNumber", d);
1094
					dayInfo.put("planDate", null);
1095
					dayInfo.put("totalKm", null);
1096
					dayInfo.put("totalMins", null);
1097
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
1098
					dayInfoList.add(dayInfo);
1099
				}
1100
			}
1101
			beatInfo.put("days", dayInfoList);
1102
			scheduledBeats.add(beatInfo);
36618 ranu 1103
		}
1104
 
1105
		Set<String> blockedDates = new HashSet<>();
1106
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 1107
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 1108
		}
36644 ranu 1109
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 1110
 
1111
		Map<String, Object> response = new HashMap<>();
1112
		response.put("holidays", holidayList);
1113
		response.put("scheduledBeats", scheduledBeats);
1114
		response.put("blockedDates", blockedDates);
1115
		return responseSender.ok(response);
1116
	}
1117
 
1118
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
1119
	public ResponseEntity<?> scheduleOnCalendar(
1120
			HttpServletRequest request,
1121
			@RequestParam String planGroupId,
1122
			@RequestParam String dates,
1123
			@RequestParam(required = false) String beatName,
1124
			@RequestParam(required = false) String beatColor) throws Exception {
1125
 
36644 ranu 1126
		int beatId = Integer.parseInt(planGroupId);
36618 ranu 1127
		Gson gson = new Gson();
1128
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
1129
		}.getType());
1130
 
36644 ranu 1131
		Beat beat = beatRepository.selectById(beatId);
1132
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 1133
 
36644 ranu 1134
		if (beatName != null) beat.setName(beatName);
1135
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36618 ranu 1136
 
36644 ranu 1137
		// Delete old schedules and create new
1138
		beatScheduleRepository.deleteByBeatId(beatId);
1139
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
1140
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
1141
			BeatSchedule schedule = new BeatSchedule();
1142
			schedule.setBeatId(beatId);
1143
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
1144
			schedule.setEndDate(schEndDate);
1145
			schedule.setDayNumber(i + 1);
1146
			schedule.setEndAction(i == dateList.size() - 1 ? "HOME" : "DAYBREAK");
1147
			schedule.setCreatedTimestamp(LocalDateTime.now());
1148
			beatScheduleRepository.persist(schedule);
36618 ranu 1149
		}
1150
 
1151
		Map<String, Object> response = new HashMap<>();
1152
		response.put("status", true);
1153
		response.put("message", "Beat scheduled successfully");
1154
		return responseSender.ok(response);
1155
	}
1156
 
36644 ranu 1157
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 1158
	@PostMapping(value = "/beatPlan/repeatBeat")
1159
	public ResponseEntity<?> repeatBeat(
1160
			HttpServletRequest request,
1161
			@RequestParam String sourcePlanGroupId,
1162
			@RequestParam int authUserId,
1163
			@RequestParam String dates) throws Exception {
1164
 
36644 ranu 1165
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 1166
		Gson gson = new Gson();
1167
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
1168
		}.getType());
1169
 
36644 ranu 1170
		Beat beat = beatRepository.selectById(beatId);
1171
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 1172
 
36644 ranu 1173
		// Remove placeholder (unscheduled) schedule rows
1174
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
1175
		for (BeatSchedule s : existing) {
1176
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
1177
				beatScheduleRepository.delete(s);
1178
			}
36618 ranu 1179
		}
1180
 
36644 ranu 1181
		// Add new real-date schedule rows for the existing beat
1182
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
1183
		for (int i = 0; i < dateList.size(); i++) {
1184
			BeatSchedule schedule = new BeatSchedule();
1185
			schedule.setBeatId(beatId);
1186
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
1187
			schedule.setEndDate(repeatEndDate);
1188
			schedule.setDayNumber(i + 1);
1189
			schedule.setEndAction(i == dateList.size() - 1 ? "HOME" : "DAYBREAK");
1190
			schedule.setCreatedTimestamp(LocalDateTime.now());
1191
			beatScheduleRepository.persist(schedule);
36618 ranu 1192
		}
1193
 
1194
		Map<String, Object> response = new HashMap<>();
1195
		response.put("status", true);
36644 ranu 1196
		response.put("planGroupId", String.valueOf(beatId));
1197
		response.put("message", "Beat scheduled successfully");
36618 ranu 1198
		return responseSender.ok(response);
1199
	}
1200
 
1201
	@GetMapping(value = "/beatPlan/availableSlots")
1202
	public ResponseEntity<?> getAvailableSlots(
1203
			@RequestParam int authUserId,
1204
			@RequestParam String month,
1205
			@RequestParam int daysNeeded) {
1206
 
1207
		YearMonth ym = YearMonth.parse(month);
1208
		LocalDate startDate = ym.atDay(1);
1209
		LocalDate endDate = ym.atEndOfMonth();
1210
		LocalDate today = LocalDate.now();
1211
 
1212
		Set<LocalDate> blocked = new HashSet<>();
1213
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
1214
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 1215
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 1216
		}
1217
 
1218
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
1219
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
1220
 
36644 ranu 1221
		// Get all scheduled dates for this user
1222
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
1223
		for (Beat b : userBeats) {
1224
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
1225
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 1226
		}
1227
 
1228
		List<String> available = new ArrayList<>();
1229
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
1230
			 !d.isAfter(endDate) && available.size() < daysNeeded;
1231
			 d = d.plusDays(1)) {
36644 ranu 1232
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 1233
		}
1234
 
1235
		Map<String, Object> response = new HashMap<>();
1236
		response.put("suggestedDates", available);
1237
		response.put("totalAvailable", available.size());
1238
		return responseSender.ok(response);
1239
	}
1240
 
1241
	// --- Sorting helpers ---
1242
 
1243
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
1244
			List<Map<String, Object>> partners, double startLat, double startLng) {
1245
		List<Map<String, Object>> withCoords = new ArrayList<>();
1246
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
1247
		for (Map<String, Object> p : partners) {
36644 ranu 1248
			if (hasValidCoords(p)) withCoords.add(p);
1249
			else withoutCoords.add(p);
36618 ranu 1250
		}
1251
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 1252
		double currentLat = startLat, currentLng = startLng;
36618 ranu 1253
		while (!withCoords.isEmpty()) {
1254
			int nearestIdx = 0;
1255
			double nearestDist = Double.MAX_VALUE;
1256
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 1257
				double dist = haversine(currentLat, currentLng,
1258
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
1259
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 1260
				if (dist < nearestDist) {
1261
					nearestDist = dist;
1262
					nearestIdx = i;
1263
				}
1264
			}
1265
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
1266
			sorted.add(nearest);
1267
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
1268
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
1269
		}
1270
		sorted.addAll(withoutCoords);
1271
		return sorted;
1272
	}
1273
 
1274
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
1275
		List<Map<String, Object>> withCoords = new ArrayList<>();
1276
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
1277
		for (Map<String, Object> p : partners) {
36644 ranu 1278
			if (hasValidCoords(p)) withCoords.add(p);
1279
			else withoutCoords.add(p);
36618 ranu 1280
		}
1281
		List<Map<String, Object>> sorted = new ArrayList<>();
1282
		if (!withCoords.isEmpty()) {
1283
			sorted.add(withCoords.remove(0));
1284
			while (!withCoords.isEmpty()) {
1285
				Map<String, Object> last = sorted.get(sorted.size() - 1);
1286
				double lastLat = Double.parseDouble(last.get("latitude").toString());
1287
				double lastLng = Double.parseDouble(last.get("longitude").toString());
1288
				int nearestIdx = 0;
1289
				double nearestDist = Double.MAX_VALUE;
1290
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 1291
					double dist = haversine(lastLat, lastLng,
1292
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
1293
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 1294
					if (dist < nearestDist) {
1295
						nearestDist = dist;
1296
						nearestIdx = i;
1297
					}
1298
				}
1299
				sorted.add(withCoords.remove(nearestIdx));
1300
			}
1301
		}
1302
		sorted.addAll(withoutCoords);
1303
		return sorted;
1304
	}
1305
 
1306
	private boolean hasValidCoords(Map<String, Object> p) {
1307
		Object lat = p.get("latitude");
1308
		Object lng = p.get("longitude");
36644 ranu 1309
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 1310
	}
1311
 
1312
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
1313
		double R = 6371;
1314
		double dLat = Math.toRadians(lat2 - lat1);
1315
		double dLng = Math.toRadians(lng2 - lng1);
1316
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
1317
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
1318
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
1319
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
1320
		return R * c;
1321
	}
1322
}