Subversion Repositories SmartDukaan

Rev

Rev 36686 | Rev 36711 | 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;
36686 ranu 53
	// Emails that bypass hierarchy and role gates — single source of truth.
54
	private static final Set<String> SUPER_ADMIN_EMAILS = new HashSet<>(Arrays.asList(
55
			"tarun.verma@smartdukaan.com"
56
	));
36618 ranu 57
	@Autowired
36686 ranu 58
	private com.spice.profitmandi.service.AuthService authService;
36618 ranu 59
	@Autowired
36686 ranu 60
	private com.spice.profitmandi.dao.repository.cs.PositionRepository positionRepository;
61
	@Autowired
36618 ranu 62
	private RetailerService retailerService;
63
	@Autowired
36644 ranu 64
	private BeatRepository beatRepository;
36618 ranu 65
	@Autowired
36644 ranu 66
	private BeatRouteRepository beatRouteRepository;
36618 ranu 67
	@Autowired
36644 ranu 68
	private BeatScheduleRepository beatScheduleRepository;
69
	@Autowired
70
	private LeadRouteRepository leadRouteRepository;
71
	@Autowired
36650 ranu 72
	private com.spice.profitmandi.dao.service.BeatPlanQueryService beatPlanQueryService;
73
	@Autowired
36618 ranu 74
	private AuthUserLocationRepository authUserLocationRepository;
75
	@Autowired
76
	private LeadRepository leadRepository;
77
	@Autowired
78
	private PublicHolidaysRepository publicHolidaysRepository;
79
	@Autowired
80
	private com.spice.profitmandi.service.GeocodingService geocodingService;
81
	@Autowired
82
	private CookiesProcessor cookiesProcessor;
83
	@Autowired
84
	private ResponseSender responseSender;
36686 ranu 85
	@Autowired
86
	private FofoStoreRepository fofoStoreRepository;
36618 ranu 87
 
88
	@GetMapping(value = "/beatPlan")
36686 ranu 89
	public String beatPlan(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
90
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36618 ranu 91
		return "beat-plan";
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
 
36686 ranu 275
	@GetMapping(value = "/beatPlanWindow")
276
	public String beatPlanWindow(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
277
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
278
		return "beat-plan-window";
36655 ranu 279
	}
280
 
36668 ranu 281
	// Helpers for XLSX bulk upload
282
	private static String readCell(org.apache.poi.ss.usermodel.Cell cell) {
283
		if (cell == null) return null;
284
		switch (cell.getCellType()) {
285
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:
286
				return cell.getStringCellValue();
287
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:
288
				if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
289
					return cell.getDateCellValue().toInstant()
290
							.atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();
291
				}
292
				double n = cell.getNumericCellValue();
293
				return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);
294
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:
295
				return String.valueOf(cell.getBooleanCellValue());
296
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:
297
				return cell.getCellFormula();
298
			default:
299
				return null;
36655 ranu 300
		}
301
	}
302
 
303
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
304
	// For each active fofo_store, compare its stored lat/lng with the geocoded
305
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
306
	// OR the store has no lat/lng yet, update the store with the geocoded
307
	// coordinates. Otherwise keep the existing values.
308
	//
309
	// Usage:
310
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
311
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
312
	//   ?thresholdKm=3      -> use a different threshold
313
	//   ?limit=100          -> process only N stores (for staged runs)
314
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
315
	public ResponseEntity<?> migrateStoreLatLng(
316
			@RequestParam(required = false, defaultValue = "false") boolean apply,
317
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
36660 ranu 318
			@RequestParam(required = false, defaultValue = "0") int limit,
319
			@RequestParam(required = false, defaultValue = "0") int offset) throws ProfitMandiBusinessException {
36655 ranu 320
 
36660 ranu 321
		List<FofoStore> all = fofoStoreRepository.selectActiveStores();
322
		int totalAvailable = all.size();
323
		// offset + limit so this can be run in batches against large datasets
324
		int from = Math.max(0, Math.min(offset, totalAvailable));
325
		int to = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;
326
		List<FofoStore> stores = all.subList(from, to);
36655 ranu 327
 
328
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
329
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
330
 
331
		int total = stores.size();
332
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
333
		List<Map<String, Object>> changes = new ArrayList<>();
334
 
335
		for (FofoStore store : stores) {
336
			try {
337
				CustomRetailer retailer = retailerMap.get(store.getId());
338
				if (retailer == null || retailer.getAddress() == null) {
339
					noAddress++;
340
					continue;
341
				}
342
 
343
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
344
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
345
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
346
				if (geoAddr == null || geoAddr.isEmpty()) {
347
					noAddress++;
348
					continue;
349
				}
350
 
351
				double[] coords = geocodingService.geocodeAddress(geoAddr);
352
				if (coords == null) {
353
					noGeocode++;
354
					continue;
355
				}
356
 
357
				Double existingLat = parseDoubleOrNull(store.getLatitude());
358
				Double existingLng = parseDoubleOrNull(store.getLongitude());
359
 
360
				boolean shouldUpdate;
361
				double distKm = -1;
362
				String reason;
363
				if (existingLat == null || existingLng == null) {
364
					shouldUpdate = true;
365
					reason = "missing existing lat/lng";
366
				} else {
367
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
368
					shouldUpdate = distKm > thresholdKm;
369
					reason = shouldUpdate
370
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
371
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
372
				}
373
 
374
				if (shouldUpdate) {
375
					if (apply) {
376
						store.setLatitude(String.valueOf(coords[0]));
377
						store.setLongitude(String.valueOf(coords[1]));
378
						fofoStoreRepository.persist(store);
379
					}
380
					updated++;
381
					Map<String, Object> ch = new HashMap<>();
382
					ch.put("storeId", store.getId());
383
					ch.put("code", store.getCode());
384
					ch.put("oldLat", existingLat);
385
					ch.put("oldLng", existingLng);
386
					ch.put("newLat", coords[0]);
387
					ch.put("newLng", coords[1]);
388
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
389
					ch.put("reason", reason);
390
					changes.add(ch);
391
				} else {
392
					kept++;
393
				}
394
			} catch (Exception e) {
395
				errored++;
396
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
397
			}
398
		}
399
 
400
		Map<String, Object> result = new HashMap<>();
401
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
402
		result.put("thresholdKm", thresholdKm);
36660 ranu 403
		result.put("totalAvailable", totalAvailable);  // total active stores in DB
404
		result.put("offset", from);
405
		result.put("processed", total);                 // stores processed this run
406
		result.put("nextOffset", to);                   // next offset to resume (or = totalAvailable when done)
407
		result.put("done", to >= totalAvailable);
36655 ranu 408
		result.put("updated", updated);
409
		result.put("kept", kept);
410
		result.put("noAddress", noAddress);
411
		result.put("noGeocode", noGeocode);
412
		result.put("errored", errored);
413
		// Limit changes preview to avoid huge responses
414
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
415
		result.put("changesShownCount", Math.min(changes.size(), 200));
416
		return responseSender.ok(result);
417
	}
418
 
36651 ranu 419
	// ====================== EDIT BEAT ======================
420
	// Update an existing beat — name + partner stops (routes).
421
	// Schedules are NOT touched here; manage them via calendar drag-drop.
422
	@PostMapping(value = "/beatPlan/updateBeat")
423
	public ResponseEntity<?> updateBeat(
424
			HttpServletRequest request,
425
			@RequestParam int beatId,
426
			@RequestParam String planData) throws Exception {
427
 
428
		Beat beat = beatRepository.selectById(beatId);
429
		if (beat == null) return responseSender.badRequest("Beat not found");
430
 
431
		Gson gson = new Gson();
432
		Type type = new TypeToken<Map<String, Object>>() {
433
		}.getType();
434
		Map<String, Object> plan = gson.fromJson(planData, type);
435
 
436
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
437
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
438
 
439
		// Update name if changed (and not colliding with another beat)
440
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
441
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
36698 ranu 442
			// Make sure no other ACTIVE beat for this user already uses this name.
443
			// Soft-deleted beats keep their name in the table; we don't want them
444
			// to block a legitimate rename.
445
			boolean collides = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId()).stream()
36651 ranu 446
					.anyMatch(b -> b.getId() != beat.getId()
447
							&& b.getName() != null
448
							&& newName.equalsIgnoreCase(b.getName().trim()));
449
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
450
			beat.setName(newName);
451
		}
452
 
453
		// Update start location from first day if present
454
		Map<String, Object> firstDay = days.get(0);
455
		if (firstDay.get("startLocationName") != null)
456
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
457
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
458
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
459
 
36681 ranu 460
		int oldTotalDays = beat.getTotalDays();
461
		int newTotalDays = days.size();
462
 
463
		// Hard rule: you cannot grow the number of days on an existing beat.
464
		// If you need more days, create a new beat. (Shrinking is allowed and
465
		// the schedules for dropped day numbers are cleaned below.)
466
		if (newTotalDays > oldTotalDays) {
467
			return responseSender.badRequest(
468
					"Cannot increase the number of days on an existing beat. "
469
							+ "Original: " + oldTotalDays + " day(s), tried: " + newTotalDays + " day(s). "
470
							+ "Please create a new beat for additional days.");
471
		}
