Subversion Repositories SmartDukaan

Rev

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