Subversion Repositories SmartDukaan

Rev

Rev 36668 | Rev 36681 | 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;
36663 ranu 98
	@Autowired
99
	private com.spice.profitmandi.dao.repository.dtr.UserRepository userRepositoryAuto;
100
	@Autowired
101
	private com.spice.profitmandi.common.web.client.RestClient restClientAuto;
102
	@Autowired
103
	private com.spice.profitmandi.dao.repository.auth.LocationTrackingRepository locationTrackingRepositoryAuto;
36650 ranu 104
 
36655 ranu 105
	private static Double parseDoubleOrNull(String s) {
106
		if (s == null || s.trim().isEmpty()) return null;
107
		try {
108
			return Double.parseDouble(s.trim());
109
		} catch (NumberFormatException e) {
110
			return null;
111
		}
112
	}
113
 
114
	private static double haversineKm(double lat1, double lng1, double lat2, double lng2) {
115
		double R = 6371;
116
		double dLat = Math.toRadians(lat2 - lat1);
117
		double dLng = Math.toRadians(lng2 - lng1);
118
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
119
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
120
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
121
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
122
		return R * c;
123
	}
124
 
36663 ranu 125
	// ====================== ASSIGN VISIT ======================
126
	// Day View "Assign Visit" — lets an admin pick parties (stores) for a specific
127
	// auth user on a specific date and pushes them as visit tasks to the v2
128
	// /profitmandi-web/v2/beat-tracking/batch endpoint.
129
 
130
	// List of parties (stores) assigned to this auth user + their dtr.users.id
131
	@GetMapping(value = "/beatPlan/assignVisit/parties")
132
	public ResponseEntity<?> assignVisitParties(@RequestParam int authUserId) throws Exception {
133
		AuthUser au = authRepository.selectById(authUserId);
134
		if (au == null) return responseSender.badRequest("Auth user not found");
135
 
136
		// Map auth_user → dtr.users via email
137
		Integer dtrUserId = null;
138
		try {
139
			com.spice.profitmandi.dao.entity.dtr.User dtrUser =
140
					userRepositoryAuto.selectByEmailId(au.getEmailId());
141
			if (dtrUser != null) dtrUserId = dtrUser.getId();
142
		} catch (Exception ignored) {
143
		}
144
 
145
		Map<Integer, List<Integer>> mapping = csService.getAuthUserIdPartnerIdMapping();
146
		List<Integer> fofoIds = mapping.get(authUserId);
147
 
148
		List<Map<String, Object>> parties = new ArrayList<>();
149
		if (fofoIds != null && !fofoIds.isEmpty()) {
150
			List<FofoStore> stores = fofoStoreRepository.selectByRetailerIds(fofoIds);
151
			Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
152
			for (FofoStore store : stores) {
153
				if (!store.isActive() || store.isClosed()) continue;
154
				CustomRetailer retailer = retailerMap.get(store.getId());
155
				Map<String, Object> p = new HashMap<>();
156
				p.put("fofoStoreId", store.getId());
157
				p.put("code", store.getCode());
158
				p.put("outletName", store.getOutletName() != null ? store.getOutletName()
159
						: (retailer != null ? retailer.getBusinessName() : "Store #" + store.getId()));
160
				p.put("latitude", store.getLatitude());
161
				p.put("longitude", store.getLongitude());
162
				p.put("city", retailer != null && retailer.getAddress() != null ? retailer.getAddress().getCity() : null);
163
				parties.add(p);
164
			}
165
			// Sort by code for stable display
166
			parties.sort((a, b) -> String.valueOf(a.get("code")).compareToIgnoreCase(String.valueOf(b.get("code"))));
167
		}
168
 
169
		Map<String, Object> result = new HashMap<>();
170
		result.put("dtrUserId", dtrUserId);
171
		result.put("authUserId", authUserId);
172
		result.put("userName", au.getFirstName() + " " + au.getLastName());
173
		result.put("parties", parties);
174
		return responseSender.ok(result);
175
	}
176
 
177
	// Submit assignment — accepts a JSON body, builds the v2 payload, posts it
178
	@PostMapping(value = "/beatPlan/assignVisit/submit")
179
	public ResponseEntity<?> assignVisitSubmit(
180
			HttpServletRequest request,
181
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
182
 
183
		Integer authUserId = body.get("authUserId") != null ? ((Number) body.get("authUserId")).intValue() : null;
184
		String planDate = (String) body.get("planDate");
185
		List<Map<String, Object>> selected = (List<Map<String, Object>>) body.get("parties");
186
		if (authUserId == null || planDate == null || selected == null || selected.isEmpty()) {
187
			return responseSender.badRequest("authUserId, planDate and parties are required");
188
		}
189
 
190
		AuthUser au = authRepository.selectById(authUserId);
191
		if (au == null) return responseSender.badRequest("Auth user not found");
192
 
193
		// Map auth → dtr.users.id (this is the userId the v2 endpoint expects)
194
		com.spice.profitmandi.dao.entity.dtr.User dtrUser;
195
		try {
196
			dtrUser = userRepositoryAuto.selectByEmailId(au.getEmailId());
197
		} catch (Exception e) {
198
			return responseSender.badRequest("Failed to look up dtr.users for this auth user");
199
		}
200
		if (dtrUser == null) {
201
			return responseSender.badRequest("No dtr.users record found for auth user " + authUserId);
202
		}
203
		int dtrUserId = dtrUser.getId();
204
 
205
		// Persist directly via the shared DAO — mirrors BeatTrackingController.createBatch
206
		// in profitmandi-web. We can't autowire a controller across WARs, but
207
		// LocationTrackingRepository lives in profitmandi-dao and is shared.
208
		LocalDate taskDate;
209
		try {
210
			taskDate = LocalDate.parse(planDate);
211
		} catch (Exception e) {
212
			return responseSender.badRequest("Invalid planDate (expected yyyy-MM-dd): " + planDate);
213
		}
214
 
215
		LocalDateTime now = LocalDateTime.now();
216
		List<com.spice.profitmandi.dao.entity.auth.LocationTracking> created = new ArrayList<>();
217
 
218
		for (Map<String, Object> p : selected) {
219
			Integer fofoStoreId = ((Number) p.get("fofoStoreId")).intValue();
220
			String outletName = (String) p.get("outletName");
221
			String lat = (String) p.get("latitude");
222
			String lng = (String) p.get("longitude");
223
			String agenda = (String) p.get("agenda");
224
			if (agenda != null) agenda = agenda.trim();
225
			if (agenda == null || agenda.isEmpty()) agenda = "Visit";
226
 
227
			String visitLocation = (lat != null && lng != null && !lat.isEmpty() && !lng.isEmpty())
228
					? (lat + "," + lng) : "0.0000,0.0000";
229
 
230
			String displayName = (outletName != null && !outletName.isEmpty()) ? outletName : ("Store #" + fofoStoreId);
231
 
232
			com.spice.profitmandi.dao.entity.auth.LocationTracking row =
233
					new com.spice.profitmandi.dao.entity.auth.LocationTracking();
234
			row.setUserId(dtrUserId);
235
			row.setDeviceId("0");
236
			row.setTaskId(fofoStoreId);
237
			row.setTaskDate(taskDate);
238
			row.setTaskName(agenda + " | " + displayName);
239
			row.setTaskType("franchisee-visit");
240
			row.setMarkType("PENDING");
241
			row.setAddress("");
242
			row.setVisitLocation(visitLocation);
243
			row.setCheckInLatLng("0.0000,0.0000");
244
			row.setCheckOutLatLng("0.0000,0.0000");
245
			row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
246
			row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
247
			row.setTransitTime(java.time.LocalTime.MIDNIGHT);
248
			row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
249
			row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
250
			row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
251
			row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
252
			row.setTotalDistance("0.0");
253
			row.setStatus(false);
254
			row.setCreatedTimestamp(now);
255
			row.setUpdatedTimestamp(now);
256
 
257
			// Do NOT try/catch this — if persist throws, let it propagate so
258
			// @Transactional(rollbackFor = Throwable.class) rolls back cleanly.
259
			// Catching here lets Hibernate try to commit a broken session, which
260
			// then surfaces the misleading "null id in entry" flush error and hides
261
			// the real root cause (constraint violation, bad value, etc.).
262
			locationTrackingRepositoryAuto.persist(row);
263
			created.add(row);
264
		}
265
		LOGGER.info("assignVisit persisted {} location_tracking rows for dtrUserId={}", created.size(), dtrUserId);
266
 
267
		Map<String, Object> result = new HashMap<>();
268
		result.put("status", true);
269
		result.put("assignedCount", created.size());
270
		result.put("dtrUserId", dtrUserId);
271
		result.put("message", created.size() + " visit tasks assigned to " + au.getFirstName() + " " + au.getLastName());
272
		return responseSender.ok(result);
273
	}
