Subversion Repositories SmartDukaan

Rev

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