Subversion Repositories SmartDukaan

Rev

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

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