274
 
36655 ranu 275
	// ====================== BASE LOCATION MANAGEMENT ======================
276
	// Inline page that lets Sales L3+ pick a user and set their base (home)
277
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
278
	// go through the L3+-guarded endpoint below.
279
	@GetMapping(value = "/beatPlan/baseLocationPage")
280
	public String baseLocationPage(HttpServletRequest request, Model model) {
281
		EscalationType[] escalationTypes = EscalationType.values();
282
		model.addAttribute("escalationTypes", escalationTypes);
283
		return "beat-plan-base-location";
284
	}
285
 
36668 ranu 286
	// Helpers for XLSX bulk upload
287
	private static String readCell(org.apache.poi.ss.usermodel.Cell cell) {
288
		if (cell == null) return null;
289
		switch (cell.getCellType()) {
290
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:
291
				return cell.getStringCellValue();
292
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:
293
				if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
294
					return cell.getDateCellValue().toInstant()
295
							.atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();
296
				}
297
				double n = cell.getNumericCellValue();
298
				return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);
299
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:
300
				return String.valueOf(cell.getBooleanCellValue());
301
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:
302
				return cell.getCellFormula();
303
			default:
304
				return null;
36655 ranu 305
		}
306
	}
307
 
308
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
309
	// For each active fofo_store, compare its stored lat/lng with the geocoded
310
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
311
	// OR the store has no lat/lng yet, update the store with the geocoded
312
	// coordinates. Otherwise keep the existing values.
313
	//
314
	// Usage:
315
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
316
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
317
	//   ?thresholdKm=3      -> use a different threshold
318
	//   ?limit=100          -> process only N stores (for staged runs)
319
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
320
	public ResponseEntity<?> migrateStoreLatLng(
321
			@RequestParam(required = false, defaultValue = "false") boolean apply,
322
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
36660 ranu 323
			@RequestParam(required = false, defaultValue = "0") int limit,
324
			@RequestParam(required = false, defaultValue = "0") int offset) throws ProfitMandiBusinessException {
36655 ranu 325
 
36660 ranu 326
		List<FofoStore> all = fofoStoreRepository.selectActiveStores();
327
		int totalAvailable = all.size();
328
		// offset + limit so this can be run in batches against large datasets
329
		int from = Math.max(0, Math.min(offset, totalAvailable));
330
		int to = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;
331
		List<FofoStore> stores = all.subList(from, to);
36655 ranu 332
 
333
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
334
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
335
 
336
		int total = stores.size();
337
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
338
		List<Map<String, Object>> changes = new ArrayList<>();
339
 
340
		for (FofoStore store : stores) {
341
			try {
342
				CustomRetailer retailer = retailerMap.get(store.getId());
343
				if (retailer == null || retailer.getAddress() == null) {
344
					noAddress++;
345
					continue;
346
				}
347
 
348
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
349
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
350
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
351
				if (geoAddr == null || geoAddr.isEmpty()) {
352
					noAddress++;
353
					continue;
354
				}
355
 
356
				double[] coords = geocodingService.geocodeAddress(geoAddr);
357
				if (coords == null) {
358
					noGeocode++;
359
					continue;
360
				}
361
 
362
				Double existingLat = parseDoubleOrNull(store.getLatitude());
363
				Double existingLng = parseDoubleOrNull(store.getLongitude());
364
 
365
				boolean shouldUpdate;
366
				double distKm = -1;
367
				String reason;
368
				if (existingLat == null || existingLng == null) {
369
					shouldUpdate = true;
370
					reason = "missing existing lat/lng";
371
				} else {
372
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
373
					shouldUpdate = distKm > thresholdKm;
374
					reason = shouldUpdate
375
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
376
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
377
				}
378
 
379
				if (shouldUpdate) {
380
					if (apply) {
381
						store.setLatitude(String.valueOf(coords[0]));
382
						store.setLongitude(String.valueOf(coords[1]));
383
						fofoStoreRepository.persist(store);
384
					}
385
					updated++;
386
					Map<String, Object> ch = new HashMap<>();
387
					ch.put("storeId", store.getId());
388
					ch.put("code", store.getCode());
389
					ch.put("oldLat", existingLat);
390
					ch.put("oldLng", existingLng);
391
					ch.put("newLat", coords[0]);
392
					ch.put("newLng", coords[1]);
393
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
394
					ch.put("reason", reason);
395
					changes.add(ch);
396
				} else {
397
					kept++;
398
				}
399
			} catch (Exception e) {
400
				errored++;
401
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
402
			}
403
		}
404
 
405
		Map<String, Object> result = new HashMap<>();
406
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
407
		result.put("thresholdKm", thresholdKm);
36660 ranu 408
		result.put("totalAvailable", totalAvailable);  // total active stores in DB
409
		result.put("offset", from);
410
		result.put("processed", total);                 // stores processed this run
411
		result.put("nextOffset", to);                   // next offset to resume (or = totalAvailable when done)
412
		result.put("done", to >= totalAvailable);
36655 ranu 413
		result.put("updated", updated);
414
		result.put("kept", kept);
415
		result.put("noAddress", noAddress);
416
		result.put("noGeocode", noGeocode);
417
		result.put("errored", errored);
418
		// Limit changes preview to avoid huge responses
419
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
420
		result.put("changesShownCount", Math.min(changes.size(), 200));
421
		return responseSender.ok(result);
422
	}
423
 
36651 ranu 424
	// ====================== EDIT BEAT ======================
425
	// Update an existing beat — name + partner stops (routes).
426
	// Schedules are NOT touched here; manage them via calendar drag-drop.
427
	@PostMapping(value = "/beatPlan/updateBeat")
428
	public ResponseEntity<?> updateBeat(
429
			HttpServletRequest request,
430
			@RequestParam int beatId,
431
			@RequestParam String planData) throws Exception {
432
 
433
		Beat beat = beatRepository.selectById(beatId);
434
		if (beat == null) return responseSender.badRequest("Beat not found");
435
 
436
		Gson gson = new Gson();
437
		Type type = new TypeToken<Map<String, Object>>() {
438
		}.getType();
439
		Map<String, Object> plan = gson.fromJson(planData, type);
440
 
441
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
442
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
443
 
444
		// Update name if changed (and not colliding with another beat)
445
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
446
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
447
			// Make sure no other beat for this user already uses this name
448
			boolean collides = beatRepository.selectByAuthUserId(beat.getAuthUserId()).stream()
449
					.anyMatch(b -> b.getId() != beat.getId()
450
							&& b.getName() != null
451
							&& newName.equalsIgnoreCase(b.getName().trim()));
452
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
453
			beat.setName(newName);
454
		}
455
 
456
		// Update start location from first day if present
457
		Map<String, Object> firstDay = days.get(0);
458
		if (firstDay.get("startLocationName") != null)
459
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
460
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
461
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
462
		beat.setTotalDays(days.size());
463
 
464
		// Replace routes (partner stops). Schedules stay intact.
465
		beatRouteRepository.deleteByBeatId(beatId);
466
		// Collect lead IDs the user kept on the plan (for date-aware edit below)
467
		Set<Integer> keptLeadIds = new HashSet<>();