472
		beat.setTotalDays(newTotalDays);
473
 
474
		// Replace routes (partner stops). Schedules stay intact (except for
475
		// dayNumber > newTotalDays cleanup below if the beat shrank).
36651 ranu 476
		beatRouteRepository.deleteByBeatId(beatId);
36681 ranu 477
		// Collect lead IDs the user kept on the plan
36651 ranu 478
		Set<Integer> keptLeadIds = new HashSet<>();
479
		for (int d = 0; d < days.size(); d++) {
480
			Map<String, Object> day = days.get(d);
481
			int dayNumber = d + 1;
482
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
483
			if (visits == null) continue;
484
			int partnerSeq = 0;
485
			for (int i = 0; i < visits.size(); i++) {
486
				Map<String, Object> v = visits.get(i);
487
				if ("lead".equals(v.get("type"))) {
488
					keptLeadIds.add(((Number) v.get("id")).intValue());
489
					continue; // leads live in lead_route, handled below
490
				}
491
				BeatRoute route = new BeatRoute();
492
				route.setBeatId(beatId);
493
				route.setFofoId(((Number) v.get("id")).intValue());
494
				route.setSequenceOrder(partnerSeq++);
495
				route.setDayNumber(dayNumber);
496
				route.setActive(true);
497
				beatRouteRepository.persist(route);
498
			}
499
		}
500
 
36681 ranu 501
		// If the beat shrank, drop schedule rows for day numbers that no longer exist
502
		if (newTotalDays < oldTotalDays) {
503
			List<BeatSchedule> currentSchedules = beatScheduleRepository.selectByBeatId(beatId);
504
			for (BeatSchedule s : currentSchedules) {
505
				if (s.getDayNumber() > newTotalDays) beatScheduleRepository.delete(s);
506
			}
507
		}
508
 
509
		// Process per-lead actions sent from the editor's removed-leads popup.
510
		// Each entry: {leadId, action: "cancel"|"reschedule", toDate?: "yyyy-MM-dd"}.
511
		// - cancel: mark the lead's current APPROVED row for this beat as CANCELLED.
512
		// - reschedule: cancel here, then create a fresh APPROVED LeadRoute on
513
		//   whichever beat this user has scheduled on toDate. If no beat exists
514
		//   on toDate, the whole update fails (so the caller can prompt again).
515
		int leadsCancelled = 0, leadsRescheduled = 0;
516
		List<String> leadFailures = new ArrayList<>();
517
		String removedLeadActionsJson = (String) plan.get("removedLeadActions");
518
		if (removedLeadActionsJson != null && !removedLeadActionsJson.isEmpty()) {
519
			Type listType = new TypeToken<List<Map<String, Object>>>() {
520
			}.getType();
521
			List<Map<String, Object>> actions = gson.fromJson(removedLeadActionsJson, listType);
522
 
523
			List<LeadRoute> beatLeads = leadRouteRepository.selectByBeatId(beatId);
524
 
525
			for (Map<String, Object> act : actions) {
526
				int leadId = ((Number) act.get("leadId")).intValue();
527
				String mode = (String) act.get("action");
528
 
529
				// Find this lead's most-recent APPROVED row on this beat
530
				LeadRoute current = beatLeads.stream()
531
						.filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus()))
532
						.findFirst().orElse(null);
533
				if (current == null) continue; // already removed/cancelled; nothing to do
534
 
535
				if ("reschedule".equalsIgnoreCase(mode)) {
536
					String toDateStr = (String) act.get("toDate");
537
					if (toDateStr == null || toDateStr.isEmpty()) {
538
						leadFailures.add("Lead " + leadId + ": reschedule date missing");
539
						continue;
540
					}
541
					LocalDate toDate = LocalDate.parse(toDateStr);
542
 
543
					// Find ANY beat this user has scheduled on toDate
544
					Beat targetBeat = null;
545
					Integer targetDayNumber = null;
546
					List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId());
547
					for (Beat b : userBeats) {
548
						List<BeatSchedule> sl = beatScheduleRepository.selectByBeatId(b.getId());
549
						for (BeatSchedule s : sl) {
550
							if (s.getStartDate() != null && s.getStartDate().equals(toDate)) {
551
								targetBeat = b;
552
								targetDayNumber = s.getDayNumber();
553
								break;
554
							}
555
						}
556
						if (targetBeat != null) break;
557
					}
558
					if (targetBeat == null) {
559
						return responseSender.badRequest(
560
								"No beat is scheduled for this user on " + toDateStr
561
										+ ". Pick a different date for lead " + leadId
562
										+ ", or choose Cancel for it.");
563
					}
564
 
565
					// Cancel the current attachment to this beat
566
					current.setStatus("CANCELLED");
567
					current.setUpdatedTimestamp(LocalDateTime.now());
568
 
569
					// Create the new attachment on the target beat/date
570
					LeadRoute fresh = new LeadRoute();
571
					fresh.setBeatId(targetBeat.getId());
572
					fresh.setLeadId(leadId);
573
					fresh.setNearestStoreId(current.getNearestStoreId());
574
					fresh.setScheduleDate(toDate);
575
					fresh.setSequenceOrder(9999); // append; the planner can reorder
576
					fresh.setStatus("APPROVED");
577
					fresh.setRequestedBy(current.getRequestedBy());
578
					fresh.setApprovedBy(current.getApprovedBy());
579
					fresh.setApprovedTimestamp(LocalDateTime.now());
580
					fresh.setCreatedTimestamp(LocalDateTime.now());
581
					fresh.setUpdatedTimestamp(LocalDateTime.now());
582
					leadRouteRepository.persist(fresh);
583
 
584
					LeadActivity la = new LeadActivity();
585
					la.setLeadId(leadId);
586
					la.setRemark("Rescheduled from beat '" + beat.getName() + "' to '"
587
							+ targetBeat.getName() + "' on " + toDateStr + " (day " + targetDayNumber + ")");
588
					la.setAuthId(0);
589
					la.setCreatedTimestamp(LocalDateTime.now());
590
					leadActivityRepositoryAuto.persist(la);
591
					leadsRescheduled++;
592
				} else {
593
					// cancel (default)
594
					current.setStatus("CANCELLED");
595
					current.setUpdatedTimestamp(LocalDateTime.now());
596
 
597
					LeadActivity la = new LeadActivity();
598
					la.setLeadId(leadId);
599
					la.setRemark("Cancelled from beat '" + beat.getName() + "' during edit");
600
					la.setAuthId(0);
601
					la.setCreatedTimestamp(LocalDateTime.now());
602
					leadActivityRepositoryAuto.persist(la);
603
					leadsCancelled++;
36651 ranu 604
				}
605
			}
606
		}
607
 
608
		Map<String, Object> response = new HashMap<>();
609
		response.put("status", true);
610
		response.put("planGroupId", String.valueOf(beat.getId()));
36681 ranu 611
		response.put("leadsCancelled", leadsCancelled);
612
		response.put("leadsRescheduled", leadsRescheduled);
613
		response.put("leadFailures", leadFailures);
614
		response.put("message", "Beat updated successfully"
615
				+ (leadsCancelled > 0 ? " (" + leadsCancelled + " lead(s) cancelled)" : "")
616
				+ (leadsRescheduled > 0 ? " (" + leadsRescheduled + " lead(s) rescheduled)" : ""));
36651 ranu 617
		return responseSender.ok(response);
618
	}
619
 
36681 ranu 620
	// Used by the edit-mode "removed leads" popup so the date picker can warn
621
	// upfront when the user picks a date that has no beat for them.
622
	@GetMapping(value = "/beatPlan/userBeatsOnDate")
623
	public ResponseEntity<?> userBeatsOnDate(
624
			@RequestParam int authUserId,
625
			@RequestParam String date) {
626
		LocalDate target;
627
		try {
628
			target = LocalDate.parse(date);
629
		} catch (Exception e) {
630
			return responseSender.badRequest("Invalid date");
631
		}
632
 
633
		List<Map<String, Object>> hits = new ArrayList<>();
634
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
635
		for (Beat b : userBeats) {
636
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
637
			for (BeatSchedule s : schedules) {
638
				if (s.getStartDate() != null && s.getStartDate().equals(target)) {
639
					Map<String, Object> m = new HashMap<>();
640
					m.put("beatId", b.getId());
641
					m.put("beatName", b.getName());
642
					m.put("dayNumber", s.getDayNumber());
643
					hits.add(m);
644
				}
645
			}
646
		}
647
		Map<String, Object> result = new HashMap<>();
648
		result.put("date", date);
649
		result.put("authUserId", authUserId);
650
		result.put("beats", hits);
651
		return responseSender.ok(result);
652
	}
653
 
36686 ranu 654
	// ====================== BASE LOCATION MANAGEMENT ======================
655
	// Inline page that lets Sales L3+ pick a user and set their base (home)
656
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
657
	// go through the L3+-guarded endpoint below.
658
	@GetMapping(value = "/beatPlan/baseLocationPage")
