Subversion Repositories SmartDukaan

Rev

Rev 36840 | Rev 37060 | 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;
36763 ranu 7
import com.spice.profitmandi.common.model.ProfitMandiConstants;
36618 ranu 8
import com.spice.profitmandi.common.web.util.ResponseSender;
9
import com.spice.profitmandi.dao.entity.auth.AuthUser;
10
import com.spice.profitmandi.dao.entity.fofo.FofoStore;
11
import com.spice.profitmandi.dao.entity.logistics.PublicHolidays;
36644 ranu 12
import com.spice.profitmandi.dao.entity.user.*;
36618 ranu 13
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
14
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
15
import com.spice.profitmandi.dao.repository.cs.CsService;
16
import com.spice.profitmandi.dao.repository.dtr.*;
17
import com.spice.profitmandi.dao.repository.logistics.PublicHolidaysRepository;
18
import com.spice.profitmandi.service.user.RetailerService;
19
import com.spice.profitmandi.web.model.LoginDetails;
20
import com.spice.profitmandi.web.util.CookiesProcessor;
21
import org.apache.logging.log4j.LogManager;
22
import org.apache.logging.log4j.Logger;
23
import org.springframework.beans.factory.annotation.Autowired;
24
import org.springframework.http.ResponseEntity;
25
import org.springframework.stereotype.Controller;
26
import org.springframework.transaction.annotation.Transactional;
27
import org.springframework.ui.Model;
28
import org.springframework.web.bind.annotation.GetMapping;
29
import org.springframework.web.bind.annotation.PostMapping;
30
import org.springframework.web.bind.annotation.RequestParam;
31
 
32
import javax.servlet.http.HttpServletRequest;
33
import java.lang.reflect.Type;
34
import java.time.DayOfWeek;
35
import java.time.LocalDate;
36
import java.time.LocalDateTime;
37
import java.time.YearMonth;
38
import java.time.format.DateTimeFormatter;
39
import java.util.*;
40
import java.util.stream.Collectors;
41
 
42
@Controller
43
@Transactional(rollbackFor = Throwable.class)
44
public class BeatPlanController {
45
	private static final Logger LOGGER = LogManager.getLogger(BeatPlanController.class);
46
	private static final String[] BEAT_COLORS = {
47
			"#3498DB", "#E74C3C", "#2ECC71", "#9B59B6", "#F39C12",
48
			"#1ABC9C", "#E67E22", "#34495E", "#16A085", "#C0392B"
49
	};
50
	@Autowired
51
	private CsService csService;
52
	@Autowired
53
	private AuthRepository authRepository;
36686 ranu 54
	// Emails that bypass hierarchy and role gates — single source of truth.
55
	private static final Set<String> SUPER_ADMIN_EMAILS = new HashSet<>(Arrays.asList(
56
			"tarun.verma@smartdukaan.com"
57
	));
36618 ranu 58
	@Autowired
36686 ranu 59
	private com.spice.profitmandi.service.AuthService authService;
36618 ranu 60
	@Autowired
36686 ranu 61
	private com.spice.profitmandi.dao.repository.cs.PositionRepository positionRepository;
62
	@Autowired
36618 ranu 63
	private RetailerService retailerService;
64
	@Autowired
36644 ranu 65
	private BeatRepository beatRepository;
36618 ranu 66
	@Autowired
36644 ranu 67
	private BeatRouteRepository beatRouteRepository;
36618 ranu 68
	@Autowired
36644 ranu 69
	private BeatScheduleRepository beatScheduleRepository;
70
	@Autowired
71
	private LeadRouteRepository leadRouteRepository;
72
	@Autowired
36650 ranu 73
	private com.spice.profitmandi.dao.service.BeatPlanQueryService beatPlanQueryService;
74
	@Autowired
36618 ranu 75
	private AuthUserLocationRepository authUserLocationRepository;
76
	@Autowired
77
	private LeadRepository leadRepository;
78
	@Autowired
79
	private PublicHolidaysRepository publicHolidaysRepository;
80
	@Autowired
81
	private com.spice.profitmandi.service.GeocodingService geocodingService;
82
	@Autowired
83
	private CookiesProcessor cookiesProcessor;
84
	@Autowired
85
	private ResponseSender responseSender;
36686 ranu 86
	@Autowired
87
	private FofoStoreRepository fofoStoreRepository;
36811 ranu 88
	@Autowired
89
	private com.spice.profitmandi.dao.repository.logistics.CompanyOfficeRepository companyOfficeRepository;
36618 ranu 90
 
91
	@GetMapping(value = "/beatPlan")
36686 ranu 92
	public String beatPlan(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
93
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36618 ranu 94
		return "beat-plan";
95
	}
96
 
36650 ranu 97
	@Autowired
98
	private com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository leadLiveLocationRepositoryAuto;
36651 ranu 99
	@Autowired
100
	private com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository leadActivityRepositoryAuto;
36663 ranu 101
	@Autowired
102
	private com.spice.profitmandi.dao.repository.dtr.UserRepository userRepositoryAuto;
103
	@Autowired
104
	private com.spice.profitmandi.common.web.client.RestClient restClientAuto;
105
	@Autowired
106
	private com.spice.profitmandi.dao.repository.auth.LocationTrackingRepository locationTrackingRepositoryAuto;
36740 ranu 107
	@Autowired
108
	private com.spice.profitmandi.dao.repository.dtr.BeatDeferredVisitRepository beatDeferredVisitRepository;
36650 ranu 109
 
36655 ranu 110
	private static Double parseDoubleOrNull(String s) {
111
		if (s == null || s.trim().isEmpty()) return null;
112
		try {
113
			return Double.parseDouble(s.trim());
114
		} catch (NumberFormatException e) {
115
			return null;
116
		}
117
	}
118
 
119
	private static double haversineKm(double lat1, double lng1, double lat2, double lng2) {
120
		double R = 6371;
121
		double dLat = Math.toRadians(lat2 - lat1);
122
		double dLng = Math.toRadians(lng2 - lng1);
123
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
124
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
125
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
126
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
127
		return R * c;
128
	}
129
 
36711 ranu 130
    // Mirrors the JS recalcDay() formula. Used by schedule/repeat endpoints
131
    // which create fresh BeatSchedule rows — they need to fill totals from the
132
    // existing beat_route table, not from anything the client posted.
133
    // Returns {totalKm, totalMins}.
134
    private double[] computeDayTotals(int beatId, int dayNumber, String endAction) {
135
        Beat beat = beatRepository.selectById(beatId);
136
        if (beat == null) return new double[]{0d, 0d};
137
 
138
        List<BeatRoute> dayRoutes = beatRouteRepository.selectByBeatId(beatId).stream()
139
                .filter(r -> r.getDayNumber() == dayNumber)
140
                .sorted(java.util.Comparator.comparingInt(BeatRoute::getSequenceOrder))
141
                .collect(Collectors.toList());
142
        if (dayRoutes.isEmpty()) return new double[]{0d, 0d};
143
 
144
        List<Integer> fofoIds = dayRoutes.stream().map(BeatRoute::getFofoId).distinct().collect(Collectors.toList());
145
        Map<Integer, FofoStore> storeMap = new HashMap<>();
146
        try {
147
            for (FofoStore fs : fofoStoreRepository.selectByRetailerIds(fofoIds)) {
148
                storeMap.put(fs.getId(), fs);
149
            }
150
        } catch (Exception ignored) { /* fall through with empty map */ }
151
 
152
        double ROAD_FACTOR = 1.3;
153
        double AVG_SPEED = 30.0; // km/h
154
        int VISIT_MINS = 30;
155
 
156
        Double prevLat = parseDoubleOrNull(beat.getStartLatitude());
157
        Double prevLng = parseDoubleOrNull(beat.getStartLongitude());
158
 
159
        double totalKm = 0d;
160
        for (BeatRoute r : dayRoutes) {
161
            FofoStore fs = storeMap.get(r.getFofoId());
162
            if (fs == null) continue;
163
            Double curLat = parseDoubleOrNull(fs.getLatitude());
164
            Double curLng = parseDoubleOrNull(fs.getLongitude());
165
            if (prevLat != null && prevLng != null && curLat != null && curLng != null) {
166
                totalKm += haversineKm(prevLat, prevLng, curLat, curLng) * ROAD_FACTOR;
167
            }
168
            if (curLat != null && curLng != null) {
169
                prevLat = curLat;
170
                prevLng = curLng;
171
            }
172
        }
173
 
174
        // Return-home leg only when end_action='HOME'
175
        if ("HOME".equalsIgnoreCase(endAction)) {
176
            Double homeLat = parseDoubleOrNull(beat.getStartLatitude());
177
            Double homeLng = parseDoubleOrNull(beat.getStartLongitude());
178
            if (prevLat != null && prevLng != null && homeLat != null && homeLng != null) {
179
                totalKm += haversineKm(prevLat, prevLng, homeLat, homeLng) * ROAD_FACTOR;
180
            }
181
        }
182
 
183
        double totalMins = (totalKm / AVG_SPEED) * 60.0 + dayRoutes.size() * VISIT_MINS;
184
        return new double[]{Math.round(totalKm * 1000d) / 1000d, Math.round(totalMins)};
185
    }
186
 
36663 ranu 187
	// ====================== ASSIGN VISIT ======================
188
	// Day View "Assign Visit" — lets an admin pick parties (stores) for a specific
189
	// auth user on a specific date and pushes them as visit tasks to the v2
190
	// /profitmandi-web/v2/beat-tracking/batch endpoint.
191
 
36716 ranu 192
	// List of parties (stores) assigned to this auth user + their dtr.users.id.
193
	// When date+beatId are passed, each party is also tagged with:
194
	//   inBeat        = is this store part of the scheduled beat's route on that date
195
	//   existingAgendas[] = agendas already saved for this store on that date (so the
196
	//                       modal can pre-fill them and let the user refill rather than re-assign)
36663 ranu 197
	@GetMapping(value = "/beatPlan/assignVisit/parties")
36716 ranu 198
	public ResponseEntity<?> assignVisitParties(
199
			@RequestParam int authUserId,
200
			@RequestParam(required = false) String date,
201
			@RequestParam(required = false) Integer beatId) throws Exception {
36663 ranu 202
		AuthUser au = authRepository.selectById(authUserId);
203
		if (au == null) return responseSender.badRequest("Auth user not found");
204
 
205
		// Map auth_user → dtr.users via email
206
		Integer dtrUserId = null;
207
		try {
208
			com.spice.profitmandi.dao.entity.dtr.User dtrUser =
209
					userRepositoryAuto.selectByEmailId(au.getEmailId());
210
			if (dtrUser != null) dtrUserId = dtrUser.getId();
211
		} catch (Exception ignored) {
212
		}
213
 
36716 ranu 214
		// Parse optional date
215
		LocalDate parsedDate = null;
216
		if (date != null && !date.isEmpty()) {
217
			try {
218
				parsedDate = LocalDate.parse(date);
219
			} catch (Exception ignored) {
220
			}
221
		}
222
 
223
		// Build (fofoId → dayNumber) of partners already in the beat's scheduled route for this date
224
		Set<Integer> inBeatFofoIds = new HashSet<>();
225
		if (beatId != null && parsedDate != null) {
226
			final LocalDate dateF = parsedDate; // capture for lambda (parsedDate is reassigned earlier so not effectively final)
227
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
228
			BeatSchedule match = schedules.stream()
229
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(dateF))
230
					.findFirst().orElse(null);
231
			if (match != null) {
232
				List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
233
				routes.stream()
234
						.filter(r -> r.getDayNumber() == match.getDayNumber() && r.isActive())
235
						.forEach(r -> inBeatFofoIds.add(r.getFofoId()));
236
			}
237
		}
238
 
239
		// Build (fofoId → existingAgendas) and (fofoId → existingDescription) from
240
		// any already-saved location_tracking rows for this user on this date.
241
		// Agenda is stored as task_name = "agenda1, agenda2 | OutletName"
242
		// so we split on " | " to peel the outlet suffix off, then split agendas by ", ".
243
		// Description is stored on task_description (free text).
244
		Map<Integer, List<String>> existingAgendaByFofo = new HashMap<>();
245
		Map<Integer, String> existingDescByFofo = new HashMap<>();
246
		Map<Integer, Integer> existingTrackingIdByFofo = new HashMap<>();
247
		if (dtrUserId != null && parsedDate != null) {
248
			List<com.spice.profitmandi.dao.entity.auth.LocationTracking> existing =
249
					locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, parsedDate);
250
			for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt : existing) {
251
				if (!"franchisee-visit".equals(lt.getTaskType())) continue;
252
				if (existingAgendaByFofo.containsKey(lt.getTaskId())) continue; // first wins
253
				String taskName = lt.getTaskName() == null ? "" : lt.getTaskName();
254
				String agendaPart = taskName;
255
				int pipeIdx = taskName.lastIndexOf(" | ");
256
				if (pipeIdx > 0) agendaPart = taskName.substring(0, pipeIdx);
257
				List<String> agendas = new ArrayList<>();
258
				for (String a : agendaPart.split(",")) {
259
					String trimmed = a.trim();
260
					if (!trimmed.isEmpty()) agendas.add(trimmed);
261
				}
262
				existingAgendaByFofo.put(lt.getTaskId(), agendas);
263
				existingDescByFofo.put(lt.getTaskId(), lt.getTaskDescription() != null ? lt.getTaskDescription() : "");
264
				existingTrackingIdByFofo.put(lt.getTaskId(), lt.getId());
265
			}
266
		}
267
 
36663 ranu 268
		Map<Integer, List<Integer>> mapping = csService.getAuthUserIdPartnerIdMapping();
269
		List<Integer> fofoIds = mapping.get(authUserId);
270
 
271
		List<Map<String, Object>> parties = new ArrayList<>();
272
		if (fofoIds != null && !fofoIds.isEmpty()) {
273
			List<FofoStore> stores = fofoStoreRepository.selectByRetailerIds(fofoIds);
274
			Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
275
			for (FofoStore store : stores) {
276
				if (!store.isActive() || store.isClosed()) continue;
277
				CustomRetailer retailer = retailerMap.get(store.getId());
278
				Map<String, Object> p = new HashMap<>();
279
				p.put("fofoStoreId", store.getId());
280
				p.put("code", store.getCode());
281
				p.put("outletName", store.getOutletName() != null ? store.getOutletName()
282
						: (retailer != null ? retailer.getBusinessName() : "Store #" + store.getId()));
283
				p.put("latitude", store.getLatitude());
284
				p.put("longitude", store.getLongitude());
285
				p.put("city", retailer != null && retailer.getAddress() != null ? retailer.getAddress().getCity() : null);
36716 ranu 286
				p.put("inBeat", inBeatFofoIds.contains(store.getId()));
287
				p.put("existingAgendas", existingAgendaByFofo.getOrDefault(store.getId(), new ArrayList<>()));
288
				p.put("existingDescription", existingDescByFofo.getOrDefault(store.getId(), ""));
289
				p.put("existingTrackingId", existingTrackingIdByFofo.get(store.getId()));
36663 ranu 290
				parties.add(p);
291
			}
36716 ranu 292
			// In-beat first, then by code
293
			parties.sort((a, b) -> {
294
				boolean ai = Boolean.TRUE.equals(a.get("inBeat"));
295
				boolean bi = Boolean.TRUE.equals(b.get("inBeat"));
296
				if (ai != bi) return ai ? -1 : 1;
297
				return String.valueOf(a.get("code")).compareToIgnoreCase(String.valueOf(b.get("code")));
298
			});
36663 ranu 299
		}
300
 
301
		Map<String, Object> result = new HashMap<>();
302
		result.put("dtrUserId", dtrUserId);
303
		result.put("authUserId", authUserId);
304
		result.put("userName", au.getFirstName() + " " + au.getLastName());
305
		result.put("parties", parties);
36716 ranu 306
		result.put("agendaOptions", com.spice.profitmandi.dao.enumuration.dtr.VisitAgenda.labels());
36663 ranu 307
		return responseSender.ok(result);
308
	}
309
 
310
	// Submit assignment — accepts a JSON body, builds the v2 payload, posts it
311
	@PostMapping(value = "/beatPlan/assignVisit/submit")
312
	public ResponseEntity<?> assignVisitSubmit(
313
			HttpServletRequest request,
314
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
315
 
316
		Integer authUserId = body.get("authUserId") != null ? ((Number) body.get("authUserId")).intValue() : null;
317
		String planDate = (String) body.get("planDate");
318
		List<Map<String, Object>> selected = (List<Map<String, Object>>) body.get("parties");
319
		if (authUserId == null || planDate == null || selected == null || selected.isEmpty()) {
320
			return responseSender.badRequest("authUserId, planDate and parties are required");
321
		}
322
 
36962 vikas 323
		// Defence-in-depth: Assign Visit is L2-and-above only (mirrors the hidden UI).
324
		if (!canEditBeat(currentUser(request))) {
325
			return responseSender.badRequest("Assigning a visit is restricted to L2 and above.");
326
		}
327
 
36663 ranu 328
		AuthUser au = authRepository.selectById(authUserId);
329
		if (au == null) return responseSender.badRequest("Auth user not found");
330
 
331
		// Map auth → dtr.users.id (this is the userId the v2 endpoint expects)
332
		com.spice.profitmandi.dao.entity.dtr.User dtrUser;
333
		try {
334
			dtrUser = userRepositoryAuto.selectByEmailId(au.getEmailId());
335
		} catch (Exception e) {
336
			return responseSender.badRequest("Failed to look up dtr.users for this auth user");
337
		}
338
		if (dtrUser == null) {
339
			return responseSender.badRequest("No dtr.users record found for auth user " + authUserId);
340
		}
341
		int dtrUserId = dtrUser.getId();
342
 
343
		// Persist directly via the shared DAO — mirrors BeatTrackingController.createBatch
344
		// in profitmandi-web. We can't autowire a controller across WARs, but
345
		// LocationTrackingRepository lives in profitmandi-dao and is shared.
346
		LocalDate taskDate;
347
		try {
348
			taskDate = LocalDate.parse(planDate);
349
		} catch (Exception e) {
350
			return responseSender.badRequest("Invalid planDate (expected yyyy-MM-dd): " + planDate);
351
		}
352
 
36716 ranu 353
		// Existing rows for this user on this date — keyed by fofoStoreId.
354
		// If a row already exists for a party, we UPDATE its agenda instead of
355
		// creating a duplicate (this is the "refill agenda" path for already-
356
		// assigned parties).
357
		Map<Integer, com.spice.profitmandi.dao.entity.auth.LocationTracking> existingByFofo = new HashMap<>();
358
		for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt :
359
				locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, taskDate)) {
360
			if (!"franchisee-visit".equals(lt.getTaskType())) continue;
361
			existingByFofo.putIfAbsent(lt.getTaskId(), lt);
362
		}