468
		for (int d = 0; d < days.size(); d++) {
469
			Map<String, Object> day = days.get(d);
470
			int dayNumber = d + 1;
471
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
472
			if (visits == null) continue;
473
			int partnerSeq = 0;
474
			for (int i = 0; i < visits.size(); i++) {
475
				Map<String, Object> v = visits.get(i);
476
				if ("lead".equals(v.get("type"))) {
477
					keptLeadIds.add(((Number) v.get("id")).intValue());
478
					continue; // leads live in lead_route, handled below
479
				}
480
				BeatRoute route = new BeatRoute();
481
				route.setBeatId(beatId);
482
				route.setFofoId(((Number) v.get("id")).intValue());
483
				route.setSequenceOrder(partnerSeq++);
484
				route.setDayNumber(dayNumber);
485
				route.setActive(true);
486
				beatRouteRepository.persist(route);
487
			}
488
		}
489
 
490
		// Date-aware lead handling: if planDate is provided, remove lead_route rows
491
		// for that (beat, date) that the user removed in the editor.
492
		String planDateStr = (String) plan.get("planDate");
493
		if (planDateStr != null && !planDateStr.isEmpty()) {
494
			try {
495
				LocalDate planDate = LocalDate.parse(planDateStr);
496
				List<LeadRoute> existing = leadRouteRepository.selectByBeatId(beatId);
497
				for (LeadRoute lr : existing) {
498
					if (!"APPROVED".equals(lr.getStatus())) continue;
499
					if (lr.getScheduleDate() == null || !lr.getScheduleDate().equals(planDate)) continue;
500
					if (keptLeadIds.contains(lr.getLeadId())) continue;
501
					// User removed this lead from the date — mark cancelled
502
					lr.setStatus("CANCELLED");
503
					lr.setUpdatedTimestamp(LocalDateTime.now());
504
					// activity log
505
					LeadActivity a = new LeadActivity();
506
					a.setLeadId(lr.getLeadId());
507
					a.setRemark("Removed from beat '" + beat.getName() + "' on " + planDate);
508
					a.setAuthId(0);
509
					a.setCreatedTimestamp(LocalDateTime.now());
510
					leadActivityRepositoryAuto.persist(a);
511
				}
512
			} catch (Exception e) {
513
				LOGGER.warn("Could not parse planDate '{}' — leads not adjusted", planDateStr);
514
			}
515
		}
516
 
517
		Map<String, Object> response = new HashMap<>();
518
		response.put("status", true);
519
		response.put("planGroupId", String.valueOf(beat.getId()));
520
		response.put("message", "Beat updated successfully");
521
		return responseSender.ok(response);
522
	}
523
 
36650 ranu 524
	// ====================== DAY VIEW ======================
525
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
526
	// scheduled in a date range across all users. Each row has a View button that
527
	// opens that user's calendar in a modal.
528
	@GetMapping(value = "/beatPlan/dayView")
529
	public String beatPlanDayView(HttpServletRequest request, Model model) {
530
		EscalationType[] escalationTypes = EscalationType.values();
531
		model.addAttribute("escalationTypes", escalationTypes);
532
		return "beat-plan-day-view";
533
	}
534
 
535
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
536
	@GetMapping(value = "/beatPlan/scheduledList")
537
	public ResponseEntity<?> scheduledList(
538
			@RequestParam(required = false) String startDate,
539
			@RequestParam(required = false) String endDate) {
540
 
541
		LocalDate start, end;
542
		try {
543
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
544
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
545
		} catch (Exception e) {
546
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
547
		}
548
 
549
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
550
				beatPlanQueryService.getAllScheduledBeats(start, end);
551
 
552
		// Resolve user names in bulk
553
		Set<Integer> userIds = beats.stream()
554
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
555
				.collect(java.util.stream.Collectors.toSet());
556
		Map<Integer, AuthUser> userMap = new HashMap<>();
557
		if (!userIds.isEmpty()) {
558
			authRepository.selectByIds(new ArrayList<>(userIds))
559
					.forEach(u -> userMap.put(u.getId(), u));
560
		}
561
 
562
		List<Map<String, Object>> rows = new ArrayList<>();
563
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
564
			AuthUser u = userMap.get(b.getAuthUserId());
565
			Map<String, Object> row = new HashMap<>();
566
			row.put("authUserId", b.getAuthUserId());
567
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
568
			row.put("scheduleDate", b.getScheduleDate().toString());
569
			row.put("dayNumber", b.getDayNumber());
570
			row.put("beatId", b.getBeatId());
571
			row.put("beatName", b.getBeatName());
572
			row.put("beatColor", b.getBeatColor());
573
			row.put("partnerCount", b.getPartnerStops().size());
574
			row.put("leadCount", b.getLeadStops().size());
575
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
576
			rows.add(row);
577
		}
578
 
579
		Map<String, Object> result = new HashMap<>();
580
		result.put("rows", rows);
581
		result.put("startDate", start.toString());
582
		result.put("endDate", end.toString());
583
		return responseSender.ok(result);
584
	}
585
 
586
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
587
	@GetMapping(value = "/beatPlan/dayViewData")
588
	public ResponseEntity<?> beatPlanDayViewData(
589
			@RequestParam int authUserId,
590
			@RequestParam String date) throws ProfitMandiBusinessException {
591
 
592
		LocalDate localDate;
593
		try {
594
			localDate = LocalDate.parse(date);
595
		} catch (Exception e) {
596
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
597
		}
598
 
599
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
600
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
601
 
602
		// Collect all partner & lead IDs to fetch metadata in bulk
603
		Set<Integer> partnerIds = new HashSet<>();
604
		Set<Integer> leadIds = new HashSet<>();
605
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
606
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
607
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
608
		}
609
 
610
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
611
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
612
				? new HashMap<>()
613
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
614
		Map<Integer, FofoStore> storeMap = new HashMap<>();
615
		if (!partnerIds.isEmpty()) {
616
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
617
					.forEach(fs -> storeMap.put(fs.getId(), fs));
618
		}
619
 
620
		// Leads: name + geo
621
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
622
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
623
		for (int leadId : leadIds) {
624
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
625
			if (l != null) leadMap.put(leadId, l);
626
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
627
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
628
			if (lg != null) leadGeoMap.put(leadId, lg);
629
		}
630
 
631
		// Enrich each stop
632
		List<Map<String, Object>> out = new ArrayList<>();
633
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
634
			Map<String, Object> beatJson = new HashMap<>();
635
			beatJson.put("beatId", b.getBeatId());
636
			beatJson.put("beatName", b.getBeatName());
637
			beatJson.put("beatColor", b.getBeatColor());
638
			beatJson.put("dayNumber", b.getDayNumber());
639
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
640
			beatJson.put("endAction", b.getEndAction());
641
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
642
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
643
			beatJson.put("startLocationName", b.getStartLocationName());
644
			beatJson.put("startLatitude", b.getStartLatitude());
645
			beatJson.put("startLongitude", b.getStartLongitude());
646
 
647
			List<Map<String, Object>> stops = new ArrayList<>();
648
			// Partners
649
			for (Map<String, Object> ps : b.getPartnerStops()) {
650
				int fofoId = (Integer) ps.get("fofoId");
651
				Map<String, Object> stop = new HashMap<>();
652
				stop.put("type", "partner");
653
				stop.put("id", fofoId);
654
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
655
				FofoStore fs = storeMap.get(fofoId);
656
				CustomRetailer cr = retailerMap.get(fofoId);
657
				stop.put("code", fs != null ? fs.getCode() : null);
658
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
659
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 660
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
661
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
662
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 663
					try {
36655 ranu 664
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
665
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
666
					} catch (NumberFormatException ignored) {
36650 ranu 667
					}
668
				}
36655 ranu 669
				if (cr != null && cr.getAddress() != null) {
670
					stop.put("address", cr.getAddress().getAddressString());
671
				}
36650 ranu 672
				stops.add(stop);
673
			}
674
			// Leads
675
			for (Map<String, Object> ls : b.getLeadStops()) {
676
				int leadId = (Integer) ls.get("leadId");
677
				Map<String, Object> stop = new HashMap<>();
678
				stop.put("type", "lead");
679
				stop.put("id", leadId);
680
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
681
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
682
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
683
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
684
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
685
				stop.put("city", l != null ? l.getCity() : null);
686
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
687
				if (lg != null) {
688
					stop.put("lat", lg.getLatitude());
689
					stop.put("lng", lg.getLongitude());
690
				}
691
				stops.add(stop);
692
			}
