Subversion Repositories SmartDukaan

Rev

Rev 36670 | Rev 36686 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

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