363
 
364
		// Defence-in-depth: Assign Visit is only valid for today's run. Hiding the
365
		// button on the UI isn't enough — block at the API too.
366
		if (!taskDate.equals(LocalDate.now())) {
367
			return responseSender.badRequest("Visits can only be assigned for today's date (" + LocalDate.now() + ")");
368
		}
369
 
36663 ranu 370
		LocalDateTime now = LocalDateTime.now();
36716 ranu 371
		int createdCount = 0, updatedCount = 0;
36663 ranu 372
 
373
		for (Map<String, Object> p : selected) {
374
			Integer fofoStoreId = ((Number) p.get("fofoStoreId")).intValue();
375
			String outletName = (String) p.get("outletName");
376
			String lat = (String) p.get("latitude");
377
			String lng = (String) p.get("longitude");
36716 ranu 378
			String description = (String) p.get("description");
379
			if (description != null) description = description.trim();
380
			if (description == null) description = "";
36663 ranu 381
 
36716 ranu 382
			// Multi-agenda: accept agendas[] (new format) or fall back to agenda (legacy single)
383
			List<String> agendas = new ArrayList<>();
384
			Object rawAgendas = p.get("agendas");
385
			if (rawAgendas instanceof List) {
386
				for (Object o : (List<?>) rawAgendas) {
387
					if (o != null) {
388
						String s = String.valueOf(o).trim();
389
						if (!s.isEmpty()) agendas.add(s);
390
					}
391
				}
392
			}
393
			if (agendas.isEmpty()) {
394
				String single = (String) p.get("agenda");
395
				if (single != null && !single.trim().isEmpty()) agendas.add(single.trim());
396
			}
397
			if (agendas.isEmpty()) agendas.add("Visit");
398
			String agendaJoined = String.join(", ", agendas);
399
 
36663 ranu 400
			String visitLocation = (lat != null && lng != null && !lat.isEmpty() && !lng.isEmpty())
401
					? (lat + "," + lng) : "0.0000,0.0000";
402
 
403
			String displayName = (outletName != null && !outletName.isEmpty()) ? outletName : ("Store #" + fofoStoreId);
36716 ranu 404
			String newTaskName = agendaJoined + " | " + displayName;
36663 ranu 405
 
36716 ranu 406
			com.spice.profitmandi.dao.entity.auth.LocationTracking existing = existingByFofo.get(fofoStoreId);
407
			if (existing != null) {
408
				// Refill — agenda (task_name), description, and visit location change
409
				existing.setTaskName(newTaskName);
410
				existing.setTaskDescription(description);
411
				existing.setVisitLocation(visitLocation);
412
				existing.setUpdatedTimestamp(now);
413
				locationTrackingRepositoryAuto.persist(existing);
414
				updatedCount++;
415
				continue;
416
			}
417
 
36663 ranu 418
			com.spice.profitmandi.dao.entity.auth.LocationTracking row =
419
					new com.spice.profitmandi.dao.entity.auth.LocationTracking();
420
			row.setUserId(dtrUserId);
421
			row.setDeviceId("0");
422
			row.setTaskId(fofoStoreId);
423
			row.setTaskDate(taskDate);
36716 ranu 424
			row.setTaskName(newTaskName);
425
			row.setTaskDescription(description);
36663 ranu 426
			row.setTaskType("franchisee-visit");
36764 ranu 427
			row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));
36663 ranu 428
			row.setAddress("");
429
			row.setVisitLocation(visitLocation);
430
			row.setCheckInLatLng("0.0000,0.0000");
431
			row.setCheckOutLatLng("0.0000,0.0000");
432
			row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
433
			row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
434
			row.setTransitTime(java.time.LocalTime.MIDNIGHT);
435
			row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
436
			row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
437
			row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
438
			row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
439
			row.setTotalDistance("0.0");
440
			row.setStatus(false);
441
			row.setCreatedTimestamp(now);
442
			row.setUpdatedTimestamp(now);
443
 
444
			// Do NOT try/catch this — if persist throws, let it propagate so
445
			// @Transactional(rollbackFor = Throwable.class) rolls back cleanly.
446
			locationTrackingRepositoryAuto.persist(row);
36716 ranu 447
			createdCount++;
36663 ranu 448
		}
36716 ranu 449
		LOGGER.info("assignVisit dtrUserId={} created={} updated={}", dtrUserId, createdCount, updatedCount);
36663 ranu 450
 
451
		Map<String, Object> result = new HashMap<>();
452
		result.put("status", true);
36716 ranu 453
		result.put("createdCount", createdCount);
454
		result.put("updatedCount", updatedCount);
36663 ranu 455
		result.put("dtrUserId", dtrUserId);
36716 ranu 456
		StringBuilder msg = new StringBuilder();
457
		if (createdCount > 0)
458
			msg.append(createdCount).append(" new visit").append(createdCount == 1 ? "" : "s").append(" assigned");
459
		if (updatedCount > 0) {
460
			if (msg.length() > 0) msg.append(", ");
461
			msg.append(updatedCount).append(" existing agenda").append(updatedCount == 1 ? "" : "s").append(" refilled");
462
		}
463
		msg.append(" for ").append(au.getFirstName()).append(" ").append(au.getLastName());
464
		result.put("message", msg.toString());
36663 ranu 465
		return responseSender.ok(result);
466
	}
467
 
36740 ranu 468
	// ====================== DEFERRED PARTNERS ======================
469
	// Heads review partners that weren't visited on their planned day and act on
470
	// them. The deferral lifecycle lives in user.beat_deferred_visit (separate
471
	// from the raw location_tracking event log). Detection = explicit
472
	// (mark_type='DEFERRED') + derived (planned beat_route minus completed visits).
473
 
474
	// Page
475
	@GetMapping(value = "/beatPlan/deferredView")
476
	public String deferredView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
477
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
478
		return "beat-plan-deferred";
479
	}
480
 
481
	// List (syncs the table first, then returns the head's downline deferrals).
482
	@GetMapping(value = "/beatPlan/deferred")
483
	public ResponseEntity<?> deferredList(
484
			HttpServletRequest request,
485
			@RequestParam(required = false) String startDate,
486
			@RequestParam(required = false) String endDate) throws Exception {
487
 
488
		LocalDate start, end;
489
		try {
490
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now().minusDays(7) : LocalDate.parse(startDate);
491
			end = (endDate == null || endDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(endDate);
492
		} catch (Exception e) {
493
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
494
		}
495
 
496
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
497
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
498
		if (me == null) return responseSender.unauthorized("Not logged in");
499
 
500
		// PURE READ. Deferrals are persisted at the write point (BeatTrackingController,
501
		// when mark_type='DEFERRED' is recorded) — this endpoint never writes.
502
		List<BeatDeferredVisit> source;
503
		if (isSuperAdmin(me)) {
504
			source = beatDeferredVisitRepository.selectByDateRange(start, end);
505
		} else {
506
			Set<Integer> downline = new HashSet<>(authService.getAllReportees(me.getId()));
507
			downline.add(me.getId());
508
			source = beatDeferredVisitRepository.selectByAuthUserIdsAndDateRange(new ArrayList<>(downline), start, end);
509
		}
510
		List<BeatDeferredVisit> rows = source.stream()
511
				.filter(r -> "DEFERRED".equals(r.getStatus()))
512
				.collect(Collectors.toList());
513
 
514
		// ---- nextScheduledDate (info only: does a future run already cover it?) ----
515
		// Only meaningful for partner visits (leads aren't on beat_route). Purely a
516
		// hint — the row stays actionable even when auto-covered, since the next run
517
		// could be far off.
518
		Map<Integer, Map<Integer, LocalDate>> coverCache = new HashMap<>();
519
		Map<Integer, LocalDate> nextByRowId = new HashMap<>();
520
		for (BeatDeferredVisit r : rows) {
521
			if (!"franchisee-visit".equals(r.getTaskType())) continue;
522
			Map<Integer, LocalDate> cover = coverCache.computeIfAbsent(r.getAuthUserId(), this::computeFutureCover);
523
			LocalDate next = cover.get(r.getFofoId());
524
			if (next != null) nextByRowId.put(r.getId(), next);
525
		}
526
 
527
		// ---- resolve user names (display name + type already denormalized on the row) ----
528
		Set<Integer> authIds = rows.stream().map(BeatDeferredVisit::getAuthUserId).collect(Collectors.toSet());
529
		Map<Integer, AuthUser> userMap = new HashMap<>();
530
		if (!authIds.isEmpty())
531
			authRepository.selectByIds(new ArrayList<>(authIds)).forEach(u -> userMap.put(u.getId(), u));
532
 
533
		List<Map<String, Object>> out = new ArrayList<>();
534
		for (BeatDeferredVisit r : rows) {
535
			AuthUser u = userMap.get(r.getAuthUserId());
536
			boolean isLead = "lead".equalsIgnoreCase(r.getTaskType());
36811 ranu 537
			boolean isOffice = "office-visit".equalsIgnoreCase(r.getTaskType());
36740 ranu 538
			Map<String, Object> row = new HashMap<>();
539
			row.put("id", r.getId());
540
			row.put("authUserId", r.getAuthUserId());
541
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : ("User #" + r.getAuthUserId()));
542
			row.put("fofoStoreId", r.getFofoId());
543
			row.put("name", r.getDisplayName() != null ? r.getDisplayName() : ("#" + r.getFofoId()));
36811 ranu 544
			row.put("type", isLead ? "Lead" : (isOffice ? "Office" : "Visit"));
36740 ranu 545
			row.put("deferredDate", r.getDeferredDate() != null ? r.getDeferredDate().toString() : null);
546
			row.put("reason", r.getReason());
547
			row.put("status", r.getStatus());
548
			LocalDate next = nextByRowId.get(r.getId());
549
			row.put("nextScheduledDate", next != null ? next.toString() : null);
550
			out.add(row);
551
		}
552
		out.sort((a, c) -> String.valueOf(a.get("deferredDate")).compareTo(String.valueOf(c.get("deferredDate"))));
553
 
554
		Map<String, Object> result = new HashMap<>();
555
		result.put("rows", out);
556
		result.put("startDate", start.toString());
557
		result.put("endDate", end.toString());
558
		return responseSender.ok(result);
559
	}
560
 
561
	// Head action on a deferral: reschedule (one-off visit, or into an existing
562
	// beat-day) or cancel. Never edits the beat template.
563
	@PostMapping(value = "/beatPlan/deferred/action")
564
	public ResponseEntity<?> deferredAction(
565
			HttpServletRequest request,
566
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
567
 
568
		Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;
569
		String action = (String) body.get("action");
570
		String toDateStr = (String) body.get("toDate");
571
		if (deferredId == null || action == null)
572
			return responseSender.badRequest("deferredId and action are required");
573
 
574
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
575
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
576
		if (me == null) return responseSender.unauthorized("Not logged in");
577
 
578
		BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);
579
		if (d == null) return responseSender.badRequest("Deferred record not found");
580
 
581
		LocalDateTime now = LocalDateTime.now();
582
 
583
		if ("cancel".equalsIgnoreCase(action)) {
36763 ranu 584
			d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.CANCELLED));
36740 ranu 585
			d.setActionBy(me.getId());
586
			d.setUpdatedTimestamp(now);
36792 ranu 587
			// Optional cancel reason — overlay onto the reason column. Original
588
			// deferred-reason is preserved as a suffix so we keep the audit trail.
589
			String cancelReason = body.get("reason") != null ? String.valueOf(body.get("reason")).trim() : "";
590
			if (!cancelReason.isEmpty()) {
591
				String prev = d.getReason() != null ? d.getReason() : "";
592
				d.setReason("Cancelled: " + cancelReason + (prev.isEmpty() ? "" : " | Original: " + prev));
593
			}
36740 ranu 594
			beatDeferredVisitRepository.persist(d);
595
			Map<String, Object> ok = new HashMap<>();
596
			ok.put("status", true);
597
			ok.put("message", "Deferred visit cancelled");
598
			return responseSender.ok(ok);
599
		}
600
 
601
		// reschedule_oneoff | reschedule_beat
602
		if (toDateStr == null || toDateStr.isEmpty())
603
			return responseSender.badRequest("toDate is required to reschedule");
604
		LocalDate toDate;
605
		try {
606
			toDate = LocalDate.parse(toDateStr);
607
		} catch (Exception e) {
608
			return responseSender.badRequest("Invalid toDate (yyyy-MM-dd)");
609
		}
36821 ranu 610
		// Reschedule must land on a STRICTLY future date — today is already in progress.
611
		if (!toDate.isAfter(LocalDate.now()))
612
			return responseSender.badRequest("Reschedule date must be in the future (today is already in progress).");
36740 ranu 613
 
614
		if ("reschedule_beat".equalsIgnoreCase(action)) {
615
			boolean hasBeat = beatRepository.selectActiveByAuthUserId(d.getAuthUserId()).stream()
616
					.flatMap(b -> beatScheduleRepository.selectByBeatId(b.getId()).stream())
617
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(toDate));
618
			if (!hasBeat)
619
				return responseSender.badRequest("No beat is scheduled for this user on " + toDateStr + ". Pick another date or use a one-off visit.");
620
		}
621
 
622
		// Resolve dtr user, then create a PENDING task on toDate. Reuse the
623
		// denormalized name + type (works for both partner visits and leads — for
624
		// leads, looking up fofo_store would be the wrong id space). For visits we
625
		// still try to pull lat/lng for the visit location.
626
		Integer dtrId = resolveDtrId(d.getAuthUserId(), new HashMap<>());
627
		if (dtrId == null) return responseSender.badRequest("No dtr.users record for this sales person");
628
		boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());
36811 ranu 629
		boolean isOffice = "office-visit".equalsIgnoreCase(d.getTaskType());
36740 ranu 630
		String visitLocation = "0.0000,0.0000";
36811 ranu 631
		if (isOffice) {
632
			// Office stops resolve lat/lng from logistics.company_office.
36740 ranu 633
			try {
36811 ranu 634
				com.spice.profitmandi.dao.entity.logistics.CompanyOffice o = companyOfficeRepository.selectById(d.getFofoId());
635
				if (o != null) visitLocation = o.getLat() + "," + o.getLng();
636
			} catch (Exception ignored) {
637
			}
638
		} else if (!isLead) {
639
			try {
36740 ranu 640
				List<FofoStore> ss = fofoStoreRepository.selectByRetailerIds(java.util.Collections.singletonList(d.getFofoId()));
641
				if (!ss.isEmpty()) {
642
					FofoStore fs = ss.get(0);
643
					if (fs.getLatitude() != null && fs.getLongitude() != null
644
							&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
645
						visitLocation = fs.getLatitude() + "," + fs.getLongitude();
646
					}
647
				}
648
			} catch (Exception ignored) {
649
			}
650
		}
651
		String taskName = d.getDisplayName() != null ? d.getDisplayName()
652
				: ((d.getReason() != null ? d.getReason() : "Rescheduled") + " | #" + d.getFofoId());
653
 
654
		com.spice.profitmandi.dao.entity.auth.LocationTracking row = new com.spice.profitmandi.dao.entity.auth.LocationTracking();
655
		row.setUserId(dtrId);
656
		row.setDeviceId("0");
657
		row.setTaskId(d.getFofoId());
658
		row.setTaskDate(toDate);
659
		row.setTaskName(taskName);
660
		row.setTaskDescription("Rescheduled from " + d.getDeferredDate());
661
		row.setTaskType(d.getTaskType() != null ? d.getTaskType() : "franchisee-visit");
36763 ranu 662
		row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));
36740 ranu 663
		row.setAddress("");
664
		row.setVisitLocation(visitLocation);
665
		row.setCheckInLatLng("0.0000,0.0000");
666
		row.setCheckOutLatLng("0.0000,0.0000");
667
		row.setCheckInTime(java.time.LocalTime.MIDNIGHT);
668
		row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);
669
		row.setTransitTime(java.time.LocalTime.MIDNIGHT);
670
		row.setTimeSpent(java.time.LocalTime.MIDNIGHT);
671
		row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);
672
		row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);
673
		row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);
674
		row.setTotalDistance("0.0");
675
		row.setStatus(false);
676
		row.setCreatedTimestamp(now);
677
		row.setUpdatedTimestamp(now);
678
		locationTrackingRepositoryAuto.persist(row);
679
 
36763 ranu 680
		d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));
36740 ranu 681
		d.setRescheduledToDate(toDate);
682
		d.setActionBy(me.getId());
683
		d.setUpdatedTimestamp(now);
684
		beatDeferredVisitRepository.persist(d);
685
 
686
		Map<String, Object> ok = new HashMap<>();
687
		ok.put("status", true);
688
		ok.put("message", "Visit rescheduled to " + toDateStr);
689
		return responseSender.ok(ok);
690
	}
691
 
692
	// Drop a deferred item into a specific upcoming BEAT run (chosen from the beat
693
	// calendar). Lead → a lead_route row on that beat/date (renders as a lead stop).
694
	// Partner visit → appended to that beat's route for the date's day_number.
695
	// The beat plan calendar then shows it. Marks the deferral RESCHEDULED.