659
	public String baseLocationPage(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
660
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
661
		return "beat-plan-base-location";
36650 ranu 662
	}
663
 
664
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
665
	@GetMapping(value = "/beatPlan/scheduledList")
666
	public ResponseEntity<?> scheduledList(
667
			@RequestParam(required = false) String startDate,
668
			@RequestParam(required = false) String endDate) {
669
 
670
		LocalDate start, end;
671
		try {
672
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
673
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
674
		} catch (Exception e) {
675
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
676
		}
677
 
678
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
679
				beatPlanQueryService.getAllScheduledBeats(start, end);
680
 
681
		// Resolve user names in bulk
682
		Set<Integer> userIds = beats.stream()
683
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
684
				.collect(java.util.stream.Collectors.toSet());
685
		Map<Integer, AuthUser> userMap = new HashMap<>();
686
		if (!userIds.isEmpty()) {
687
			authRepository.selectByIds(new ArrayList<>(userIds))
688
					.forEach(u -> userMap.put(u.getId(), u));
689
		}
690
 
691
		List<Map<String, Object>> rows = new ArrayList<>();
692
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
693
			AuthUser u = userMap.get(b.getAuthUserId());
694
			Map<String, Object> row = new HashMap<>();
695
			row.put("authUserId", b.getAuthUserId());
696
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
697
			row.put("scheduleDate", b.getScheduleDate().toString());
698
			row.put("dayNumber", b.getDayNumber());
699
			row.put("beatId", b.getBeatId());
700
			row.put("beatName", b.getBeatName());
701
			row.put("beatColor", b.getBeatColor());
702
			row.put("partnerCount", b.getPartnerStops().size());
703
			row.put("leadCount", b.getLeadStops().size());
704
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
705
			rows.add(row);
706
		}
707
 
708
		Map<String, Object> result = new HashMap<>();
709
		result.put("rows", rows);
710
		result.put("startDate", start.toString());
711
		result.put("endDate", end.toString());
712
		return responseSender.ok(result);
713
	}
714
 
715
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
716
	@GetMapping(value = "/beatPlan/dayViewData")
717
	public ResponseEntity<?> beatPlanDayViewData(
718
			@RequestParam int authUserId,
719
			@RequestParam String date) throws ProfitMandiBusinessException {
720
 
721
		LocalDate localDate;
722
		try {
723
			localDate = LocalDate.parse(date);
724
		} catch (Exception e) {
725
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
726
		}
727
 
728
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
729
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
730
 
731
		// Collect all partner & lead IDs to fetch metadata in bulk
732
		Set<Integer> partnerIds = new HashSet<>();
733
		Set<Integer> leadIds = new HashSet<>();
734
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
735
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
736
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
737
		}
738
 
739
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
740
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
741
				? new HashMap<>()
742
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
743
		Map<Integer, FofoStore> storeMap = new HashMap<>();
744
		if (!partnerIds.isEmpty()) {
745
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
746
					.forEach(fs -> storeMap.put(fs.getId(), fs));
747
		}
748
 
749
		// Leads: name + geo
750
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
751
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
752
		for (int leadId : leadIds) {
753
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
754
			if (l != null) leadMap.put(leadId, l);
755
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
756
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
757
			if (lg != null) leadGeoMap.put(leadId, lg);
758
		}
759
 
760
		// Enrich each stop
761
		List<Map<String, Object>> out = new ArrayList<>();
762
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
763
			Map<String, Object> beatJson = new HashMap<>();
764
			beatJson.put("beatId", b.getBeatId());
765
			beatJson.put("beatName", b.getBeatName());
766
			beatJson.put("beatColor", b.getBeatColor());
767
			beatJson.put("dayNumber", b.getDayNumber());
768
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
769
			beatJson.put("endAction", b.getEndAction());
770
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
771
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
772
			beatJson.put("startLocationName", b.getStartLocationName());
773
			beatJson.put("startLatitude", b.getStartLatitude());
774
			beatJson.put("startLongitude", b.getStartLongitude());
775
 
776
			List<Map<String, Object>> stops = new ArrayList<>();
777
			// Partners
778
			for (Map<String, Object> ps : b.getPartnerStops()) {
779
				int fofoId = (Integer) ps.get("fofoId");
780
				Map<String, Object> stop = new HashMap<>();
781
				stop.put("type", "partner");
782
				stop.put("id", fofoId);
783
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
784
				FofoStore fs = storeMap.get(fofoId);
785
				CustomRetailer cr = retailerMap.get(fofoId);
786
				stop.put("code", fs != null ? fs.getCode() : null);
787
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
788
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 789
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
790
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
791
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 792
					try {
36655 ranu 793
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
794
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
795
					} catch (NumberFormatException ignored) {
36650 ranu 796
					}
797
				}
36655 ranu 798
				if (cr != null && cr.getAddress() != null) {
799
					stop.put("address", cr.getAddress().getAddressString());
800
				}
36650 ranu 801
				stops.add(stop);
802
			}
803
			// Leads
804
			for (Map<String, Object> ls : b.getLeadStops()) {
805
				int leadId = (Integer) ls.get("leadId");
806
				Map<String, Object> stop = new HashMap<>();
807
				stop.put("type", "lead");
808
				stop.put("id", leadId);
809
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
810
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
811
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
812
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
813
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
814
				stop.put("city", l != null ? l.getCity() : null);
815
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
816
				if (lg != null) {
817
					stop.put("lat", lg.getLatitude());
818
					stop.put("lng", lg.getLongitude());
819
				}
820
				stops.add(stop);
821
			}
822
			beatJson.put("stops", stops);
823
			beatJson.put("partnerCount", b.getPartnerStops().size());
824
			beatJson.put("leadCount", b.getLeadStops().size());
825
			out.add(beatJson);
826
		}
827
 
828
		Map<String, Object> result = new HashMap<>();
829
		result.put("beats", out);
830
		return responseSender.ok(result);
831
	}
832
 
36686 ranu 833
	// ====================== DAY VIEW ======================
834
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
835
	// scheduled in a date range across all users. Each row has a View button that
836
	// opens that user's calendar in a modal.
837
	@GetMapping(value = "/beatPlan/dayView")
838
	public String beatPlanDayView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
839
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
840
		return "beat-plan-day-view";
36618 ranu 841
	}
842
 
36644 ranu 843
	// Returns visits for a beat.
844
	// - Partner stops (beat_route) belong to the beat template — always returned.
845
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
846
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 847
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 848
	public ResponseEntity<?> getBeatVisits(
849
			@RequestParam String planGroupId,
850
			@RequestParam(required = false) String planDate) {
851
 
852
		int beatId;
853
		try {
854
			beatId = Integer.parseInt(planGroupId);
855
		} catch (NumberFormatException e) {
856
			return responseSender.ok(new ArrayList<>());
857
		}
858
 
859
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
860
		List<Map<String, Object>> result = new ArrayList<>();
861
 
862
		// Partner stops — always (they belong to the beat template)
863
		for (BeatRoute r : routes) {
36632 ranu 864
			Map<String, Object> map = new HashMap<>();
36644 ranu 865
			map.put("fofoId", r.getFofoId());
866
			map.put("dayNumber", r.getDayNumber());
867
			map.put("sequenceOrder", r.getSequenceOrder());
868
			map.put("visitType", "partner");
869
			result.add(map);
870
		}
871
 
872
		// Lead stops — only for the requested run date
873
		if (planDate != null && !planDate.isEmpty()) {
874
			LocalDate date = LocalDate.parse(planDate);
875
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
876
			for (LeadRoute lr : leads) {
877
				if ("APPROVED".equals(lr.getStatus())
878
						&& lr.getScheduleDate() != null
879
						&& lr.getScheduleDate().equals(date)) {
880
					Map<String, Object> map = new HashMap<>();
881
					map.put("fofoId", lr.getLeadId());
882
					map.put("dayNumber", 1);
883
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
884
					map.put("visitType", "lead");
885
					result.add(map);
886
				}
887
			}
888
		}
889
 
890
		// Sort by dayNumber then sequenceOrder
891
		result.sort((a, b) -> {
892
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
893
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
894
		});
895
 
36632 ranu 896
		return responseSender.ok(result);
897
	}
898
 
36681 ranu 899
	// Returns the user's DEFAULT base location. Falls back to most-recent for
900
	// legacy users who pre-date the is_default column.
36618 ranu 901
	@GetMapping(value = "/beatPlan/getBaseLocation")
902
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
36681 ranu 903
		AuthUserLocation baseLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36618 ranu 904
		if (baseLoc == null) {
905
			return responseSender.ok(new HashMap<>());
906
		}
907
		Map<String, Object> result = new HashMap<>();
908
		result.put("id", baseLoc.getId());
909
		result.put("locationName", baseLoc.getLocationName());
910
		result.put("latitude", baseLoc.getLatitude());
911
		result.put("longitude", baseLoc.getLongitude());
912
		result.put("address", baseLoc.getAddress());
36681 ranu 913
		result.put("isDefault", baseLoc.isDefault());
36618 ranu 914
		return responseSender.ok(result);
915
	}
916
 
36681 ranu 917
	// Returns ALL BASE locations for a user, default first.