693
			beatJson.put("stops", stops);
694
			beatJson.put("partnerCount", b.getPartnerStops().size());
695
			beatJson.put("leadCount", b.getLeadStops().size());
696
			out.add(beatJson);
697
		}
698
 
699
		Map<String, Object> result = new HashMap<>();
700
		result.put("beats", out);
701
		return responseSender.ok(result);
702
	}
703
 
36618 ranu 704
	@GetMapping(value = "/beatPlan/getAuthUsers")
705
	public ResponseEntity<?> getAuthUsers(
706
			@RequestParam int categoryId,
707
			@RequestParam EscalationType escalationType) {
708
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
709
		List<Map<String, Object>> result = authUsers.stream()
710
				.filter(au -> au.getActive())
711
				.map(au -> {
712
					Map<String, Object> map = new HashMap<>();
713
					map.put("id", au.getId());
714
					map.put("name", au.getFirstName() + " " + au.getLastName());
715
					return map;
716
				})
717
				.collect(Collectors.toList());
718
		return responseSender.ok(result);
719
	}
720
 
36644 ranu 721
	// Returns visits for a beat.
722
	// - Partner stops (beat_route) belong to the beat template — always returned.
723
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
724
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 725
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 726
	public ResponseEntity<?> getBeatVisits(
727
			@RequestParam String planGroupId,
728
			@RequestParam(required = false) String planDate) {
729
 
730
		int beatId;
731
		try {
732
			beatId = Integer.parseInt(planGroupId);
733
		} catch (NumberFormatException e) {
734
			return responseSender.ok(new ArrayList<>());
735
		}
736
 
737
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
738
		List<Map<String, Object>> result = new ArrayList<>();
739
 
740
		// Partner stops — always (they belong to the beat template)
741
		for (BeatRoute r : routes) {
36632 ranu 742
			Map<String, Object> map = new HashMap<>();
36644 ranu 743
			map.put("fofoId", r.getFofoId());
744
			map.put("dayNumber", r.getDayNumber());
745
			map.put("sequenceOrder", r.getSequenceOrder());
746
			map.put("visitType", "partner");
747
			result.add(map);
748
		}
749
 
750
		// Lead stops — only for the requested run date
751
		if (planDate != null && !planDate.isEmpty()) {
752
			LocalDate date = LocalDate.parse(planDate);
753
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
754
			for (LeadRoute lr : leads) {
755
				if ("APPROVED".equals(lr.getStatus())
756
						&& lr.getScheduleDate() != null
757
						&& lr.getScheduleDate().equals(date)) {
758
					Map<String, Object> map = new HashMap<>();
759
					map.put("fofoId", lr.getLeadId());
760
					map.put("dayNumber", 1);
761
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
762
					map.put("visitType", "lead");
763
					result.add(map);
764
				}
765
			}
766
		}
767
 
768
		// Sort by dayNumber then sequenceOrder
769
		result.sort((a, b) -> {
770
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
771
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
772
		});
773
 
36632 ranu 774
		return responseSender.ok(result);
775
	}
776
 
36618 ranu 777
	@GetMapping(value = "/beatPlan/getBaseLocation")
778
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
779
		AuthUserLocation baseLoc = authUserLocationRepository.selectLatestByAuthUserIdAndType(authUserId, "BASE");
780
		if (baseLoc == null) {
781
			return responseSender.ok(new HashMap<>());
782
		}
783
		Map<String, Object> result = new HashMap<>();
784
		result.put("id", baseLoc.getId());
785
		result.put("locationName", baseLoc.getLocationName());
786
		result.put("latitude", baseLoc.getLatitude());
787
		result.put("longitude", baseLoc.getLongitude());
788
		result.put("address", baseLoc.getAddress());
789
		return responseSender.ok(result);
790
	}
791
 
792
	@PostMapping(value = "/beatPlan/saveBaseLocation")
793
	public ResponseEntity<?> saveBaseLocation(
794
			@RequestParam int authUserId,
795
			@RequestParam String locationName,
796
			@RequestParam String latitude,
797
			@RequestParam String longitude,
798
			@RequestParam(required = false) String address) {
799
		AuthUserLocation loc = new AuthUserLocation();
800
		loc.setAuthUserId(authUserId);
801
		loc.setLocationType("BASE");
802
		loc.setLocationName(locationName);
803
		loc.setLatitude(latitude);
804
		loc.setLongitude(longitude);
805
		loc.setAddress(address);
806
		loc.setCreatedTimestamp(LocalDateTime.now());
807
		authUserLocationRepository.persist(loc);
808
 
809
		Map<String, Object> result = new HashMap<>();
810
		result.put("status", true);
811
		result.put("id", loc.getId());
812
		return responseSender.ok(result);
813
	}
814
 
815
	@GetMapping(value = "/beatPlan/getPartners")
816
	public ResponseEntity<?> getPartners(
817
			@RequestParam int authUserId,
818
			@RequestParam int categoryId,
819
			@RequestParam(required = false) String startLat,
820
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
821
 
822
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdPartnerIdMapping();
823
		List<Integer> fofoIds = pp.get(authUserId);
824
 
36644 ranu 825
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 826
			Map<String, Object> empty = new HashMap<>();
827
			empty.put("partners", new ArrayList<>());
828
			return responseSender.ok(empty);
829
		}
830
 
831
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
832
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
833
 
834
		List<Map<String, Object>> partners = new ArrayList<>();
835
 
836
		for (FofoStore store : fofoStores) {
837
			if (!store.isActive() || store.isClosed()) continue;
838
			CustomRetailer retailer = retailerMap.get(store.getId());
839
 
840
			Map<String, Object> partnerData = new HashMap<>();
841
			partnerData.put("fofoId", store.getId());
842
			partnerData.put("code", store.getCode());
843
			partnerData.put("outletName", store.getOutletName());
844
			partnerData.put("type", "partner");
845
 
36655 ranu 846
			// Use FofoStore lat/lng directly (migrated from address geocode)
847
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
848
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
849
				partnerData.put("latitude", store.getLatitude());
850
				partnerData.put("longitude", store.getLongitude());
851
			}
852
 
36618 ranu 853
			if (retailer != null) {
854
				partnerData.put("businessName", retailer.getBusinessName());
855
				if (retailer.getAddress() != null) {
36644 ranu 856
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 857
				}
858
			}
859
			partners.add(partnerData);
860
		}
861
 
862
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
863
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
864
		} else {
865
			partners = sortByNearestNeighbor(partners);
866
		}
867
 
868
		Map<String, Object> response = new HashMap<>();
869
		response.put("partners", partners);
870
		return responseSender.ok(response);
871
	}
872
 
873
	@PostMapping(value = "/beatPlan/submitPlan")
874
	public ResponseEntity<?> submitPlan(
875
			HttpServletRequest request,
876
			@RequestParam int authUserId,
877
			@RequestParam String planData) throws Exception {
878
 
879
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
880
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
881
 
882
		Gson gson = new Gson();
883
		Type type = new TypeToken<Map<String, Object>>() {
884
		}.getType();
885
		Map<String, Object> plan = gson.fromJson(planData, type);
886
 
887
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
888
		List<String> dates = (List<String>) plan.get("dates");
889
 
36644 ranu 890
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 891
 
36644 ranu 892
		// Duplicate check — same name + same authUserId = duplicate
893
		List<Beat> existingBeats = beatRepository.selectByAuthUserId(authUserId);
894
		for (Beat existing : existingBeats) {
895
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
896
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 897
				Map<String, Object> response = new HashMap<>();
898
				response.put("status", true);
36644 ranu 899
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 900
				response.put("duplicate", true);
36644 ranu 901
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 902
				return responseSender.ok(response);
903
			}
904
		}
905
 
36644 ranu 906
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
907
		int totalDays = days.size();
36618 ranu 908
 
36644 ranu 909
		// Create Beat master
910
		Beat beat = new Beat();
911
		beat.setName(beatName);
912
		beat.setAuthUserId(authUserId);
913
		beat.setBeatColor(beatColor);
914
		beat.setTotalDays(totalDays);