696
	@PostMapping(value = "/beatPlan/deferred/assignToBeat")
697
	public ResponseEntity<?> deferredAssignToBeat(
698
			HttpServletRequest request,
699
			@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {
700
 
701
		Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;
702
		Integer beatId = body.get("beatId") != null ? ((Number) body.get("beatId")).intValue() : null;
703
		String dateStr = (String) body.get("date");
704
		if (deferredId == null || beatId == null || dateStr == null)
705
			return responseSender.badRequest("deferredId, beatId and date are required");
706
 
707
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
708
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
709
		if (me == null) return responseSender.unauthorized("Not logged in");
710
 
711
		LocalDate date;
712
		try {
713
			date = LocalDate.parse(dateStr);
714
		} catch (Exception e) {
715
			return responseSender.badRequest("Invalid date (yyyy-MM-dd)");
716
		}
36821 ranu 717
		// Drop-onto-beat must land on a STRICTLY future date — today is already in progress.
718
		if (!date.isAfter(LocalDate.now()))
719
			return responseSender.badRequest("Pick a future date — today is already in progress.");
36740 ranu 720
 
721
		BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);
722
		if (d == null) return responseSender.badRequest("Deferred record not found");
723
 
724
		// A deferral can only move FORWARD — never onto the day it was deferred or earlier.
725
		if (d.getDeferredDate() != null && !date.isAfter(d.getDeferredDate())) {
726
			return responseSender.badRequest("A deferred item can only be moved to a date after "
727
					+ d.getDeferredDate() + " (it was deferred that day).");
728
		}
729
 
730
		Beat beat = beatRepository.selectById(beatId);
731
		if (beat == null) return responseSender.badRequest("Beat not found");
732
 
733
		// The beat must actually run on the chosen date — get that run's day number.
734
		BeatSchedule sched = beatScheduleRepository.selectByBeatId(beatId).stream()
735
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(date))
736
				.findFirst().orElse(null);
737
		if (sched == null) return responseSender.badRequest("That beat is not scheduled on " + dateStr);
738
 
739
		LocalDateTime now = LocalDateTime.now();
740
		boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());
741
 
742
		if (isLead) {
743
			// Avoid duplicating the same lead on the same beat/date
744
			boolean exists = leadRouteRepository.selectByBeatId(beatId).stream()
745
					.anyMatch(lr -> lr.getLeadId() == d.getFofoId()
746
							&& date.equals(lr.getScheduleDate())
747
							&& !"CANCELLED".equals(lr.getStatus()));
748
			if (!exists) {
749
				LeadRoute lr = new LeadRoute();
750
				lr.setBeatId(beatId);
751
				lr.setLeadId(d.getFofoId());
752
				lr.setScheduleDate(date);
753
				lr.setSequenceOrder(9999); // append; planner can reorder
754
				lr.setStatus("APPROVED");
755
				lr.setApprovedBy(me.getId());
756
				lr.setApprovedTimestamp(now);
757
				lr.setCreatedTimestamp(now);
758
				lr.setUpdatedTimestamp(now);
759
				leadRouteRepository.persist(lr);
760
			}
761
		} else {
762
			// Partner visit → append to that beat's route for the date's day number,
763
			// if not already present on that day.
764
			boolean exists = beatRouteRepository.selectByBeatId(beatId).stream()
765
					.anyMatch(r -> r.getFofoId() == d.getFofoId() && r.getDayNumber() == sched.getDayNumber() && r.isActive());
766
			if (!exists) {
767
				int nextSeq = beatRouteRepository.selectByBeatId(beatId).stream()
768
						.filter(r -> r.getDayNumber() == sched.getDayNumber())
769
						.mapToInt(BeatRoute::getSequenceOrder).max().orElse(-1) + 1;
770
				BeatRoute br = new BeatRoute();
771
				br.setBeatId(beatId);
772
				br.setFofoId(d.getFofoId());
36811 ranu 773
				br.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
36740 ranu 774
				br.setDayNumber(sched.getDayNumber());
775
				br.setSequenceOrder(nextSeq);
776
				br.setActive(true);
777
				beatRouteRepository.persist(br);
778
			}
779
		}
780
 
36763 ranu 781
		d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));
36740 ranu 782
		d.setRescheduledToDate(date);
783
		d.setActionBy(me.getId());
784
		d.setUpdatedTimestamp(now);
785
		beatDeferredVisitRepository.persist(d);
786
 
787
		Map<String, Object> ok = new HashMap<>();
788
		ok.put("status", true);
789
		ok.put("message", (isLead ? "Lead" : "Partner") + " added to beat '" + beat.getName() + "' on " + dateStr);
790
		return responseSender.ok(ok);
791
	}
792
 
793
	// authUserId -> dtr.users id (via shared email), memoized in the passed cache.
794
	// Used by the reschedule action to create the new PENDING location_tracking row.
795
	private Integer resolveDtrId(int authUserId, Map<Integer, Integer> cache) {
796
		if (cache.containsKey(authUserId)) return cache.get(authUserId);
797
		Integer dtrId = null;
798
		try {
799
			AuthUser au = authRepository.selectById(authUserId);
800
			if (au != null && au.getEmailId() != null) {
801
				com.spice.profitmandi.dao.entity.dtr.User u = userRepositoryAuto.selectByEmailId(au.getEmailId());
802
				if (u != null) dtrId = u.getId();
803
			}
804
		} catch (Exception ignored) {
805
		}
806
		cache.put(authUserId, dtrId);
807
		return dtrId;
808
	}
809
 
810
	// For an auth user: fofoId -> earliest upcoming (>= today) scheduled date where
811
	// an active beat's route still includes that partner (the "Next Scheduled" hint).
812
	private Map<Integer, LocalDate> computeFutureCover(int authUserId) {
813
		LocalDate today = LocalDate.now();
814
		Map<Integer, LocalDate> cover = new HashMap<>();
815
		for (Beat b : beatRepository.selectActiveByAuthUserId(authUserId)) {
816
			LocalDate earliest = null;
817
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(b.getId())) {
818
				LocalDate dt = s.getStartDate();
819
				if (dt != null && dt.getYear() != 9999 && !dt.isBefore(today)) {
820
					if (earliest == null || dt.isBefore(earliest)) earliest = dt;
821
				}
822
			}
823
			if (earliest == null) continue;
824
			for (BeatRoute rt : beatRouteRepository.selectByBeatId(b.getId())) {
825
				if (!rt.isActive()) continue;
826
				LocalDate cur = cover.get(rt.getFofoId());
827
				if (cur == null || earliest.isBefore(cur)) cover.put(rt.getFofoId(), earliest);
828
			}
829
		}
830
		return cover;
831
	}
832
 
36686 ranu 833
	@GetMapping(value = "/beatPlanWindow")
834
	public String beatPlanWindow(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
835
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36962 vikas 836
		model.addAttribute("canScheduleToday", canScheduleToday(currentUser(request)));
36686 ranu 837
		return "beat-plan-window";
36655 ranu 838
	}
839
 
36668 ranu 840
	// Helpers for XLSX bulk upload
841
	private static String readCell(org.apache.poi.ss.usermodel.Cell cell) {
842
		if (cell == null) return null;
843
		switch (cell.getCellType()) {
844
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:
845
				return cell.getStringCellValue();
846
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:
847
				if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {
848
					return cell.getDateCellValue().toInstant()
849
							.atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();
850
				}
851
				double n = cell.getNumericCellValue();
852
				return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);
853
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:
854
				return String.valueOf(cell.getBooleanCellValue());
855
			case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:
856
				return cell.getCellFormula();
857
			default:
858
				return null;
36655 ranu 859
		}
860
	}
861
 
862
	// ====================== ONE-TIME LAT/LNG MIGRATION ======================
863
	// For each active fofo_store, compare its stored lat/lng with the geocoded
864
	// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)
865
	// OR the store has no lat/lng yet, update the store with the geocoded
866
	// coordinates. Otherwise keep the existing values.
867
	//
868
	// Usage:
869
	//   GET /beatPlan/migrateStoreLatLng              -> dry run, default 5km, all
870
	//   GET /beatPlan/migrateStoreLatLng?apply=true   -> actually update
871
	//   ?thresholdKm=3      -> use a different threshold
872
	//   ?limit=100          -> process only N stores (for staged runs)
873
	@GetMapping(value = "/beatPlan/migrateStoreLatLng")
874
	public ResponseEntity<?> migrateStoreLatLng(
875
			@RequestParam(required = false, defaultValue = "false") boolean apply,
876
			@RequestParam(required = false, defaultValue = "5") double thresholdKm,
36660 ranu 877
			@RequestParam(required = false, defaultValue = "0") int limit,
36727 ranu 878
			@RequestParam(required = false, defaultValue = "0") int offset,
879
			@RequestParam(required = false, defaultValue = "40") int maxSeconds) throws ProfitMandiBusinessException {
36655 ranu 880
 
36660 ranu 881
		List<FofoStore> all = fofoStoreRepository.selectActiveStores();
882
		int totalAvailable = all.size();
883
		int from = Math.max(0, Math.min(offset, totalAvailable));
36655 ranu 884
 
36727 ranu 885
		// Hard cap (if limit given), else go to the end of the list.
886
		int hardTo = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;
887
 
888
		// Time budget: stop processing once we approach the gateway timeout and
889
		// return nextOffset so the caller can resume. Geocoding is the slow part
890
		// (network/cache), so a fixed batch size could still time out on a cache-miss
891
		// run — a wall-clock budget is safer. maxSeconds defaults to 40 (< typical 60s gateway).
892
		long deadlineMs = System.currentTimeMillis() + Math.max(5, maxSeconds) * 1000L;
893
 
894
		List<FofoStore> stores = all.subList(from, hardTo);
36655 ranu 895
		List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());
896
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);
897
 
36727 ranu 898
		int total = 0;            // stores actually processed this call
36655 ranu 899
		int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
36727 ranu 900
		boolean stoppedOnTime = false;
901
		int nextIndex = from;     // absolute index of next unprocessed store
36655 ranu 902
		List<Map<String, Object>> changes = new ArrayList<>();
903
 
904
		for (FofoStore store : stores) {
36727 ranu 905
			// Stop before doing more slow geocoding work if we've spent our budget.
906
			if (System.currentTimeMillis() >= deadlineMs) {
907
				stoppedOnTime = true;
908
				break;
909
			}
910
			total++;
911
			nextIndex++;
36655 ranu 912
			try {
913
				CustomRetailer retailer = retailerMap.get(store.getId());
914
				if (retailer == null || retailer.getAddress() == null) {
915
					noAddress++;
916
					continue;
917
				}
918
 
919
				String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(
920
						retailer.getAddress().getLine1(), retailer.getAddress().getCity(),
921
						retailer.getAddress().getState(), retailer.getAddress().getPinCode());
922
				if (geoAddr == null || geoAddr.isEmpty()) {
923
					noAddress++;
924
					continue;
925
				}
926
 
927
				double[] coords = geocodingService.geocodeAddress(geoAddr);
928
				if (coords == null) {
929
					noGeocode++;
930
					continue;
931
				}
932
 
933
				Double existingLat = parseDoubleOrNull(store.getLatitude());
934
				Double existingLng = parseDoubleOrNull(store.getLongitude());
935
 
936
				boolean shouldUpdate;
937
				double distKm = -1;
938
				String reason;
939
				if (existingLat == null || existingLng == null) {
940
					shouldUpdate = true;
941
					reason = "missing existing lat/lng";
942
				} else {
943
					distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);
944
					shouldUpdate = distKm > thresholdKm;
945
					reason = shouldUpdate
946
							? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km"
947
							: "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";
948
				}
949
 
950
				if (shouldUpdate) {
951
					if (apply) {
952
						store.setLatitude(String.valueOf(coords[0]));
953
						store.setLongitude(String.valueOf(coords[1]));
36727 ranu 954
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
36655 ranu 955
						fofoStoreRepository.persist(store);
956
					}
957
					updated++;
958
					Map<String, Object> ch = new HashMap<>();
959
					ch.put("storeId", store.getId());
960
					ch.put("code", store.getCode());
961
					ch.put("oldLat", existingLat);
962
					ch.put("oldLng", existingLng);
963
					ch.put("newLat", coords[0]);
964
					ch.put("newLng", coords[1]);
965
					ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);
966
					ch.put("reason", reason);
967
					changes.add(ch);
968
				} else {
36727 ranu 969
					// Verified-kept: lat/lng was already within threshold. Still stamp it
970
					// so "processed vs pending" can be told from lat_lng_updated_timestamp.
971
					if (apply) {
972
						store.setLatLngUpdatedTimestamp(LocalDateTime.now());
973
						fofoStoreRepository.persist(store);
974
					}
36655 ranu 975
					kept++;
976
				}
977
			} catch (Exception e) {
978
				errored++;
979
				LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());
980
			}
981
		}
982
 
983
		Map<String, Object> result = new HashMap<>();
984
		result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");
985
		result.put("thresholdKm", thresholdKm);
36727 ranu 986
		result.put("totalAvailable", totalAvailable);   // total active stores in DB
36660 ranu 987
		result.put("offset", from);
36727 ranu 988
		result.put("processed", total);                  // stores processed this call
989
		result.put("nextOffset", nextIndex);             // resume here next call
990
		result.put("done", nextIndex >= totalAvailable); // true when nothing left
991
		result.put("stoppedOnTimeBudget", stoppedOnTime);// true if we paused for time, not because we finished
36655 ranu 992
		result.put("updated", updated);
993
		result.put("kept", kept);
994
		result.put("noAddress", noAddress);
995
		result.put("noGeocode", noGeocode);
996
		result.put("errored", errored);
997
		// Limit changes preview to avoid huge responses
998
		result.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);
999
		result.put("changesShownCount", Math.min(changes.size(), 200));
1000
		return responseSender.ok(result);
1001
	}
1002
 
36651 ranu 1003
	// ====================== EDIT BEAT ======================
1004
	// Update an existing beat — name + partner stops (routes).
1005
	// Schedules are NOT touched here; manage them via calendar drag-drop.
1006
	@PostMapping(value = "/beatPlan/updateBeat")
1007
	public ResponseEntity<?> updateBeat(
1008
			HttpServletRequest request,
1009
			@RequestParam int beatId,
1010
			@RequestParam String planData) throws Exception {
1011
 
1012
		Beat beat = beatRepository.selectById(beatId);
1013
		if (beat == null) return responseSender.badRequest("Beat not found");
1014
 
36821 ranu 1015
		// Refuse edits while the beat is "live" — i.e., a schedule row covers today.
1016
		// The salesperson is already on-route; mutating the plan mid-day would
1017
		// break their location_tracking timeline. Edits resume tomorrow.
1018
		LocalDate today = LocalDate.now();
1019
		boolean runningToday = beatScheduleRepository.selectByBeatId(beatId).stream()
1020
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(today));
1021
		if (runningToday) {
1022
			return responseSender.badRequest(
1023
					"This beat is scheduled to run today — editing is locked until tomorrow. "
1024
							+ "Use the Deferred panel for today's adjustments.");
1025
		}
1026
 
36651 ranu 1027
		Gson gson = new Gson();
1028
		Type type = new TypeToken<Map<String, Object>>() {
1029
		}.getType();
1030
		Map<String, Object> plan = gson.fromJson(planData, type);
1031
 
1032
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
1033
		if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");
1034
 
1035
		// Update name if changed (and not colliding with another beat)
1036
		String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();
1037
		if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {
36698 ranu 1038
			// Make sure no other ACTIVE beat for this user already uses this name.
1039
			// Soft-deleted beats keep their name in the table; we don't want them
1040
			// to block a legitimate rename.
1041
			boolean collides = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId()).stream()
36651 ranu 1042
					.anyMatch(b -> b.getId() != beat.getId()
1043
							&& b.getName() != null
1044
							&& newName.equalsIgnoreCase(b.getName().trim()));
1045
			if (collides) return responseSender.badRequest("Another beat with this name already exists");
1046
			beat.setName(newName);
1047
		}
1048
 
1049
		// Update start location from first day if present
1050
		Map<String, Object> firstDay = days.get(0);
1051
		if (firstDay.get("startLocationName") != null)
1052
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1053
		if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));
1054
		if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));
1055
 
36681 ranu 1056
		int oldTotalDays = beat.getTotalDays();
1057
		int newTotalDays = days.size();
1058
 
1059
		// Hard rule: you cannot grow the number of days on an existing beat.
1060
		// If you need more days, create a new beat. (Shrinking is allowed and
1061
		// the schedules for dropped day numbers are cleaned below.)
1062
		if (newTotalDays > oldTotalDays) {
1063
			return responseSender.badRequest(
1064
					"Cannot increase the number of days on an existing beat. "
1065
							+ "Original: " + oldTotalDays + " day(s), tried: " + newTotalDays + " day(s). "
1066
							+ "Please create a new beat for additional days.");
1067
		}
1068
		beat.setTotalDays(newTotalDays);
1069
 
1070
		// Replace routes (partner stops). Schedules stay intact (except for
36711 ranu 1071
        // dayNumber > newTotalDays cleanup + total km/min refresh below).
36651 ranu 1072
		beatRouteRepository.deleteByBeatId(beatId);
36681 ranu 1073
		// Collect lead IDs the user kept on the plan
36651 ranu 1074
		Set<Integer> keptLeadIds = new HashSet<>();