918
	@GetMapping(value = "/beatPlan/listBaseLocations")
919
	public ResponseEntity<?> listBaseLocations(@RequestParam int authUserId) {
920
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
921
		// Default at the top, then by created desc (the repo already returns desc).
922
		all.sort((a, b) -> {
923
			if (a.isDefault() && !b.isDefault()) return -1;
924
			if (!a.isDefault() && b.isDefault()) return 1;
925
			return 0;
926
		});
927
		List<Map<String, Object>> rows = new ArrayList<>();
928
		for (AuthUserLocation l : all) {
929
			Map<String, Object> row = new HashMap<>();
930
			row.put("id", l.getId());
931
			row.put("locationName", l.getLocationName());
932
			row.put("latitude", l.getLatitude());
933
			row.put("longitude", l.getLongitude());
934
			row.put("address", l.getAddress());
935
			row.put("isDefault", l.isDefault());
936
			row.put("createdTimestamp", l.getCreatedTimestamp() != null ? l.getCreatedTimestamp().toString() : null);
937
			rows.add(row);
938
		}
939
		Map<String, Object> result = new HashMap<>();
940
		result.put("authUserId", authUserId);
941
		result.put("locations", rows);
942
		return responseSender.ok(result);
943
	}
944
 
945
	// Flip the default flag — set this id default, clear all others.
946
	@PostMapping(value = "/beatPlan/setDefaultBaseLocation")
947
	public ResponseEntity<?> setDefaultBaseLocation(
948
			HttpServletRequest request,
949
			@RequestParam int id) throws ProfitMandiBusinessException {
950
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
951
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
952
		if (me == null) return responseSender.unauthorized("Not logged in");
953
		if (!isBaseLocationManager(me)) {
954
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
955
		}
956
 
957
		AuthUserLocation target = authUserLocationRepository.selectById(id);
958
		if (target == null) return responseSender.badRequest("Location not found");
959
 
960
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(target.getAuthUserId(), "BASE");
961
		for (AuthUserLocation l : all) {
962
			boolean shouldBeDefault = (l.getId() == id);
963
			if (l.isDefault() != shouldBeDefault) {
964
				l.setDefault(shouldBeDefault);
965
				authUserLocationRepository.persist(l); // saveOrUpdate
966
			}
967
		}
968
 
969
		Map<String, Object> result = new HashMap<>();
970
		result.put("status", true);
971
		result.put("id", id);
972
		result.put("message", "Default base location updated");
973
		return responseSender.ok(result);
974
	}
975
 
976
	// Delete a base location. The DEFAULT one cannot be deleted — user must
977
	// first pick another row as default.
978
	@PostMapping(value = "/beatPlan/deleteBaseLocation")
979
	public ResponseEntity<?> deleteBaseLocation(
980
			HttpServletRequest request,
981
			@RequestParam int id) throws ProfitMandiBusinessException {
982
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
983
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
984
		if (me == null) return responseSender.unauthorized("Not logged in");
985
		if (!isBaseLocationManager(me)) {
986
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
987
		}
988
 
989
		AuthUserLocation target = authUserLocationRepository.selectById(id);
990
		if (target == null) return responseSender.badRequest("Location not found");
991
		if (target.isDefault()) {
992
			return responseSender.badRequest("Default base location cannot be removed. Set another location as default first.");
993
		}
994
 
995
		authUserLocationRepository.delete(target);
996
 
997
		Map<String, Object> result = new HashMap<>();
998
		result.put("status", true);
999
		result.put("message", "Base location removed");
1000
		return responseSender.ok(result);
1001
	}
1002
 
36686 ranu 1003
	@GetMapping(value = "/beatPlan/getAuthUsers")
1004
	public ResponseEntity<?> getAuthUsers(
1005
			HttpServletRequest request,
1006
			@RequestParam int categoryId,
1007
			@RequestParam EscalationType escalationType) throws ProfitMandiBusinessException {
1008
 
1009
		// Hierarchy filter: a manager only sees users in their downline
1010
		// (themselves + every reportee under them, recursively). Super-admin
1011
		// emails bypass the filter and see everyone. Downline is computed by
1012
		// AuthService.getAllReportees (existing recursive walker).
1013
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1014
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1015
 
1016
		final Set<Integer> visible;
1017
		if (me == null || isSuperAdmin(me)) {
1018
			visible = null; // null = no filter
1019
		} else {
1020
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
1021
			visible.add(me.getId()); // include self
1022
		}
1023
 
1024
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
1025
		List<Map<String, Object>> result = authUsers.stream()
1026
				.filter(au -> au.getActive())
1027
				.filter(au -> visible == null || visible.contains(au.getId()))
1028
				.map(au -> {
1029
					Map<String, Object> map = new HashMap<>();
1030
					map.put("id", au.getId());
1031
					map.put("name", au.getFirstName() + " " + au.getLastName());
1032
					return map;
1033
				})
1034
				.collect(Collectors.toList());
1035
		return responseSender.ok(result);
1036
	}
1037
 
1038
	private boolean isSuperAdmin(AuthUser me) {
36681 ranu 1039
		String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";
36686 ranu 1040
		return SUPER_ADMIN_EMAILS.contains(myEmail);
1041
	}
36681 ranu 1042
 
36686 ranu 1043
	// Returns the user's highest escalation level across all positions.
1044
	// Mirrors OrderController.getSalesEscalationLevel but category-agnostic.
1045
	private EscalationType getHighestEscalation(int authUserId) {
1046
		EscalationType highest = null;
1047
		List<com.spice.profitmandi.dao.entity.cs.Position> positions = positionRepository.selectPositionByAuthId(authUserId);
1048
		for (com.spice.profitmandi.dao.entity.cs.Position p : positions) {
1049
			if (highest == null || p.getEscalationType().isGreaterThanEqualTo(highest)) {
1050
				highest = p.getEscalationType();
1051
			}
1052
		}
1053
		return highest;
1054
	}
1055
 
1056
	// Returns the escalation levels a user can manage — strictly below their own.
1057
	// L3 → [L1, L2]; L4 → [L1, L2, L3]; Final → all levels. Super-admin → all levels.
1058
	private List<EscalationType> getVisibleEscalationLevels(AuthUser me) {
1059
		if (isSuperAdmin(me)) return EscalationType.escalations;
1060
		EscalationType mine = getHighestEscalation(me.getId());
1061
		if (mine == null) return java.util.Collections.emptyList();
1062
		List<EscalationType> below = new ArrayList<>();
1063
		for (EscalationType e : EscalationType.escalations) {
1064
			if (mine.isGreaterThanEqualTo(e) && !e.equals(mine)) below.add(e);
1065
		}
1066
		return below;
1067
	}
1068
 
1069
	private List<EscalationType> visibleLevelsFor(HttpServletRequest request) throws ProfitMandiBusinessException {
1070
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1071
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1072
		return me == null ? java.util.Collections.emptyList() : getVisibleEscalationLevels(me);
1073
	}
1074
 
1075
	// Shared permission check for base-location admin actions: Sales L3+ OR super-admin.
1076
	private boolean isBaseLocationManager(AuthUser me) {
1077
		if (isSuperAdmin(me)) return true;
36681 ranu 1078
		return csService.getAuthUserIds(
1079
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
1080
						Arrays.asList(EscalationType.L3, EscalationType.L4))
1081
				.stream().anyMatch(u -> u.getId() == me.getId());
1082
	}
1083
 
36618 ranu 1084
	@PostMapping(value = "/beatPlan/saveBaseLocation")
1085
	public ResponseEntity<?> saveBaseLocation(
1086
			@RequestParam int authUserId,
1087
			@RequestParam String locationName,
1088
			@RequestParam String latitude,
1089
			@RequestParam String longitude,
1090
			@RequestParam(required = false) String address) {
1091
		AuthUserLocation loc = new AuthUserLocation();
1092
		loc.setAuthUserId(authUserId);
1093
		loc.setLocationType("BASE");
1094
		loc.setLocationName(locationName);
1095
		loc.setLatitude(latitude);
1096
		loc.setLongitude(longitude);
1097
		loc.setAddress(address);
1098
		loc.setCreatedTimestamp(LocalDateTime.now());
36681 ranu 1099
 
1100
		// First BASE for this user → auto-default so every user always has one.
1101
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1102
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1103
		loc.setDefault(existing.isEmpty() || noExistingDefault);
36618 ranu 1104
		authUserLocationRepository.persist(loc);
1105
 
1106
		Map<String, Object> result = new HashMap<>();
1107
		result.put("status", true);
1108
		result.put("id", loc.getId());
36681 ranu 1109
		result.put("isDefault", loc.isDefault());
36618 ranu 1110
		return responseSender.ok(result);
1111
	}
1112
 
1113
	@GetMapping(value = "/beatPlan/getPartners")
1114
	public ResponseEntity<?> getPartners(
1115
			@RequestParam int authUserId,
1116
			@RequestParam int categoryId,
1117
			@RequestParam(required = false) String startLat,
1118
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
1119
 
1120
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdPartnerIdMapping();
1121
		List<Integer> fofoIds = pp.get(authUserId);
1122
 
36644 ranu 1123
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 1124
			Map<String, Object> empty = new HashMap<>();
1125
			empty.put("partners", new ArrayList<>());
1126
			return responseSender.ok(empty);
1127
		}