915
		beat.setActive(true);
916
		beat.setCreatedBy(currentUser.getId());
917
		beat.setCreatedTimestamp(LocalDateTime.now());
918
 
919
		// Set start location from first day
920
		if (!days.isEmpty()) {
921
			Map<String, Object> firstDay = days.get(0);
922
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
923
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
924
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
925
		}
926
		beatRepository.persist(beat);
927
 
928
		// End date of the whole beat = last scheduled day's date
929
		LocalDate beatEndDate = null;
930
		if (dates != null) {
931
			for (int d = dates.size() - 1; d >= 0; d--) {
932
				if (dates.get(d) != null) {
933
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
934
					break;
935
				}
936
			}
937
		}
938
 
939
		// Create routes and schedules for each day
36618 ranu 940
		for (int d = 0; d < days.size(); d++) {
941
			Map<String, Object> day = days.get(d);
942
			int dayNumber = d + 1;
943
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 944
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 945
 
36644 ranu 946
			// Auto-determine end action: last day = HOME, others = DAYBREAK
947
			String endAction = (String) day.get("endAction");
948
			if (endAction == null || endAction.isEmpty()) {
949
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 950
			}
951
 
36644 ranu 952
			// Always create schedule (even if planDate is null — unscheduled beat)
953
			BeatSchedule schedule = new BeatSchedule();
954
			schedule.setBeatId(beat.getId());
955
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
956
			schedule.setEndDate(beatEndDate);
957
			schedule.setDayNumber(dayNumber);
958
			schedule.setEndAction(endAction);
959
			schedule.setStayLocationName((String) day.get("stayLocationName"));
960
			schedule.setStayLatitude((String) day.get("stayLatitude"));
961
			schedule.setStayLongitude((String) day.get("stayLongitude"));
962
			if (day.get("totalDistanceKm") != null)
963
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
964
			if (day.get("totalTimeMins") != null)
965
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
966
			schedule.setCreatedTimestamp(LocalDateTime.now());
967
			beatScheduleRepository.persist(schedule);
968
 
969
			// Routes (stops)
36618 ranu 970
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
971
			if (visits != null) {
972
				for (int i = 0; i < visits.size(); i++) {
973
					Map<String, Object> visit = visits.get(i);
36644 ranu 974
					BeatRoute route = new BeatRoute();
975
					route.setBeatId(beat.getId());
976
					route.setFofoId(((Number) visit.get("id")).intValue());
977
					route.setSequenceOrder(i);
978
					route.setDayNumber(dayNumber);
979
					route.setActive(true);
980
					beatRouteRepository.persist(route);
36618 ranu 981
				}
982
			}
983
		}
984
 
985
		Map<String, Object> response = new HashMap<>();
986
		response.put("status", true);
36644 ranu 987
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 988
		response.put("message", "Beat plan submitted successfully");
989
		return responseSender.ok(response);
990
	}
991
 
36632 ranu 992
	// ============ BULK UPLOAD ============
993
 
994
	@GetMapping(value = "/beatPlan/bulkUpload")
995
	public String bulkUploadPage(HttpServletRequest request, Model model) {
996
		return "beat-plan-bulk";
997
	}
998
 
36668 ranu 999
	@PostMapping(value = "/beatPlan/updateBaseLocation")
1000
	public ResponseEntity<?> updateBaseLocation(
1001
			HttpServletRequest request,
1002
			@RequestParam int authUserId,
1003
			@RequestParam String locationName,
1004
			@RequestParam String latitude,
1005
			@RequestParam String longitude,
1006
			@RequestParam(required = false) String address) throws Exception {
1007
 
1008
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1009
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1010
		if (me == null) return responseSender.unauthorized("Not logged in");
1011
 
1012
		// Permission gate: Sales L3+ OR explicit allow-list
1013
		// Allow-list (lowercase emails) — people outside Sales L3/L4 who still need
1014
		// to manage base locations (e.g., Tarun Verma).
1015
		Set<String> baseLocationAllowList = new HashSet<>(Arrays.asList(
1016
				"tarun.verma@smartdukaan.com"
1017
		));
1018
 
1019
		String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";
1020
		boolean isWhitelisted = baseLocationAllowList.contains(myEmail);
1021
 
1022
		boolean isSalesL3Plus = csService.getAuthUserIds(
1023
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
1024
						Arrays.asList(EscalationType.L3, EscalationType.L4))
1025
				.stream().anyMatch(u -> u.getId() == me.getId());
1026
 
1027
		if (!isSalesL3Plus && !isWhitelisted) {
1028
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");
1029
		}
1030
 
1031
		AuthUserLocation loc = new AuthUserLocation();
1032
		loc.setAuthUserId(authUserId);
1033
		loc.setLocationType("BASE");
1034
		loc.setLocationName(locationName);
1035
		loc.setLatitude(latitude);
1036
		loc.setLongitude(longitude);
1037
		loc.setAddress(address);
1038
		loc.setCreatedTimestamp(LocalDateTime.now());
1039
		authUserLocationRepository.persist(loc);
1040
 
1041
		Map<String, Object> result = new HashMap<>();
1042
		result.put("status", true);
1043
		result.put("id", loc.getId());
1044
		result.put("message", "Base location updated");
1045
		return responseSender.ok(result);
1046
	}
1047
 
36632 ranu 1048
	@GetMapping(value = "/beatPlan/downloadTemplate")
36668 ranu 1049
	public ResponseEntity<?> downloadTemplate() throws java.io.IOException {
1050
		org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
1051
		org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");
36632 ranu 1052
 
36668 ranu 1053
		String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};
1054
 
1055
		// Header style
1056
		org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();
1057
		org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();
1058
		headerFont.setBold(true);
1059
		headerStyle.setFont(headerFont);
1060
		headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));
1061
		headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
1062
 
1063
		org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);
1064
		for (int i = 0; i < cols.length; i++) {
1065
			org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);
1066
			c.setCellValue(cols[i]);
1067
			c.setCellStyle(headerStyle);
1068
		}
1069
 
1070
		// Example rows — one partner per row. Inheritable columns blank after first row of a beat.
1071
		Object[][] sample = {
1072
				{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},
1073
				{"", "", "", "1", "2", "RJBUN1449"},
1074
				{"", "", "", "1", "3", "RJDEG1443"},
1075
				{"", "", "", "2", "1", "RJALR1362"},
1076
				{"", "", "", "2", "2", "RJBTR1388"},
1077
				{"", "", "", "3", "1", "RJRSD1518"},
1078
				{"", "", "", "3", "2", "RJSML356"},
1079
				{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},
1080
				{"", "", "", "1", "2", "UPAGR102"},
1081
		};
1082
		for (int r = 0; r < sample.length; r++) {
1083
			org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);
1084
			for (int c = 0; c < cols.length; c++) {
1085
				row.createCell(c).setCellValue(sample[r][c].toString());
1086
			}
1087
		}
1088
		for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);
1089
 
1090
		java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1091
		wb.write(out);
1092
		wb.close();
1093
 
36632 ranu 1094
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
36668 ranu 1095
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");
1096
		headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
1097
		return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
36632 ranu 1098
	}
1099
 
1100
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
1101
	public ResponseEntity<?> bulkUploadProcess(
1102
			HttpServletRequest request,
1103
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
1104
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
1105
 
1106
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1107
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1108
 
36668 ranu 1109
		// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date
1110
		// appear ONLY on the first row of a beat; subsequent rows inherit them.
1111
		org.apache.poi.ss.usermodel.Workbook workbook =
1112
				new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());
1113
		org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
36632 ranu 1114
 
36668 ranu 1115
		// Header → column index map
1116
		org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);
1117
		if (headerRow == null) {
1118
			workbook.close();
1119
			return responseSender.badRequest("Empty file");
1120
		}
1121
		Map<String, Integer> colIdx = new HashMap<>();
1122
		for (int i = 0; i < headerRow.getLastCellNum(); i++) {
1123
			String h = readCell(headerRow.getCell(i));
1124
			if (h != null) colIdx.put(h.trim().toLowerCase(), i);
1125
		}