1075
		for (int d = 0; d < days.size(); d++) {
1076
			Map<String, Object> day = days.get(d);
1077
			int dayNumber = d + 1;
1078
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1079
			if (visits == null) continue;
1080
			int partnerSeq = 0;
1081
			for (int i = 0; i < visits.size(); i++) {
1082
				Map<String, Object> v = visits.get(i);
1083
				if ("lead".equals(v.get("type"))) {
1084
					keptLeadIds.add(((Number) v.get("id")).intValue());
1085
					continue; // leads live in lead_route, handled below
1086
				}
1087
				BeatRoute route = new BeatRoute();
1088
				route.setBeatId(beatId);
1089
				route.setFofoId(((Number) v.get("id")).intValue());
36811 ranu 1090
				route.setVisitType("office".equals(v.get("type"))
1091
						? com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE
1092
						: com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
36651 ranu 1093
				route.setSequenceOrder(partnerSeq++);
1094
				route.setDayNumber(dayNumber);
1095
				route.setActive(true);
36711 ranu 1096
                if (v.get("distanceFromPrevKm") != null)
1097
                    route.setDistanceFromPrevKm(((Number) v.get("distanceFromPrevKm")).doubleValue());
1098
                if (v.get("timeFromPrevMins") != null)
1099
                    route.setTimeFromPrevMins(((Number) v.get("timeFromPrevMins")).intValue());
36651 ranu 1100
				beatRouteRepository.persist(route);
1101
			}
1102
		}
1103
 
36681 ranu 1104
		// If the beat shrank, drop schedule rows for day numbers that no longer exist
1105
		if (newTotalDays < oldTotalDays) {
1106
			List<BeatSchedule> currentSchedules = beatScheduleRepository.selectByBeatId(beatId);
1107
			for (BeatSchedule s : currentSchedules) {
1108
				if (s.getDayNumber() > newTotalDays) beatScheduleRepository.delete(s);
1109
			}
1110
		}
1111
 
36711 ranu 1112
        // Refresh the day-level totals on every remaining schedule row so they
1113
        // reflect the post-edit route. Previously updateBeat left these stale
1114
        // (or NULL, for beats created before this fix), which is what the user
1115
        // reported. Keyed by dayNumber so multi-instance beats all get updated.
1116
        Map<Integer, Map<String, Object>> dayByNumber = new HashMap<>();
1117
        for (int d = 0; d < days.size(); d++) {
1118
            dayByNumber.put(d + 1, days.get(d));
1119
        }
1120
        List<BeatSchedule> allSchedules = beatScheduleRepository.selectByBeatId(beatId);
1121
        for (BeatSchedule s : allSchedules) {
1122
            Map<String, Object> day = dayByNumber.get(s.getDayNumber());
1123
            if (day == null) continue;
1124
            if (day.get("totalDistanceKm") != null)
1125
                s.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1126
            if (day.get("totalTimeMins") != null)
1127
                s.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1128
        }
1129
 
36681 ranu 1130
		// Process per-lead actions sent from the editor's removed-leads popup.
1131
		// Each entry: {leadId, action: "cancel"|"reschedule", toDate?: "yyyy-MM-dd"}.
1132
		// - cancel: mark the lead's current APPROVED row for this beat as CANCELLED.
1133
		// - reschedule: cancel here, then create a fresh APPROVED LeadRoute on
1134
		//   whichever beat this user has scheduled on toDate. If no beat exists
1135
		//   on toDate, the whole update fails (so the caller can prompt again).
1136
		int leadsCancelled = 0, leadsRescheduled = 0;
1137
		List<String> leadFailures = new ArrayList<>();
1138
		String removedLeadActionsJson = (String) plan.get("removedLeadActions");
1139
		if (removedLeadActionsJson != null && !removedLeadActionsJson.isEmpty()) {
1140
			Type listType = new TypeToken<List<Map<String, Object>>>() {
1141
			}.getType();
1142
			List<Map<String, Object>> actions = gson.fromJson(removedLeadActionsJson, listType);
1143
 
1144
			List<LeadRoute> beatLeads = leadRouteRepository.selectByBeatId(beatId);
1145
 
1146
			for (Map<String, Object> act : actions) {
1147
				int leadId = ((Number) act.get("leadId")).intValue();
1148
				String mode = (String) act.get("action");
1149
 
1150
				// Find this lead's most-recent APPROVED row on this beat
1151
				LeadRoute current = beatLeads.stream()
1152
						.filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus()))
1153
						.findFirst().orElse(null);
1154
				if (current == null) continue; // already removed/cancelled; nothing to do
1155
 
1156
				if ("reschedule".equalsIgnoreCase(mode)) {
1157
					String toDateStr = (String) act.get("toDate");
1158
					if (toDateStr == null || toDateStr.isEmpty()) {
1159
						leadFailures.add("Lead " + leadId + ": reschedule date missing");
1160
						continue;
1161
					}
1162
					LocalDate toDate = LocalDate.parse(toDateStr);
1163
 
1164
					// Find ANY beat this user has scheduled on toDate
1165
					Beat targetBeat = null;
1166
					Integer targetDayNumber = null;
1167
					List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId());
1168
					for (Beat b : userBeats) {
1169
						List<BeatSchedule> sl = beatScheduleRepository.selectByBeatId(b.getId());
1170
						for (BeatSchedule s : sl) {
1171
							if (s.getStartDate() != null && s.getStartDate().equals(toDate)) {
1172
								targetBeat = b;
1173
								targetDayNumber = s.getDayNumber();
1174
								break;
1175
							}
1176
						}
1177
						if (targetBeat != null) break;
1178
					}
1179
					if (targetBeat == null) {
1180
						return responseSender.badRequest(
1181
								"No beat is scheduled for this user on " + toDateStr
1182
										+ ". Pick a different date for lead " + leadId
1183
										+ ", or choose Cancel for it.");
1184
					}
1185
 
1186
					// Cancel the current attachment to this beat
1187
					current.setStatus("CANCELLED");
1188
					current.setUpdatedTimestamp(LocalDateTime.now());
1189
 
1190
					// Create the new attachment on the target beat/date
1191
					LeadRoute fresh = new LeadRoute();
1192
					fresh.setBeatId(targetBeat.getId());
1193
					fresh.setLeadId(leadId);
1194
					fresh.setNearestStoreId(current.getNearestStoreId());
1195
					fresh.setScheduleDate(toDate);
1196
					fresh.setSequenceOrder(9999); // append; the planner can reorder
1197
					fresh.setStatus("APPROVED");
1198
					fresh.setRequestedBy(current.getRequestedBy());
1199
					fresh.setApprovedBy(current.getApprovedBy());
1200
					fresh.setApprovedTimestamp(LocalDateTime.now());
1201
					fresh.setCreatedTimestamp(LocalDateTime.now());
1202
					fresh.setUpdatedTimestamp(LocalDateTime.now());
1203
					leadRouteRepository.persist(fresh);
1204
 
1205
					LeadActivity la = new LeadActivity();
1206
					la.setLeadId(leadId);
1207
					la.setRemark("Rescheduled from beat '" + beat.getName() + "' to '"
1208
							+ targetBeat.getName() + "' on " + toDateStr + " (day " + targetDayNumber + ")");
1209
					la.setAuthId(0);
1210
					la.setCreatedTimestamp(LocalDateTime.now());
1211
					leadActivityRepositoryAuto.persist(la);
1212
					leadsRescheduled++;
1213
				} else {
1214
					// cancel (default)
1215
					current.setStatus("CANCELLED");
1216
					current.setUpdatedTimestamp(LocalDateTime.now());
1217
 
1218
					LeadActivity la = new LeadActivity();
1219
					la.setLeadId(leadId);
1220
					la.setRemark("Cancelled from beat '" + beat.getName() + "' during edit");
1221
					la.setAuthId(0);
1222
					la.setCreatedTimestamp(LocalDateTime.now());
1223
					leadActivityRepositoryAuto.persist(la);
1224
					leadsCancelled++;
36651 ranu 1225
				}
1226
			}
1227
		}
1228
 
1229
		Map<String, Object> response = new HashMap<>();
1230
		response.put("status", true);
1231
		response.put("planGroupId", String.valueOf(beat.getId()));
36681 ranu 1232
		response.put("leadsCancelled", leadsCancelled);
1233
		response.put("leadsRescheduled", leadsRescheduled);
1234
		response.put("leadFailures", leadFailures);
1235
		response.put("message", "Beat updated successfully"
1236
				+ (leadsCancelled > 0 ? " (" + leadsCancelled + " lead(s) cancelled)" : "")
1237
				+ (leadsRescheduled > 0 ? " (" + leadsRescheduled + " lead(s) rescheduled)" : ""));
36651 ranu 1238
		return responseSender.ok(response);
1239
	}
1240
 
36681 ranu 1241
	// Used by the edit-mode "removed leads" popup so the date picker can warn
1242
	// upfront when the user picks a date that has no beat for them.
1243
	@GetMapping(value = "/beatPlan/userBeatsOnDate")
1244
	public ResponseEntity<?> userBeatsOnDate(
1245
			@RequestParam int authUserId,
1246
			@RequestParam String date) {
1247
		LocalDate target;
1248
		try {
1249
			target = LocalDate.parse(date);
1250
		} catch (Exception e) {
1251
			return responseSender.badRequest("Invalid date");
1252
		}
1253
 
1254
		List<Map<String, Object>> hits = new ArrayList<>();
1255
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
1256
		for (Beat b : userBeats) {
1257
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
1258
			for (BeatSchedule s : schedules) {
1259
				if (s.getStartDate() != null && s.getStartDate().equals(target)) {
1260
					Map<String, Object> m = new HashMap<>();
1261
					m.put("beatId", b.getId());
1262
					m.put("beatName", b.getName());
1263
					m.put("dayNumber", s.getDayNumber());
1264
					hits.add(m);
1265
				}
1266
			}
1267
		}
1268
		Map<String, Object> result = new HashMap<>();
1269
		result.put("date", date);
1270
		result.put("authUserId", authUserId);
1271
		result.put("beats", hits);
1272
		return responseSender.ok(result);
1273
	}
1274
 
36686 ranu 1275
	// ====================== BASE LOCATION MANAGEMENT ======================
1276
	// Inline page that lets Sales L3+ pick a user and set their base (home)
1277
	// location via map. Reads use the existing /beatPlan/getBaseLocation, writes
1278
	// go through the L3+-guarded endpoint below.
1279
	@GetMapping(value = "/beatPlan/baseLocationPage")
1280
	public String baseLocationPage(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1281
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
1282
		return "beat-plan-base-location";
36650 ranu 1283
	}
1284
 
1285
	// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].
36821 ranu 1286
	// Hierarchy-scoped: a manager sees only their downline + self; super-admins see all.
1287
	// Optional categoryId + escalationType further narrow the listing to users at
1288
	// a specific level (e.g. all Sales L1 in scope).
36650 ranu 1289
	@GetMapping(value = "/beatPlan/scheduledList")
1290
	public ResponseEntity<?> scheduledList(
36821 ranu 1291
			HttpServletRequest request,
36650 ranu 1292
			@RequestParam(required = false) String startDate,
36821 ranu 1293
			@RequestParam(required = false) String endDate,
1294
			@RequestParam(required = false) Integer categoryId,
1295
			@RequestParam(required = false) com.spice.profitmandi.dao.enumuration.cs.EscalationType escalationType) throws ProfitMandiBusinessException {
36650 ranu 1296
 
1297
		LocalDate start, end;
1298
		try {
1299
			start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);
1300
			end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);
1301
		} catch (Exception e) {
1302
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
1303
		}
1304
 
36821 ranu 1305
		// ---- Scope: which auth users does the caller get to see ----
1306
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1307
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1308
		Set<Integer> visible = null;
1309
		if (me != null && !isSuperAdmin(me)) {
1310
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
1311
			visible.add(me.getId());
1312
		}
1313
		// If a (category, level) was picked, further restrict to that population.
1314
		Set<Integer> levelFilter = null;
1315
		if (categoryId != null && escalationType != null) {
1316
			levelFilter = csService.getAuthUserByCategoryId(categoryId, escalationType).stream()
1317
					.filter(AuthUser::getActive)
1318
					.map(AuthUser::getId)
1319
					.collect(java.util.stream.Collectors.toSet());
1320
		}
1321
 
36650 ranu 1322
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
1323
				beatPlanQueryService.getAllScheduledBeats(start, end);
1324
 
36821 ranu 1325
		final Set<Integer> visibleF = visible;
1326
		final Set<Integer> levelF = levelFilter;
1327
		beats = beats.stream()
1328
				.filter(b -> visibleF == null || visibleF.contains(b.getAuthUserId()))
1329
				.filter(b -> levelF == null || levelF.contains(b.getAuthUserId()))
1330
				.collect(java.util.stream.Collectors.toList());
1331
 
36650 ranu 1332
		// Resolve user names in bulk
1333
		Set<Integer> userIds = beats.stream()
1334
				.map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId)
1335
				.collect(java.util.stream.Collectors.toSet());
1336
		Map<Integer, AuthUser> userMap = new HashMap<>();
1337
		if (!userIds.isEmpty()) {
1338
			authRepository.selectByIds(new ArrayList<>(userIds))
1339
					.forEach(u -> userMap.put(u.getId(), u));
1340
		}
1341
 
1342
		List<Map<String, Object>> rows = new ArrayList<>();
1343
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1344
			AuthUser u = userMap.get(b.getAuthUserId());
1345
			Map<String, Object> row = new HashMap<>();
1346
			row.put("authUserId", b.getAuthUserId());
1347
			row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());
1348
			row.put("scheduleDate", b.getScheduleDate().toString());
1349
			row.put("dayNumber", b.getDayNumber());
1350
			row.put("beatId", b.getBeatId());
1351
			row.put("beatName", b.getBeatName());
1352
			row.put("beatColor", b.getBeatColor());
1353
			row.put("partnerCount", b.getPartnerStops().size());
1354
			row.put("leadCount", b.getLeadStops().size());
1355
			row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());
1356
			rows.add(row);
1357
		}
1358
 
1359
		Map<String, Object> result = new HashMap<>();
1360
		result.put("rows", rows);
1361
		result.put("startDate", start.toString());
1362
		result.put("endDate", end.toString());
1363
		return responseSender.ok(result);
1364
	}
1365
 
1366
	// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords
1367
	@GetMapping(value = "/beatPlan/dayViewData")
1368
	public ResponseEntity<?> beatPlanDayViewData(
1369
			@RequestParam int authUserId,
1370
			@RequestParam String date) throws ProfitMandiBusinessException {
1371
 
1372
		LocalDate localDate;
1373
		try {
1374
			localDate = LocalDate.parse(date);
1375
		} catch (Exception e) {
1376
			return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");
1377
		}
1378
 
1379
		List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
1380
				beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);
1381
 
1382
		// Collect all partner & lead IDs to fetch metadata in bulk
1383
		Set<Integer> partnerIds = new HashSet<>();
1384
		Set<Integer> leadIds = new HashSet<>();
1385
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1386
			b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));
1387
			b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));
1388
		}
1389
 
1390
		// Partners: name + geocoded lat/lng (geocoder is cached in Redis)
1391
		Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()
1392
				? new HashMap<>()
1393
				: retailerService.getFofoRetailers(new ArrayList<>(partnerIds));
1394
		Map<Integer, FofoStore> storeMap = new HashMap<>();
1395
		if (!partnerIds.isEmpty()) {
1396
			fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds))
1397
					.forEach(fs -> storeMap.put(fs.getId(), fs));
1398
		}
1399
 
1400
		// Leads: name + geo
1401
		Map<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();
1402
		Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();
1403
		for (int leadId : leadIds) {
1404
			com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);
1405
			if (l != null) leadMap.put(leadId, l);
1406
			com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =
1407
					leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);
1408
			if (lg != null) leadGeoMap.put(leadId, lg);
1409
		}
1410
 
1411
		// Enrich each stop
1412
		List<Map<String, Object>> out = new ArrayList<>();
1413
		for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {
1414
			Map<String, Object> beatJson = new HashMap<>();
1415
			beatJson.put("beatId", b.getBeatId());
1416
			beatJson.put("beatName", b.getBeatName());
1417
			beatJson.put("beatColor", b.getBeatColor());
1418
			beatJson.put("dayNumber", b.getDayNumber());
1419
			beatJson.put("scheduleDate", b.getScheduleDate().toString());
1420
			beatJson.put("endAction", b.getEndAction());
1421
			beatJson.put("totalDistanceKm", b.getTotalDistanceKm());
1422
			beatJson.put("totalTimeMins", b.getTotalTimeMins());
1423
			beatJson.put("startLocationName", b.getStartLocationName());
1424
			beatJson.put("startLatitude", b.getStartLatitude());
1425
			beatJson.put("startLongitude", b.getStartLongitude());
1426
 
1427
			List<Map<String, Object>> stops = new ArrayList<>();
1428
			// Partners
1429
			for (Map<String, Object> ps : b.getPartnerStops()) {
1430
				int fofoId = (Integer) ps.get("fofoId");
1431
				Map<String, Object> stop = new HashMap<>();
1432
				stop.put("type", "partner");
1433
				stop.put("id", fofoId);
1434
				stop.put("sequenceOrder", ps.get("sequenceOrder"));
1435
				FofoStore fs = storeMap.get(fofoId);
1436
				CustomRetailer cr = retailerMap.get(fofoId);
1437
				stop.put("code", fs != null ? fs.getCode() : null);
1438
				stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName()
1439
						: (cr != null ? cr.getBusinessName() : "Store #" + fofoId));
36655 ranu 1440
				// Use FofoStore lat/lng directly (no geocoding needed after migration)
1441
				if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null
1442
						&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {
36650 ranu 1443
					try {
36655 ranu 1444
						stop.put("lat", Double.parseDouble(fs.getLatitude()));
1445
						stop.put("lng", Double.parseDouble(fs.getLongitude()));
1446
					} catch (NumberFormatException ignored) {
36650 ranu 1447
					}
1448
				}
36655 ranu 1449
				if (cr != null && cr.getAddress() != null) {
1450
					stop.put("address", cr.getAddress().getAddressString());
1451
				}
36650 ranu 1452
				stops.add(stop);
1453
			}
1454
			// Leads
1455
			for (Map<String, Object> ls : b.getLeadStops()) {
1456
				int leadId = (Integer) ls.get("leadId");
1457
				Map<String, Object> stop = new HashMap<>();
1458
				stop.put("type", "lead");
1459
				stop.put("id", leadId);
1460
				stop.put("sequenceOrder", ls.get("sequenceOrder"));
1461
				stop.put("nearestStoreId", ls.get("nearestStoreId"));
1462
				com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);