1128
 
1129
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
1130
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
1131
 
1132
		List<Map<String, Object>> partners = new ArrayList<>();
1133
 
1134
		for (FofoStore store : fofoStores) {
1135
			if (!store.isActive() || store.isClosed()) continue;
1136
			CustomRetailer retailer = retailerMap.get(store.getId());
1137
 
1138
			Map<String, Object> partnerData = new HashMap<>();
1139
			partnerData.put("fofoId", store.getId());
1140
			partnerData.put("code", store.getCode());
1141
			partnerData.put("outletName", store.getOutletName());
1142
			partnerData.put("type", "partner");
1143
 
36655 ranu 1144
			// Use FofoStore lat/lng directly (migrated from address geocode)
1145
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
1146
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
1147
				partnerData.put("latitude", store.getLatitude());
1148
				partnerData.put("longitude", store.getLongitude());
1149
			}
1150
 
36618 ranu 1151
			if (retailer != null) {
1152
				partnerData.put("businessName", retailer.getBusinessName());
1153
				if (retailer.getAddress() != null) {
36644 ranu 1154
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 1155
				}
1156
			}
1157
			partners.add(partnerData);
1158
		}
1159
 
1160
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
1161
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
1162
		} else {
1163
			partners = sortByNearestNeighbor(partners);
1164
		}
1165
 
1166
		Map<String, Object> response = new HashMap<>();
1167
		response.put("partners", partners);
1168
		return responseSender.ok(response);
1169
	}
1170
 
1171
	@PostMapping(value = "/beatPlan/submitPlan")
1172
	public ResponseEntity<?> submitPlan(
1173
			HttpServletRequest request,
1174
			@RequestParam int authUserId,
1175
			@RequestParam String planData) throws Exception {
1176
 
1177
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1178
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1179
 
1180
		Gson gson = new Gson();
1181
		Type type = new TypeToken<Map<String, Object>>() {
1182
		}.getType();
1183
		Map<String, Object> plan = gson.fromJson(planData, type);
1184
 
1185
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
1186
		List<String> dates = (List<String>) plan.get("dates");
1187
 
36644 ranu 1188
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 1189
 
36698 ranu 1190
		// Duplicate check — same name + same authUserId among ACTIVE beats only.
1191
		// Soft-deleted beats keep the name in the table; we don't want them to
1192
		// block the user from reusing a name they "deleted".
1193
		List<Beat> existingBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36644 ranu 1194
		for (Beat existing : existingBeats) {
1195
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
1196
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 1197
				Map<String, Object> response = new HashMap<>();
1198
				response.put("status", true);
36644 ranu 1199
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 1200
				response.put("duplicate", true);
36644 ranu 1201
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 1202
				return responseSender.ok(response);
1203
			}
1204
		}
1205
 
36644 ranu 1206
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
1207
		int totalDays = days.size();
36618 ranu 1208
 
36644 ranu 1209
		// Create Beat master
1210
		Beat beat = new Beat();
1211
		beat.setName(beatName);
1212
		beat.setAuthUserId(authUserId);
1213
		beat.setBeatColor(beatColor);
1214
		beat.setTotalDays(totalDays);
1215
		beat.setActive(true);
1216
		beat.setCreatedBy(currentUser.getId());
1217
		beat.setCreatedTimestamp(LocalDateTime.now());
1218
 
1219
		// Set start location from first day
1220
		if (!days.isEmpty()) {
1221
			Map<String, Object> firstDay = days.get(0);
1222
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1223
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
1224
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
1225
		}
1226
		beatRepository.persist(beat);
1227
 
1228
		// End date of the whole beat = last scheduled day's date
1229
		LocalDate beatEndDate = null;
1230
		if (dates != null) {
1231
			for (int d = dates.size() - 1; d >= 0; d--) {
1232
				if (dates.get(d) != null) {
1233
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
1234
					break;
1235
				}
1236
			}
1237
		}
1238
 
1239
		// Create routes and schedules for each day
36618 ranu 1240
		for (int d = 0; d < days.size(); d++) {
1241
			Map<String, Object> day = days.get(d);
1242
			int dayNumber = d + 1;
1243
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 1244
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 1245
 
36644 ranu 1246
			// Auto-determine end action: last day = HOME, others = DAYBREAK
1247
			String endAction = (String) day.get("endAction");
1248
			if (endAction == null || endAction.isEmpty()) {
1249
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 1250
			}
1251
 
36644 ranu 1252
			// Always create schedule (even if planDate is null — unscheduled beat)
1253
			BeatSchedule schedule = new BeatSchedule();
1254
			schedule.setBeatId(beat.getId());
1255
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
1256
			schedule.setEndDate(beatEndDate);
1257
			schedule.setDayNumber(dayNumber);
1258
			schedule.setEndAction(endAction);
1259
			schedule.setStayLocationName((String) day.get("stayLocationName"));
1260
			schedule.setStayLatitude((String) day.get("stayLatitude"));
1261
			schedule.setStayLongitude((String) day.get("stayLongitude"));
1262
			if (day.get("totalDistanceKm") != null)
1263
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1264
			if (day.get("totalTimeMins") != null)
1265
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1266
			schedule.setCreatedTimestamp(LocalDateTime.now());
1267
			beatScheduleRepository.persist(schedule);
1268
 
1269
			// Routes (stops)
36618 ranu 1270
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1271
			if (visits != null) {
1272
				for (int i = 0; i < visits.size(); i++) {
1273
					Map<String, Object> visit = visits.get(i);
36644 ranu 1274
					BeatRoute route = new BeatRoute();
1275
					route.setBeatId(beat.getId());
1276
					route.setFofoId(((Number) visit.get("id")).intValue());
1277
					route.setSequenceOrder(i);
1278
					route.setDayNumber(dayNumber);
1279
					route.setActive(true);
1280
					beatRouteRepository.persist(route);
36618 ranu 1281
				}
1282
			}
1283
		}
1284
 
1285
		Map<String, Object> response = new HashMap<>();
1286
		response.put("status", true);
36644 ranu 1287
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 1288
		response.put("message", "Beat plan submitted successfully");
1289
		return responseSender.ok(response);
1290
	}
1291
 
36632 ranu 1292
	// ============ BULK UPLOAD ============
1293
 
1294
	@GetMapping(value = "/beatPlan/bulkUpload")
1295
	public String bulkUploadPage(HttpServletRequest request, Model model) {
1296
		return "beat-plan-bulk";
1297
	}
1298
 
36681 ranu 1299
	// Adds a new base location for the user. Caller can request this new row
1300
	// becomes the default. If the user has NO base locations yet, the new row
1301
	// is auto-defaulted (so every user always has exactly one default).
36668 ranu 1302
	@PostMapping(value = "/beatPlan/updateBaseLocation")
1303
	public ResponseEntity<?> updateBaseLocation(
1304
			HttpServletRequest request,
1305
			@RequestParam int authUserId,
1306
			@RequestParam String locationName,
1307
			@RequestParam String latitude,
1308
			@RequestParam String longitude,
36681 ranu 1309
			@RequestParam(required = false) String address,
1310
			@RequestParam(required = false, defaultValue = "false") boolean isDefault) throws Exception {
36668 ranu 1311
 
1312
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1313
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1314
		if (me == null) return responseSender.unauthorized("Not logged in");
36681 ranu 1315
		if (!isBaseLocationManager(me)) {
1316
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");
1317
		}
36668 ranu 1318
 
36681 ranu 1319
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1320
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1321
		boolean makeDefault = isDefault || existing.isEmpty() || noExistingDefault;
36668 ranu 1322
 
36681 ranu 1323
		// If this new row becomes the default, clear any existing default.
1324
		if (makeDefault) {
1325
			for (AuthUserLocation e : existing) {
1326
				if (e.isDefault()) {
1327
					e.setDefault(false);
1328
					authUserLocationRepository.persist(e);
1329
				}
1330
			}
36668 ranu 1331
		}
1332
 
1333
		AuthUserLocation loc = new AuthUserLocation();
1334
		loc.setAuthUserId(authUserId);
1335
		loc.setLocationType("BASE");
1336
		loc.setLocationName(locationName);
1337
		loc.setLatitude(latitude);
1338
		loc.setLongitude(longitude);
1339
		loc.setAddress(address);
36681 ranu 1340
		loc.setDefault(makeDefault);
36668 ranu 1341
		loc.setCreatedTimestamp(LocalDateTime.now());
1342
		authUserLocationRepository.persist(loc);
1343
 
1344
		Map<String, Object> result = new HashMap<>();
1345
		result.put("status", true);
1346
		result.put("id", loc.getId());
36681 ranu 1347
		result.put("isDefault", loc.isDefault());
1348
		result.put("message", makeDefault ? "Base location added and set as default" : "Base location added");
36668 ranu 1349
		return responseSender.ok(result);
1350
	}
1351
 
36632 ranu 1352
	@GetMapping(value = "/beatPlan/downloadTemplate")