1126
		for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {
1127
			if (!colIdx.containsKey(required)) {
1128
				workbook.close();
1129
				return responseSender.badRequest("Missing required column: " + required);
1130
			}
1131
		}
36632 ranu 1132
 
36668 ranu 1133
		// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_order
1134
		Map<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();
1135
		String currentKey = null;
1136
		String currentBeatName = null;
1137
		String currentAuthId = null;
1138
		String currentStartDate = null;
1139
 
1140
		for (int r = 1; r <= sheet.getLastRowNum(); r++) {
1141
			org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
1142
			if (row == null) continue;
1143
 
1144
			String beatName = readCell(row.getCell(colIdx.get("beat_name")));
1145
			String authId = readCell(row.getCell(colIdx.get("auth_user_id")));
1146
			String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;
1147
			String dayNumber = readCell(row.getCell(colIdx.get("day_number")));
1148
			String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;
1149
			String code = readCell(row.getCell(colIdx.get("partner_code")));
1150
 
1151
			if (beatName != null && !beatName.trim().isEmpty()) {
1152
				// Start of a new beat — capture inheritable fields
1153
				currentBeatName = beatName.trim().replaceAll("\\s+", " ");
1154
				currentAuthId = authId != null ? authId.trim() : null;
1155
				currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;
1156
				currentKey = currentBeatName + "|" + currentAuthId;
1157
			}
1158
			if (currentKey == null) continue; // partner row before any beat header — skip
1159
			if (code == null || code.trim().isEmpty()) continue;
1160
 
1161
			final String beatNameF = currentBeatName;
1162
			final String authIdF = currentAuthId;
1163
			final String startDateF = currentStartDate;
1164
			BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));
1165
 
1166
			int day;
1167
			try {
1168
				day = Integer.parseInt(dayNumber.trim());
1169
			} catch (Exception e) {
1170
				continue;
1171
			} // bad day → skip row
1172
 
1173
			int seq = -1;
1174
			if (seqOrder != null && !seqOrder.trim().isEmpty()) {
1175
				try {
1176
					seq = Integer.parseInt(seqOrder.trim());
1177
				} catch (Exception ignore) {
1178
				}
1179
			}
1180
			g.addPartner(day, seq, code.trim(), r + 1);
36632 ranu 1181
		}
36668 ranu 1182
		workbook.close();
36632 ranu 1183
 
1184
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
1185
		Map<String, Integer> codeToId = new HashMap<>();
36644 ranu 1186
		for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());
36632 ranu 1187
 
1188
		LocalDate holidayStart = LocalDate.now();
36644 ranu 1189
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 1190
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
1191
 
36644 ranu 1192
		int beatsCreated = 0, errors = 0;
36632 ranu 1193
		List<String> errorMessages = new ArrayList<>();
1194
 
36668 ranu 1195
		for (BulkBeatGroup g : beatGroups.values()) {
36632 ranu 1196
			try {
36668 ranu 1197
				String beatName = g.beatName;
1198
				int authUserId;
1199
				try {
1200
					authUserId = Integer.parseInt(g.authUserId);
1201
				} catch (Exception e) {
1202
					errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'. Skipped.");
1203
					errors++;
1204
					continue;
1205
				}
36632 ranu 1206
 
36668 ranu 1207
				LocalDate startDate = (g.startDate == null) ? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
36632 ranu 1208
				if (startDate != null && startDate.isBefore(LocalDate.now())) {
36644 ranu 1209
					errorMessages.add("Beat '" + beatName + "': start_date in past. Skipped.");
36632 ranu 1210
					errors++;
1211
					continue;
1212
				}
1213
 
36668 ranu 1214
				List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
1215
				Collections.sort(sortedDays);
1216
 
36632 ranu 1217
				List<LocalDate> scheduleDates = new ArrayList<>();
1218
				if (startDate != null) {
1219
					LocalDate d = startDate;
36668 ranu 1220
					while (scheduleDates.size() < sortedDays.size()) {
1221
						if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
36644 ranu 1222
							d = d.plusDays(1);
1223
							continue;
36632 ranu 1224
						}
36644 ranu 1225
						scheduleDates.add(d);
36632 ranu 1226
						d = d.plusDays(1);
1227
					}
1228
				}
1229
 
36668 ranu 1230
				// Duplicate check
36644 ranu 1231
				boolean isDuplicate = beatRepository.selectByAuthUserId(authUserId).stream()
1232
						.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
1233
				if (isDuplicate) {
1234
					errorMessages.add("Beat '" + beatName + "' already exists for user " + authUserId + ". Skipped.");
1235
					errors++;
1236
					continue;
1237
				}
36632 ranu 1238
 
36644 ranu 1239
				String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
36632 ranu 1240
				AuthUserLocation homeLoc = authUserLocationRepository.selectLatestByAuthUserIdAndType(authUserId, "BASE");
1241
 
36644 ranu 1242
				Beat beat = new Beat();
1243
				beat.setName(beatName);
1244
				beat.setAuthUserId(authUserId);
1245
				beat.setBeatColor(beatColor);
36668 ranu 1246
				beat.setTotalDays(sortedDays.size());
36644 ranu 1247
				beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
1248
				beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
1249
				beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
1250
				beat.setActive(true);
1251
				beat.setCreatedBy(currentUser.getId());
1252
				beat.setCreatedTimestamp(LocalDateTime.now());
1253
				beatRepository.persist(beat);
36632 ranu 1254
 
36668 ranu 1255
				LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
36632 ranu 1256
 
36668 ranu 1257
				for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
1258
					int dayNumber = sortedDays.get(dayIdx);
1259
					LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
1260
 
36644 ranu 1261
					BeatSchedule schedule = new BeatSchedule();
1262
					schedule.setBeatId(beat.getId());
1263
					schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
1264
					schedule.setEndDate(bulkEndDate);
1265
					schedule.setDayNumber(dayNumber);
36668 ranu 1266
					schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
36644 ranu 1267
					schedule.setCreatedTimestamp(LocalDateTime.now());
1268
					beatScheduleRepository.persist(schedule);
36632 ranu 1269
 
36668 ranu 1270
					List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
1271
					// Sort by explicit sequence_order when present, else by row order
1272
					partners.sort((a, b) -> {
1273
						if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
1274
						if (a.seq >= 0) return -1;
1275
						if (b.seq >= 0) return 1;
1276
						return Integer.compare(a.rowNum, b.rowNum);
1277
					});
1278
 
1279
					int autoSeq = 0;
1280
					for (BulkPartner p : partners) {
1281
						Integer fofoId = codeToId.get(p.code);
36632 ranu 1282
						if (fofoId == null) {
36668 ranu 1283
							errorMessages.add("Row " + p.rowNum + ": code not found '" + p.code + "'");
36632 ranu 1284
							errors++;
1285
							continue;
1286
						}
36644 ranu 1287
						BeatRoute route = new BeatRoute();
1288
						route.setBeatId(beat.getId());
1289
						route.setFofoId(fofoId);
36668 ranu 1290
						route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
36644 ranu 1291
						route.setDayNumber(dayNumber);
1292
						route.setActive(true);
1293
						beatRouteRepository.persist(route);
36668 ranu 1294
						autoSeq++;
36632 ranu 1295
					}
1296
				}
1297
				beatsCreated++;
1298
			} catch (Exception e) {
1299
				errors++;
36668 ranu 1300
				errorMessages.add("Error: " + g.beatName + " - " + e.getMessage());
36632 ranu 1301
			}
1302
		}
1303
 
1304
		Map<String, Object> response = new HashMap<>();
1305
		response.put("status", true);
1306
		response.put("beatsCreated", beatsCreated);
1307
		response.put("errors", errors);
1308
		response.put("errorMessages", errorMessages);
1309
		return responseSender.ok(response);
1310
	}
1311
 
36668 ranu 1312
	private static class BulkBeatGroup {
1313
		final String beatName;
1314
		final String authUserId;
1315
		final String startDate;
1316
		final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();
1317
 
1318
		BulkBeatGroup(String beatName, String authUserId, String startDate) {
1319
			this.beatName = beatName;
1320
			this.authUserId = authUserId;
1321
			this.startDate = startDate;
1322
		}
1323
 
1324
		void addPartner(int day, int seq, String code, int rowNum) {
1325
			dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));