1463
				stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);
1464
				stop.put("mobile", l != null ? l.getLeadMobile() : null);
1465
				stop.put("city", l != null ? l.getCity() : null);
1466
				com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);
1467
				if (lg != null) {
1468
					stop.put("lat", lg.getLatitude());
1469
					stop.put("lng", lg.getLongitude());
1470
				}
1471
				stops.add(stop);
1472
			}
1473
			beatJson.put("stops", stops);
1474
			beatJson.put("partnerCount", b.getPartnerStops().size());
1475
			beatJson.put("leadCount", b.getLeadStops().size());
1476
			out.add(beatJson);
1477
		}
1478
 
1479
		Map<String, Object> result = new HashMap<>();
1480
		result.put("beats", out);
1481
		return responseSender.ok(result);
1482
	}
1483
 
36686 ranu 1484
	// ====================== DAY VIEW ======================
1485
	// Inline page (loaded into dashboard #main-content): tabular list of all beats
1486
	// scheduled in a date range across all users. Each row has a View button that
1487
	// opens that user's calendar in a modal.
1488
	@GetMapping(value = "/beatPlan/dayView")
1489
	public String beatPlanDayView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {
1490
		model.addAttribute("escalationTypes", visibleLevelsFor(request));
36821 ranu 1491
		// Matches /beatPlan/getAuthUsers and the Beat Report — default Sales category.
1492
		model.addAttribute("categoryId", com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES);
36962 vikas 1493
		model.addAttribute("canEditBeat", canEditBeat(currentUser(request)));
36686 ranu 1494
		return "beat-plan-day-view";
36618 ranu 1495
	}
1496
 
36644 ranu 1497
	// Returns visits for a beat.
1498
	// - Partner stops (beat_route) belong to the beat template — always returned.
1499
	// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate
1500
	//   is given and matches the lead's schedule_date. (No planDate = template view.)
36632 ranu 1501
	@GetMapping(value = "/beatPlan/getBeatVisits")
36644 ranu 1502
	public ResponseEntity<?> getBeatVisits(
1503
			@RequestParam String planGroupId,
1504
			@RequestParam(required = false) String planDate) {
1505
 
1506
		int beatId;
1507
		try {
1508
			beatId = Integer.parseInt(planGroupId);
1509
		} catch (NumberFormatException e) {
1510
			return responseSender.ok(new ArrayList<>());
1511
		}
1512
 
1513
		List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);
1514
		List<Map<String, Object>> result = new ArrayList<>();
1515
 
36811 ranu 1516
		// Stops — partner OR office, dispatched by visit_type. Partners are
1517
		// enriched on the client from the partner map (already in scope);
1518
		// offices are enriched here because the client has no office map.
36644 ranu 1519
		for (BeatRoute r : routes) {
36632 ranu 1520
			Map<String, Object> map = new HashMap<>();
36644 ranu 1521
			map.put("fofoId", r.getFofoId());
1522
			map.put("dayNumber", r.getDayNumber());
1523
			map.put("sequenceOrder", r.getSequenceOrder());
36811 ranu 1524
			if (r.getVisitType() == com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE) {
1525
				map.put("visitType", "office");
1526
				try {
1527
					com.spice.profitmandi.dao.entity.logistics.CompanyOffice o =
1528
							companyOfficeRepository.selectById(r.getFofoId());
1529
					if (o != null) {
1530
						map.put("code", o.getCode());
1531
						map.put("name", o.getName());
1532
						map.put("latitude", String.valueOf(o.getLat()));
1533
						map.put("longitude", String.valueOf(o.getLng()));
1534
					}
1535
				} catch (Exception ignored) {
1536
				}
1537
			} else {
1538
				map.put("visitType", "partner");
1539
			}
36644 ranu 1540
			result.add(map);
1541
		}
1542
 
1543
		// Lead stops — only for the requested run date
1544
		if (planDate != null && !planDate.isEmpty()) {
1545
			LocalDate date = LocalDate.parse(planDate);
1546
			List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);
1547
			for (LeadRoute lr : leads) {
1548
				if ("APPROVED".equals(lr.getStatus())
1549
						&& lr.getScheduleDate() != null
1550
						&& lr.getScheduleDate().equals(date)) {
1551
					Map<String, Object> map = new HashMap<>();
1552
					map.put("fofoId", lr.getLeadId());
1553
					map.put("dayNumber", 1);
1554
					map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);
1555
					map.put("visitType", "lead");
1556
					result.add(map);
1557
				}
1558
			}
1559
		}
1560
 
1561
		// Sort by dayNumber then sequenceOrder
1562
		result.sort((a, b) -> {
1563
			int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));
1564
			return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));
1565
		});
1566
 
36632 ranu 1567
		return responseSender.ok(result);
1568
	}
1569
 
36681 ranu 1570
	// Returns the user's DEFAULT base location. Falls back to most-recent for
1571
	// legacy users who pre-date the is_default column.
36618 ranu 1572
	@GetMapping(value = "/beatPlan/getBaseLocation")
1573
	public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
36681 ranu 1574
		AuthUserLocation baseLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36618 ranu 1575
		if (baseLoc == null) {
1576
			return responseSender.ok(new HashMap<>());
1577
		}
1578
		Map<String, Object> result = new HashMap<>();
1579
		result.put("id", baseLoc.getId());
1580
		result.put("locationName", baseLoc.getLocationName());
1581
		result.put("latitude", baseLoc.getLatitude());
1582
		result.put("longitude", baseLoc.getLongitude());
1583
		result.put("address", baseLoc.getAddress());
36681 ranu 1584
		result.put("isDefault", baseLoc.isDefault());
36618 ranu 1585
		return responseSender.ok(result);
1586
	}
1587
 
36681 ranu 1588
	// Returns ALL BASE locations for a user, default first.
1589
	@GetMapping(value = "/beatPlan/listBaseLocations")
1590
	public ResponseEntity<?> listBaseLocations(@RequestParam int authUserId) {
1591
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1592
		// Default at the top, then by created desc (the repo already returns desc).
1593
		all.sort((a, b) -> {
1594
			if (a.isDefault() && !b.isDefault()) return -1;
1595
			if (!a.isDefault() && b.isDefault()) return 1;
1596
			return 0;
1597
		});
1598
		List<Map<String, Object>> rows = new ArrayList<>();
1599
		for (AuthUserLocation l : all) {
1600
			Map<String, Object> row = new HashMap<>();
1601
			row.put("id", l.getId());
1602
			row.put("locationName", l.getLocationName());
1603
			row.put("latitude", l.getLatitude());
1604
			row.put("longitude", l.getLongitude());
1605
			row.put("address", l.getAddress());
1606
			row.put("isDefault", l.isDefault());
1607
			row.put("createdTimestamp", l.getCreatedTimestamp() != null ? l.getCreatedTimestamp().toString() : null);
1608
			rows.add(row);
1609
		}
1610
		Map<String, Object> result = new HashMap<>();
1611
		result.put("authUserId", authUserId);
1612
		result.put("locations", rows);
1613
		return responseSender.ok(result);
1614
	}
1615
 
1616
	// Flip the default flag — set this id default, clear all others.
1617
	@PostMapping(value = "/beatPlan/setDefaultBaseLocation")
1618
	public ResponseEntity<?> setDefaultBaseLocation(
1619
			HttpServletRequest request,
1620
			@RequestParam int id) throws ProfitMandiBusinessException {
1621
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1622
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1623
		if (me == null) return responseSender.unauthorized("Not logged in");
1624
		if (!isBaseLocationManager(me)) {
1625
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
1626
		}
1627
 
1628
		AuthUserLocation target = authUserLocationRepository.selectById(id);
1629
		if (target == null) return responseSender.badRequest("Location not found");
1630
 
1631
		List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(target.getAuthUserId(), "BASE");
1632
		for (AuthUserLocation l : all) {
1633
			boolean shouldBeDefault = (l.getId() == id);
1634
			if (l.isDefault() != shouldBeDefault) {
1635
				l.setDefault(shouldBeDefault);
1636
				authUserLocationRepository.persist(l); // saveOrUpdate
1637
			}
1638
		}
1639
 
1640
		Map<String, Object> result = new HashMap<>();
1641
		result.put("status", true);
1642
		result.put("id", id);
1643
		result.put("message", "Default base location updated");
1644
		return responseSender.ok(result);
1645
	}
1646
 
1647
	// Delete a base location. The DEFAULT one cannot be deleted — user must
1648
	// first pick another row as default.
1649
	@PostMapping(value = "/beatPlan/deleteBaseLocation")
1650
	public ResponseEntity<?> deleteBaseLocation(
1651
			HttpServletRequest request,
1652
			@RequestParam int id) throws ProfitMandiBusinessException {
1653
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1654
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
1655
		if (me == null) return responseSender.unauthorized("Not logged in");
1656
		if (!isBaseLocationManager(me)) {
1657
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");
1658
		}
1659
 
1660
		AuthUserLocation target = authUserLocationRepository.selectById(id);
1661
		if (target == null) return responseSender.badRequest("Location not found");
1662
		if (target.isDefault()) {
1663
			return responseSender.badRequest("Default base location cannot be removed. Set another location as default first.");
1664
		}
1665
 
1666
		authUserLocationRepository.delete(target);
1667
 
1668
		Map<String, Object> result = new HashMap<>();
1669
		result.put("status", true);
1670
		result.put("message", "Base location removed");
1671
		return responseSender.ok(result);
1672
	}
1673
 
36686 ranu 1674
	@GetMapping(value = "/beatPlan/getAuthUsers")
1675
	public ResponseEntity<?> getAuthUsers(
1676
			HttpServletRequest request,
1677
			@RequestParam int categoryId,
1678
			@RequestParam EscalationType escalationType) throws ProfitMandiBusinessException {
1679
 
1680
		// Hierarchy filter: a manager only sees users in their downline
1681
		// (themselves + every reportee under them, recursively). Super-admin
1682
		// emails bypass the filter and see everyone. Downline is computed by
1683
		// AuthService.getAllReportees (existing recursive walker).
1684
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1685
		AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1686
 
1687
		final Set<Integer> visible;
1688
		if (me == null || isSuperAdmin(me)) {
1689
			visible = null; // null = no filter
1690
		} else {
1691
			visible = new HashSet<>(authService.getAllReportees(me.getId()));
1692
			visible.add(me.getId()); // include self
1693
		}
1694
 
1695
		List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
1696
		List<Map<String, Object>> result = authUsers.stream()
1697
				.filter(au -> au.getActive())
1698
				.filter(au -> visible == null || visible.contains(au.getId()))
1699
				.map(au -> {
1700
					Map<String, Object> map = new HashMap<>();
1701
					map.put("id", au.getId());
1702
					map.put("name", au.getFirstName() + " " + au.getLastName());
1703
					return map;
1704
				})
1705
				.collect(Collectors.toList());
1706
		return responseSender.ok(result);
1707
	}
1708
 
1709
	private boolean isSuperAdmin(AuthUser me) {
36681 ranu 1710
		String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";
36686 ranu 1711
		return SUPER_ADMIN_EMAILS.contains(myEmail);
1712
	}
36681 ranu 1713
 
36686 ranu 1714
	// Returns the user's highest escalation level across all positions.
1715
	// Mirrors OrderController.getSalesEscalationLevel but category-agnostic.
1716
	private EscalationType getHighestEscalation(int authUserId) {
1717
		EscalationType highest = null;
1718
		List<com.spice.profitmandi.dao.entity.cs.Position> positions = positionRepository.selectPositionByAuthId(authUserId);
1719
		for (com.spice.profitmandi.dao.entity.cs.Position p : positions) {
1720
			if (highest == null || p.getEscalationType().isGreaterThanEqualTo(highest)) {
1721
				highest = p.getEscalationType();
1722
			}
1723
		}
1724
		return highest;
1725
	}
1726
 
1727
	// Returns the escalation levels a user can manage — strictly below their own.
1728
	// L3 → [L1, L2]; L4 → [L1, L2, L3]; Final → all levels. Super-admin → all levels.
1729
	private List<EscalationType> getVisibleEscalationLevels(AuthUser me) {
1730
		if (isSuperAdmin(me)) return EscalationType.escalations;
1731
		EscalationType mine = getHighestEscalation(me.getId());
1732
		if (mine == null) return java.util.Collections.emptyList();
1733
		List<EscalationType> below = new ArrayList<>();
1734
		for (EscalationType e : EscalationType.escalations) {
1735
			if (mine.isGreaterThanEqualTo(e) && !e.equals(mine)) below.add(e);
1736
		}
1737
		return below;
1738
	}
1739
 
36962 vikas 1740
	private AuthUser currentUser(HttpServletRequest request) throws ProfitMandiBusinessException {
1741
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
1742
		return (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;
1743
	}
1744
 
36686 ranu 1745
	private List<EscalationType> visibleLevelsFor(HttpServletRequest request) throws ProfitMandiBusinessException {
36962 vikas 1746
		AuthUser me = currentUser(request);
36686 ranu 1747
		return me == null ? java.util.Collections.emptyList() : getVisibleEscalationLevels(me);
1748
	}
1749
 
36962 vikas 1750
	// Same-day scheduling is restricted to L4+ operators (or super-admin). Everyone
1751
	// else can only schedule beats for future dates.
1752
	private boolean canScheduleToday(AuthUser me) {
1753
		if (me == null) return false;
1754
		if (isSuperAdmin(me)) return true;
1755
		EscalationType lvl = getHighestEscalation(me.getId());
1756
		return lvl != null && lvl.isGreaterThanEqualTo(EscalationType.L4);
1757
	}
1758
 
1759
	// Editing a beat / assigning a visit from the day-view is restricted to L2 and
1760
	// above (or super-admin). L1 executives can view but not edit.
1761
	private boolean canEditBeat(AuthUser me) {
1762
		if (me == null) return false;
1763
		if (isSuperAdmin(me)) return true;
1764
		EscalationType lvl = getHighestEscalation(me.getId());
1765
		return lvl != null && lvl.isGreaterThanEqualTo(EscalationType.L2);
1766
	}
1767
 
36686 ranu 1768
	// Shared permission check for base-location admin actions: Sales L3+ OR super-admin.
1769
	private boolean isBaseLocationManager(AuthUser me) {
1770
		if (isSuperAdmin(me)) return true;
36681 ranu 1771
		return csService.getAuthUserIds(
1772
						com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,
1773
						Arrays.asList(EscalationType.L3, EscalationType.L4))
1774
				.stream().anyMatch(u -> u.getId() == me.getId());
1775
	}
1776
 
36618 ranu 1777
	@PostMapping(value = "/beatPlan/saveBaseLocation")
1778
	public ResponseEntity<?> saveBaseLocation(
1779
			@RequestParam int authUserId,
1780
			@RequestParam String locationName,
1781
			@RequestParam String latitude,
1782
			@RequestParam String longitude,
1783
			@RequestParam(required = false) String address) {
1784
		AuthUserLocation loc = new AuthUserLocation();
1785
		loc.setAuthUserId(authUserId);
1786
		loc.setLocationType("BASE");
1787
		loc.setLocationName(locationName);
1788
		loc.setLatitude(latitude);
1789
		loc.setLongitude(longitude);
1790
		loc.setAddress(address);
1791
		loc.setCreatedTimestamp(LocalDateTime.now());
36681 ranu 1792
 
1793
		// First BASE for this user → auto-default so every user always has one.
1794
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
1795
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
1796
		loc.setDefault(existing.isEmpty() || noExistingDefault);
36618 ranu 1797
		authUserLocationRepository.persist(loc);
1798
 
1799
		Map<String, Object> result = new HashMap<>();
1800
		result.put("status", true);
1801
		result.put("id", loc.getId());
36681 ranu 1802
		result.put("isDefault", loc.isDefault());
36618 ranu 1803
		return responseSender.ok(result);
1804
	}
1805
 
1806
	@GetMapping(value = "/beatPlan/getPartners")
1807
	public ResponseEntity<?> getPartners(
1808
			@RequestParam int authUserId,
1809
			@RequestParam int categoryId,
1810
			@RequestParam(required = false) String startLat,
1811
			@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {
1812
 
36802 ranu 1813
		// Beat planning needs every partner ever assigned — inactive ones included —
1814
		// so the planner can keep building beats around a partner that was paused
1815
		// after the assignment was made. The closed-store skip happens below per row.
1816
		Map<Integer, List<Integer>> pp = csService.getAuthUserIdAllPartnerIdMapping();
36618 ranu 1817
		List<Integer> fofoIds = pp.get(authUserId);
1818
 
36644 ranu 1819
		if (fofoIds == null || fofoIds.isEmpty()) {
36618 ranu 1820
			Map<String, Object> empty = new HashMap<>();
1821
			empty.put("partners", new ArrayList<>());
1822
			return responseSender.ok(empty);
1823
		}
1824
 
1825
		List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
1826
		Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);
1827
 
1828
		List<Map<String, Object>> partners = new ArrayList<>();
1829
 
1830
		for (FofoStore store : fofoStores) {
36802 ranu 1831
			// Closed partners are gone for good — skip. Inactive ones are kept
1832
			// so the planner can still drop a beat onto them (the assignment
1833
			// pre-dates the deactivation); the UI tags them visually.
1834
			if (store.isClosed()) continue;
36618 ranu 1835
			CustomRetailer retailer = retailerMap.get(store.getId());
1836
 
1837
			Map<String, Object> partnerData = new HashMap<>();
1838
			partnerData.put("fofoId", store.getId());
1839
			partnerData.put("code", store.getCode());
1840
			partnerData.put("outletName", store.getOutletName());
36802 ranu 1841
			partnerData.put("active", store.isActive());
36618 ranu 1842
			partnerData.put("type", "partner");
1843
 
36655 ranu 1844
			// Use FofoStore lat/lng directly (migrated from address geocode)
1845
			if (store.getLatitude() != null && !store.getLatitude().isEmpty()
1846
					&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {
1847
				partnerData.put("latitude", store.getLatitude());
1848
				partnerData.put("longitude", store.getLongitude());
1849
			}
1850
 
36618 ranu 1851
			if (retailer != null) {
1852
				partnerData.put("businessName", retailer.getBusinessName());
1853
				if (retailer.getAddress() != null) {
36644 ranu 1854
					partnerData.put("address", retailer.getAddress().getAddressString());
36618 ranu 1855
				}
1856
			}
1857
			partners.add(partnerData);
1858
		}
1859
 
1860
		if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
1861
			partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
1862
		} else {
1863
			partners = sortByNearestNeighbor(partners);
1864
		}
1865
 
1866
		Map<String, Object> response = new HashMap<>();
1867
		response.put("partners", partners);
1868
		return responseSender.ok(response);
1869
	}
1870
 
1871
	@PostMapping(value = "/beatPlan/submitPlan")
1872
	public ResponseEntity<?> submitPlan(
1873
			HttpServletRequest request,
1874
			@RequestParam int authUserId,
1875
			@RequestParam String planData) throws Exception {
1876
 
1877
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
1878
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
1879
 
1880
		Gson gson = new Gson();
1881
		Type type = new TypeToken<Map<String, Object>>() {
1882
		}.getType();
1883
		Map<String, Object> plan = gson.fromJson(planData, type);
1884
 
1885
		List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
1886
		List<String> dates = (List<String>) plan.get("dates");
1887
 
36644 ranu 1888
		String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();
36618 ranu 1889
 
36698 ranu 1890
		// Duplicate check — same name + same authUserId among ACTIVE beats only.
1891
		// Soft-deleted beats keep the name in the table; we don't want them to
1892
		// block the user from reusing a name they "deleted".
1893
		List<Beat> existingBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36644 ranu 1894
		for (Beat existing : existingBeats) {
1895
			if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {
1896
				LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());
36618 ranu 1897
				Map<String, Object> response = new HashMap<>();
1898
				response.put("status", true);
36644 ranu 1899
				response.put("planGroupId", String.valueOf(existing.getId()));
36618 ranu 1900
				response.put("duplicate", true);
36644 ranu 1901
				response.put("message", "Beat '" + beatName + "' already exists");
36618 ranu 1902
				return responseSender.ok(response);
1903
			}
1904
		}
1905
 
36644 ranu 1906
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
1907
		int totalDays = days.size();
36618 ranu 1908
 
36785 ranu 1909
		// One-beat-per-day guard: reject if any of the requested dates already
1910
		// has a beat scheduled for this user.
1911
		if (dates != null) {
1912
			List<LocalDate> candidateDates = new ArrayList<>();
1913
			for (String dStr : dates) {
1914
				if (dStr != null && !dStr.isEmpty()) {
1915
					try {
1916
						candidateDates.add(LocalDate.parse(dStr, DateTimeFormatter.ISO_DATE));
1917
					} catch (Exception ignored) {
1918
					}
1919
				}
1920
			}
1921
			Map<String, Object> conflict = findScheduleConflict(authUserId, candidateDates, 0);
1922
			if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
1923
		}
1924
 
36644 ranu 1925
		// Create Beat master
1926
		Beat beat = new Beat();
1927
		beat.setName(beatName);
1928
		beat.setAuthUserId(authUserId);
1929
		beat.setBeatColor(beatColor);
1930
		beat.setTotalDays(totalDays);
1931
		beat.setActive(true);
1932
		beat.setCreatedBy(currentUser.getId());
1933
		beat.setCreatedTimestamp(LocalDateTime.now());
1934
 
1935
		// Set start location from first day
1936
		if (!days.isEmpty()) {
1937
			Map<String, Object> firstDay = days.get(0);
1938
			beat.setStartLocationName((String) firstDay.get("startLocationName"));
1939
			beat.setStartLatitude((String) firstDay.get("startLatitude"));
1940
			beat.setStartLongitude((String) firstDay.get("startLongitude"));
1941
		}
1942
		beatRepository.persist(beat);
1943
 
1944
		// End date of the whole beat = last scheduled day's date
1945
		LocalDate beatEndDate = null;
1946
		if (dates != null) {
1947
			for (int d = dates.size() - 1; d >= 0; d--) {
1948
				if (dates.get(d) != null) {
1949
					beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);
1950
					break;
1951
				}
1952
			}
1953
		}