36668 ranu 1353
	public ResponseEntity<?> downloadTemplate() throws java.io.IOException {
1354
		org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
1355
		org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");
36632 ranu 1356
 
36668 ranu 1357
		String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};
1358
 
1359
		// Header style
1360
		org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();
1361
		org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();
1362
		headerFont.setBold(true);
1363
		headerStyle.setFont(headerFont);
1364
		headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));
1365
		headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
1366
 
1367
		org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);
1368
		for (int i = 0; i < cols.length; i++) {
1369
			org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);
1370
			c.setCellValue(cols[i]);
1371
			c.setCellStyle(headerStyle);
1372
		}
1373
 
1374
		// Example rows — one partner per row. Inheritable columns blank after first row of a beat.
1375
		Object[][] sample = {
1376
				{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},
1377
				{"", "", "", "1", "2", "RJBUN1449"},
1378
				{"", "", "", "1", "3", "RJDEG1443"},
1379
				{"", "", "", "2", "1", "RJALR1362"},
1380
				{"", "", "", "2", "2", "RJBTR1388"},
1381
				{"", "", "", "3", "1", "RJRSD1518"},
1382
				{"", "", "", "3", "2", "RJSML356"},
1383
				{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},
1384
				{"", "", "", "1", "2", "UPAGR102"},
1385
		};
1386
		for (int r = 0; r < sample.length; r++) {
1387
			org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);
1388
			for (int c = 0; c < cols.length; c++) {
1389
				row.createCell(c).setCellValue(sample[r][c].toString());
1390
			}
1391
		}
1392
		for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);
1393
 
1394
		java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1395
		wb.write(out);
1396
		wb.close();
1397
 
36632 ranu 1398
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
36668 ranu 1399
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");
1400
		headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
1401
		return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
36632 ranu 1402
	}
1403
 
1404
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
1405
	public ResponseEntity<?> bulkUploadProcess(
1406
			HttpServletRequest request,
1407
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
1408
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
1409
 
1410
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1411
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1412
 
36668 ranu 1413
		// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date
1414
		// appear ONLY on the first row of a beat; subsequent rows inherit them.
1415
		org.apache.poi.ss.usermodel.Workbook workbook =
1416
				new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());
1417
		org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
36632 ranu 1418
 
36668 ranu 1419
		// Header → column index map
1420
		org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);
1421
		if (headerRow == null) {
1422
			workbook.close();
1423
			return responseSender.badRequest("Empty file");
1424
		}
1425
		Map<String, Integer> colIdx = new HashMap<>();
1426
		for (int i = 0; i < headerRow.getLastCellNum(); i++) {
1427
			String h = readCell(headerRow.getCell(i));
1428
			if (h != null) colIdx.put(h.trim().toLowerCase(), i);
1429
		}
1430
		for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {
1431
			if (!colIdx.containsKey(required)) {
1432
				workbook.close();
1433
				return responseSender.badRequest("Missing required column: " + required);
1434
			}
1435
		}
36632 ranu 1436
 
36668 ranu 1437
		// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_order
1438
		Map<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();
1439
		String currentKey = null;
1440
		String currentBeatName = null;
1441
		String currentAuthId = null;
1442
		String currentStartDate = null;
1443
 
1444
		for (int r = 1; r <= sheet.getLastRowNum(); r++) {
1445
			org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
1446
			if (row == null) continue;
1447
 
1448
			String beatName = readCell(row.getCell(colIdx.get("beat_name")));
1449
			String authId = readCell(row.getCell(colIdx.get("auth_user_id")));
1450
			String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;
1451
			String dayNumber = readCell(row.getCell(colIdx.get("day_number")));
1452
			String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;
1453
			String code = readCell(row.getCell(colIdx.get("partner_code")));
1454
 
1455
			if (beatName != null && !beatName.trim().isEmpty()) {
1456
				// Start of a new beat — capture inheritable fields
1457
				currentBeatName = beatName.trim().replaceAll("\\s+", " ");
1458
				currentAuthId = authId != null ? authId.trim() : null;
1459
				currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;
1460
				currentKey = currentBeatName + "|" + currentAuthId;
1461
			}
1462
			if (currentKey == null) continue; // partner row before any beat header — skip
1463
			if (code == null || code.trim().isEmpty()) continue;
1464
 
1465
			final String beatNameF = currentBeatName;
1466
			final String authIdF = currentAuthId;
1467
			final String startDateF = currentStartDate;
1468
			BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));
1469
 
1470
			int day;
1471
			try {
1472
				day = Integer.parseInt(dayNumber.trim());
1473
			} catch (Exception e) {
1474
				continue;
1475
			} // bad day → skip row
1476
 
1477
			int seq = -1;
1478
			if (seqOrder != null && !seqOrder.trim().isEmpty()) {
1479
				try {
1480
					seq = Integer.parseInt(seqOrder.trim());
1481
				} catch (Exception ignore) {
1482
				}
1483
			}
1484
			g.addPartner(day, seq, code.trim(), r + 1);
36632 ranu 1485
		}
36668 ranu 1486
		workbook.close();
36632 ranu 1487
 
1488
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
1489
		Map<String, Integer> codeToId = new HashMap<>();
36644 ranu 1490
		for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());
36632 ranu 1491
 
1492
		LocalDate holidayStart = LocalDate.now();
36644 ranu 1493
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 1494
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
1495
 
36644 ranu 1496
		int beatsCreated = 0, errors = 0;
36632 ranu 1497
		List<String> errorMessages = new ArrayList<>();
1498
 
36668 ranu 1499
		for (BulkBeatGroup g : beatGroups.values()) {
36632 ranu 1500
			try {
36668 ranu 1501
				String beatName = g.beatName;
1502
				int authUserId;
1503
				try {
1504
					authUserId = Integer.parseInt(g.authUserId);
1505
				} catch (Exception e) {
1506
					errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'. Skipped.");
1507
					errors++;
1508
					continue;
1509
				}
36632 ranu 1510
 
36668 ranu 1511
				LocalDate startDate = (g.startDate == null) ? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
36632 ranu 1512
				if (startDate != null && startDate.isBefore(LocalDate.now())) {
36644 ranu 1513
					errorMessages.add("Beat '" + beatName + "': start_date in past. Skipped.");
36632 ranu 1514
					errors++;
1515
					continue;
1516
				}
1517
 
36668 ranu 1518
				List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
1519
				Collections.sort(sortedDays);
1520
 
36632 ranu 1521
				List<LocalDate> scheduleDates = new ArrayList<>();
1522
				if (startDate != null) {
1523
					LocalDate d = startDate;
36668 ranu 1524
					while (scheduleDates.size() < sortedDays.size()) {
1525
						if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
36644 ranu 1526
							d = d.plusDays(1);
1527
							continue;
36632 ranu 1528
						}
36644 ranu 1529
						scheduleDates.add(d);
36632 ranu 1530
						d = d.plusDays(1);
1531
					}
1532
				}
1533
 
36698 ranu 1534
				// Duplicate check — ACTIVE beats only (soft-deleted names are reusable)
1535
				boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
36644 ranu 1536
						.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
1537
				if (isDuplicate) {
1538
					errorMessages.add("Beat '" + beatName + "' already exists for user " + authUserId + ". Skipped.");
1539
					errors++;
1540
					continue;
1541
				}
36632 ranu 1542
 
36644 ranu 1543
				String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
36681 ranu 1544
				AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36632 ranu 1545
 
36644 ranu 1546
				Beat beat = new Beat();
1547
				beat.setName(beatName);
1548
				beat.setAuthUserId(authUserId);
1549
				beat.setBeatColor(beatColor);
36668 ranu 1550
				beat.setTotalDays(sortedDays.size());
36644 ranu 1551
				beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
1552
				beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
1553
				beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
1554
				beat.setActive(true);
1555
				beat.setCreatedBy(currentUser.getId());
1556
				beat.setCreatedTimestamp(LocalDateTime.now());
1557
				beatRepository.persist(beat);
36632 ranu 1558
 
36668 ranu 1559
				LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
36632 ranu 1560
 
36668 ranu 1561
				for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
1562
					int dayNumber = sortedDays.get(dayIdx);
1563
					LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
1564
 
36644 ranu 1565
					BeatSchedule schedule = new BeatSchedule();
1566
					schedule.setBeatId(beat.getId());
1567
					schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
1568
					schedule.setEndDate(bulkEndDate);
1569
					schedule.setDayNumber(dayNumber);
36668 ranu 1570
					schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
36644 ranu 1571
					schedule.setCreatedTimestamp(LocalDateTime.now());
1572
					beatScheduleRepository.persist(schedule);
36632 ranu 1573
 
36668 ranu 1574
					List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
1575
					// Sort by explicit sequence_order when present, else by row order
1576
					partners.sort((a, b) -> {
1577
						if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
1578
						if (a.seq >= 0) return -1;
1579
						if (b.seq >= 0) return 1;
1580
						return Integer.compare(a.rowNum, b.rowNum);
1581
					});
1582
 
1583
					int autoSeq = 0;
1584
					for (BulkPartner p : partners) {
1585
						Integer fofoId = codeToId.get(p.code);
36632 ranu 1586
						if (fofoId == null) {
36668 ranu 1587
							errorMessages.add("Row " + p.rowNum + ": code not found '" + p.code + "'");
36632 ranu 1588
							errors++;
1589
							continue;
1590
						}
36644 ranu 1591
						BeatRoute route = new BeatRoute();
1592
						route.setBeatId(beat.getId());
1593
						route.setFofoId(fofoId);
36668 ranu 1594
						route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
36644 ranu 1595
						route.setDayNumber(dayNumber);
1596
						route.setActive(true);
1597
						beatRouteRepository.persist(route);
36668 ranu 1598
						autoSeq++;
36632 ranu 1599
					}
1600
				}