1326
		}
1327
	}
1328
 
1329
	private static class BulkPartner {
1330
		final int seq;
1331
		final String code;
1332
		final int rowNum;
1333
 
1334
		BulkPartner(int seq, String code, int rowNum) {
1335
			this.seq = seq;
1336
			this.code = code;
1337
			this.rowNum = rowNum;
1338
		}
1339
	}
1340
 
36644 ranu 1341
	// ============ CALENDAR ============
36618 ranu 1342
 
1343
	@PostMapping(value = "/beatPlan/delete")
1344
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 1345
		int beatId = Integer.parseInt(planGroupId);
1346
		beatRouteRepository.deleteByBeatId(beatId);
1347
		beatScheduleRepository.deleteByBeatId(beatId);
1348
		Beat beat = beatRepository.selectById(beatId);
1349
		if (beat != null) {
1350
			beat.setActive(false);
1351
		}
36618 ranu 1352
 
1353
		Map<String, Object> response = new HashMap<>();
1354
		response.put("status", true);
1355
		response.put("message", "Beat deleted");
1356
		return responseSender.ok(response);
1357
	}
1358
 
36670 ranu 1359
	// Unschedule the beat from ONE specific date — does NOT delete the beat.
1360
	// The beat (and its route template) stays; only the matching beat_schedule
1361
	// row is removed. If no real-date schedules remain, a placeholder
1362
	// (9999-12-31) row is added so the beat still shows up as "unscheduled".
1363
	@PostMapping(value = "/beatPlan/unscheduleDate")
1364
	public ResponseEntity<?> unscheduleDate(
1365
			@RequestParam String planGroupId,
1366
			@RequestParam String date) {
1367
		int beatId = Integer.parseInt(planGroupId);
1368
		LocalDate target = LocalDate.parse(date);
1369
 
1370
		Beat beat = beatRepository.selectById(beatId);
1371
		if (beat == null) return responseSender.badRequest("Beat not found");
1372
 
1373
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
1374
		int removed = 0;
1375
		for (BeatSchedule s : schedules) {
1376
			if (s.getStartDate() != null && s.getStartDate().equals(target)) {
1377
				beatScheduleRepository.delete(s);
1378
				removed++;
1379
			}
1380
		}
1381
		if (removed == 0) return responseSender.badRequest("No schedule found for that date");
1382
 
1383
		// If no real-date schedules left, drop in a placeholder so the beat
1384
		// remains visible in the unscheduled bucket.
1385
		boolean hasReal = schedules.stream()
1386
				.filter(s -> !s.getStartDate().equals(target))
1387
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);
1388
		if (!hasReal) {
1389
			boolean hasPlaceholder = schedules.stream()
1390
					.filter(s -> !s.getStartDate().equals(target))
1391
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);
1392
			if (!hasPlaceholder) {
1393
				BeatSchedule ph = new BeatSchedule();
1394
				ph.setBeatId(beatId);
1395
				ph.setStartDate(LocalDate.of(9999, 12, 31));
1396
				ph.setDayNumber(1);
1397
				ph.setEndAction("HOME");
1398
				ph.setCreatedTimestamp(LocalDateTime.now());
1399
				beatScheduleRepository.persist(ph);
1400
			}
1401
		}
1402
 
1403
		Map<String, Object> response = new HashMap<>();
1404
		response.put("status", true);
1405
		response.put("message", "Unscheduled from " + date);
1406
		return responseSender.ok(response);
1407
	}
1408
 
1409
	// Move a beat from one date to another — used by calendar drag-and-drop.
1410
	// Just updates the start_date of the matching beat_schedule row.
1411
	@PostMapping(value = "/beatPlan/moveScheduleDate")
1412
	public ResponseEntity<?> moveScheduleDate(
1413
			@RequestParam String planGroupId,
1414
			@RequestParam String fromDate,
1415
			@RequestParam String toDate) {
1416
		int beatId = Integer.parseInt(planGroupId);
1417
		LocalDate from = LocalDate.parse(fromDate);
1418
		LocalDate to = LocalDate.parse(toDate);
1419
 
1420
		if (from.equals(to)) {
1421
			Map<String, Object> ok = new HashMap<>();
1422
			ok.put("status", true);
1423
			ok.put("message", "Same date — no change");
1424
			return responseSender.ok(ok);
1425
		}
1426
 
1427
		Beat beat = beatRepository.selectById(beatId);
1428
		if (beat == null) return responseSender.badRequest("Beat not found");
1429
 
1430
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
1431
 
1432
		// Reject if the beat is already on the target date
1433
		boolean conflict = schedules.stream()
1434
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
1435
		if (conflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
1436
 
1437
		BeatSchedule match = schedules.stream()
1438
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
1439
				.findFirst().orElse(null);
1440
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
1441
 
1442
		match.setStartDate(to);
1443
		// Recompute endDate as the max across all (post-update) schedules
1444
		LocalDate newEnd = schedules.stream()
1445
				.map(s -> s == match ? to : s.getStartDate())
1446
				.filter(d -> d != null && d.getYear() != 9999)
1447
				.max(LocalDate::compareTo).orElse(to);
1448
		for (BeatSchedule s : schedules) {
1449
			if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {
1450
				s.setEndDate(newEnd);
1451
			}
1452
		}
1453
 
1454
		Map<String, Object> response = new HashMap<>();
1455
		response.put("status", true);
1456
		response.put("message", "Moved from " + fromDate + " to " + toDate);
1457
		return responseSender.ok(response);
1458
	}
1459
 
36618 ranu 1460
	@GetMapping(value = "/beatPlan/calendar")
1461
	public ResponseEntity<?> getCalendar(
1462
			@RequestParam int authUserId,
1463
			@RequestParam String month) {
1464
 
1465
		YearMonth ym = YearMonth.parse(month);
1466
		LocalDate startDate = ym.atDay(1);
1467
		LocalDate endDate = ym.atEndOfMonth();
1468
 
1469
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
1470
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
1471
			Map<String, String> m = new HashMap<>();
1472
			m.put("date", h.getDate().toString());
1473
			m.put("occasion", h.getOccasion());
1474
			return m;
1475
		}).collect(Collectors.toList());
1476
 
36644 ranu 1477
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 1478
		LocalDate today = LocalDate.now();
1479
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
1480
 
36644 ranu 1481
		for (Beat beat : allBeats) {
1482
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
1483
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 1484
 
36644 ranu 1485
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
1486
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
1487
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
1488
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 1489
 
1490
			String status;
1491
			if (allNullDates) status = "unscheduled";
1492
			else if (hasToday) status = "running";
1493
			else if (allPast) status = "completed";
36644 ranu 1494
			else status = "scheduled";
36618 ranu 1495
 
36644 ranu 1496
			Map<String, Object> beatInfo = new HashMap<>();
1497
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
1498
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
1499
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
1500
			beatInfo.put("status", status);
36618 ranu 1501
 
1502
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 1503
			for (BeatSchedule s : schedules) {
36618 ranu 1504
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 1505
				dayInfo.put("dayNumber", s.getDayNumber());
1506
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
1507
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
1508
				dayInfo.put("totalKm", s.getTotalDistanceKm());
1509
				dayInfo.put("totalMins", s.getTotalTimeMins());
1510
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
1511
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 1512
				dayInfoList.add(dayInfo);
1513
			}
36644 ranu 1514
			if (schedules.isEmpty()) {
1515
				// No schedule at all — show from routes
1516
				Map<Integer, Long> dayCounts = routes.stream()
1517
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
1518
				for (int d = 1; d <= beat.getTotalDays(); d++) {
1519
					Map<String, Object> dayInfo = new HashMap<>();
1520
					dayInfo.put("dayNumber", d);
1521
					dayInfo.put("planDate", null);
1522
					dayInfo.put("totalKm", null);
1523
					dayInfo.put("totalMins", null);
1524
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
1525
					dayInfoList.add(dayInfo);
1526
				}
1527
			}
1528
			beatInfo.put("days", dayInfoList);
1529
			scheduledBeats.add(beatInfo);
36618 ranu 1530
		}