1954
 
1955
		// Create routes and schedules for each day
36618 ranu 1956
		for (int d = 0; d < days.size(); d++) {
1957
			Map<String, Object> day = days.get(d);
1958
			int dayNumber = d + 1;
1959
			LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
36644 ranu 1960
					? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;
36618 ranu 1961
 
36644 ranu 1962
			// Auto-determine end action: last day = HOME, others = DAYBREAK
1963
			String endAction = (String) day.get("endAction");
1964
			if (endAction == null || endAction.isEmpty()) {
1965
				endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";
36618 ranu 1966
			}
1967
 
36644 ranu 1968
			// Always create schedule (even if planDate is null — unscheduled beat)
1969
			BeatSchedule schedule = new BeatSchedule();
1970
			schedule.setBeatId(beat.getId());
1971
			schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduled
1972
			schedule.setEndDate(beatEndDate);
1973
			schedule.setDayNumber(dayNumber);
1974
			schedule.setEndAction(endAction);
1975
			schedule.setStayLocationName((String) day.get("stayLocationName"));
1976
			schedule.setStayLatitude((String) day.get("stayLatitude"));
1977
			schedule.setStayLongitude((String) day.get("stayLongitude"));
1978
			if (day.get("totalDistanceKm") != null)
1979
				schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
1980
			if (day.get("totalTimeMins") != null)
1981
				schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
1982
			schedule.setCreatedTimestamp(LocalDateTime.now());
1983
			beatScheduleRepository.persist(schedule);
1984
 
36711 ranu 1985
            // Routes (stops) — also persist per-leg distance/time supplied by the
1986
            // client so reports/dashboards don't have to recompute from lat/lng.
36618 ranu 1987
			List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
1988
			if (visits != null) {
1989
				for (int i = 0; i < visits.size(); i++) {
1990
					Map<String, Object> visit = visits.get(i);
36644 ranu 1991
					BeatRoute route = new BeatRoute();
1992
					route.setBeatId(beat.getId());
1993
					route.setFofoId(((Number) visit.get("id")).intValue());
36811 ranu 1994
					route.setVisitType("office".equals(visit.get("type"))
1995
							? com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE
1996
							: com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
36644 ranu 1997
					route.setSequenceOrder(i);
1998
					route.setDayNumber(dayNumber);
1999
					route.setActive(true);
36711 ranu 2000
                    if (visit.get("distanceFromPrevKm") != null)
2001
                        route.setDistanceFromPrevKm(((Number) visit.get("distanceFromPrevKm")).doubleValue());
2002
                    if (visit.get("timeFromPrevMins") != null)
2003
                        route.setTimeFromPrevMins(((Number) visit.get("timeFromPrevMins")).intValue());
36644 ranu 2004
					beatRouteRepository.persist(route);
36618 ranu 2005
				}
2006
			}
2007
		}
2008
 
2009
		Map<String, Object> response = new HashMap<>();
2010
		response.put("status", true);
36644 ranu 2011
		response.put("planGroupId", String.valueOf(beat.getId()));
36618 ranu 2012
		response.put("message", "Beat plan submitted successfully");
2013
		return responseSender.ok(response);
2014
	}
2015
 
36632 ranu 2016
	// ============ BULK UPLOAD ============
2017
 
2018
	@GetMapping(value = "/beatPlan/bulkUpload")
2019
	public String bulkUploadPage(HttpServletRequest request, Model model) {
2020
		return "beat-plan-bulk";
2021
	}
2022
 
36681 ranu 2023
	// Adds a new base location for the user. Caller can request this new row
2024
	// becomes the default. If the user has NO base locations yet, the new row
2025
	// is auto-defaulted (so every user always has exactly one default).
36668 ranu 2026
	@PostMapping(value = "/beatPlan/updateBaseLocation")
2027
	public ResponseEntity<?> updateBaseLocation(
2028
			HttpServletRequest request,
2029
			@RequestParam int authUserId,
2030
			@RequestParam String locationName,
2031
			@RequestParam String latitude,
2032
			@RequestParam String longitude,
36681 ranu 2033
			@RequestParam(required = false) String address,
2034
			@RequestParam(required = false, defaultValue = "false") boolean isDefault) throws Exception {
36668 ranu 2035
 
2036
		LoginDetails ld = cookiesProcessor.getCookiesObject(request);
2037
		AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());
2038
		if (me == null) return responseSender.unauthorized("Not logged in");
36681 ranu 2039
		if (!isBaseLocationManager(me)) {
2040
			return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");
2041
		}
36668 ranu 2042
 
36681 ranu 2043
		List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");
2044
		boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);
2045
		boolean makeDefault = isDefault || existing.isEmpty() || noExistingDefault;
36668 ranu 2046
 
36681 ranu 2047
		// If this new row becomes the default, clear any existing default.
2048
		if (makeDefault) {
2049
			for (AuthUserLocation e : existing) {
2050
				if (e.isDefault()) {
2051
					e.setDefault(false);
2052
					authUserLocationRepository.persist(e);
2053
				}
2054
			}
36668 ranu 2055
		}
2056
 
2057
		AuthUserLocation loc = new AuthUserLocation();
2058
		loc.setAuthUserId(authUserId);
2059
		loc.setLocationType("BASE");
2060
		loc.setLocationName(locationName);
2061
		loc.setLatitude(latitude);
2062
		loc.setLongitude(longitude);
2063
		loc.setAddress(address);
36681 ranu 2064
		loc.setDefault(makeDefault);
36668 ranu 2065
		loc.setCreatedTimestamp(LocalDateTime.now());
2066
		authUserLocationRepository.persist(loc);
2067
 
2068
		Map<String, Object> result = new HashMap<>();
2069
		result.put("status", true);
2070
		result.put("id", loc.getId());
36681 ranu 2071
		result.put("isDefault", loc.isDefault());
2072
		result.put("message", makeDefault ? "Base location added and set as default" : "Base location added");
36668 ranu 2073
		return responseSender.ok(result);
2074
	}
2075
 
36814 ranu 2076
	// Read-only list of company offices — gives BMs a quick lookup of the codes
2077
	// they'll need to drop into the bulk-upload sheet for OFFICE stops.
2078
	// Two URL mappings: /companyOffice/list (canonical) + /company-office-list
2079
	// (matches the menu's action_class so the sidebar link works without an extra
2080
	// auth.menu update).
2081
	@GetMapping(value = {"/companyOffice/list", "/company-office-list"})
2082
	public String companyOfficeList(Model model) {
2083
		List<com.spice.profitmandi.dao.entity.logistics.CompanyOffice> offices = companyOfficeRepository.selectAll();
2084
		// Active first, then sort by code so the bulk-upload reference is stable across page loads.
2085
		offices.sort((a, b) -> {
2086
			int aa = a.isActive() ? 0 : 1;
2087
			int bb = b.isActive() ? 0 : 1;
2088
			if (aa != bb) return Integer.compare(aa, bb);
2089
			String ac = a.getCode() != null ? a.getCode() : "";
2090
			String bc = b.getCode() != null ? b.getCode() : "";
2091
			return ac.compareToIgnoreCase(bc);
2092
		});
2093
		model.addAttribute("offices", offices);
2094
		return "company-office-list";
2095
	}
2096
 
36632 ranu 2097
	@GetMapping(value = "/beatPlan/downloadTemplate")
36668 ranu 2098
	public ResponseEntity<?> downloadTemplate() throws java.io.IOException {
2099
		org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
2100
		org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");
36632 ranu 2101
 
36668 ranu 2102
		String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};
2103
 
2104
		// Header style
2105
		org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();
2106
		org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();
2107
		headerFont.setBold(true);
2108
		headerStyle.setFont(headerFont);
2109
		headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));
2110
		headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
2111
 
2112
		org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);
2113
		for (int i = 0; i < cols.length; i++) {
2114
			org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);
2115
			c.setCellValue(cols[i]);
2116
			c.setCellStyle(headerStyle);
2117
		}
2118
 
2119
		// Example rows — one partner per row. Inheritable columns blank after first row of a beat.
2120
		Object[][] sample = {
2121
				{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},
2122
				{"", "", "", "1", "2", "RJBUN1449"},
2123
				{"", "", "", "1", "3", "RJDEG1443"},
2124
				{"", "", "", "2", "1", "RJALR1362"},
2125
				{"", "", "", "2", "2", "RJBTR1388"},
2126
				{"", "", "", "3", "1", "RJRSD1518"},
2127
				{"", "", "", "3", "2", "RJSML356"},
2128
				{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},
2129
				{"", "", "", "1", "2", "UPAGR102"},
2130
		};
2131
		for (int r = 0; r < sample.length; r++) {
2132
			org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);
2133
			for (int c = 0; c < cols.length; c++) {
2134
				row.createCell(c).setCellValue(sample[r][c].toString());
2135
			}
2136
		}
2137
		for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);
2138
 
2139
		java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
2140
		wb.write(out);
2141
		wb.close();
2142
 
36632 ranu 2143
		org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
36668 ranu 2144
		headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");
2145
		headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
2146
		return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
36632 ranu 2147
	}
2148
 
2149
	@PostMapping(value = "/beatPlan/bulkUploadProcess")
2150
	public ResponseEntity<?> bulkUploadProcess(
2151
			HttpServletRequest request,
2152
			@RequestParam("file") org.springframework.web.multipart.MultipartFile file,
2153
			@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {
2154
 
2155
		LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
2156
		AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());
2157
 
36668 ranu 2158
		// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date
2159
		// appear ONLY on the first row of a beat; subsequent rows inherit them.
2160
		org.apache.poi.ss.usermodel.Workbook workbook =
2161
				new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());
2162
		org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
36632 ranu 2163
 
36668 ranu 2164
		// Header → column index map
2165
		org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);
2166
		if (headerRow == null) {
2167
			workbook.close();
2168
			return responseSender.badRequest("Empty file");
2169
		}
2170
		Map<String, Integer> colIdx = new HashMap<>();
2171
		for (int i = 0; i < headerRow.getLastCellNum(); i++) {
2172
			String h = readCell(headerRow.getCell(i));
2173
			if (h != null) colIdx.put(h.trim().toLowerCase(), i);
2174
		}
2175
		for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {
2176
			if (!colIdx.containsKey(required)) {
2177
				workbook.close();
2178
				return responseSender.badRequest("Missing required column: " + required);
2179
			}
2180
		}
36632 ranu 2181
 
36668 ranu 2182
		// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_order
2183
		Map<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();
2184
		String currentKey = null;
2185
		String currentBeatName = null;
2186
		String currentAuthId = null;
2187
		String currentStartDate = null;
2188
 
2189
		for (int r = 1; r <= sheet.getLastRowNum(); r++) {
2190
			org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);
2191
			if (row == null) continue;
2192
 
2193
			String beatName = readCell(row.getCell(colIdx.get("beat_name")));
2194
			String authId = readCell(row.getCell(colIdx.get("auth_user_id")));
2195
			String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;
2196
			String dayNumber = readCell(row.getCell(colIdx.get("day_number")));
2197
			String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;
2198
			String code = readCell(row.getCell(colIdx.get("partner_code")));
2199
 
2200
			if (beatName != null && !beatName.trim().isEmpty()) {
2201
				// Start of a new beat — capture inheritable fields
2202
				currentBeatName = beatName.trim().replaceAll("\\s+", " ");
2203
				currentAuthId = authId != null ? authId.trim() : null;
2204
				currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;
2205
				currentKey = currentBeatName + "|" + currentAuthId;
2206
			}
2207
			if (currentKey == null) continue; // partner row before any beat header — skip
2208
			if (code == null || code.trim().isEmpty()) continue;
2209
 
2210
			final String beatNameF = currentBeatName;
2211
			final String authIdF = currentAuthId;
2212
			final String startDateF = currentStartDate;
2213
			BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));
2214
 
2215
			int day;
2216
			try {
2217
				day = Integer.parseInt(dayNumber.trim());
2218
			} catch (Exception e) {
2219
				continue;
2220
			} // bad day → skip row
2221
 
2222
			int seq = -1;
2223
			if (seqOrder != null && !seqOrder.trim().isEmpty()) {
2224
				try {
2225
					seq = Integer.parseInt(seqOrder.trim());
2226
				} catch (Exception ignore) {
2227
				}
2228
			}
2229
			g.addPartner(day, seq, code.trim(), r + 1);
36632 ranu 2230
		}
36668 ranu 2231
		workbook.close();
36632 ranu 2232
 
36811 ranu 2233
		// Partner-code lookup (legacy).
36632 ranu 2234
		List<FofoStore> allStores = fofoStoreRepository.selectAll();
2235
		Map<String, Integer> codeToId = new HashMap<>();
36644 ranu 2236
		for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());
36632 ranu 2237
 
36811 ranu 2238
		// Office-code lookup — office stops share the same `partner_code` column in the bulk
2239
		// sheet; resolution dispatches by which catalogue the code belongs to. A code present
2240
		// in BOTH catalogues is treated as an error so the planner fixes the collision.
2241
		Map<String, Integer> officeCodeToId = new HashMap<>();
2242
		for (com.spice.profitmandi.dao.entity.logistics.CompanyOffice o : companyOfficeRepository.selectAll()) {
2243
			if (o.getCode() != null && !o.getCode().isEmpty()) officeCodeToId.put(o.getCode(), o.getId());
2244
		}
2245
 
36632 ranu 2246
		LocalDate holidayStart = LocalDate.now();
36644 ranu 2247
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
36632 ranu 2248
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
2249
 
36785 ranu 2250
		// =====================================================================
2251
		// All-or-nothing import: validate every group first; only persist if
2252
		// the entire file passes. A single bad row blocks the whole upload
2253
		// so the user can fix and re-upload without partial creations.