1601
				beatsCreated++;
1602
			} catch (Exception e) {
1603
				errors++;
36668 ranu 1604
				errorMessages.add("Error: " + g.beatName + " - " + e.getMessage());
36632 ranu 1605
			}
1606
		}
1607
 
1608
		Map<String, Object> response = new HashMap<>();
1609
		response.put("status", true);
1610
		response.put("beatsCreated", beatsCreated);
1611
		response.put("errors", errors);
1612
		response.put("errorMessages", errorMessages);
1613
		return responseSender.ok(response);
1614
	}
1615
 
36668 ranu 1616
	private static class BulkBeatGroup {
1617
		final String beatName;
1618
		final String authUserId;
1619
		final String startDate;
1620
		final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();
1621
 
1622
		BulkBeatGroup(String beatName, String authUserId, String startDate) {
1623
			this.beatName = beatName;
1624
			this.authUserId = authUserId;
1625
			this.startDate = startDate;
1626
		}
1627
 
1628
		void addPartner(int day, int seq, String code, int rowNum) {
1629
			dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));
1630
		}
1631
	}
1632
 
1633
	private static class BulkPartner {
1634
		final int seq;
1635
		final String code;
1636
		final int rowNum;
1637
 
1638
		BulkPartner(int seq, String code, int rowNum) {
1639
			this.seq = seq;
1640
			this.code = code;
1641
			this.rowNum = rowNum;
1642
		}
1643
	}
1644
 
36644 ranu 1645
	// ============ CALENDAR ============
36618 ranu 1646
 
1647
	@PostMapping(value = "/beatPlan/delete")
1648
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 1649
		int beatId = Integer.parseInt(planGroupId);
36698 ranu 1650
		// Hard delete — wipe all child rows first, then the beat itself.
1651
		// The name slot is freed naturally because the row is gone.
36644 ranu 1652
		beatRouteRepository.deleteByBeatId(beatId);
1653
		beatScheduleRepository.deleteByBeatId(beatId);
36698 ranu 1654
		leadRouteRepository.deleteByBeatId(beatId);
36644 ranu 1655
		Beat beat = beatRepository.selectById(beatId);
1656
		if (beat != null) {
36698 ranu 1657
			beatRepository.delete(beat);
36644 ranu 1658
		}
36618 ranu 1659
 
1660
		Map<String, Object> response = new HashMap<>();
1661
		response.put("status", true);
1662
		response.put("message", "Beat deleted");
1663
		return responseSender.ok(response);
1664
	}
1665
 
36670 ranu 1666
	// Unschedule the beat from ONE specific date — does NOT delete the beat.
1667
	// The beat (and its route template) stays; only the matching beat_schedule
1668
	// row is removed. If no real-date schedules remain, a placeholder
1669
	// (9999-12-31) row is added so the beat still shows up as "unscheduled".
1670
	@PostMapping(value = "/beatPlan/unscheduleDate")
1671
	public ResponseEntity<?> unscheduleDate(
1672
			@RequestParam String planGroupId,
1673
			@RequestParam String date) {
1674
		int beatId = Integer.parseInt(planGroupId);
1675
		LocalDate target = LocalDate.parse(date);
1676
 
1677
		Beat beat = beatRepository.selectById(beatId);
1678
		if (beat == null) return responseSender.badRequest("Beat not found");
1679
 
1680
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
1681
		int removed = 0;
1682
		for (BeatSchedule s : schedules) {
1683
			if (s.getStartDate() != null && s.getStartDate().equals(target)) {
1684
				beatScheduleRepository.delete(s);
1685
				removed++;
1686
			}
1687
		}
1688
		if (removed == 0) return responseSender.badRequest("No schedule found for that date");
1689
 
1690
		// If no real-date schedules left, drop in a placeholder so the beat
1691
		// remains visible in the unscheduled bucket.
1692
		boolean hasReal = schedules.stream()
1693
				.filter(s -> !s.getStartDate().equals(target))
1694
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);
1695
		if (!hasReal) {
1696
			boolean hasPlaceholder = schedules.stream()
1697
					.filter(s -> !s.getStartDate().equals(target))
1698
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);
1699
			if (!hasPlaceholder) {
1700
				BeatSchedule ph = new BeatSchedule();
1701
				ph.setBeatId(beatId);
1702
				ph.setStartDate(LocalDate.of(9999, 12, 31));
1703
				ph.setDayNumber(1);
1704
				ph.setEndAction("HOME");
1705
				ph.setCreatedTimestamp(LocalDateTime.now());
1706
				beatScheduleRepository.persist(ph);
1707
			}
1708
		}
1709
 
1710
		Map<String, Object> response = new HashMap<>();
1711
		response.put("status", true);
1712
		response.put("message", "Unscheduled from " + date);
1713
		return responseSender.ok(response);
1714
	}
1715
 
1716
	// Move a beat from one date to another — used by calendar drag-and-drop.
1717
	// Just updates the start_date of the matching beat_schedule row.
1718
	@PostMapping(value = "/beatPlan/moveScheduleDate")
1719
	public ResponseEntity<?> moveScheduleDate(
1720
			@RequestParam String planGroupId,
1721
			@RequestParam String fromDate,
1722
			@RequestParam String toDate) {
1723
		int beatId = Integer.parseInt(planGroupId);
1724
		LocalDate from = LocalDate.parse(fromDate);
1725
		LocalDate to = LocalDate.parse(toDate);
1726
 
1727
		if (from.equals(to)) {
1728
			Map<String, Object> ok = new HashMap<>();
1729
			ok.put("status", true);
1730
			ok.put("message", "Same date — no change");
1731
			return responseSender.ok(ok);
1732
		}
1733
 
1734
		Beat beat = beatRepository.selectById(beatId);
1735
		if (beat == null) return responseSender.badRequest("Beat not found");
1736
 
1737
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
1738
 
1739
		// Reject if the beat is already on the target date
1740
		boolean conflict = schedules.stream()
1741
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
1742
		if (conflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
1743
 
1744
		BeatSchedule match = schedules.stream()
1745
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
1746
				.findFirst().orElse(null);
1747
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
1748
 
1749
		match.setStartDate(to);
1750
		// Recompute endDate as the max across all (post-update) schedules
1751
		LocalDate newEnd = schedules.stream()
1752
				.map(s -> s == match ? to : s.getStartDate())
1753
				.filter(d -> d != null && d.getYear() != 9999)
1754
				.max(LocalDate::compareTo).orElse(to);
1755
		for (BeatSchedule s : schedules) {
1756
			if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {
1757
				s.setEndDate(newEnd);
1758
			}
1759
		}
1760
 
1761
		Map<String, Object> response = new HashMap<>();
1762
		response.put("status", true);
1763
		response.put("message", "Moved from " + fromDate + " to " + toDate);
1764
		return responseSender.ok(response);
1765
	}
1766
 
36618 ranu 1767
	@GetMapping(value = "/beatPlan/calendar")
1768
	public ResponseEntity<?> getCalendar(
1769
			@RequestParam int authUserId,
1770
			@RequestParam String month) {
1771
 
1772
		YearMonth ym = YearMonth.parse(month);
1773
		LocalDate startDate = ym.atDay(1);
1774
		LocalDate endDate = ym.atEndOfMonth();
1775
 
1776
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
1777
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
1778
			Map<String, String> m = new HashMap<>();
1779
			m.put("date", h.getDate().toString());
1780
			m.put("occasion", h.getOccasion());
1781
			return m;
1782
		}).collect(Collectors.toList());
1783
 
36644 ranu 1784
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 1785
		LocalDate today = LocalDate.now();
1786
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
1787
 