1531
 
1532
		Set<String> blockedDates = new HashSet<>();
1533
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 1534
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 1535
		}
36644 ranu 1536
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 1537
 
1538
		Map<String, Object> response = new HashMap<>();
1539
		response.put("holidays", holidayList);
1540
		response.put("scheduledBeats", scheduledBeats);
1541
		response.put("blockedDates", blockedDates);
1542
		return responseSender.ok(response);
1543
	}
1544
 
1545
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
1546
	public ResponseEntity<?> scheduleOnCalendar(
1547
			HttpServletRequest request,
1548
			@RequestParam String planGroupId,
1549
			@RequestParam String dates,
1550
			@RequestParam(required = false) String beatName,
1551
			@RequestParam(required = false) String beatColor) throws Exception {
1552
 
36644 ranu 1553
		int beatId = Integer.parseInt(planGroupId);
36618 ranu 1554
		Gson gson = new Gson();
1555
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
1556
		}.getType());
1557
 
36644 ranu 1558
		Beat beat = beatRepository.selectById(beatId);
1559
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 1560
 
36644 ranu 1561
		if (beatName != null) beat.setName(beatName);
1562
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36618 ranu 1563
 
36644 ranu 1564
		// Delete old schedules and create new
1565
		beatScheduleRepository.deleteByBeatId(beatId);
1566
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
1567
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
1568
			BeatSchedule schedule = new BeatSchedule();
1569
			schedule.setBeatId(beatId);
1570
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
1571
			schedule.setEndDate(schEndDate);
1572
			schedule.setDayNumber(i + 1);
1573
			schedule.setEndAction(i == dateList.size() - 1 ? "HOME" : "DAYBREAK");
1574
			schedule.setCreatedTimestamp(LocalDateTime.now());
1575
			beatScheduleRepository.persist(schedule);
36618 ranu 1576
		}
1577
 
1578
		Map<String, Object> response = new HashMap<>();
1579
		response.put("status", true);
1580
		response.put("message", "Beat scheduled successfully");
1581
		return responseSender.ok(response);
1582
	}
1583
 
36644 ranu 1584
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 1585
	@PostMapping(value = "/beatPlan/repeatBeat")
1586
	public ResponseEntity<?> repeatBeat(
1587
			HttpServletRequest request,
1588
			@RequestParam String sourcePlanGroupId,
1589
			@RequestParam int authUserId,
1590
			@RequestParam String dates) throws Exception {
1591
 
36644 ranu 1592
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 1593
		Gson gson = new Gson();
1594
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
1595
		}.getType());
1596
 
36644 ranu 1597
		Beat beat = beatRepository.selectById(beatId);
1598
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 1599
 
36644 ranu 1600
		// Remove placeholder (unscheduled) schedule rows
1601
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
1602
		for (BeatSchedule s : existing) {
1603
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
1604
				beatScheduleRepository.delete(s);
1605
			}
36618 ranu 1606
		}
1607
 
36644 ranu 1608
		// Add new real-date schedule rows for the existing beat
1609
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
1610
		for (int i = 0; i < dateList.size(); i++) {
1611
			BeatSchedule schedule = new BeatSchedule();
1612
			schedule.setBeatId(beatId);
1613
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
1614
			schedule.setEndDate(repeatEndDate);
1615
			schedule.setDayNumber(i + 1);
1616
			schedule.setEndAction(i == dateList.size() - 1 ? "HOME" : "DAYBREAK");
1617
			schedule.setCreatedTimestamp(LocalDateTime.now());
1618
			beatScheduleRepository.persist(schedule);
36618 ranu 1619
		}
1620
 
1621
		Map<String, Object> response = new HashMap<>();
1622
		response.put("status", true);
36644 ranu 1623
		response.put("planGroupId", String.valueOf(beatId));
1624
		response.put("message", "Beat scheduled successfully");
36618 ranu 1625
		return responseSender.ok(response);
1626
	}
1627
 
1628
	@GetMapping(value = "/beatPlan/availableSlots")
1629
	public ResponseEntity<?> getAvailableSlots(
1630
			@RequestParam int authUserId,
1631
			@RequestParam String month,
1632
			@RequestParam int daysNeeded) {
1633
 
1634
		YearMonth ym = YearMonth.parse(month);
1635
		LocalDate startDate = ym.atDay(1);
1636
		LocalDate endDate = ym.atEndOfMonth();
1637
		LocalDate today = LocalDate.now();
1638
 
1639
		Set<LocalDate> blocked = new HashSet<>();
1640
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
1641
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 1642
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 1643
		}
1644
 
1645
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
1646
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
1647
 
36644 ranu 1648
		// Get all scheduled dates for this user
1649
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
1650
		for (Beat b : userBeats) {
1651
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
1652
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 1653
		}
1654
 
1655
		List<String> available = new ArrayList<>();
1656
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
1657
			 !d.isAfter(endDate) && available.size() < daysNeeded;
1658
			 d = d.plusDays(1)) {
36644 ranu 1659
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 1660
		}
1661
 
1662
		Map<String, Object> response = new HashMap<>();
1663
		response.put("suggestedDates", available);
1664
		response.put("totalAvailable", available.size());
1665
		return responseSender.ok(response);
1666
	}
1667
 
1668
	// --- Sorting helpers ---
1669
 
1670
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
1671
			List<Map<String, Object>> partners, double startLat, double startLng) {
1672
		List<Map<String, Object>> withCoords = new ArrayList<>();
1673
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
1674
		for (Map<String, Object> p : partners) {
36644 ranu 1675
			if (hasValidCoords(p)) withCoords.add(p);
1676
			else withoutCoords.add(p);
36618 ranu 1677
		}
1678
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 1679
		double currentLat = startLat, currentLng = startLng;
36618 ranu 1680
		while (!withCoords.isEmpty()) {
1681
			int nearestIdx = 0;
1682
			double nearestDist = Double.MAX_VALUE;
1683
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 1684
				double dist = haversine(currentLat, currentLng,
1685
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
1686
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 1687
				if (dist < nearestDist) {
1688
					nearestDist = dist;
1689
					nearestIdx = i;
1690
				}
1691
			}
1692
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
1693
			sorted.add(nearest);
1694
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
1695
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
1696
		}
1697
		sorted.addAll(withoutCoords);
1698
		return sorted;
1699
	}
1700
 
1701
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
1702
		List<Map<String, Object>> withCoords = new ArrayList<>();
1703
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
1704
		for (Map<String, Object> p : partners) {
36644 ranu 1705
			if (hasValidCoords(p)) withCoords.add(p);
1706
			else withoutCoords.add(p);
36618 ranu 1707
		}
1708
		List<Map<String, Object>> sorted = new ArrayList<>();
1709
		if (!withCoords.isEmpty()) {
1710
			sorted.add(withCoords.remove(0));
1711
			while (!withCoords.isEmpty()) {
1712
				Map<String, Object> last = sorted.get(sorted.size() - 1);
1713
				double lastLat = Double.parseDouble(last.get("latitude").toString());
1714
				double lastLng = Double.parseDouble(last.get("longitude").toString());
1715
				int nearestIdx = 0;
1716
				double nearestDist = Double.MAX_VALUE;
1717
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 1718
					double dist = haversine(lastLat, lastLng,
1719
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
1720
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 1721
					if (dist < nearestDist) {
1722
						nearestDist = dist;
1723
						nearestIdx = i;
1724
					}
1725
				}
1726
				sorted.add(withCoords.remove(nearestIdx));
1727
			}
1728
		}
1729
		sorted.addAll(withoutCoords);
1730
		return sorted;
1731
	}
1732
 
1733
	private boolean hasValidCoords(Map<String, Object> p) {
1734
		Object lat = p.get("latitude");
1735
		Object lng = p.get("longitude");
36644 ranu 1736
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 1737
	}
1738
 
1739
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
1740
		double R = 6371;
1741
		double dLat = Math.toRadians(lat2 - lat1);
1742
		double dLng = Math.toRadians(lng2 - lng1);
1743
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
1744
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
1745
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
1746
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
1747
		return R * c;
1748
	}
1749
}