2254
		// =====================================================================
2255
 
36632 ranu 2256
		List<String> errorMessages = new ArrayList<>();
36785 ranu 2257
		List<ValidatedBulkBeat> ready = new ArrayList<>();
36632 ranu 2258
 
36785 ranu 2259
		// ----- Phase 1: validate every group, collect ALL errors -----
36668 ranu 2260
		for (BulkBeatGroup g : beatGroups.values()) {
36785 ranu 2261
			String beatName = g.beatName;
2262
 
2263
			int authUserId;
36632 ranu 2264
			try {
36785 ranu 2265
				authUserId = Integer.parseInt(g.authUserId);
2266
			} catch (Exception e) {
2267
				errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'.");
2268
				continue;
2269
			}
36632 ranu 2270
 
36785 ranu 2271
			LocalDate startDate;
2272
			try {
2273
				startDate = (g.startDate == null || g.startDate.isEmpty())
2274
						? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
2275
			} catch (Exception e) {
2276
				errorMessages.add("Beat '" + beatName + "': invalid start_date '" + g.startDate + "'.");
2277
				continue;
2278
			}
2279
			if (startDate != null && startDate.isBefore(LocalDate.now())) {
2280
				errorMessages.add("Beat '" + beatName + "': start_date in past.");
2281
				continue;
2282
			}
36632 ranu 2283
 
36785 ranu 2284
			List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
2285
			Collections.sort(sortedDays);
36668 ranu 2286
 
36785 ranu 2287
			List<LocalDate> scheduleDates = new ArrayList<>();
2288
			if (startDate != null) {
2289
				LocalDate d = startDate;
2290
				while (scheduleDates.size() < sortedDays.size()) {
2291
					if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
36632 ranu 2292
						d = d.plusDays(1);
36785 ranu 2293
						continue;
36632 ranu 2294
					}
36785 ranu 2295
					scheduleDates.add(d);
2296
					d = d.plusDays(1);
36632 ranu 2297
				}
36785 ranu 2298
			}
36632 ranu 2299
 
36785 ranu 2300
			// Duplicate beat-name check (ACTIVE only; soft-deleted names are reusable).
2301
			boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
2302
					.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
2303
			if (isDuplicate) {
2304
				errorMessages.add("Beat '" + beatName + "': already exists for user " + authUserId + ".");
2305
				continue;
2306
			}
2307
 
2308
			// One-beat-per-day guard (against existing beats).
2309
			Map<String, Object> bulkConflict = findScheduleConflict(authUserId, scheduleDates, 0);
2310
			if (bulkConflict != null) {
2311
				errorMessages.add("Beat '" + beatName + "': " + scheduleConflictMessage(bulkConflict));
2312
				continue;
2313
			}
2314
 
36811 ranu 2315
			// Validate codes upfront so we don't half-persist. A code may belong to
2316
			// fofo_store (PARTNER) or company_office (OFFICE) but not both.
36785 ranu 2317
			List<String> badCodes = new ArrayList<>();
36811 ranu 2318
			List<String> ambiguousCodes = new ArrayList<>();
36785 ranu 2319
			for (List<BulkPartner> ps : g.dayToPartners.values()) {
2320
				for (BulkPartner p : ps) {
36811 ranu 2321
					boolean inPartner = codeToId.containsKey(p.code);
2322
					boolean inOffice = officeCodeToId.containsKey(p.code);
2323
					if (inPartner && inOffice) {
2324
						ambiguousCodes.add(p.code + " (row " + p.rowNum + ")");
2325
					} else if (!inPartner && !inOffice) {
36785 ranu 2326
						badCodes.add(p.code + " (row " + p.rowNum + ")");
2327
					}
36644 ranu 2328
				}
36785 ranu 2329
			}
2330
			if (!badCodes.isEmpty()) {
36811 ranu 2331
				errorMessages.add("Beat '" + beatName + "': unknown code(s) — " + String.join(", ", badCodes) + ".");
36785 ranu 2332
				continue;
2333
			}
36811 ranu 2334
			if (!ambiguousCodes.isEmpty()) {
2335
				errorMessages.add("Beat '" + beatName + "': code(s) exist in both partner and office catalogues — " + String.join(", ", ambiguousCodes) + ".");
2336
				continue;
2337
			}
36632 ranu 2338
 
36785 ranu 2339
			ready.add(new ValidatedBulkBeat(g, authUserId, sortedDays, scheduleDates));
2340
		}
36632 ranu 2341
 
36785 ranu 2342
		// Intra-file conflict: two beats in the same upload requesting the same
2343
		// user + date. Caught here so the user fixes the file before re-uploading.
2344
		Set<String> seenUserDates = new HashSet<>();
2345
		for (ValidatedBulkBeat v : ready) {
2346
			for (LocalDate sd : v.scheduleDates) {
2347
				String key = v.authUserId + "|" + sd;
2348
				if (!seenUserDates.add(key)) {
2349
					errorMessages.add("Beat '" + v.g.beatName + "': date " + sd + " is also claimed by another beat in this file for the same user.");
2350
					break;
2351
				}
2352
			}
2353
		}
36632 ranu 2354
 
36785 ranu 2355
		// All-or-nothing: any error → return without persisting anything.
2356
		if (!errorMessages.isEmpty()) {
2357
			Map<String, Object> response = new HashMap<>();
2358
			response.put("status", false);
2359
			response.put("beatsCreated", 0);
2360
			response.put("errors", errorMessages.size());
2361
			response.put("errorMessages", errorMessages);
2362
			response.put("message", "No beats created. Fix the issues below and re-upload the file.");
2363
			return responseSender.ok(response);
2364
		}
36632 ranu 2365
 
36785 ranu 2366
		// ----- Phase 2: persist (only reached when every row was clean) -----
2367
		int beatsCreated = 0;
2368
		for (ValidatedBulkBeat v : ready) {
2369
			BulkBeatGroup g = v.g;
2370
			String beatName = g.beatName;
2371
			int authUserId = v.authUserId;
2372
			List<Integer> sortedDays = v.sortedDays;
2373
			List<LocalDate> scheduleDates = v.scheduleDates;
36668 ranu 2374
 
36785 ranu 2375
			String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
2376
			AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
36632 ranu 2377
 
36785 ranu 2378
			Beat beat = new Beat();
2379
			beat.setName(beatName);
2380
			beat.setAuthUserId(authUserId);
2381
			beat.setBeatColor(beatColor);
2382
			beat.setTotalDays(sortedDays.size());
2383
			beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
2384
			beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
2385
			beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
2386
			beat.setActive(true);
2387
			beat.setCreatedBy(currentUser.getId());
2388
			beat.setCreatedTimestamp(LocalDateTime.now());
2389
			beatRepository.persist(beat);
36668 ranu 2390
 
36785 ranu 2391
			LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
2392
 
2393
			for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
2394
				int dayNumber = sortedDays.get(dayIdx);
2395
				LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
2396
 
2397
				BeatSchedule schedule = new BeatSchedule();
2398
				schedule.setBeatId(beat.getId());
2399
				schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
2400
				schedule.setEndDate(bulkEndDate);
2401
				schedule.setDayNumber(dayNumber);
2402
				schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
2403
				schedule.setCreatedTimestamp(LocalDateTime.now());
2404
				beatScheduleRepository.persist(schedule);
2405
 
2406
				List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
2407
				partners.sort((a, b) -> {
2408
					if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
2409
					if (a.seq >= 0) return -1;
2410
					if (b.seq >= 0) return 1;
2411
					return Integer.compare(a.rowNum, b.rowNum);
2412
				});
2413
 
2414
				int autoSeq = 0;
2415
				for (BulkPartner p : partners) {
36811 ranu 2416
					Integer partnerId = codeToId.get(p.code);
2417
					Integer officeId = officeCodeToId.get(p.code);
36785 ranu 2418
					// Codes were validated in Phase 1, this is just a safety net.
36811 ranu 2419
					if (partnerId == null && officeId == null) continue;
36785 ranu 2420
					BeatRoute route = new BeatRoute();
2421
					route.setBeatId(beat.getId());
36811 ranu 2422
					if (partnerId != null) {
2423
						route.setFofoId(partnerId);
2424
						route.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);
2425
					} else {
2426
						route.setFofoId(officeId);
2427
						route.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE);
2428
					}
36785 ranu 2429
					route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
2430
					route.setDayNumber(dayNumber);
2431
					route.setActive(true);
2432
					beatRouteRepository.persist(route);
2433
					autoSeq++;
36632 ranu 2434
				}
2435
			}
36785 ranu 2436
			beatsCreated++;
36632 ranu 2437
		}
2438
 
2439
		Map<String, Object> response = new HashMap<>();
2440
		response.put("status", true);
2441
		response.put("beatsCreated", beatsCreated);
36785 ranu 2442
		response.put("errors", 0);
36632 ranu 2443
		response.put("errorMessages", errorMessages);
36785 ranu 2444
		response.put("message", beatsCreated + " beat(s) created.");
36632 ranu 2445
		return responseSender.ok(response);
2446
	}
2447
 
36785 ranu 2448
	// Move a beat from one date to another — used by calendar drag-and-drop.
2449
	// Behaviour: if the target date already has ANOTHER beat scheduled (for the
2450
	// same sales user), the two schedules swap — the other beat slides onto
2451
	// the source date. If the target date is empty, the source date becomes empty.
2452
	@PostMapping(value = "/beatPlan/moveScheduleDate")
2453
	public ResponseEntity<?> moveScheduleDate(
2454
			@RequestParam String planGroupId,
2455
			@RequestParam String fromDate,
2456
			@RequestParam String toDate) {
2457
		int beatId = Integer.parseInt(planGroupId);
2458
		LocalDate from = LocalDate.parse(fromDate);
2459
		LocalDate to = LocalDate.parse(toDate);
2460
 
2461
		if (from.equals(to)) {
2462
			Map<String, Object> ok = new HashMap<>();
2463
			ok.put("status", true);
2464
			ok.put("message", "Same date — no change");
2465
			return responseSender.ok(ok);
2466
		}
2467
 
2468
		// Today is the live/running slot — a beat already running today can't be
2469
		// bumped, and a future beat can't be moved onto today.
2470
		LocalDate today = LocalDate.now();
2471
		if (to.equals(today) || from.equals(today) || from.isBefore(today) || to.isBefore(today)) {
2472
			return responseSender.badRequest("Cannot move to or from today / a past date — today's beat is live. Use future dates only.");
2473
		}
2474
 
2475
		Beat beat = beatRepository.selectById(beatId);
2476
		if (beat == null) return responseSender.badRequest("Beat not found");
2477
 
2478
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
2479
 
2480
		// Reject if THIS beat already has a different schedule row on the target date
2481
		// (it would create two schedule rows of the same beat on one day).
2482
		boolean selfConflict = schedules.stream()
2483
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
2484
		if (selfConflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
2485
 
2486
		BeatSchedule match = schedules.stream()
2487
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
2488
				.findFirst().orElse(null);
2489
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
2490
 
2491
		// Look for ANY OTHER beat (same sales user) whose schedule sits on the target
2492
		// date — if found we'll swap it onto the source date.
2493
		BeatSchedule otherSchedule = null;
2494
		List<BeatSchedule> otherSchedules = null;
2495
		Beat otherBeat = null;
2496
		for (Beat ub : beatRepository.selectActiveByAuthUserId(beat.getAuthUserId())) {
2497
			if (ub.getId() == beatId) continue;
2498
			List<BeatSchedule> ubSchedules = beatScheduleRepository.selectByBeatId(ub.getId());
2499
			BeatSchedule hit = ubSchedules.stream()
2500
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(to))
2501
					.findFirst().orElse(null);
2502
			if (hit != null) {
2503
				otherSchedule = hit;
2504
				otherSchedules = ubSchedules;
2505
				otherBeat = ub;
2506
				break;
2507
			}
2508
		}
2509
 
36840 ranu 2510
		// Multi-day beats can't be drag-swapped — moving one of their day_numbers
2511
		// in isolation would leave the other days behind and break the
2512
		// Day 1 → Day 2 → … chronological contiguity of the plan. Block both
2513
		// sides of the swap, point the BM at the Reschedule flow which moves
2514
		// every day together.
2515
		if (beat.getTotalDays() > 1) {
2516
			return responseSender.badRequest(
2517
					"\"" + (beat.getName() != null ? beat.getName() : "Beat #" + beat.getId())
2518
							+ "\" is a multi-day beat (" + beat.getTotalDays()
2519
							+ " days). Drag-swap is only allowed for single-day beats. Use Reschedule to move the whole plan together.");
2520
		}
2521
		if (otherBeat != null && otherBeat.getTotalDays() > 1) {
2522
			return responseSender.badRequest(
2523
					"Target date already has \"" + (otherBeat.getName() != null ? otherBeat.getName() : "Beat #" + otherBeat.getId())
2524
							+ "\", a multi-day beat (" + otherBeat.getTotalDays()
2525
							+ " days). Drag-swap is only allowed when both beats are single-day. Use Reschedule on one of them first.");
2526
		}
2527
 
2528
		// Move the dragged beat onto the target date. Per-row invariant:
2529
		// each schedule row's end_date == its own start_date (single-day shape).
2530
		// We touch ONLY the two rows being swapped — other schedule rows for the
2531
		// same beat (e.g. older dates) are left exactly as they were.
36785 ranu 2532
		match.setStartDate(to);
36840 ranu 2533
		match.setEndDate(to);
36785 ranu 2534
		if (otherSchedule != null) {
2535
			otherSchedule.setStartDate(from);
36840 ranu 2536
			otherSchedule.setEndDate(from);
36785 ranu 2537
		}
2538
 
2539
		Map<String, Object> response = new HashMap<>();
2540
		response.put("status", true);
2541
		response.put("message", otherBeat != null
2542
				? "Swapped with \"" + (otherBeat.getName() != null ? otherBeat.getName() : "beat") + "\" on " + toDate
2543
				: "Moved from " + fromDate + " to " + toDate);
2544
		response.put("swapped", otherBeat != null);
2545
		return responseSender.ok(response);
2546
	}
2547
 
36668 ranu 2548
	private static class BulkBeatGroup {
2549
		final String beatName;
2550
		final String authUserId;
2551
		final String startDate;
2552
		final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();
2553
 
2554
		BulkBeatGroup(String beatName, String authUserId, String startDate) {
2555
			this.beatName = beatName;
2556
			this.authUserId = authUserId;
2557
			this.startDate = startDate;
2558
		}
2559
 
2560
		void addPartner(int day, int seq, String code, int rowNum) {
2561
			dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));
2562
		}
2563
	}
2564
 
2565
	private static class BulkPartner {
2566
		final int seq;
2567
		final String code;
2568
		final int rowNum;
2569
 
2570
		BulkPartner(int seq, String code, int rowNum) {
2571
			this.seq = seq;
2572
			this.code = code;
2573
			this.rowNum = rowNum;
2574
		}
2575
	}
2576
 
36644 ranu 2577
	// ============ CALENDAR ============
36618 ranu 2578
 
2579
	@PostMapping(value = "/beatPlan/delete")
2580
	public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
36644 ranu 2581
		int beatId = Integer.parseInt(planGroupId);
36698 ranu 2582
		// Hard delete — wipe all child rows first, then the beat itself.
2583
		// The name slot is freed naturally because the row is gone.
36644 ranu 2584
		beatRouteRepository.deleteByBeatId(beatId);
2585
		beatScheduleRepository.deleteByBeatId(beatId);
36698 ranu 2586
		leadRouteRepository.deleteByBeatId(beatId);
36644 ranu 2587
		Beat beat = beatRepository.selectById(beatId);
2588
		if (beat != null) {
36698 ranu 2589
			beatRepository.delete(beat);
36644 ranu 2590
		}
36618 ranu 2591
 
2592
		Map<String, Object> response = new HashMap<>();
2593
		response.put("status", true);
2594
		response.put("message", "Beat deleted");
2595
		return responseSender.ok(response);
2596
	}
2597
 
36670 ranu 2598
	// Unschedule the beat from ONE specific date — does NOT delete the beat.
2599
	// The beat (and its route template) stays; only the matching beat_schedule
2600
	// row is removed. If no real-date schedules remain, a placeholder
2601
	// (9999-12-31) row is added so the beat still shows up as "unscheduled".
2602
	@PostMapping(value = "/beatPlan/unscheduleDate")
2603
	public ResponseEntity<?> unscheduleDate(
2604
			@RequestParam String planGroupId,
2605
			@RequestParam String date) {
2606
		int beatId = Integer.parseInt(planGroupId);
2607
		LocalDate target = LocalDate.parse(date);
2608
 
2609
		Beat beat = beatRepository.selectById(beatId);
2610
		if (beat == null) return responseSender.badRequest("Beat not found");
2611
 
2612
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
2613
		int removed = 0;
2614
		for (BeatSchedule s : schedules) {
2615
			if (s.getStartDate() != null && s.getStartDate().equals(target)) {
2616
				beatScheduleRepository.delete(s);
2617
				removed++;
2618
			}
2619
		}
2620
		if (removed == 0) return responseSender.badRequest("No schedule found for that date");
2621
 
2622
		// If no real-date schedules left, drop in a placeholder so the beat
2623
		// remains visible in the unscheduled bucket.
2624
		boolean hasReal = schedules.stream()
2625
				.filter(s -> !s.getStartDate().equals(target))
2626
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);
2627
		if (!hasReal) {
2628
			boolean hasPlaceholder = schedules.stream()
2629
					.filter(s -> !s.getStartDate().equals(target))
2630
					.anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);
2631
			if (!hasPlaceholder) {
2632
				BeatSchedule ph = new BeatSchedule();
2633
				ph.setBeatId(beatId);
2634
				ph.setStartDate(LocalDate.of(9999, 12, 31));
2635
				ph.setDayNumber(1);
2636
				ph.setEndAction("HOME");
2637
				ph.setCreatedTimestamp(LocalDateTime.now());
2638
				beatScheduleRepository.persist(ph);
2639
			}