36644 ranu 1788
		for (Beat beat : allBeats) {
1789
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
1790
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 1791
 
36644 ranu 1792
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
1793
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
1794
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
1795
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 1796
 
1797
			String status;
1798
			if (allNullDates) status = "unscheduled";
1799
			else if (hasToday) status = "running";
1800
			else if (allPast) status = "completed";
36644 ranu 1801
			else status = "scheduled";
36618 ranu 1802
 
36644 ranu 1803
			Map<String, Object> beatInfo = new HashMap<>();
1804
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
1805
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
1806
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
1807
			beatInfo.put("status", status);
36618 ranu 1808
 
1809
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 1810
			for (BeatSchedule s : schedules) {
36618 ranu 1811
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 1812
				dayInfo.put("dayNumber", s.getDayNumber());
1813
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
1814
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
1815
				dayInfo.put("totalKm", s.getTotalDistanceKm());
1816
				dayInfo.put("totalMins", s.getTotalTimeMins());
1817
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
1818
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 1819
				dayInfoList.add(dayInfo);
1820
			}
36644 ranu 1821
			if (schedules.isEmpty()) {
1822
				// No schedule at all — show from routes
1823
				Map<Integer, Long> dayCounts = routes.stream()
1824
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
1825
				for (int d = 1; d <= beat.getTotalDays(); d++) {
1826
					Map<String, Object> dayInfo = new HashMap<>();
1827
					dayInfo.put("dayNumber", d);
1828
					dayInfo.put("planDate", null);
1829
					dayInfo.put("totalKm", null);
1830
					dayInfo.put("totalMins", null);
1831
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
1832
					dayInfoList.add(dayInfo);
1833
				}
1834
			}
1835
			beatInfo.put("days", dayInfoList);
1836
			scheduledBeats.add(beatInfo);
36618 ranu 1837
		}
1838
 
1839
		Set<String> blockedDates = new HashSet<>();
1840
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 1841
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 1842
		}
36644 ranu 1843
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 1844
 
1845
		Map<String, Object> response = new HashMap<>();
1846
		response.put("holidays", holidayList);
1847
		response.put("scheduledBeats", scheduledBeats);
1848
		response.put("blockedDates", blockedDates);
1849
		return responseSender.ok(response);
1850
	}
1851
 
1852
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
1853
	public ResponseEntity<?> scheduleOnCalendar(
1854
			HttpServletRequest request,
1855
			@RequestParam String planGroupId,
1856
			@RequestParam String dates,
1857
			@RequestParam(required = false) String beatName,
1858
			@RequestParam(required = false) String beatColor) throws Exception {
1859
 
36644 ranu 1860
		int beatId = Integer.parseInt(planGroupId);
36618 ranu 1861
		Gson gson = new Gson();
1862
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
1863
		}.getType());
1864
 
36644 ranu 1865
		Beat beat = beatRepository.selectById(beatId);
1866
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 1867
 
36644 ranu 1868
		if (beatName != null) beat.setName(beatName);
1869
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36618 ranu 1870
 
36644 ranu 1871
		// Delete old schedules and create new
1872
		beatScheduleRepository.deleteByBeatId(beatId);
1873
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
1874
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
1875
			BeatSchedule schedule = new BeatSchedule();
1876
			schedule.setBeatId(beatId);
1877
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
1878
			schedule.setEndDate(schEndDate);
1879
			schedule.setDayNumber(i + 1);
1880
			schedule.setEndAction(i == dateList.size() - 1 ? "HOME" : "DAYBREAK");
1881
			schedule.setCreatedTimestamp(LocalDateTime.now());
1882
			beatScheduleRepository.persist(schedule);
36618 ranu 1883
		}
1884
 
1885
		Map<String, Object> response = new HashMap<>();
1886
		response.put("status", true);
1887
		response.put("message", "Beat scheduled successfully");
1888
		return responseSender.ok(response);
1889
	}
1890
 
36644 ranu 1891
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 1892
	@PostMapping(value = "/beatPlan/repeatBeat")
1893
	public ResponseEntity<?> repeatBeat(
1894
			HttpServletRequest request,
1895
			@RequestParam String sourcePlanGroupId,
1896
			@RequestParam int authUserId,
1897
			@RequestParam String dates) throws Exception {
1898
 
36644 ranu 1899
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 1900
		Gson gson = new Gson();
1901
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
1902
		}.getType());
1903
 
36644 ranu 1904
		Beat beat = beatRepository.selectById(beatId);
1905
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 1906
 
36644 ranu 1907
		// Remove placeholder (unscheduled) schedule rows
1908
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
1909
		for (BeatSchedule s : existing) {
1910
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
1911
				beatScheduleRepository.delete(s);
1912
			}
36618 ranu 1913
		}
1914
 
36644 ranu 1915
		// Add new real-date schedule rows for the existing beat
1916
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
1917
		for (int i = 0; i < dateList.size(); i++) {
1918
			BeatSchedule schedule = new BeatSchedule();
1919
			schedule.setBeatId(beatId);
1920
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
1921
			schedule.setEndDate(repeatEndDate);
1922
			schedule.setDayNumber(i + 1);
1923
			schedule.setEndAction(i == dateList.size() - 1 ? "HOME" : "DAYBREAK");
1924
			schedule.setCreatedTimestamp(LocalDateTime.now());
1925
			beatScheduleRepository.persist(schedule);
36618 ranu 1926
		}
1927
 
1928
		Map<String, Object> response = new HashMap<>();
1929
		response.put("status", true);
36644 ranu 1930
		response.put("planGroupId", String.valueOf(beatId));
1931
		response.put("message", "Beat scheduled successfully");
36618 ranu 1932
		return responseSender.ok(response);
1933
	}
1934
 
1935
	@GetMapping(value = "/beatPlan/availableSlots")
1936
	public ResponseEntity<?> getAvailableSlots(
1937
			@RequestParam int authUserId,
1938
			@RequestParam String month,
1939
			@RequestParam int daysNeeded) {
1940
 
1941
		YearMonth ym = YearMonth.parse(month);
1942
		LocalDate startDate = ym.atDay(1);
1943
		LocalDate endDate = ym.atEndOfMonth();
1944
		LocalDate today = LocalDate.now();
1945
 
1946
		Set<LocalDate> blocked = new HashSet<>();
1947
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
1948
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 1949
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 1950
		}
1951
 
1952
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
1953
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
1954
 
36644 ranu 1955
		// Get all scheduled dates for this user
1956
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
1957
		for (Beat b : userBeats) {
1958
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
1959
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 1960
		}
1961
 
1962
		List<String> available = new ArrayList<>();
1963
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
1964
			 !d.isAfter(endDate) && available.size() < daysNeeded;
1965
			 d = d.plusDays(1)) {
36644 ranu 1966
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 1967
		}
1968
 
1969
		Map<String, Object> response = new HashMap<>();
1970
		response.put("suggestedDates", available);
1971
		response.put("totalAvailable", available.size());
1972
		return responseSender.ok(response);
1973
	}
1974
 
1975
	// --- Sorting helpers ---
1976
 
1977
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
1978
			List<Map<String, Object>> partners, double startLat, double startLng) {
1979
		List<Map<String, Object>> withCoords = new ArrayList<>();
1980
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
1981
		for (Map<String, Object> p : partners) {
36644 ranu 1982
			if (hasValidCoords(p)) withCoords.add(p);
1983
			else withoutCoords.add(p);
36618 ranu 1984
		}
1985
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 1986
		double currentLat = startLat, currentLng = startLng;
36618 ranu 1987
		while (!withCoords.isEmpty()) {
1988
			int nearestIdx = 0;
1989
			double nearestDist = Double.MAX_VALUE;
1990
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 1991
				double dist = haversine(currentLat, currentLng,
1992
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
1993
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 1994
				if (dist < nearestDist) {
1995
					nearestDist = dist;
1996
					nearestIdx = i;
1997
				}
1998
			}
1999
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
2000
			sorted.add(nearest);
2001
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
2002
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
2003
		}
2004
		sorted.addAll(withoutCoords);
2005
		return sorted;
2006
	}
2007
 
2008
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
2009
		List<Map<String, Object>> withCoords = new ArrayList<>();
2010
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2011
		for (Map<String, Object> p : partners) {
36644 ranu 2012
			if (hasValidCoords(p)) withCoords.add(p);
2013
			else withoutCoords.add(p);
36618 ranu 2014
		}
2015
		List<Map<String, Object>> sorted = new ArrayList<>();
2016
		if (!withCoords.isEmpty()) {
2017
			sorted.add(withCoords.remove(0));
2018
			while (!withCoords.isEmpty()) {
2019
				Map<String, Object> last = sorted.get(sorted.size() - 1);
2020
				double lastLat = Double.parseDouble(last.get("latitude").toString());
2021
				double lastLng = Double.parseDouble(last.get("longitude").toString());
2022
				int nearestIdx = 0;
2023
				double nearestDist = Double.MAX_VALUE;
2024
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2025
					double dist = haversine(lastLat, lastLng,
2026
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
2027
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 2028
					if (dist < nearestDist) {
2029
						nearestDist = dist;
2030
						nearestIdx = i;
2031
					}
2032
				}
2033
				sorted.add(withCoords.remove(nearestIdx));
2034
			}
2035
		}
2036
		sorted.addAll(withoutCoords);
2037
		return sorted;
2038
	}
2039
 
2040
	private boolean hasValidCoords(Map<String, Object> p) {
2041
		Object lat = p.get("latitude");
2042
		Object lng = p.get("longitude");
36644 ranu 2043
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 2044
	}
2045
 
2046
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
2047
		double R = 6371;
2048
		double dLat = Math.toRadians(lat2 - lat1);
2049
		double dLng = Math.toRadians(lng2 - lng1);
2050
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
2051
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
2052
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
2053
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
2054
		return R * c;
2055
	}
2056
}