2640
		}
2641
 
2642
		Map<String, Object> response = new HashMap<>();
2643
		response.put("status", true);
2644
		response.put("message", "Unscheduled from " + date);
2645
		return responseSender.ok(response);
2646
	}
2647
 
36785 ranu 2648
	/**
2649
	 * Per-user one-beat-per-day guard. Walks every active beat the sales user
2650
	 * already has and looks for a schedule row on any of the candidate dates.
2651
	 * Returns null if the candidate dates are clear, otherwise a {date,beatName}
2652
	 * map describing the first collision so the caller can surface a clean error.
2653
	 * Pass `excludeBeatId` so callers that are re-scheduling an existing beat
2654
	 * don't trip on their own pre-existing schedule rows; pass 0 for new beats.
2655
	 */
2656
	private Map<String, Object> findScheduleConflict(int authUserId, java.util.Collection<LocalDate> candidates, int excludeBeatId) {
2657
		if (candidates == null || candidates.isEmpty()) return null;
2658
		Set<LocalDate> ds = new HashSet<>();
2659
		for (LocalDate d : candidates) {
2660
			if (d != null && d.getYear() != 9999) ds.add(d);
2661
		}
2662
		if (ds.isEmpty()) return null;
2663
		for (Beat ub : beatRepository.selectActiveByAuthUserId(authUserId)) {
2664
			if (ub.getId() == excludeBeatId) continue;
2665
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(ub.getId())) {
2666
				if (s.getStartDate() != null && ds.contains(s.getStartDate())) {
2667
					Map<String, Object> conflict = new HashMap<>();
2668
					conflict.put("date", s.getStartDate().toString());
2669
					conflict.put("beatName", ub.getName() != null ? ub.getName() : "Beat #" + ub.getId());
2670
					return conflict;
2671
				}
2672
			}
2673
		}
2674
		return null;
2675
	}
2676
 
2677
	private String scheduleConflictMessage(Map<String, Object> conflict) {
2678
		return "Cannot schedule on " + conflict.get("date")
2679
				+ " — \"" + conflict.get("beatName") + "\" is already scheduled for this user on that day.";
2680
	}
2681
 
2682
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
2683
	public ResponseEntity<?> scheduleOnCalendar(
2684
			HttpServletRequest request,
36670 ranu 2685
			@RequestParam String planGroupId,
36785 ranu 2686
			@RequestParam String dates,
2687
			@RequestParam(required = false) String beatName,
2688
			@RequestParam(required = false) String beatColor) throws Exception {
2689
 
36670 ranu 2690
		int beatId = Integer.parseInt(planGroupId);
36785 ranu 2691
		Gson gson = new Gson();
2692
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2693
		}.getType());
36670 ranu 2694
 
2695
		Beat beat = beatRepository.selectById(beatId);
2696
		if (beat == null) return responseSender.badRequest("Beat not found");
2697
 
36785 ranu 2698
		if (beatName != null) beat.setName(beatName);
2699
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
36670 ranu 2700
 
36785 ranu 2701
		// One-beat-per-day guard: reject if any of the requested dates already
2702
		// has another beat scheduled for this user (excluding this beat itself).
2703
		List<LocalDate> requested = new ArrayList<>();
2704
		for (String s : dateList) {
2705
			try {
2706
				requested.add(LocalDate.parse(s));
2707
			} catch (Exception ignored) {
36670 ranu 2708
			}
2709
		}
36962 vikas 2710
		// Same-day scheduling is restricted to L4+ operators.
2711
		if (requested.contains(LocalDate.now()) && !canScheduleToday(currentUser(request))) {
2712
			return responseSender.badRequest("Scheduling a beat for today is restricted to L4 and above.");
2713
		}
36785 ranu 2714
		Map<String, Object> conflict = findScheduleConflict(beat.getAuthUserId(), requested, beatId);
2715
		if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
36670 ranu 2716
 
36785 ranu 2717
		// Delete old schedules and create new
2718
		beatScheduleRepository.deleteByBeatId(beatId);
2719
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
2720
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
2721
            int dayNumber = i + 1;
2722
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
2723
			BeatSchedule schedule = new BeatSchedule();
2724
			schedule.setBeatId(beatId);
2725
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
2726
			schedule.setEndDate(schEndDate);
2727
            schedule.setDayNumber(dayNumber);
2728
            schedule.setEndAction(endAction);
2729
            // Fill total_distance_km / total_time_mins from beat_route so the new
2730
            // schedule row isn't NULL (this was the bug — these were left unset).
2731
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
2732
            schedule.setTotalDistanceKm(totals[0]);
2733
            schedule.setTotalTimeMins((int) totals[1]);
2734
			schedule.setCreatedTimestamp(LocalDateTime.now());
2735
			beatScheduleRepository.persist(schedule);
2736
		}
2737
 
36670 ranu 2738
		Map<String, Object> response = new HashMap<>();
2739
		response.put("status", true);
36785 ranu 2740
		response.put("message", "Beat scheduled successfully");
36670 ranu 2741
		return responseSender.ok(response);
2742
	}
2743
 
36618 ranu 2744
	@GetMapping(value = "/beatPlan/calendar")
2745
	public ResponseEntity<?> getCalendar(
2746
			@RequestParam int authUserId,
2747
			@RequestParam String month) {
2748
 
2749
		YearMonth ym = YearMonth.parse(month);
2750
		LocalDate startDate = ym.atDay(1);
2751
		LocalDate endDate = ym.atEndOfMonth();
2752
 
2753
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
2754
		List<Map<String, String>> holidayList = holidays.stream().map(h -> {
2755
			Map<String, String> m = new HashMap<>();
2756
			m.put("date", h.getDate().toString());
2757
			m.put("occasion", h.getOccasion());
2758
			return m;
2759
		}).collect(Collectors.toList());
2760
 
36644 ranu 2761
		List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);
36618 ranu 2762
		LocalDate today = LocalDate.now();
2763
		List<Map<String, Object>> scheduledBeats = new ArrayList<>();
2764
 
36644 ranu 2765
		for (Beat beat : allBeats) {
2766
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());
2767
			List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());
36618 ranu 2768
 
36644 ranu 2769
			boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);
2770
			boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));
2771
			boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));
2772
			boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));
36618 ranu 2773
 
2774
			String status;
2775
			if (allNullDates) status = "unscheduled";
2776
			else if (hasToday) status = "running";
2777
			else if (allPast) status = "completed";
36644 ranu 2778
			else status = "scheduled";
36618 ranu 2779
 
36644 ranu 2780
			Map<String, Object> beatInfo = new HashMap<>();
2781
			beatInfo.put("planGroupId", String.valueOf(beat.getId()));
2782
			beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");
2783
			beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");
2784
			beatInfo.put("status", status);
36728 vikas 2785
			beatInfo.put("totalDays", beat.getTotalDays());
36618 ranu 2786
 
2787
			List<Map<String, Object>> dayInfoList = new ArrayList<>();
36644 ranu 2788
			for (BeatSchedule s : schedules) {
36618 ranu 2789
				Map<String, Object> dayInfo = new HashMap<>();
36644 ranu 2790
				dayInfo.put("dayNumber", s.getDayNumber());
2791
				boolean isUnscheduled = s.getStartDate().getYear() == 9999;
2792
				dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());
2793
				dayInfo.put("totalKm", s.getTotalDistanceKm());
2794
				dayInfo.put("totalMins", s.getTotalTimeMins());
36711 ranu 2795
                // endAction tells the planner whether to draw the return-to-home line
2796
                // for this day (HOME) or end at the last stop (DAYBREAK).
2797
                dayInfo.put("endAction", s.getEndAction());
36644 ranu 2798
				long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();
2799
				dayInfo.put("visitCount", (int) visitCount);
36618 ranu 2800
				dayInfoList.add(dayInfo);
2801
			}
36644 ranu 2802
			if (schedules.isEmpty()) {
2803
				// No schedule at all — show from routes
2804
				Map<Integer, Long> dayCounts = routes.stream()
2805
						.collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));
2806
				for (int d = 1; d <= beat.getTotalDays(); d++) {
2807
					Map<String, Object> dayInfo = new HashMap<>();
2808
					dayInfo.put("dayNumber", d);
2809
					dayInfo.put("planDate", null);
2810
					dayInfo.put("totalKm", null);
2811
					dayInfo.put("totalMins", null);
2812
					dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());
2813
					dayInfoList.add(dayInfo);
2814
				}
2815
			}
2816
			beatInfo.put("days", dayInfoList);
2817
			scheduledBeats.add(beatInfo);
36618 ranu 2818
		}
2819
 
2820
		Set<String> blockedDates = new HashSet<>();
2821
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
36644 ranu 2822
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());
36618 ranu 2823
		}
36644 ranu 2824
		for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());
36618 ranu 2825
 
2826
		Map<String, Object> response = new HashMap<>();
2827
		response.put("holidays", holidayList);
2828
		response.put("scheduledBeats", scheduledBeats);
2829
		response.put("blockedDates", blockedDates);
2830
		return responseSender.ok(response);
2831
	}
2832
 
36644 ranu 2833
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
36618 ranu 2834
	@PostMapping(value = "/beatPlan/repeatBeat")
2835
	public ResponseEntity<?> repeatBeat(
2836
			HttpServletRequest request,
2837
			@RequestParam String sourcePlanGroupId,
2838
			@RequestParam int authUserId,
2839
			@RequestParam String dates) throws Exception {
2840
 
36644 ranu 2841
		int beatId = Integer.parseInt(sourcePlanGroupId);
36618 ranu 2842
		Gson gson = new Gson();
2843
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2844
		}.getType());
2845
 
36644 ranu 2846
		Beat beat = beatRepository.selectById(beatId);
2847
		if (beat == null) return responseSender.badRequest("Beat not found");
36618 ranu 2848
 
36785 ranu 2849
		// One-beat-per-day guard: reject if any of the new dates already has
2850
		// another beat scheduled for this user (excluding this beat itself).
2851
		List<LocalDate> repeatDates = new ArrayList<>();
2852
		for (String s : dateList) {
2853
			try {
2854
				repeatDates.add(LocalDate.parse(s));
2855
			} catch (Exception ignored) {
2856
			}
2857
		}
2858
		Map<String, Object> repeatConflict = findScheduleConflict(beat.getAuthUserId(), repeatDates, beatId);
2859
		if (repeatConflict != null) return responseSender.badRequest(scheduleConflictMessage(repeatConflict));
2860
 
36644 ranu 2861
		// Remove placeholder (unscheduled) schedule rows
2862
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
2863
		for (BeatSchedule s : existing) {
2864
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
2865
				beatScheduleRepository.delete(s);
2866
			}
36618 ranu 2867
		}
2868
 
36711 ranu 2869
        // Add new real-date schedule rows for the existing beat — fill totals
2870
        // from beat_route so total_distance_km / total_time_mins aren't NULL.
36644 ranu 2871
		LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
2872
		for (int i = 0; i < dateList.size(); i++) {
36711 ranu 2873
            int dayNumber = i + 1;
2874
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
36644 ranu 2875
			BeatSchedule schedule = new BeatSchedule();
2876
			schedule.setBeatId(beatId);
2877
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
2878
			schedule.setEndDate(repeatEndDate);
36711 ranu 2879
            schedule.setDayNumber(dayNumber);
2880
            schedule.setEndAction(endAction);
2881
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
2882
            schedule.setTotalDistanceKm(totals[0]);
2883
            schedule.setTotalTimeMins((int) totals[1]);
36644 ranu 2884
			schedule.setCreatedTimestamp(LocalDateTime.now());
2885
			beatScheduleRepository.persist(schedule);
36618 ranu 2886
		}
2887
 
2888
		Map<String, Object> response = new HashMap<>();
2889
		response.put("status", true);
36644 ranu 2890
		response.put("planGroupId", String.valueOf(beatId));
2891
		response.put("message", "Beat scheduled successfully");
36618 ranu 2892
		return responseSender.ok(response);
2893
	}
2894
 
36785 ranu 2895
	private static class ValidatedBulkBeat {
2896
		final BulkBeatGroup g;
2897
		final int authUserId;
2898
		final List<Integer> sortedDays;
2899
		final List<LocalDate> scheduleDates;
2900
 
2901
		ValidatedBulkBeat(BulkBeatGroup g, int authUserId, List<Integer> sortedDays, List<LocalDate> scheduleDates) {
2902
			this.g = g;
2903
			this.authUserId = authUserId;
2904
			this.sortedDays = sortedDays;
2905
			this.scheduleDates = scheduleDates;
2906
		}
2907
	}
2908
 
36618 ranu 2909
	@GetMapping(value = "/beatPlan/availableSlots")
2910
	public ResponseEntity<?> getAvailableSlots(
2911
			@RequestParam int authUserId,
2912
			@RequestParam String month,
2913
			@RequestParam int daysNeeded) {
2914
 
2915
		YearMonth ym = YearMonth.parse(month);
2916
		LocalDate startDate = ym.atDay(1);
2917
		LocalDate endDate = ym.atEndOfMonth();
2918
		LocalDate today = LocalDate.now();
2919
 
2920
		Set<LocalDate> blocked = new HashSet<>();
2921
		for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
2922
			if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
36644 ranu 2923
			if (!d.isAfter(today)) blocked.add(d);
36618 ranu 2924
		}
2925
 
2926
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
2927
		for (PublicHolidays h : holidays) blocked.add(h.getDate());
2928
 
36644 ranu 2929
		// Get all scheduled dates for this user
2930
		List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);
2931
		for (Beat b : userBeats) {
2932
			List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());
2933
			for (BeatSchedule s : schedules) blocked.add(s.getStartDate());
36618 ranu 2934
		}
2935
 
2936
		List<String> available = new ArrayList<>();
2937
		for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
2938
			 !d.isAfter(endDate) && available.size() < daysNeeded;
2939
			 d = d.plusDays(1)) {
36644 ranu 2940
			if (!blocked.contains(d)) available.add(d.toString());
36618 ranu 2941
		}
2942
 
2943
		Map<String, Object> response = new HashMap<>();
2944
		response.put("suggestedDates", available);
2945
		response.put("totalAvailable", available.size());
2946
		return responseSender.ok(response);
2947
	}
2948
 
2949
	// --- Sorting helpers ---
2950
 
2951
	private List<Map<String, Object>> sortByNearestNeighborFromStart(
2952
			List<Map<String, Object>> partners, double startLat, double startLng) {
2953
		List<Map<String, Object>> withCoords = new ArrayList<>();
2954
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2955
		for (Map<String, Object> p : partners) {
36644 ranu 2956
			if (hasValidCoords(p)) withCoords.add(p);
2957
			else withoutCoords.add(p);
36618 ranu 2958
		}
2959
		List<Map<String, Object>> sorted = new ArrayList<>();
36644 ranu 2960
		double currentLat = startLat, currentLng = startLng;
36618 ranu 2961
		while (!withCoords.isEmpty()) {
2962
			int nearestIdx = 0;
2963
			double nearestDist = Double.MAX_VALUE;
2964
			for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2965
				double dist = haversine(currentLat, currentLng,
2966
						Double.parseDouble(withCoords.get(i).get("latitude").toString()),
2967
						Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 2968
				if (dist < nearestDist) {
2969
					nearestDist = dist;
2970
					nearestIdx = i;
2971
				}
2972
			}
2973
			Map<String, Object> nearest = withCoords.remove(nearestIdx);
2974
			sorted.add(nearest);
2975
			currentLat = Double.parseDouble(nearest.get("latitude").toString());
2976
			currentLng = Double.parseDouble(nearest.get("longitude").toString());
2977
		}
2978
		sorted.addAll(withoutCoords);
2979
		return sorted;
2980
	}
2981
 
2982
	private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
2983
		List<Map<String, Object>> withCoords = new ArrayList<>();
2984
		List<Map<String, Object>> withoutCoords = new ArrayList<>();
2985
		for (Map<String, Object> p : partners) {
36644 ranu 2986
			if (hasValidCoords(p)) withCoords.add(p);
2987
			else withoutCoords.add(p);
36618 ranu 2988
		}
2989
		List<Map<String, Object>> sorted = new ArrayList<>();
2990
		if (!withCoords.isEmpty()) {
2991
			sorted.add(withCoords.remove(0));
2992
			while (!withCoords.isEmpty()) {
2993
				Map<String, Object> last = sorted.get(sorted.size() - 1);
2994
				double lastLat = Double.parseDouble(last.get("latitude").toString());
2995
				double lastLng = Double.parseDouble(last.get("longitude").toString());
2996
				int nearestIdx = 0;
2997
				double nearestDist = Double.MAX_VALUE;
2998
				for (int i = 0; i < withCoords.size(); i++) {
36644 ranu 2999
					double dist = haversine(lastLat, lastLng,
3000
							Double.parseDouble(withCoords.get(i).get("latitude").toString()),
3001
							Double.parseDouble(withCoords.get(i).get("longitude").toString()));
36618 ranu 3002
					if (dist < nearestDist) {
3003
						nearestDist = dist;
3004
						nearestIdx = i;
3005
					}
3006
				}
3007
				sorted.add(withCoords.remove(nearestIdx));
3008
			}
3009
		}
3010
		sorted.addAll(withoutCoords);
3011
		return sorted;
3012
	}
3013
 
3014
	private boolean hasValidCoords(Map<String, Object> p) {
3015
		Object lat = p.get("latitude");
3016
		Object lng = p.get("longitude");
36644 ranu 3017
		return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();
36618 ranu 3018
	}
3019
 
3020
	private double haversine(double lat1, double lng1, double lat2, double lng2) {
3021
		double R = 6371;
3022
		double dLat = Math.toRadians(lat2 - lat1);
3023
		double dLng = Math.toRadians(lng2 - lng1);
3024
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
3025
				+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
3026
				* Math.sin(dLng / 2) * Math.sin(dLng / 2);
3027
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
3028
		return R * c;
3029
	}
3030
}