Rev 36814 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed
package com.spice.profitmandi.web.controller;import com.google.gson.Gson;import com.google.gson.reflect.TypeToken;import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;import com.spice.profitmandi.common.model.CustomRetailer;import com.spice.profitmandi.common.model.ProfitMandiConstants;import com.spice.profitmandi.common.web.util.ResponseSender;import com.spice.profitmandi.dao.entity.auth.AuthUser;import com.spice.profitmandi.dao.entity.fofo.FofoStore;import com.spice.profitmandi.dao.entity.logistics.PublicHolidays;import com.spice.profitmandi.dao.entity.user.*;import com.spice.profitmandi.dao.enumuration.cs.EscalationType;import com.spice.profitmandi.dao.repository.auth.AuthRepository;import com.spice.profitmandi.dao.repository.cs.CsService;import com.spice.profitmandi.dao.repository.dtr.*;import com.spice.profitmandi.dao.repository.logistics.PublicHolidaysRepository;import com.spice.profitmandi.service.user.RetailerService;import com.spice.profitmandi.web.model.LoginDetails;import com.spice.profitmandi.web.util.CookiesProcessor;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.http.ResponseEntity;import org.springframework.stereotype.Controller;import org.springframework.transaction.annotation.Transactional;import org.springframework.ui.Model;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestParam;import javax.servlet.http.HttpServletRequest;import java.lang.reflect.Type;import java.time.DayOfWeek;import java.time.LocalDate;import java.time.LocalDateTime;import java.time.YearMonth;import java.time.format.DateTimeFormatter;import java.util.*;import java.util.stream.Collectors;@Controller@Transactional(rollbackFor = Throwable.class)public class BeatPlanController {private static final Logger LOGGER = LogManager.getLogger(BeatPlanController.class);private static final String[] BEAT_COLORS = {"#3498DB", "#E74C3C", "#2ECC71", "#9B59B6", "#F39C12","#1ABC9C", "#E67E22", "#34495E", "#16A085", "#C0392B"};@Autowiredprivate CsService csService;@Autowiredprivate AuthRepository authRepository;// Emails that bypass hierarchy and role gates — single source of truth.private static final Set<String> SUPER_ADMIN_EMAILS = new HashSet<>(Arrays.asList("tarun.verma@smartdukaan.com"));@Autowiredprivate com.spice.profitmandi.service.AuthService authService;@Autowiredprivate com.spice.profitmandi.dao.repository.cs.PositionRepository positionRepository;@Autowiredprivate RetailerService retailerService;@Autowiredprivate BeatRepository beatRepository;@Autowiredprivate BeatRouteRepository beatRouteRepository;@Autowiredprivate BeatScheduleRepository beatScheduleRepository;@Autowiredprivate LeadRouteRepository leadRouteRepository;@Autowiredprivate com.spice.profitmandi.dao.service.BeatPlanQueryService beatPlanQueryService;@Autowiredprivate AuthUserLocationRepository authUserLocationRepository;@Autowiredprivate LeadRepository leadRepository;@Autowiredprivate PublicHolidaysRepository publicHolidaysRepository;@Autowiredprivate com.spice.profitmandi.service.GeocodingService geocodingService;@Autowiredprivate CookiesProcessor cookiesProcessor;@Autowiredprivate ResponseSender responseSender;@Autowiredprivate FofoStoreRepository fofoStoreRepository;@Autowiredprivate com.spice.profitmandi.dao.repository.logistics.CompanyOfficeRepository companyOfficeRepository;@GetMapping(value = "/beatPlan")public String beatPlan(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {model.addAttribute("escalationTypes", visibleLevelsFor(request));return "beat-plan";}@Autowiredprivate com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository leadLiveLocationRepositoryAuto;@Autowiredprivate com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository leadActivityRepositoryAuto;@Autowiredprivate com.spice.profitmandi.dao.repository.dtr.UserRepository userRepositoryAuto;@Autowiredprivate com.spice.profitmandi.common.web.client.RestClient restClientAuto;@Autowiredprivate com.spice.profitmandi.dao.repository.auth.LocationTrackingRepository locationTrackingRepositoryAuto;@Autowiredprivate com.spice.profitmandi.dao.repository.dtr.BeatDeferredVisitRepository beatDeferredVisitRepository;private static Double parseDoubleOrNull(String s) {if (s == null || s.trim().isEmpty()) return null;try {return Double.parseDouble(s.trim());} catch (NumberFormatException e) {return null;}}private static double haversineKm(double lat1, double lng1, double lat2, double lng2) {double R = 6371;double dLat = Math.toRadians(lat2 - lat1);double dLng = Math.toRadians(lng2 - lng1);double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))* Math.sin(dLng / 2) * Math.sin(dLng / 2);double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));return R * c;}// Mirrors the JS recalcDay() formula. Used by schedule/repeat endpoints// which create fresh BeatSchedule rows — they need to fill totals from the// existing beat_route table, not from anything the client posted.// Returns {totalKm, totalMins}.private double[] computeDayTotals(int beatId, int dayNumber, String endAction) {Beat beat = beatRepository.selectById(beatId);if (beat == null) return new double[]{0d, 0d};List<BeatRoute> dayRoutes = beatRouteRepository.selectByBeatId(beatId).stream().filter(r -> r.getDayNumber() == dayNumber).sorted(java.util.Comparator.comparingInt(BeatRoute::getSequenceOrder)).collect(Collectors.toList());if (dayRoutes.isEmpty()) return new double[]{0d, 0d};List<Integer> fofoIds = dayRoutes.stream().map(BeatRoute::getFofoId).distinct().collect(Collectors.toList());Map<Integer, FofoStore> storeMap = new HashMap<>();try {for (FofoStore fs : fofoStoreRepository.selectByRetailerIds(fofoIds)) {storeMap.put(fs.getId(), fs);}} catch (Exception ignored) { /* fall through with empty map */ }double ROAD_FACTOR = 1.3;double AVG_SPEED = 30.0; // km/hint VISIT_MINS = 30;Double prevLat = parseDoubleOrNull(beat.getStartLatitude());Double prevLng = parseDoubleOrNull(beat.getStartLongitude());double totalKm = 0d;for (BeatRoute r : dayRoutes) {FofoStore fs = storeMap.get(r.getFofoId());if (fs == null) continue;Double curLat = parseDoubleOrNull(fs.getLatitude());Double curLng = parseDoubleOrNull(fs.getLongitude());if (prevLat != null && prevLng != null && curLat != null && curLng != null) {totalKm += haversineKm(prevLat, prevLng, curLat, curLng) * ROAD_FACTOR;}if (curLat != null && curLng != null) {prevLat = curLat;prevLng = curLng;}}// Return-home leg only when end_action='HOME'if ("HOME".equalsIgnoreCase(endAction)) {Double homeLat = parseDoubleOrNull(beat.getStartLatitude());Double homeLng = parseDoubleOrNull(beat.getStartLongitude());if (prevLat != null && prevLng != null && homeLat != null && homeLng != null) {totalKm += haversineKm(prevLat, prevLng, homeLat, homeLng) * ROAD_FACTOR;}}double totalMins = (totalKm / AVG_SPEED) * 60.0 + dayRoutes.size() * VISIT_MINS;return new double[]{Math.round(totalKm * 1000d) / 1000d, Math.round(totalMins)};}// ====================== ASSIGN VISIT ======================// Day View "Assign Visit" — lets an admin pick parties (stores) for a specific// auth user on a specific date and pushes them as visit tasks to the v2// /profitmandi-web/v2/beat-tracking/batch endpoint.// List of parties (stores) assigned to this auth user + their dtr.users.id.// When date+beatId are passed, each party is also tagged with:// inBeat = is this store part of the scheduled beat's route on that date// existingAgendas[] = agendas already saved for this store on that date (so the// modal can pre-fill them and let the user refill rather than re-assign)@GetMapping(value = "/beatPlan/assignVisit/parties")public ResponseEntity<?> assignVisitParties(@RequestParam int authUserId,@RequestParam(required = false) String date,@RequestParam(required = false) Integer beatId) throws Exception {AuthUser au = authRepository.selectById(authUserId);if (au == null) return responseSender.badRequest("Auth user not found");// Map auth_user → dtr.users via emailInteger dtrUserId = null;try {com.spice.profitmandi.dao.entity.dtr.User dtrUser =userRepositoryAuto.selectByEmailId(au.getEmailId());if (dtrUser != null) dtrUserId = dtrUser.getId();} catch (Exception ignored) {}// Parse optional dateLocalDate parsedDate = null;if (date != null && !date.isEmpty()) {try {parsedDate = LocalDate.parse(date);} catch (Exception ignored) {}}// Build (fofoId → dayNumber) of partners already in the beat's scheduled route for this dateSet<Integer> inBeatFofoIds = new HashSet<>();if (beatId != null && parsedDate != null) {final LocalDate dateF = parsedDate; // capture for lambda (parsedDate is reassigned earlier so not effectively final)List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);BeatSchedule match = schedules.stream().filter(s -> s.getStartDate() != null && s.getStartDate().equals(dateF)).findFirst().orElse(null);if (match != null) {List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);routes.stream().filter(r -> r.getDayNumber() == match.getDayNumber() && r.isActive()).forEach(r -> inBeatFofoIds.add(r.getFofoId()));}}// Build (fofoId → existingAgendas) and (fofoId → existingDescription) from// any already-saved location_tracking rows for this user on this date.// Agenda is stored as task_name = "agenda1, agenda2 | OutletName"// so we split on " | " to peel the outlet suffix off, then split agendas by ", ".// Description is stored on task_description (free text).Map<Integer, List<String>> existingAgendaByFofo = new HashMap<>();Map<Integer, String> existingDescByFofo = new HashMap<>();Map<Integer, Integer> existingTrackingIdByFofo = new HashMap<>();if (dtrUserId != null && parsedDate != null) {List<com.spice.profitmandi.dao.entity.auth.LocationTracking> existing =locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, parsedDate);for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt : existing) {if (!"franchisee-visit".equals(lt.getTaskType())) continue;if (existingAgendaByFofo.containsKey(lt.getTaskId())) continue; // first winsString taskName = lt.getTaskName() == null ? "" : lt.getTaskName();String agendaPart = taskName;int pipeIdx = taskName.lastIndexOf(" | ");if (pipeIdx > 0) agendaPart = taskName.substring(0, pipeIdx);List<String> agendas = new ArrayList<>();for (String a : agendaPart.split(",")) {String trimmed = a.trim();if (!trimmed.isEmpty()) agendas.add(trimmed);}existingAgendaByFofo.put(lt.getTaskId(), agendas);existingDescByFofo.put(lt.getTaskId(), lt.getTaskDescription() != null ? lt.getTaskDescription() : "");existingTrackingIdByFofo.put(lt.getTaskId(), lt.getId());}}Map<Integer, List<Integer>> mapping = csService.getAuthUserIdPartnerIdMapping();List<Integer> fofoIds = mapping.get(authUserId);List<Map<String, Object>> parties = new ArrayList<>();if (fofoIds != null && !fofoIds.isEmpty()) {List<FofoStore> stores = fofoStoreRepository.selectByRetailerIds(fofoIds);Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);for (FofoStore store : stores) {if (!store.isActive() || store.isClosed()) continue;CustomRetailer retailer = retailerMap.get(store.getId());Map<String, Object> p = new HashMap<>();p.put("fofoStoreId", store.getId());p.put("code", store.getCode());p.put("outletName", store.getOutletName() != null ? store.getOutletName(): (retailer != null ? retailer.getBusinessName() : "Store #" + store.getId()));p.put("latitude", store.getLatitude());p.put("longitude", store.getLongitude());p.put("city", retailer != null && retailer.getAddress() != null ? retailer.getAddress().getCity() : null);p.put("inBeat", inBeatFofoIds.contains(store.getId()));p.put("existingAgendas", existingAgendaByFofo.getOrDefault(store.getId(), new ArrayList<>()));p.put("existingDescription", existingDescByFofo.getOrDefault(store.getId(), ""));p.put("existingTrackingId", existingTrackingIdByFofo.get(store.getId()));parties.add(p);}// In-beat first, then by codeparties.sort((a, b) -> {boolean ai = Boolean.TRUE.equals(a.get("inBeat"));boolean bi = Boolean.TRUE.equals(b.get("inBeat"));if (ai != bi) return ai ? -1 : 1;return String.valueOf(a.get("code")).compareToIgnoreCase(String.valueOf(b.get("code")));});}Map<String, Object> result = new HashMap<>();result.put("dtrUserId", dtrUserId);result.put("authUserId", authUserId);result.put("userName", au.getFirstName() + " " + au.getLastName());result.put("parties", parties);result.put("agendaOptions", com.spice.profitmandi.dao.enumuration.dtr.VisitAgenda.labels());return responseSender.ok(result);}// Submit assignment — accepts a JSON body, builds the v2 payload, posts it@PostMapping(value = "/beatPlan/assignVisit/submit")public ResponseEntity<?> assignVisitSubmit(HttpServletRequest request,@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {Integer authUserId = body.get("authUserId") != null ? ((Number) body.get("authUserId")).intValue() : null;String planDate = (String) body.get("planDate");List<Map<String, Object>> selected = (List<Map<String, Object>>) body.get("parties");if (authUserId == null || planDate == null || selected == null || selected.isEmpty()) {return responseSender.badRequest("authUserId, planDate and parties are required");}AuthUser au = authRepository.selectById(authUserId);if (au == null) return responseSender.badRequest("Auth user not found");// Map auth → dtr.users.id (this is the userId the v2 endpoint expects)com.spice.profitmandi.dao.entity.dtr.User dtrUser;try {dtrUser = userRepositoryAuto.selectByEmailId(au.getEmailId());} catch (Exception e) {return responseSender.badRequest("Failed to look up dtr.users for this auth user");}if (dtrUser == null) {return responseSender.badRequest("No dtr.users record found for auth user " + authUserId);}int dtrUserId = dtrUser.getId();// Persist directly via the shared DAO — mirrors BeatTrackingController.createBatch// in profitmandi-web. We can't autowire a controller across WARs, but// LocationTrackingRepository lives in profitmandi-dao and is shared.LocalDate taskDate;try {taskDate = LocalDate.parse(planDate);} catch (Exception e) {return responseSender.badRequest("Invalid planDate (expected yyyy-MM-dd): " + planDate);}// Existing rows for this user on this date — keyed by fofoStoreId.// If a row already exists for a party, we UPDATE its agenda instead of// creating a duplicate (this is the "refill agenda" path for already-// assigned parties).Map<Integer, com.spice.profitmandi.dao.entity.auth.LocationTracking> existingByFofo = new HashMap<>();for (com.spice.profitmandi.dao.entity.auth.LocationTracking lt :locationTrackingRepositoryAuto.findByUserAndDate(dtrUserId, taskDate)) {if (!"franchisee-visit".equals(lt.getTaskType())) continue;existingByFofo.putIfAbsent(lt.getTaskId(), lt);}// Defence-in-depth: Assign Visit is only valid for today's run. Hiding the// button on the UI isn't enough — block at the API too.if (!taskDate.equals(LocalDate.now())) {return responseSender.badRequest("Visits can only be assigned for today's date (" + LocalDate.now() + ")");}LocalDateTime now = LocalDateTime.now();int createdCount = 0, updatedCount = 0;for (Map<String, Object> p : selected) {Integer fofoStoreId = ((Number) p.get("fofoStoreId")).intValue();String outletName = (String) p.get("outletName");String lat = (String) p.get("latitude");String lng = (String) p.get("longitude");String description = (String) p.get("description");if (description != null) description = description.trim();if (description == null) description = "";// Multi-agenda: accept agendas[] (new format) or fall back to agenda (legacy single)List<String> agendas = new ArrayList<>();Object rawAgendas = p.get("agendas");if (rawAgendas instanceof List) {for (Object o : (List<?>) rawAgendas) {if (o != null) {String s = String.valueOf(o).trim();if (!s.isEmpty()) agendas.add(s);}}}if (agendas.isEmpty()) {String single = (String) p.get("agenda");if (single != null && !single.trim().isEmpty()) agendas.add(single.trim());}if (agendas.isEmpty()) agendas.add("Visit");String agendaJoined = String.join(", ", agendas);String visitLocation = (lat != null && lng != null && !lat.isEmpty() && !lng.isEmpty())? (lat + "," + lng) : "0.0000,0.0000";String displayName = (outletName != null && !outletName.isEmpty()) ? outletName : ("Store #" + fofoStoreId);String newTaskName = agendaJoined + " | " + displayName;com.spice.profitmandi.dao.entity.auth.LocationTracking existing = existingByFofo.get(fofoStoreId);if (existing != null) {// Refill — agenda (task_name), description, and visit location changeexisting.setTaskName(newTaskName);existing.setTaskDescription(description);existing.setVisitLocation(visitLocation);existing.setUpdatedTimestamp(now);locationTrackingRepositoryAuto.persist(existing);updatedCount++;continue;}com.spice.profitmandi.dao.entity.auth.LocationTracking row =new com.spice.profitmandi.dao.entity.auth.LocationTracking();row.setUserId(dtrUserId);row.setDeviceId("0");row.setTaskId(fofoStoreId);row.setTaskDate(taskDate);row.setTaskName(newTaskName);row.setTaskDescription(description);row.setTaskType("franchisee-visit");row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));row.setAddress("");row.setVisitLocation(visitLocation);row.setCheckInLatLng("0.0000,0.0000");row.setCheckOutLatLng("0.0000,0.0000");row.setCheckInTime(java.time.LocalTime.MIDNIGHT);row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);row.setTransitTime(java.time.LocalTime.MIDNIGHT);row.setTimeSpent(java.time.LocalTime.MIDNIGHT);row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);row.setTotalDistance("0.0");row.setStatus(false);row.setCreatedTimestamp(now);row.setUpdatedTimestamp(now);// Do NOT try/catch this — if persist throws, let it propagate so// @Transactional(rollbackFor = Throwable.class) rolls back cleanly.locationTrackingRepositoryAuto.persist(row);createdCount++;}LOGGER.info("assignVisit dtrUserId={} created={} updated={}", dtrUserId, createdCount, updatedCount);Map<String, Object> result = new HashMap<>();result.put("status", true);result.put("createdCount", createdCount);result.put("updatedCount", updatedCount);result.put("dtrUserId", dtrUserId);StringBuilder msg = new StringBuilder();if (createdCount > 0)msg.append(createdCount).append(" new visit").append(createdCount == 1 ? "" : "s").append(" assigned");if (updatedCount > 0) {if (msg.length() > 0) msg.append(", ");msg.append(updatedCount).append(" existing agenda").append(updatedCount == 1 ? "" : "s").append(" refilled");}msg.append(" for ").append(au.getFirstName()).append(" ").append(au.getLastName());result.put("message", msg.toString());return responseSender.ok(result);}// ====================== DEFERRED PARTNERS ======================// Heads review partners that weren't visited on their planned day and act on// them. The deferral lifecycle lives in user.beat_deferred_visit (separate// from the raw location_tracking event log). Detection = explicit// (mark_type='DEFERRED') + derived (planned beat_route minus completed visits).// Page@GetMapping(value = "/beatPlan/deferredView")public String deferredView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {model.addAttribute("escalationTypes", visibleLevelsFor(request));return "beat-plan-deferred";}// List (syncs the table first, then returns the head's downline deferrals).@GetMapping(value = "/beatPlan/deferred")public ResponseEntity<?> deferredList(HttpServletRequest request,@RequestParam(required = false) String startDate,@RequestParam(required = false) String endDate) throws Exception {LocalDate start, end;try {start = (startDate == null || startDate.isEmpty()) ? LocalDate.now().minusDays(7) : LocalDate.parse(startDate);end = (endDate == null || endDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(endDate);} catch (Exception e) {return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");}LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;if (me == null) return responseSender.unauthorized("Not logged in");// PURE READ. Deferrals are persisted at the write point (BeatTrackingController,// when mark_type='DEFERRED' is recorded) — this endpoint never writes.List<BeatDeferredVisit> source;if (isSuperAdmin(me)) {source = beatDeferredVisitRepository.selectByDateRange(start, end);} else {Set<Integer> downline = new HashSet<>(authService.getAllReportees(me.getId()));downline.add(me.getId());source = beatDeferredVisitRepository.selectByAuthUserIdsAndDateRange(new ArrayList<>(downline), start, end);}List<BeatDeferredVisit> rows = source.stream().filter(r -> "DEFERRED".equals(r.getStatus())).collect(Collectors.toList());// ---- nextScheduledDate (info only: does a future run already cover it?) ----// Only meaningful for partner visits (leads aren't on beat_route). Purely a// hint — the row stays actionable even when auto-covered, since the next run// could be far off.Map<Integer, Map<Integer, LocalDate>> coverCache = new HashMap<>();Map<Integer, LocalDate> nextByRowId = new HashMap<>();for (BeatDeferredVisit r : rows) {if (!"franchisee-visit".equals(r.getTaskType())) continue;Map<Integer, LocalDate> cover = coverCache.computeIfAbsent(r.getAuthUserId(), this::computeFutureCover);LocalDate next = cover.get(r.getFofoId());if (next != null) nextByRowId.put(r.getId(), next);}// ---- resolve user names (display name + type already denormalized on the row) ----Set<Integer> authIds = rows.stream().map(BeatDeferredVisit::getAuthUserId).collect(Collectors.toSet());Map<Integer, AuthUser> userMap = new HashMap<>();if (!authIds.isEmpty())authRepository.selectByIds(new ArrayList<>(authIds)).forEach(u -> userMap.put(u.getId(), u));List<Map<String, Object>> out = new ArrayList<>();for (BeatDeferredVisit r : rows) {AuthUser u = userMap.get(r.getAuthUserId());boolean isLead = "lead".equalsIgnoreCase(r.getTaskType());boolean isOffice = "office-visit".equalsIgnoreCase(r.getTaskType());Map<String, Object> row = new HashMap<>();row.put("id", r.getId());row.put("authUserId", r.getAuthUserId());row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : ("User #" + r.getAuthUserId()));row.put("fofoStoreId", r.getFofoId());row.put("name", r.getDisplayName() != null ? r.getDisplayName() : ("#" + r.getFofoId()));row.put("type", isLead ? "Lead" : (isOffice ? "Office" : "Visit"));row.put("deferredDate", r.getDeferredDate() != null ? r.getDeferredDate().toString() : null);row.put("reason", r.getReason());row.put("status", r.getStatus());LocalDate next = nextByRowId.get(r.getId());row.put("nextScheduledDate", next != null ? next.toString() : null);out.add(row);}out.sort((a, c) -> String.valueOf(a.get("deferredDate")).compareTo(String.valueOf(c.get("deferredDate"))));Map<String, Object> result = new HashMap<>();result.put("rows", out);result.put("startDate", start.toString());result.put("endDate", end.toString());return responseSender.ok(result);}// Head action on a deferral: reschedule (one-off visit, or into an existing// beat-day) or cancel. Never edits the beat template.@PostMapping(value = "/beatPlan/deferred/action")public ResponseEntity<?> deferredAction(HttpServletRequest request,@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;String action = (String) body.get("action");String toDateStr = (String) body.get("toDate");if (deferredId == null || action == null)return responseSender.badRequest("deferredId and action are required");LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;if (me == null) return responseSender.unauthorized("Not logged in");BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);if (d == null) return responseSender.badRequest("Deferred record not found");LocalDateTime now = LocalDateTime.now();if ("cancel".equalsIgnoreCase(action)) {d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.CANCELLED));d.setActionBy(me.getId());d.setUpdatedTimestamp(now);// Optional cancel reason — overlay onto the reason column. Original// deferred-reason is preserved as a suffix so we keep the audit trail.String cancelReason = body.get("reason") != null ? String.valueOf(body.get("reason")).trim() : "";if (!cancelReason.isEmpty()) {String prev = d.getReason() != null ? d.getReason() : "";d.setReason("Cancelled: " + cancelReason + (prev.isEmpty() ? "" : " | Original: " + prev));}beatDeferredVisitRepository.persist(d);Map<String, Object> ok = new HashMap<>();ok.put("status", true);ok.put("message", "Deferred visit cancelled");return responseSender.ok(ok);}// reschedule_oneoff | reschedule_beatif (toDateStr == null || toDateStr.isEmpty())return responseSender.badRequest("toDate is required to reschedule");LocalDate toDate;try {toDate = LocalDate.parse(toDateStr);} catch (Exception e) {return responseSender.badRequest("Invalid toDate (yyyy-MM-dd)");}// Reschedule must land on a STRICTLY future date — today is already in progress.if (!toDate.isAfter(LocalDate.now()))return responseSender.badRequest("Reschedule date must be in the future (today is already in progress).");if ("reschedule_beat".equalsIgnoreCase(action)) {boolean hasBeat = beatRepository.selectActiveByAuthUserId(d.getAuthUserId()).stream().flatMap(b -> beatScheduleRepository.selectByBeatId(b.getId()).stream()).anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(toDate));if (!hasBeat)return responseSender.badRequest("No beat is scheduled for this user on " + toDateStr + ". Pick another date or use a one-off visit.");}// Resolve dtr user, then create a PENDING task on toDate. Reuse the// denormalized name + type (works for both partner visits and leads — for// leads, looking up fofo_store would be the wrong id space). For visits we// still try to pull lat/lng for the visit location.Integer dtrId = resolveDtrId(d.getAuthUserId(), new HashMap<>());if (dtrId == null) return responseSender.badRequest("No dtr.users record for this sales person");boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());boolean isOffice = "office-visit".equalsIgnoreCase(d.getTaskType());String visitLocation = "0.0000,0.0000";if (isOffice) {// Office stops resolve lat/lng from logistics.company_office.try {com.spice.profitmandi.dao.entity.logistics.CompanyOffice o = companyOfficeRepository.selectById(d.getFofoId());if (o != null) visitLocation = o.getLat() + "," + o.getLng();} catch (Exception ignored) {}} else if (!isLead) {try {List<FofoStore> ss = fofoStoreRepository.selectByRetailerIds(java.util.Collections.singletonList(d.getFofoId()));if (!ss.isEmpty()) {FofoStore fs = ss.get(0);if (fs.getLatitude() != null && fs.getLongitude() != null&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {visitLocation = fs.getLatitude() + "," + fs.getLongitude();}}} catch (Exception ignored) {}}String taskName = d.getDisplayName() != null ? d.getDisplayName(): ((d.getReason() != null ? d.getReason() : "Rescheduled") + " | #" + d.getFofoId());com.spice.profitmandi.dao.entity.auth.LocationTracking row = new com.spice.profitmandi.dao.entity.auth.LocationTracking();row.setUserId(dtrId);row.setDeviceId("0");row.setTaskId(d.getFofoId());row.setTaskDate(toDate);row.setTaskName(taskName);row.setTaskDescription("Rescheduled from " + d.getDeferredDate());row.setTaskType(d.getTaskType() != null ? d.getTaskType() : "franchisee-visit");row.setMarkType(String.valueOf(ProfitMandiConstants.MARK_TYPE.PENDING));row.setAddress("");row.setVisitLocation(visitLocation);row.setCheckInLatLng("0.0000,0.0000");row.setCheckOutLatLng("0.0000,0.0000");row.setCheckInTime(java.time.LocalTime.MIDNIGHT);row.setCheckOutTime(java.time.LocalTime.MIDNIGHT);row.setTransitTime(java.time.LocalTime.MIDNIGHT);row.setTimeSpent(java.time.LocalTime.MIDNIGHT);row.setEstimatedTime(java.time.LocalTime.MIDNIGHT);row.setSessionStartTime(java.time.LocalTime.MIDNIGHT);row.setSessionEndTime(java.time.LocalTime.MIDNIGHT);row.setTotalDistance("0.0");row.setStatus(false);row.setCreatedTimestamp(now);row.setUpdatedTimestamp(now);locationTrackingRepositoryAuto.persist(row);d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));d.setRescheduledToDate(toDate);d.setActionBy(me.getId());d.setUpdatedTimestamp(now);beatDeferredVisitRepository.persist(d);Map<String, Object> ok = new HashMap<>();ok.put("status", true);ok.put("message", "Visit rescheduled to " + toDateStr);return responseSender.ok(ok);}// Drop a deferred item into a specific upcoming BEAT run (chosen from the beat// calendar). Lead → a lead_route row on that beat/date (renders as a lead stop).// Partner visit → appended to that beat's route for the date's day_number.// The beat plan calendar then shows it. Marks the deferral RESCHEDULED.@PostMapping(value = "/beatPlan/deferred/assignToBeat")public ResponseEntity<?> deferredAssignToBeat(HttpServletRequest request,@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) throws Exception {Integer deferredId = body.get("deferredId") != null ? ((Number) body.get("deferredId")).intValue() : null;Integer beatId = body.get("beatId") != null ? ((Number) body.get("beatId")).intValue() : null;String dateStr = (String) body.get("date");if (deferredId == null || beatId == null || dateStr == null)return responseSender.badRequest("deferredId, beatId and date are required");LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;if (me == null) return responseSender.unauthorized("Not logged in");LocalDate date;try {date = LocalDate.parse(dateStr);} catch (Exception e) {return responseSender.badRequest("Invalid date (yyyy-MM-dd)");}// Drop-onto-beat must land on a STRICTLY future date — today is already in progress.if (!date.isAfter(LocalDate.now()))return responseSender.badRequest("Pick a future date — today is already in progress.");BeatDeferredVisit d = beatDeferredVisitRepository.selectById(deferredId);if (d == null) return responseSender.badRequest("Deferred record not found");// A deferral can only move FORWARD — never onto the day it was deferred or earlier.if (d.getDeferredDate() != null && !date.isAfter(d.getDeferredDate())) {return responseSender.badRequest("A deferred item can only be moved to a date after "+ d.getDeferredDate() + " (it was deferred that day).");}Beat beat = beatRepository.selectById(beatId);if (beat == null) return responseSender.badRequest("Beat not found");// The beat must actually run on the chosen date — get that run's day number.BeatSchedule sched = beatScheduleRepository.selectByBeatId(beatId).stream().filter(s -> s.getStartDate() != null && s.getStartDate().equals(date)).findFirst().orElse(null);if (sched == null) return responseSender.badRequest("That beat is not scheduled on " + dateStr);LocalDateTime now = LocalDateTime.now();boolean isLead = "lead".equalsIgnoreCase(d.getTaskType());if (isLead) {// Avoid duplicating the same lead on the same beat/dateboolean exists = leadRouteRepository.selectByBeatId(beatId).stream().anyMatch(lr -> lr.getLeadId() == d.getFofoId()&& date.equals(lr.getScheduleDate())&& !"CANCELLED".equals(lr.getStatus()));if (!exists) {LeadRoute lr = new LeadRoute();lr.setBeatId(beatId);lr.setLeadId(d.getFofoId());lr.setScheduleDate(date);lr.setSequenceOrder(9999); // append; planner can reorderlr.setStatus("APPROVED");lr.setApprovedBy(me.getId());lr.setApprovedTimestamp(now);lr.setCreatedTimestamp(now);lr.setUpdatedTimestamp(now);leadRouteRepository.persist(lr);}} else {// Partner visit → append to that beat's route for the date's day number,// if not already present on that day.boolean exists = beatRouteRepository.selectByBeatId(beatId).stream().anyMatch(r -> r.getFofoId() == d.getFofoId() && r.getDayNumber() == sched.getDayNumber() && r.isActive());if (!exists) {int nextSeq = beatRouteRepository.selectByBeatId(beatId).stream().filter(r -> r.getDayNumber() == sched.getDayNumber()).mapToInt(BeatRoute::getSequenceOrder).max().orElse(-1) + 1;BeatRoute br = new BeatRoute();br.setBeatId(beatId);br.setFofoId(d.getFofoId());br.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);br.setDayNumber(sched.getDayNumber());br.setSequenceOrder(nextSeq);br.setActive(true);beatRouteRepository.persist(br);}}d.setStatus(String.valueOf(ProfitMandiConstants.MARK_TYPE.RESCHEDULED));d.setRescheduledToDate(date);d.setActionBy(me.getId());d.setUpdatedTimestamp(now);beatDeferredVisitRepository.persist(d);Map<String, Object> ok = new HashMap<>();ok.put("status", true);ok.put("message", (isLead ? "Lead" : "Partner") + " added to beat '" + beat.getName() + "' on " + dateStr);return responseSender.ok(ok);}// authUserId -> dtr.users id (via shared email), memoized in the passed cache.// Used by the reschedule action to create the new PENDING location_tracking row.private Integer resolveDtrId(int authUserId, Map<Integer, Integer> cache) {if (cache.containsKey(authUserId)) return cache.get(authUserId);Integer dtrId = null;try {AuthUser au = authRepository.selectById(authUserId);if (au != null && au.getEmailId() != null) {com.spice.profitmandi.dao.entity.dtr.User u = userRepositoryAuto.selectByEmailId(au.getEmailId());if (u != null) dtrId = u.getId();}} catch (Exception ignored) {}cache.put(authUserId, dtrId);return dtrId;}// For an auth user: fofoId -> earliest upcoming (>= today) scheduled date where// an active beat's route still includes that partner (the "Next Scheduled" hint).private Map<Integer, LocalDate> computeFutureCover(int authUserId) {LocalDate today = LocalDate.now();Map<Integer, LocalDate> cover = new HashMap<>();for (Beat b : beatRepository.selectActiveByAuthUserId(authUserId)) {LocalDate earliest = null;for (BeatSchedule s : beatScheduleRepository.selectByBeatId(b.getId())) {LocalDate dt = s.getStartDate();if (dt != null && dt.getYear() != 9999 && !dt.isBefore(today)) {if (earliest == null || dt.isBefore(earliest)) earliest = dt;}}if (earliest == null) continue;for (BeatRoute rt : beatRouteRepository.selectByBeatId(b.getId())) {if (!rt.isActive()) continue;LocalDate cur = cover.get(rt.getFofoId());if (cur == null || earliest.isBefore(cur)) cover.put(rt.getFofoId(), earliest);}}return cover;}@GetMapping(value = "/beatPlanWindow")public String beatPlanWindow(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {model.addAttribute("escalationTypes", visibleLevelsFor(request));return "beat-plan-window";}// Helpers for XLSX bulk uploadprivate static String readCell(org.apache.poi.ss.usermodel.Cell cell) {if (cell == null) return null;switch (cell.getCellType()) {case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_STRING:return cell.getStringCellValue();case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_NUMERIC:if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) {return cell.getDateCellValue().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString();}double n = cell.getNumericCellValue();return (n == Math.floor(n)) ? String.valueOf((long) n) : String.valueOf(n);case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_BOOLEAN:return String.valueOf(cell.getBooleanCellValue());case org.apache.poi.ss.usermodel.Cell.CELL_TYPE_FORMULA:return cell.getCellFormula();default:return null;}}// ====================== ONE-TIME LAT/LNG MIGRATION ======================// For each active fofo_store, compare its stored lat/lng with the geocoded// address lat/lng (cached in Redis). If the gap is > thresholdKm (default 5)// OR the store has no lat/lng yet, update the store with the geocoded// coordinates. Otherwise keep the existing values.//// Usage:// GET /beatPlan/migrateStoreLatLng -> dry run, default 5km, all// GET /beatPlan/migrateStoreLatLng?apply=true -> actually update// ?thresholdKm=3 -> use a different threshold// ?limit=100 -> process only N stores (for staged runs)@GetMapping(value = "/beatPlan/migrateStoreLatLng")public ResponseEntity<?> migrateStoreLatLng(@RequestParam(required = false, defaultValue = "false") boolean apply,@RequestParam(required = false, defaultValue = "5") double thresholdKm,@RequestParam(required = false, defaultValue = "0") int limit,@RequestParam(required = false, defaultValue = "0") int offset,@RequestParam(required = false, defaultValue = "40") int maxSeconds) throws ProfitMandiBusinessException {List<FofoStore> all = fofoStoreRepository.selectActiveStores();int totalAvailable = all.size();int from = Math.max(0, Math.min(offset, totalAvailable));// Hard cap (if limit given), else go to the end of the list.int hardTo = limit > 0 ? Math.min(from + limit, totalAvailable) : totalAvailable;// Time budget: stop processing once we approach the gateway timeout and// return nextOffset so the caller can resume. Geocoding is the slow part// (network/cache), so a fixed batch size could still time out on a cache-miss// run — a wall-clock budget is safer. maxSeconds defaults to 40 (< typical 60s gateway).long deadlineMs = System.currentTimeMillis() + Math.max(5, maxSeconds) * 1000L;List<FofoStore> stores = all.subList(from, hardTo);List<Integer> ids = stores.stream().map(FofoStore::getId).collect(Collectors.toList());Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(ids);int total = 0; // stores actually processed this callint updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;boolean stoppedOnTime = false;int nextIndex = from; // absolute index of next unprocessed storeList<Map<String, Object>> changes = new ArrayList<>();for (FofoStore store : stores) {// Stop before doing more slow geocoding work if we've spent our budget.if (System.currentTimeMillis() >= deadlineMs) {stoppedOnTime = true;break;}total++;nextIndex++;try {CustomRetailer retailer = retailerMap.get(store.getId());if (retailer == null || retailer.getAddress() == null) {noAddress++;continue;}String geoAddr = com.spice.profitmandi.service.GeocodingService.buildGeoAddress(retailer.getAddress().getLine1(), retailer.getAddress().getCity(),retailer.getAddress().getState(), retailer.getAddress().getPinCode());if (geoAddr == null || geoAddr.isEmpty()) {noAddress++;continue;}double[] coords = geocodingService.geocodeAddress(geoAddr);if (coords == null) {noGeocode++;continue;}Double existingLat = parseDoubleOrNull(store.getLatitude());Double existingLng = parseDoubleOrNull(store.getLongitude());boolean shouldUpdate;double distKm = -1;String reason;if (existingLat == null || existingLng == null) {shouldUpdate = true;reason = "missing existing lat/lng";} else {distKm = haversineKm(existingLat, existingLng, coords[0], coords[1]);shouldUpdate = distKm > thresholdKm;reason = shouldUpdate? "gap " + Math.round(distKm * 10.0) / 10.0 + "km > " + thresholdKm + "km": "gap " + Math.round(distKm * 10.0) / 10.0 + "km within " + thresholdKm + "km";}if (shouldUpdate) {if (apply) {store.setLatitude(String.valueOf(coords[0]));store.setLongitude(String.valueOf(coords[1]));store.setLatLngUpdatedTimestamp(LocalDateTime.now());fofoStoreRepository.persist(store);}updated++;Map<String, Object> ch = new HashMap<>();ch.put("storeId", store.getId());ch.put("code", store.getCode());ch.put("oldLat", existingLat);ch.put("oldLng", existingLng);ch.put("newLat", coords[0]);ch.put("newLng", coords[1]);ch.put("distKm", distKm >= 0 ? Math.round(distKm * 10.0) / 10.0 : null);ch.put("reason", reason);changes.add(ch);} else {// Verified-kept: lat/lng was already within threshold. Still stamp it// so "processed vs pending" can be told from lat_lng_updated_timestamp.if (apply) {store.setLatLngUpdatedTimestamp(LocalDateTime.now());fofoStoreRepository.persist(store);}kept++;}} catch (Exception e) {errored++;LOGGER.warn("Geocode/migrate failed for fofoId={}: {}", store.getId(), e.getMessage());}}Map<String, Object> result = new HashMap<>();result.put("mode", apply ? "APPLIED" : "DRY RUN — pass &apply=true to actually update");result.put("thresholdKm", thresholdKm);result.put("totalAvailable", totalAvailable); // total active stores in DBresult.put("offset", from);result.put("processed", total); // stores processed this callresult.put("nextOffset", nextIndex); // resume here next callresult.put("done", nextIndex >= totalAvailable); // true when nothing leftresult.put("stoppedOnTimeBudget", stoppedOnTime);// true if we paused for time, not because we finishedresult.put("updated", updated);result.put("kept", kept);result.put("noAddress", noAddress);result.put("noGeocode", noGeocode);result.put("errored", errored);// Limit changes preview to avoid huge responsesresult.put("changes", changes.size() > 200 ? changes.subList(0, 200) : changes);result.put("changesShownCount", Math.min(changes.size(), 200));return responseSender.ok(result);}// ====================== EDIT BEAT ======================// Update an existing beat — name + partner stops (routes).// Schedules are NOT touched here; manage them via calendar drag-drop.@PostMapping(value = "/beatPlan/updateBeat")public ResponseEntity<?> updateBeat(HttpServletRequest request,@RequestParam int beatId,@RequestParam String planData) throws Exception {Beat beat = beatRepository.selectById(beatId);if (beat == null) return responseSender.badRequest("Beat not found");// Refuse edits while the beat is "live" — i.e., a schedule row covers today.// The salesperson is already on-route; mutating the plan mid-day would// break their location_tracking timeline. Edits resume tomorrow.LocalDate today = LocalDate.now();boolean runningToday = beatScheduleRepository.selectByBeatId(beatId).stream().anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(today));if (runningToday) {return responseSender.badRequest("This beat is scheduled to run today — editing is locked until tomorrow. "+ "Use the Deferred panel for today's adjustments.");}Gson gson = new Gson();Type type = new TypeToken<Map<String, Object>>() {}.getType();Map<String, Object> plan = gson.fromJson(planData, type);List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");if (days == null || days.isEmpty()) return responseSender.badRequest("No days provided");// Update name if changed (and not colliding with another beat)String newName = plan.get("beatName") != null ? ((String) plan.get("beatName")).trim() : beat.getName();if (newName != null && !newName.equalsIgnoreCase(beat.getName())) {// Make sure no other ACTIVE beat for this user already uses this name.// Soft-deleted beats keep their name in the table; we don't want them// to block a legitimate rename.boolean collides = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId()).stream().anyMatch(b -> b.getId() != beat.getId()&& b.getName() != null&& newName.equalsIgnoreCase(b.getName().trim()));if (collides) return responseSender.badRequest("Another beat with this name already exists");beat.setName(newName);}// Update start location from first day if presentMap<String, Object> firstDay = days.get(0);if (firstDay.get("startLocationName") != null)beat.setStartLocationName((String) firstDay.get("startLocationName"));if (firstDay.get("startLatitude") != null) beat.setStartLatitude((String) firstDay.get("startLatitude"));if (firstDay.get("startLongitude") != null) beat.setStartLongitude((String) firstDay.get("startLongitude"));int oldTotalDays = beat.getTotalDays();int newTotalDays = days.size();// Hard rule: you cannot grow the number of days on an existing beat.// If you need more days, create a new beat. (Shrinking is allowed and// the schedules for dropped day numbers are cleaned below.)if (newTotalDays > oldTotalDays) {return responseSender.badRequest("Cannot increase the number of days on an existing beat. "+ "Original: " + oldTotalDays + " day(s), tried: " + newTotalDays + " day(s). "+ "Please create a new beat for additional days.");}beat.setTotalDays(newTotalDays);// Replace routes (partner stops). Schedules stay intact (except for// dayNumber > newTotalDays cleanup + total km/min refresh below).beatRouteRepository.deleteByBeatId(beatId);// Collect lead IDs the user kept on the planSet<Integer> keptLeadIds = new HashSet<>();for (int d = 0; d < days.size(); d++) {Map<String, Object> day = days.get(d);int dayNumber = d + 1;List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");if (visits == null) continue;int partnerSeq = 0;for (int i = 0; i < visits.size(); i++) {Map<String, Object> v = visits.get(i);if ("lead".equals(v.get("type"))) {keptLeadIds.add(((Number) v.get("id")).intValue());continue; // leads live in lead_route, handled below}BeatRoute route = new BeatRoute();route.setBeatId(beatId);route.setFofoId(((Number) v.get("id")).intValue());route.setVisitType("office".equals(v.get("type"))? com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE: com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);route.setSequenceOrder(partnerSeq++);route.setDayNumber(dayNumber);route.setActive(true);if (v.get("distanceFromPrevKm") != null)route.setDistanceFromPrevKm(((Number) v.get("distanceFromPrevKm")).doubleValue());if (v.get("timeFromPrevMins") != null)route.setTimeFromPrevMins(((Number) v.get("timeFromPrevMins")).intValue());beatRouteRepository.persist(route);}}// If the beat shrank, drop schedule rows for day numbers that no longer existif (newTotalDays < oldTotalDays) {List<BeatSchedule> currentSchedules = beatScheduleRepository.selectByBeatId(beatId);for (BeatSchedule s : currentSchedules) {if (s.getDayNumber() > newTotalDays) beatScheduleRepository.delete(s);}}// Refresh the day-level totals on every remaining schedule row so they// reflect the post-edit route. Previously updateBeat left these stale// (or NULL, for beats created before this fix), which is what the user// reported. Keyed by dayNumber so multi-instance beats all get updated.Map<Integer, Map<String, Object>> dayByNumber = new HashMap<>();for (int d = 0; d < days.size(); d++) {dayByNumber.put(d + 1, days.get(d));}List<BeatSchedule> allSchedules = beatScheduleRepository.selectByBeatId(beatId);for (BeatSchedule s : allSchedules) {Map<String, Object> day = dayByNumber.get(s.getDayNumber());if (day == null) continue;if (day.get("totalDistanceKm") != null)s.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());if (day.get("totalTimeMins") != null)s.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());}// Process per-lead actions sent from the editor's removed-leads popup.// Each entry: {leadId, action: "cancel"|"reschedule", toDate?: "yyyy-MM-dd"}.// - cancel: mark the lead's current APPROVED row for this beat as CANCELLED.// - reschedule: cancel here, then create a fresh APPROVED LeadRoute on// whichever beat this user has scheduled on toDate. If no beat exists// on toDate, the whole update fails (so the caller can prompt again).int leadsCancelled = 0, leadsRescheduled = 0;List<String> leadFailures = new ArrayList<>();String removedLeadActionsJson = (String) plan.get("removedLeadActions");if (removedLeadActionsJson != null && !removedLeadActionsJson.isEmpty()) {Type listType = new TypeToken<List<Map<String, Object>>>() {}.getType();List<Map<String, Object>> actions = gson.fromJson(removedLeadActionsJson, listType);List<LeadRoute> beatLeads = leadRouteRepository.selectByBeatId(beatId);for (Map<String, Object> act : actions) {int leadId = ((Number) act.get("leadId")).intValue();String mode = (String) act.get("action");// Find this lead's most-recent APPROVED row on this beatLeadRoute current = beatLeads.stream().filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus())).findFirst().orElse(null);if (current == null) continue; // already removed/cancelled; nothing to doif ("reschedule".equalsIgnoreCase(mode)) {String toDateStr = (String) act.get("toDate");if (toDateStr == null || toDateStr.isEmpty()) {leadFailures.add("Lead " + leadId + ": reschedule date missing");continue;}LocalDate toDate = LocalDate.parse(toDateStr);// Find ANY beat this user has scheduled on toDateBeat targetBeat = null;Integer targetDayNumber = null;List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(beat.getAuthUserId());for (Beat b : userBeats) {List<BeatSchedule> sl = beatScheduleRepository.selectByBeatId(b.getId());for (BeatSchedule s : sl) {if (s.getStartDate() != null && s.getStartDate().equals(toDate)) {targetBeat = b;targetDayNumber = s.getDayNumber();break;}}if (targetBeat != null) break;}if (targetBeat == null) {return responseSender.badRequest("No beat is scheduled for this user on " + toDateStr+ ". Pick a different date for lead " + leadId+ ", or choose Cancel for it.");}// Cancel the current attachment to this beatcurrent.setStatus("CANCELLED");current.setUpdatedTimestamp(LocalDateTime.now());// Create the new attachment on the target beat/dateLeadRoute fresh = new LeadRoute();fresh.setBeatId(targetBeat.getId());fresh.setLeadId(leadId);fresh.setNearestStoreId(current.getNearestStoreId());fresh.setScheduleDate(toDate);fresh.setSequenceOrder(9999); // append; the planner can reorderfresh.setStatus("APPROVED");fresh.setRequestedBy(current.getRequestedBy());fresh.setApprovedBy(current.getApprovedBy());fresh.setApprovedTimestamp(LocalDateTime.now());fresh.setCreatedTimestamp(LocalDateTime.now());fresh.setUpdatedTimestamp(LocalDateTime.now());leadRouteRepository.persist(fresh);LeadActivity la = new LeadActivity();la.setLeadId(leadId);la.setRemark("Rescheduled from beat '" + beat.getName() + "' to '"+ targetBeat.getName() + "' on " + toDateStr + " (day " + targetDayNumber + ")");la.setAuthId(0);la.setCreatedTimestamp(LocalDateTime.now());leadActivityRepositoryAuto.persist(la);leadsRescheduled++;} else {// cancel (default)current.setStatus("CANCELLED");current.setUpdatedTimestamp(LocalDateTime.now());LeadActivity la = new LeadActivity();la.setLeadId(leadId);la.setRemark("Cancelled from beat '" + beat.getName() + "' during edit");la.setAuthId(0);la.setCreatedTimestamp(LocalDateTime.now());leadActivityRepositoryAuto.persist(la);leadsCancelled++;}}}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("planGroupId", String.valueOf(beat.getId()));response.put("leadsCancelled", leadsCancelled);response.put("leadsRescheduled", leadsRescheduled);response.put("leadFailures", leadFailures);response.put("message", "Beat updated successfully"+ (leadsCancelled > 0 ? " (" + leadsCancelled + " lead(s) cancelled)" : "")+ (leadsRescheduled > 0 ? " (" + leadsRescheduled + " lead(s) rescheduled)" : ""));return responseSender.ok(response);}// Used by the edit-mode "removed leads" popup so the date picker can warn// upfront when the user picks a date that has no beat for them.@GetMapping(value = "/beatPlan/userBeatsOnDate")public ResponseEntity<?> userBeatsOnDate(@RequestParam int authUserId,@RequestParam String date) {LocalDate target;try {target = LocalDate.parse(date);} catch (Exception e) {return responseSender.badRequest("Invalid date");}List<Map<String, Object>> hits = new ArrayList<>();List<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);for (Beat b : userBeats) {List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());for (BeatSchedule s : schedules) {if (s.getStartDate() != null && s.getStartDate().equals(target)) {Map<String, Object> m = new HashMap<>();m.put("beatId", b.getId());m.put("beatName", b.getName());m.put("dayNumber", s.getDayNumber());hits.add(m);}}}Map<String, Object> result = new HashMap<>();result.put("date", date);result.put("authUserId", authUserId);result.put("beats", hits);return responseSender.ok(result);}// ====================== BASE LOCATION MANAGEMENT ======================// Inline page that lets Sales L3+ pick a user and set their base (home)// location via map. Reads use the existing /beatPlan/getBaseLocation, writes// go through the L3+-guarded endpoint below.@GetMapping(value = "/beatPlan/baseLocationPage")public String baseLocationPage(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {model.addAttribute("escalationTypes", visibleLevelsFor(request));return "beat-plan-base-location";}// Tabular JSON: one row per (beat, scheduled date) in [startDate, endDate].// Hierarchy-scoped: a manager sees only their downline + self; super-admins see all.// Optional categoryId + escalationType further narrow the listing to users at// a specific level (e.g. all Sales L1 in scope).@GetMapping(value = "/beatPlan/scheduledList")public ResponseEntity<?> scheduledList(HttpServletRequest request,@RequestParam(required = false) String startDate,@RequestParam(required = false) String endDate,@RequestParam(required = false) Integer categoryId,@RequestParam(required = false) com.spice.profitmandi.dao.enumuration.cs.EscalationType escalationType) throws ProfitMandiBusinessException {LocalDate start, end;try {start = (startDate == null || startDate.isEmpty()) ? LocalDate.now() : LocalDate.parse(startDate);end = (endDate == null || endDate.isEmpty()) ? start.plusDays(7) : LocalDate.parse(endDate);} catch (Exception e) {return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");}// ---- Scope: which auth users does the caller get to see ----LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;Set<Integer> visible = null;if (me != null && !isSuperAdmin(me)) {visible = new HashSet<>(authService.getAllReportees(me.getId()));visible.add(me.getId());}// If a (category, level) was picked, further restrict to that population.Set<Integer> levelFilter = null;if (categoryId != null && escalationType != null) {levelFilter = csService.getAuthUserByCategoryId(categoryId, escalationType).stream().filter(AuthUser::getActive).map(AuthUser::getId).collect(java.util.stream.Collectors.toSet());}List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =beatPlanQueryService.getAllScheduledBeats(start, end);final Set<Integer> visibleF = visible;final Set<Integer> levelF = levelFilter;beats = beats.stream().filter(b -> visibleF == null || visibleF.contains(b.getAuthUserId())).filter(b -> levelF == null || levelF.contains(b.getAuthUserId())).collect(java.util.stream.Collectors.toList());// Resolve user names in bulkSet<Integer> userIds = beats.stream().map(com.spice.profitmandi.dao.model.BeatDayDetails::getAuthUserId).collect(java.util.stream.Collectors.toSet());Map<Integer, AuthUser> userMap = new HashMap<>();if (!userIds.isEmpty()) {authRepository.selectByIds(new ArrayList<>(userIds)).forEach(u -> userMap.put(u.getId(), u));}List<Map<String, Object>> rows = new ArrayList<>();for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {AuthUser u = userMap.get(b.getAuthUserId());Map<String, Object> row = new HashMap<>();row.put("authUserId", b.getAuthUserId());row.put("userName", u != null ? (u.getFirstName() + " " + u.getLastName()) : "User #" + b.getAuthUserId());row.put("scheduleDate", b.getScheduleDate().toString());row.put("dayNumber", b.getDayNumber());row.put("beatId", b.getBeatId());row.put("beatName", b.getBeatName());row.put("beatColor", b.getBeatColor());row.put("partnerCount", b.getPartnerStops().size());row.put("leadCount", b.getLeadStops().size());row.put("visitCount", b.getPartnerStops().size() + b.getLeadStops().size());rows.add(row);}Map<String, Object> result = new HashMap<>();result.put("rows", rows);result.put("startDate", start.toString());result.put("endDate", end.toString());return responseSender.ok(result);}// JSON: beats running for (authUserId, date) — enriched with partner/lead names & coords@GetMapping(value = "/beatPlan/dayViewData")public ResponseEntity<?> beatPlanDayViewData(@RequestParam int authUserId,@RequestParam String date) throws ProfitMandiBusinessException {LocalDate localDate;try {localDate = LocalDate.parse(date);} catch (Exception e) {return responseSender.badRequest("Invalid date — expected yyyy-MM-dd");}List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =beatPlanQueryService.getBeatsForUserOnDate(authUserId, localDate);// Collect all partner & lead IDs to fetch metadata in bulkSet<Integer> partnerIds = new HashSet<>();Set<Integer> leadIds = new HashSet<>();for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {b.getPartnerStops().forEach(s -> partnerIds.add((Integer) s.get("fofoId")));b.getLeadStops().forEach(s -> leadIds.add((Integer) s.get("leadId")));}// Partners: name + geocoded lat/lng (geocoder is cached in Redis)Map<Integer, CustomRetailer> retailerMap = partnerIds.isEmpty()? new HashMap<>(): retailerService.getFofoRetailers(new ArrayList<>(partnerIds));Map<Integer, FofoStore> storeMap = new HashMap<>();if (!partnerIds.isEmpty()) {fofoStoreRepository.selectByRetailerIds(new ArrayList<>(partnerIds)).forEach(fs -> storeMap.put(fs.getId(), fs));}// Leads: name + geoMap<Integer, com.spice.profitmandi.dao.entity.user.Lead> leadMap = new HashMap<>();Map<Integer, com.spice.profitmandi.dao.entity.user.LeadLiveLocation> leadGeoMap = new HashMap<>();for (int leadId : leadIds) {com.spice.profitmandi.dao.entity.user.Lead l = leadRepository.selectById(leadId);if (l != null) leadMap.put(leadId, l);com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg =leadLiveLocationRepositoryAuto.selectApprovedByLeadId(leadId);if (lg != null) leadGeoMap.put(leadId, lg);}// Enrich each stopList<Map<String, Object>> out = new ArrayList<>();for (com.spice.profitmandi.dao.model.BeatDayDetails b : beats) {Map<String, Object> beatJson = new HashMap<>();beatJson.put("beatId", b.getBeatId());beatJson.put("beatName", b.getBeatName());beatJson.put("beatColor", b.getBeatColor());beatJson.put("dayNumber", b.getDayNumber());beatJson.put("scheduleDate", b.getScheduleDate().toString());beatJson.put("endAction", b.getEndAction());beatJson.put("totalDistanceKm", b.getTotalDistanceKm());beatJson.put("totalTimeMins", b.getTotalTimeMins());beatJson.put("startLocationName", b.getStartLocationName());beatJson.put("startLatitude", b.getStartLatitude());beatJson.put("startLongitude", b.getStartLongitude());List<Map<String, Object>> stops = new ArrayList<>();// Partnersfor (Map<String, Object> ps : b.getPartnerStops()) {int fofoId = (Integer) ps.get("fofoId");Map<String, Object> stop = new HashMap<>();stop.put("type", "partner");stop.put("id", fofoId);stop.put("sequenceOrder", ps.get("sequenceOrder"));FofoStore fs = storeMap.get(fofoId);CustomRetailer cr = retailerMap.get(fofoId);stop.put("code", fs != null ? fs.getCode() : null);stop.put("name", fs != null && fs.getOutletName() != null ? fs.getOutletName(): (cr != null ? cr.getBusinessName() : "Store #" + fofoId));// Use FofoStore lat/lng directly (no geocoding needed after migration)if (fs != null && fs.getLatitude() != null && fs.getLongitude() != null&& !fs.getLatitude().isEmpty() && !fs.getLongitude().isEmpty()) {try {stop.put("lat", Double.parseDouble(fs.getLatitude()));stop.put("lng", Double.parseDouble(fs.getLongitude()));} catch (NumberFormatException ignored) {}}if (cr != null && cr.getAddress() != null) {stop.put("address", cr.getAddress().getAddressString());}stops.add(stop);}// Leadsfor (Map<String, Object> ls : b.getLeadStops()) {int leadId = (Integer) ls.get("leadId");Map<String, Object> stop = new HashMap<>();stop.put("type", "lead");stop.put("id", leadId);stop.put("sequenceOrder", ls.get("sequenceOrder"));stop.put("nearestStoreId", ls.get("nearestStoreId"));com.spice.profitmandi.dao.entity.user.Lead l = leadMap.get(leadId);stop.put("name", l != null ? l.getFirstName() + " " + l.getLastName() : "Lead #" + leadId);stop.put("mobile", l != null ? l.getLeadMobile() : null);stop.put("city", l != null ? l.getCity() : null);com.spice.profitmandi.dao.entity.user.LeadLiveLocation lg = leadGeoMap.get(leadId);if (lg != null) {stop.put("lat", lg.getLatitude());stop.put("lng", lg.getLongitude());}stops.add(stop);}beatJson.put("stops", stops);beatJson.put("partnerCount", b.getPartnerStops().size());beatJson.put("leadCount", b.getLeadStops().size());out.add(beatJson);}Map<String, Object> result = new HashMap<>();result.put("beats", out);return responseSender.ok(result);}// ====================== DAY VIEW ======================// Inline page (loaded into dashboard #main-content): tabular list of all beats// scheduled in a date range across all users. Each row has a View button that// opens that user's calendar in a modal.@GetMapping(value = "/beatPlan/dayView")public String beatPlanDayView(HttpServletRequest request, Model model) throws ProfitMandiBusinessException {model.addAttribute("escalationTypes", visibleLevelsFor(request));// Matches /beatPlan/getAuthUsers and the Beat Report — default Sales category.model.addAttribute("categoryId", com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES);return "beat-plan-day-view";}// Returns visits for a beat.// - Partner stops (beat_route) belong to the beat template — always returned.// - Lead stops (lead_route) belong to a specific run — returned ONLY when planDate// is given and matches the lead's schedule_date. (No planDate = template view.)@GetMapping(value = "/beatPlan/getBeatVisits")public ResponseEntity<?> getBeatVisits(@RequestParam String planGroupId,@RequestParam(required = false) String planDate) {int beatId;try {beatId = Integer.parseInt(planGroupId);} catch (NumberFormatException e) {return responseSender.ok(new ArrayList<>());}List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beatId);List<Map<String, Object>> result = new ArrayList<>();// Stops — partner OR office, dispatched by visit_type. Partners are// enriched on the client from the partner map (already in scope);// offices are enriched here because the client has no office map.for (BeatRoute r : routes) {Map<String, Object> map = new HashMap<>();map.put("fofoId", r.getFofoId());map.put("dayNumber", r.getDayNumber());map.put("sequenceOrder", r.getSequenceOrder());if (r.getVisitType() == com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE) {map.put("visitType", "office");try {com.spice.profitmandi.dao.entity.logistics.CompanyOffice o =companyOfficeRepository.selectById(r.getFofoId());if (o != null) {map.put("code", o.getCode());map.put("name", o.getName());map.put("latitude", String.valueOf(o.getLat()));map.put("longitude", String.valueOf(o.getLng()));}} catch (Exception ignored) {}} else {map.put("visitType", "partner");}result.add(map);}// Lead stops — only for the requested run dateif (planDate != null && !planDate.isEmpty()) {LocalDate date = LocalDate.parse(planDate);List<LeadRoute> leads = leadRouteRepository.selectByBeatId(beatId);for (LeadRoute lr : leads) {if ("APPROVED".equals(lr.getStatus())&& lr.getScheduleDate() != null&& lr.getScheduleDate().equals(date)) {Map<String, Object> map = new HashMap<>();map.put("fofoId", lr.getLeadId());map.put("dayNumber", 1);map.put("sequenceOrder", lr.getSequenceOrder() != null ? lr.getSequenceOrder() : 999);map.put("visitType", "lead");result.add(map);}}}// Sort by dayNumber then sequenceOrderresult.sort((a, b) -> {int cmp = Integer.compare((int) a.get("dayNumber"), (int) b.get("dayNumber"));return cmp != 0 ? cmp : Integer.compare((int) a.get("sequenceOrder"), (int) b.get("sequenceOrder"));});return responseSender.ok(result);}// Returns the user's DEFAULT base location. Falls back to most-recent for// legacy users who pre-date the is_default column.@GetMapping(value = "/beatPlan/getBaseLocation")public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {AuthUserLocation baseLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");if (baseLoc == null) {return responseSender.ok(new HashMap<>());}Map<String, Object> result = new HashMap<>();result.put("id", baseLoc.getId());result.put("locationName", baseLoc.getLocationName());result.put("latitude", baseLoc.getLatitude());result.put("longitude", baseLoc.getLongitude());result.put("address", baseLoc.getAddress());result.put("isDefault", baseLoc.isDefault());return responseSender.ok(result);}// Returns ALL BASE locations for a user, default first.@GetMapping(value = "/beatPlan/listBaseLocations")public ResponseEntity<?> listBaseLocations(@RequestParam int authUserId) {List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");// Default at the top, then by created desc (the repo already returns desc).all.sort((a, b) -> {if (a.isDefault() && !b.isDefault()) return -1;if (!a.isDefault() && b.isDefault()) return 1;return 0;});List<Map<String, Object>> rows = new ArrayList<>();for (AuthUserLocation l : all) {Map<String, Object> row = new HashMap<>();row.put("id", l.getId());row.put("locationName", l.getLocationName());row.put("latitude", l.getLatitude());row.put("longitude", l.getLongitude());row.put("address", l.getAddress());row.put("isDefault", l.isDefault());row.put("createdTimestamp", l.getCreatedTimestamp() != null ? l.getCreatedTimestamp().toString() : null);rows.add(row);}Map<String, Object> result = new HashMap<>();result.put("authUserId", authUserId);result.put("locations", rows);return responseSender.ok(result);}// Flip the default flag — set this id default, clear all others.@PostMapping(value = "/beatPlan/setDefaultBaseLocation")public ResponseEntity<?> setDefaultBaseLocation(HttpServletRequest request,@RequestParam int id) throws ProfitMandiBusinessException {LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());if (me == null) return responseSender.unauthorized("Not logged in");if (!isBaseLocationManager(me)) {return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");}AuthUserLocation target = authUserLocationRepository.selectById(id);if (target == null) return responseSender.badRequest("Location not found");List<AuthUserLocation> all = authUserLocationRepository.selectAllByAuthUserIdAndType(target.getAuthUserId(), "BASE");for (AuthUserLocation l : all) {boolean shouldBeDefault = (l.getId() == id);if (l.isDefault() != shouldBeDefault) {l.setDefault(shouldBeDefault);authUserLocationRepository.persist(l); // saveOrUpdate}}Map<String, Object> result = new HashMap<>();result.put("status", true);result.put("id", id);result.put("message", "Default base location updated");return responseSender.ok(result);}// Delete a base location. The DEFAULT one cannot be deleted — user must// first pick another row as default.@PostMapping(value = "/beatPlan/deleteBaseLocation")public ResponseEntity<?> deleteBaseLocation(HttpServletRequest request,@RequestParam int id) throws ProfitMandiBusinessException {LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());if (me == null) return responseSender.unauthorized("Not logged in");if (!isBaseLocationManager(me)) {return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can manage base locations.");}AuthUserLocation target = authUserLocationRepository.selectById(id);if (target == null) return responseSender.badRequest("Location not found");if (target.isDefault()) {return responseSender.badRequest("Default base location cannot be removed. Set another location as default first.");}authUserLocationRepository.delete(target);Map<String, Object> result = new HashMap<>();result.put("status", true);result.put("message", "Base location removed");return responseSender.ok(result);}@GetMapping(value = "/beatPlan/getAuthUsers")public ResponseEntity<?> getAuthUsers(HttpServletRequest request,@RequestParam int categoryId,@RequestParam EscalationType escalationType) throws ProfitMandiBusinessException {// Hierarchy filter: a manager only sees users in their downline// (themselves + every reportee under them, recursively). Super-admin// emails bypass the filter and see everyone. Downline is computed by// AuthService.getAllReportees (existing recursive walker).LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;final Set<Integer> visible;if (me == null || isSuperAdmin(me)) {visible = null; // null = no filter} else {visible = new HashSet<>(authService.getAllReportees(me.getId()));visible.add(me.getId()); // include self}List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);List<Map<String, Object>> result = authUsers.stream().filter(au -> au.getActive()).filter(au -> visible == null || visible.contains(au.getId())).map(au -> {Map<String, Object> map = new HashMap<>();map.put("id", au.getId());map.put("name", au.getFirstName() + " " + au.getLastName());return map;}).collect(Collectors.toList());return responseSender.ok(result);}private boolean isSuperAdmin(AuthUser me) {String myEmail = me.getEmailId() != null ? me.getEmailId().toLowerCase() : "";return SUPER_ADMIN_EMAILS.contains(myEmail);}// Returns the user's highest escalation level across all positions.// Mirrors OrderController.getSalesEscalationLevel but category-agnostic.private EscalationType getHighestEscalation(int authUserId) {EscalationType highest = null;List<com.spice.profitmandi.dao.entity.cs.Position> positions = positionRepository.selectPositionByAuthId(authUserId);for (com.spice.profitmandi.dao.entity.cs.Position p : positions) {if (highest == null || p.getEscalationType().isGreaterThanEqualTo(highest)) {highest = p.getEscalationType();}}return highest;}// Returns the escalation levels a user can manage — strictly below their own.// L3 → [L1, L2]; L4 → [L1, L2, L3]; Final → all levels. Super-admin → all levels.private List<EscalationType> getVisibleEscalationLevels(AuthUser me) {if (isSuperAdmin(me)) return EscalationType.escalations;EscalationType mine = getHighestEscalation(me.getId());if (mine == null) return java.util.Collections.emptyList();List<EscalationType> below = new ArrayList<>();for (EscalationType e : EscalationType.escalations) {if (mine.isGreaterThanEqualTo(e) && !e.equals(mine)) below.add(e);}return below;}private List<EscalationType> visibleLevelsFor(HttpServletRequest request) throws ProfitMandiBusinessException {LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = (ld != null) ? authRepository.selectByEmailOrMobile(ld.getEmailId()) : null;return me == null ? java.util.Collections.emptyList() : getVisibleEscalationLevels(me);}// Shared permission check for base-location admin actions: Sales L3+ OR super-admin.private boolean isBaseLocationManager(AuthUser me) {if (isSuperAdmin(me)) return true;return csService.getAuthUserIds(com.spice.profitmandi.common.model.ProfitMandiConstants.TICKET_CATEGORY_SALES,Arrays.asList(EscalationType.L3, EscalationType.L4)).stream().anyMatch(u -> u.getId() == me.getId());}@PostMapping(value = "/beatPlan/saveBaseLocation")public ResponseEntity<?> saveBaseLocation(@RequestParam int authUserId,@RequestParam String locationName,@RequestParam String latitude,@RequestParam String longitude,@RequestParam(required = false) String address) {AuthUserLocation loc = new AuthUserLocation();loc.setAuthUserId(authUserId);loc.setLocationType("BASE");loc.setLocationName(locationName);loc.setLatitude(latitude);loc.setLongitude(longitude);loc.setAddress(address);loc.setCreatedTimestamp(LocalDateTime.now());// First BASE for this user → auto-default so every user always has one.List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);loc.setDefault(existing.isEmpty() || noExistingDefault);authUserLocationRepository.persist(loc);Map<String, Object> result = new HashMap<>();result.put("status", true);result.put("id", loc.getId());result.put("isDefault", loc.isDefault());return responseSender.ok(result);}@GetMapping(value = "/beatPlan/getPartners")public ResponseEntity<?> getPartners(@RequestParam int authUserId,@RequestParam int categoryId,@RequestParam(required = false) String startLat,@RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {// Beat planning needs every partner ever assigned — inactive ones included —// so the planner can keep building beats around a partner that was paused// after the assignment was made. The closed-store skip happens below per row.Map<Integer, List<Integer>> pp = csService.getAuthUserIdAllPartnerIdMapping();List<Integer> fofoIds = pp.get(authUserId);if (fofoIds == null || fofoIds.isEmpty()) {Map<String, Object> empty = new HashMap<>();empty.put("partners", new ArrayList<>());return responseSender.ok(empty);}List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);List<Map<String, Object>> partners = new ArrayList<>();for (FofoStore store : fofoStores) {// Closed partners are gone for good — skip. Inactive ones are kept// so the planner can still drop a beat onto them (the assignment// pre-dates the deactivation); the UI tags them visually.if (store.isClosed()) continue;CustomRetailer retailer = retailerMap.get(store.getId());Map<String, Object> partnerData = new HashMap<>();partnerData.put("fofoId", store.getId());partnerData.put("code", store.getCode());partnerData.put("outletName", store.getOutletName());partnerData.put("active", store.isActive());partnerData.put("type", "partner");// Use FofoStore lat/lng directly (migrated from address geocode)if (store.getLatitude() != null && !store.getLatitude().isEmpty()&& store.getLongitude() != null && !store.getLongitude().isEmpty()) {partnerData.put("latitude", store.getLatitude());partnerData.put("longitude", store.getLongitude());}if (retailer != null) {partnerData.put("businessName", retailer.getBusinessName());if (retailer.getAddress() != null) {partnerData.put("address", retailer.getAddress().getAddressString());}}partners.add(partnerData);}if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));} else {partners = sortByNearestNeighbor(partners);}Map<String, Object> response = new HashMap<>();response.put("partners", partners);return responseSender.ok(response);}@PostMapping(value = "/beatPlan/submitPlan")public ResponseEntity<?> submitPlan(HttpServletRequest request,@RequestParam int authUserId,@RequestParam String planData) throws Exception {LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());Gson gson = new Gson();Type type = new TypeToken<Map<String, Object>>() {}.getType();Map<String, Object> plan = gson.fromJson(planData, type);List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");List<String> dates = (List<String>) plan.get("dates");String beatName = (plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat").trim();// Duplicate check — same name + same authUserId among ACTIVE beats only.// Soft-deleted beats keep the name in the table; we don't want them to// block the user from reusing a name they "deleted".List<Beat> existingBeats = beatRepository.selectActiveByAuthUserId(authUserId);for (Beat existing : existingBeats) {if (existing.getName() != null && beatName.equalsIgnoreCase(existing.getName().trim())) {LOGGER.info("Duplicate beat blocked: name='{}' authUserId={} existingId={}", beatName, authUserId, existing.getId());Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("planGroupId", String.valueOf(existing.getId()));response.put("duplicate", true);response.put("message", "Beat '" + beatName + "' already exists");return responseSender.ok(response);}}String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];int totalDays = days.size();// One-beat-per-day guard: reject if any of the requested dates already// has a beat scheduled for this user.if (dates != null) {List<LocalDate> candidateDates = new ArrayList<>();for (String dStr : dates) {if (dStr != null && !dStr.isEmpty()) {try {candidateDates.add(LocalDate.parse(dStr, DateTimeFormatter.ISO_DATE));} catch (Exception ignored) {}}}Map<String, Object> conflict = findScheduleConflict(authUserId, candidateDates, 0);if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));}// Create Beat masterBeat beat = new Beat();beat.setName(beatName);beat.setAuthUserId(authUserId);beat.setBeatColor(beatColor);beat.setTotalDays(totalDays);beat.setActive(true);beat.setCreatedBy(currentUser.getId());beat.setCreatedTimestamp(LocalDateTime.now());// Set start location from first dayif (!days.isEmpty()) {Map<String, Object> firstDay = days.get(0);beat.setStartLocationName((String) firstDay.get("startLocationName"));beat.setStartLatitude((String) firstDay.get("startLatitude"));beat.setStartLongitude((String) firstDay.get("startLongitude"));}beatRepository.persist(beat);// End date of the whole beat = last scheduled day's dateLocalDate beatEndDate = null;if (dates != null) {for (int d = dates.size() - 1; d >= 0; d--) {if (dates.get(d) != null) {beatEndDate = LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE);break;}}}// Create routes and schedules for each dayfor (int d = 0; d < days.size(); d++) {Map<String, Object> day = days.get(d);int dayNumber = d + 1;LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE) : null;// Auto-determine end action: last day = HOME, others = DAYBREAKString endAction = (String) day.get("endAction");if (endAction == null || endAction.isEmpty()) {endAction = (dayNumber == totalDays) ? "HOME" : "DAYBREAK";}// Always create schedule (even if planDate is null — unscheduled beat)BeatSchedule schedule = new BeatSchedule();schedule.setBeatId(beat.getId());schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31)); // placeholder for unscheduledschedule.setEndDate(beatEndDate);schedule.setDayNumber(dayNumber);schedule.setEndAction(endAction);schedule.setStayLocationName((String) day.get("stayLocationName"));schedule.setStayLatitude((String) day.get("stayLatitude"));schedule.setStayLongitude((String) day.get("stayLongitude"));if (day.get("totalDistanceKm") != null)schedule.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());if (day.get("totalTimeMins") != null)schedule.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());schedule.setCreatedTimestamp(LocalDateTime.now());beatScheduleRepository.persist(schedule);// Routes (stops) — also persist per-leg distance/time supplied by the// client so reports/dashboards don't have to recompute from lat/lng.List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");if (visits != null) {for (int i = 0; i < visits.size(); i++) {Map<String, Object> visit = visits.get(i);BeatRoute route = new BeatRoute();route.setBeatId(beat.getId());route.setFofoId(((Number) visit.get("id")).intValue());route.setVisitType("office".equals(visit.get("type"))? com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE: com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);route.setSequenceOrder(i);route.setDayNumber(dayNumber);route.setActive(true);if (visit.get("distanceFromPrevKm") != null)route.setDistanceFromPrevKm(((Number) visit.get("distanceFromPrevKm")).doubleValue());if (visit.get("timeFromPrevMins") != null)route.setTimeFromPrevMins(((Number) visit.get("timeFromPrevMins")).intValue());beatRouteRepository.persist(route);}}}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("planGroupId", String.valueOf(beat.getId()));response.put("message", "Beat plan submitted successfully");return responseSender.ok(response);}// ============ BULK UPLOAD ============@GetMapping(value = "/beatPlan/bulkUpload")public String bulkUploadPage(HttpServletRequest request, Model model) {return "beat-plan-bulk";}// Adds a new base location for the user. Caller can request this new row// becomes the default. If the user has NO base locations yet, the new row// is auto-defaulted (so every user always has exactly one default).@PostMapping(value = "/beatPlan/updateBaseLocation")public ResponseEntity<?> updateBaseLocation(HttpServletRequest request,@RequestParam int authUserId,@RequestParam String locationName,@RequestParam String latitude,@RequestParam String longitude,@RequestParam(required = false) String address,@RequestParam(required = false, defaultValue = "false") boolean isDefault) throws Exception {LoginDetails ld = cookiesProcessor.getCookiesObject(request);AuthUser me = authRepository.selectByEmailOrMobile(ld.getEmailId());if (me == null) return responseSender.unauthorized("Not logged in");if (!isBaseLocationManager(me)) {return responseSender.forbidden("You are not authorized for this action. Only Sales L3 and above can update base location.");}List<AuthUserLocation> existing = authUserLocationRepository.selectAllByAuthUserIdAndType(authUserId, "BASE");boolean noExistingDefault = existing.stream().noneMatch(AuthUserLocation::isDefault);boolean makeDefault = isDefault || existing.isEmpty() || noExistingDefault;// If this new row becomes the default, clear any existing default.if (makeDefault) {for (AuthUserLocation e : existing) {if (e.isDefault()) {e.setDefault(false);authUserLocationRepository.persist(e);}}}AuthUserLocation loc = new AuthUserLocation();loc.setAuthUserId(authUserId);loc.setLocationType("BASE");loc.setLocationName(locationName);loc.setLatitude(latitude);loc.setLongitude(longitude);loc.setAddress(address);loc.setDefault(makeDefault);loc.setCreatedTimestamp(LocalDateTime.now());authUserLocationRepository.persist(loc);Map<String, Object> result = new HashMap<>();result.put("status", true);result.put("id", loc.getId());result.put("isDefault", loc.isDefault());result.put("message", makeDefault ? "Base location added and set as default" : "Base location added");return responseSender.ok(result);}// Read-only list of company offices — gives BMs a quick lookup of the codes// they'll need to drop into the bulk-upload sheet for OFFICE stops.// Two URL mappings: /companyOffice/list (canonical) + /company-office-list// (matches the menu's action_class so the sidebar link works without an extra// auth.menu update).@GetMapping(value = {"/companyOffice/list", "/company-office-list"})public String companyOfficeList(Model model) {List<com.spice.profitmandi.dao.entity.logistics.CompanyOffice> offices = companyOfficeRepository.selectAll();// Active first, then sort by code so the bulk-upload reference is stable across page loads.offices.sort((a, b) -> {int aa = a.isActive() ? 0 : 1;int bb = b.isActive() ? 0 : 1;if (aa != bb) return Integer.compare(aa, bb);String ac = a.getCode() != null ? a.getCode() : "";String bc = b.getCode() != null ? b.getCode() : "";return ac.compareToIgnoreCase(bc);});model.addAttribute("offices", offices);return "company-office-list";}@GetMapping(value = "/beatPlan/downloadTemplate")public ResponseEntity<?> downloadTemplate() throws java.io.IOException {org.apache.poi.xssf.usermodel.XSSFWorkbook wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("beat-plan");String[] cols = {"beat_name", "auth_user_id", "start_date", "day_number", "sequence_order", "partner_code"};// Header styleorg.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = wb.createCellStyle();org.apache.poi.xssf.usermodel.XSSFFont headerFont = wb.createFont();headerFont.setBold(true);headerStyle.setFont(headerFont);headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(new java.awt.Color(230, 230, 230)));headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);org.apache.poi.xssf.usermodel.XSSFRow header = sheet.createRow(0);for (int i = 0; i < cols.length; i++) {org.apache.poi.xssf.usermodel.XSSFCell c = header.createCell(i);c.setCellValue(cols[i]);c.setCellStyle(headerStyle);}// Example rows — one partner per row. Inheritable columns blank after first row of a beat.Object[][] sample = {{"Jaipur East Route", "280", "2026-06-02", "1", "1", "RJKAI1478"},{"", "", "", "1", "2", "RJBUN1449"},{"", "", "", "1", "3", "RJDEG1443"},{"", "", "", "2", "1", "RJALR1362"},{"", "", "", "2", "2", "RJBTR1388"},{"", "", "", "3", "1", "RJRSD1518"},{"", "", "", "3", "2", "RJSML356"},{"Agra Circuit", "145", "2026-06-05", "1", "1", "UPAGR101"},{"", "", "", "1", "2", "UPAGR102"},};for (int r = 0; r < sample.length; r++) {org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(r + 1);for (int c = 0; c < cols.length; c++) {row.createCell(c).setCellValue(sample[r][c].toString());}}for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i);java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();wb.write(out);wb.close();org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();headers.add("Content-Disposition", "attachment; filename=beat_plan_template.xlsx");headers.add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);}@PostMapping(value = "/beatPlan/bulkUploadProcess")public ResponseEntity<?> bulkUploadProcess(HttpServletRequest request,@RequestParam("file") org.springframework.web.multipart.MultipartFile file,@RequestParam(value = "includeSundays", defaultValue = "false") boolean includeSundays) throws Exception {LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());// Read .xlsx — one partner per row. beat_name / auth_user_id / start_date// appear ONLY on the first row of a beat; subsequent rows inherit them.org.apache.poi.ss.usermodel.Workbook workbook =new org.apache.poi.xssf.usermodel.XSSFWorkbook(file.getInputStream());org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);// Header → column index maporg.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(0);if (headerRow == null) {workbook.close();return responseSender.badRequest("Empty file");}Map<String, Integer> colIdx = new HashMap<>();for (int i = 0; i < headerRow.getLastCellNum(); i++) {String h = readCell(headerRow.getCell(i));if (h != null) colIdx.put(h.trim().toLowerCase(), i);}for (String required : new String[]{"beat_name", "auth_user_id", "day_number", "partner_code"}) {if (!colIdx.containsKey(required)) {workbook.close();return responseSender.badRequest("Missing required column: " + required);}}// Walk rows, group partners by (beat_name + auth_user_id) → day_number → sequence_orderMap<String, BulkBeatGroup> beatGroups = new LinkedHashMap<>();String currentKey = null;String currentBeatName = null;String currentAuthId = null;String currentStartDate = null;for (int r = 1; r <= sheet.getLastRowNum(); r++) {org.apache.poi.ss.usermodel.Row row = sheet.getRow(r);if (row == null) continue;String beatName = readCell(row.getCell(colIdx.get("beat_name")));String authId = readCell(row.getCell(colIdx.get("auth_user_id")));String startDate = colIdx.containsKey("start_date") ? readCell(row.getCell(colIdx.get("start_date"))) : null;String dayNumber = readCell(row.getCell(colIdx.get("day_number")));String seqOrder = colIdx.containsKey("sequence_order") ? readCell(row.getCell(colIdx.get("sequence_order"))) : null;String code = readCell(row.getCell(colIdx.get("partner_code")));if (beatName != null && !beatName.trim().isEmpty()) {// Start of a new beat — capture inheritable fieldscurrentBeatName = beatName.trim().replaceAll("\\s+", " ");currentAuthId = authId != null ? authId.trim() : null;currentStartDate = (startDate != null && !startDate.trim().isEmpty()) ? startDate.trim() : null;currentKey = currentBeatName + "|" + currentAuthId;}if (currentKey == null) continue; // partner row before any beat header — skipif (code == null || code.trim().isEmpty()) continue;final String beatNameF = currentBeatName;final String authIdF = currentAuthId;final String startDateF = currentStartDate;BulkBeatGroup g = beatGroups.computeIfAbsent(currentKey, k -> new BulkBeatGroup(beatNameF, authIdF, startDateF));int day;try {day = Integer.parseInt(dayNumber.trim());} catch (Exception e) {continue;} // bad day → skip rowint seq = -1;if (seqOrder != null && !seqOrder.trim().isEmpty()) {try {seq = Integer.parseInt(seqOrder.trim());} catch (Exception ignore) {}}g.addPartner(day, seq, code.trim(), r + 1);}workbook.close();// Partner-code lookup (legacy).List<FofoStore> allStores = fofoStoreRepository.selectAll();Map<String, Integer> codeToId = new HashMap<>();for (FofoStore store : allStores) codeToId.put(store.getCode(), store.getId());// Office-code lookup — office stops share the same `partner_code` column in the bulk// sheet; resolution dispatches by which catalogue the code belongs to. A code present// in BOTH catalogues is treated as an error so the planner fixes the collision.Map<String, Integer> officeCodeToId = new HashMap<>();for (com.spice.profitmandi.dao.entity.logistics.CompanyOffice o : companyOfficeRepository.selectAll()) {if (o.getCode() != null && !o.getCode().isEmpty()) officeCodeToId.put(o.getCode(), o.getId());}LocalDate holidayStart = LocalDate.now();List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());// =====================================================================// All-or-nothing import: validate every group first; only persist if// the entire file passes. A single bad row blocks the whole upload// so the user can fix and re-upload without partial creations.// =====================================================================List<String> errorMessages = new ArrayList<>();List<ValidatedBulkBeat> ready = new ArrayList<>();// ----- Phase 1: validate every group, collect ALL errors -----for (BulkBeatGroup g : beatGroups.values()) {String beatName = g.beatName;int authUserId;try {authUserId = Integer.parseInt(g.authUserId);} catch (Exception e) {errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'.");continue;}LocalDate startDate;try {startDate = (g.startDate == null || g.startDate.isEmpty())? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);} catch (Exception e) {errorMessages.add("Beat '" + beatName + "': invalid start_date '" + g.startDate + "'.");continue;}if (startDate != null && startDate.isBefore(LocalDate.now())) {errorMessages.add("Beat '" + beatName + "': start_date in past.");continue;}List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());Collections.sort(sortedDays);List<LocalDate> scheduleDates = new ArrayList<>();if (startDate != null) {LocalDate d = startDate;while (scheduleDates.size() < sortedDays.size()) {if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {d = d.plusDays(1);continue;}scheduleDates.add(d);d = d.plusDays(1);}}// Duplicate beat-name check (ACTIVE only; soft-deleted names are reusable).boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream().anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));if (isDuplicate) {errorMessages.add("Beat '" + beatName + "': already exists for user " + authUserId + ".");continue;}// One-beat-per-day guard (against existing beats).Map<String, Object> bulkConflict = findScheduleConflict(authUserId, scheduleDates, 0);if (bulkConflict != null) {errorMessages.add("Beat '" + beatName + "': " + scheduleConflictMessage(bulkConflict));continue;}// Validate codes upfront so we don't half-persist. A code may belong to// fofo_store (PARTNER) or company_office (OFFICE) but not both.List<String> badCodes = new ArrayList<>();List<String> ambiguousCodes = new ArrayList<>();for (List<BulkPartner> ps : g.dayToPartners.values()) {for (BulkPartner p : ps) {boolean inPartner = codeToId.containsKey(p.code);boolean inOffice = officeCodeToId.containsKey(p.code);if (inPartner && inOffice) {ambiguousCodes.add(p.code + " (row " + p.rowNum + ")");} else if (!inPartner && !inOffice) {badCodes.add(p.code + " (row " + p.rowNum + ")");}}}if (!badCodes.isEmpty()) {errorMessages.add("Beat '" + beatName + "': unknown code(s) — " + String.join(", ", badCodes) + ".");continue;}if (!ambiguousCodes.isEmpty()) {errorMessages.add("Beat '" + beatName + "': code(s) exist in both partner and office catalogues — " + String.join(", ", ambiguousCodes) + ".");continue;}ready.add(new ValidatedBulkBeat(g, authUserId, sortedDays, scheduleDates));}// Intra-file conflict: two beats in the same upload requesting the same// user + date. Caught here so the user fixes the file before re-uploading.Set<String> seenUserDates = new HashSet<>();for (ValidatedBulkBeat v : ready) {for (LocalDate sd : v.scheduleDates) {String key = v.authUserId + "|" + sd;if (!seenUserDates.add(key)) {errorMessages.add("Beat '" + v.g.beatName + "': date " + sd + " is also claimed by another beat in this file for the same user.");break;}}}// All-or-nothing: any error → return without persisting anything.if (!errorMessages.isEmpty()) {Map<String, Object> response = new HashMap<>();response.put("status", false);response.put("beatsCreated", 0);response.put("errors", errorMessages.size());response.put("errorMessages", errorMessages);response.put("message", "No beats created. Fix the issues below and re-upload the file.");return responseSender.ok(response);}// ----- Phase 2: persist (only reached when every row was clean) -----int beatsCreated = 0;for (ValidatedBulkBeat v : ready) {BulkBeatGroup g = v.g;String beatName = g.beatName;int authUserId = v.authUserId;List<Integer> sortedDays = v.sortedDays;List<LocalDate> scheduleDates = v.scheduleDates;String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");Beat beat = new Beat();beat.setName(beatName);beat.setAuthUserId(authUserId);beat.setBeatColor(beatColor);beat.setTotalDays(sortedDays.size());beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);beat.setActive(true);beat.setCreatedBy(currentUser.getId());beat.setCreatedTimestamp(LocalDateTime.now());beatRepository.persist(beat);LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {int dayNumber = sortedDays.get(dayIdx);LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;BeatSchedule schedule = new BeatSchedule();schedule.setBeatId(beat.getId());schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));schedule.setEndDate(bulkEndDate);schedule.setDayNumber(dayNumber);schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");schedule.setCreatedTimestamp(LocalDateTime.now());beatScheduleRepository.persist(schedule);List<BulkPartner> partners = g.dayToPartners.get(dayNumber);partners.sort((a, b) -> {if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);if (a.seq >= 0) return -1;if (b.seq >= 0) return 1;return Integer.compare(a.rowNum, b.rowNum);});int autoSeq = 0;for (BulkPartner p : partners) {Integer partnerId = codeToId.get(p.code);Integer officeId = officeCodeToId.get(p.code);// Codes were validated in Phase 1, this is just a safety net.if (partnerId == null && officeId == null) continue;BeatRoute route = new BeatRoute();route.setBeatId(beat.getId());if (partnerId != null) {route.setFofoId(partnerId);route.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.PARTNER);} else {route.setFofoId(officeId);route.setVisitType(com.spice.profitmandi.dao.enumuration.dtr.BeatVisitType.OFFICE);}route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);route.setDayNumber(dayNumber);route.setActive(true);beatRouteRepository.persist(route);autoSeq++;}}beatsCreated++;}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("beatsCreated", beatsCreated);response.put("errors", 0);response.put("errorMessages", errorMessages);response.put("message", beatsCreated + " beat(s) created.");return responseSender.ok(response);}// Move a beat from one date to another — used by calendar drag-and-drop.// Behaviour: if the target date already has ANOTHER beat scheduled (for the// same sales user), the two schedules swap — the other beat slides onto// the source date. If the target date is empty, the source date becomes empty.@PostMapping(value = "/beatPlan/moveScheduleDate")public ResponseEntity<?> moveScheduleDate(@RequestParam String planGroupId,@RequestParam String fromDate,@RequestParam String toDate) {int beatId = Integer.parseInt(planGroupId);LocalDate from = LocalDate.parse(fromDate);LocalDate to = LocalDate.parse(toDate);if (from.equals(to)) {Map<String, Object> ok = new HashMap<>();ok.put("status", true);ok.put("message", "Same date — no change");return responseSender.ok(ok);}// Today is the live/running slot — a beat already running today can't be// bumped, and a future beat can't be moved onto today.LocalDate today = LocalDate.now();if (to.equals(today) || from.equals(today) || from.isBefore(today) || to.isBefore(today)) {return responseSender.badRequest("Cannot move to or from today / a past date — today's beat is live. Use future dates only.");}Beat beat = beatRepository.selectById(beatId);if (beat == null) return responseSender.badRequest("Beat not found");List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);// Reject if THIS beat already has a different schedule row on the target date// (it would create two schedule rows of the same beat on one day).boolean selfConflict = schedules.stream().anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));if (selfConflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);BeatSchedule match = schedules.stream().filter(s -> s.getStartDate() != null && s.getStartDate().equals(from)).findFirst().orElse(null);if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);// Look for ANY OTHER beat (same sales user) whose schedule sits on the target// date — if found we'll swap it onto the source date.BeatSchedule otherSchedule = null;List<BeatSchedule> otherSchedules = null;Beat otherBeat = null;for (Beat ub : beatRepository.selectActiveByAuthUserId(beat.getAuthUserId())) {if (ub.getId() == beatId) continue;List<BeatSchedule> ubSchedules = beatScheduleRepository.selectByBeatId(ub.getId());BeatSchedule hit = ubSchedules.stream().filter(s -> s.getStartDate() != null && s.getStartDate().equals(to)).findFirst().orElse(null);if (hit != null) {otherSchedule = hit;otherSchedules = ubSchedules;otherBeat = ub;break;}}// Move the dragged beat onto the target date.match.setStartDate(to);// If another beat occupied the target, slide it onto the source date (swap).if (otherSchedule != null) {otherSchedule.setStartDate(from);}// Recompute endDate as the max across each affected beat's schedules so// the [startDate, endDate] envelope stays consistent.recomputeEndDate(schedules);if (otherSchedules != null) {recomputeEndDate(otherSchedules);}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("message", otherBeat != null? "Swapped with \"" + (otherBeat.getName() != null ? otherBeat.getName() : "beat") + "\" on " + toDate: "Moved from " + fromDate + " to " + toDate);response.put("swapped", otherBeat != null);return responseSender.ok(response);}private static class BulkBeatGroup {final String beatName;final String authUserId;final String startDate;final Map<Integer, List<BulkPartner>> dayToPartners = new LinkedHashMap<>();BulkBeatGroup(String beatName, String authUserId, String startDate) {this.beatName = beatName;this.authUserId = authUserId;this.startDate = startDate;}void addPartner(int day, int seq, String code, int rowNum) {dayToPartners.computeIfAbsent(day, k -> new ArrayList<>()).add(new BulkPartner(seq, code, rowNum));}}private static class BulkPartner {final int seq;final String code;final int rowNum;BulkPartner(int seq, String code, int rowNum) {this.seq = seq;this.code = code;this.rowNum = rowNum;}}// ============ CALENDAR ============@PostMapping(value = "/beatPlan/delete")public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {int beatId = Integer.parseInt(planGroupId);// Hard delete — wipe all child rows first, then the beat itself.// The name slot is freed naturally because the row is gone.beatRouteRepository.deleteByBeatId(beatId);beatScheduleRepository.deleteByBeatId(beatId);leadRouteRepository.deleteByBeatId(beatId);Beat beat = beatRepository.selectById(beatId);if (beat != null) {beatRepository.delete(beat);}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("message", "Beat deleted");return responseSender.ok(response);}// Unschedule the beat from ONE specific date — does NOT delete the beat.// The beat (and its route template) stays; only the matching beat_schedule// row is removed. If no real-date schedules remain, a placeholder// (9999-12-31) row is added so the beat still shows up as "unscheduled".@PostMapping(value = "/beatPlan/unscheduleDate")public ResponseEntity<?> unscheduleDate(@RequestParam String planGroupId,@RequestParam String date) {int beatId = Integer.parseInt(planGroupId);LocalDate target = LocalDate.parse(date);Beat beat = beatRepository.selectById(beatId);if (beat == null) return responseSender.badRequest("Beat not found");List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);int removed = 0;for (BeatSchedule s : schedules) {if (s.getStartDate() != null && s.getStartDate().equals(target)) {beatScheduleRepository.delete(s);removed++;}}if (removed == 0) return responseSender.badRequest("No schedule found for that date");// If no real-date schedules left, drop in a placeholder so the beat// remains visible in the unscheduled bucket.boolean hasReal = schedules.stream().filter(s -> !s.getStartDate().equals(target)).anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() != 9999);if (!hasReal) {boolean hasPlaceholder = schedules.stream().filter(s -> !s.getStartDate().equals(target)).anyMatch(s -> s.getStartDate() != null && s.getStartDate().getYear() == 9999);if (!hasPlaceholder) {BeatSchedule ph = new BeatSchedule();ph.setBeatId(beatId);ph.setStartDate(LocalDate.of(9999, 12, 31));ph.setDayNumber(1);ph.setEndAction("HOME");ph.setCreatedTimestamp(LocalDateTime.now());beatScheduleRepository.persist(ph);}}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("message", "Unscheduled from " + date);return responseSender.ok(response);}private void recomputeEndDate(List<BeatSchedule> schedules) {LocalDate newEnd = schedules.stream().map(BeatSchedule::getStartDate).filter(d -> d != null && d.getYear() != 9999).max(LocalDate::compareTo).orElse(null);if (newEnd == null) return;for (BeatSchedule s : schedules) {if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {s.setEndDate(newEnd);}}}/*** Per-user one-beat-per-day guard. Walks every active beat the sales user* already has and looks for a schedule row on any of the candidate dates.* Returns null if the candidate dates are clear, otherwise a {date,beatName}* map describing the first collision so the caller can surface a clean error.* Pass `excludeBeatId` so callers that are re-scheduling an existing beat* don't trip on their own pre-existing schedule rows; pass 0 for new beats.*/private Map<String, Object> findScheduleConflict(int authUserId, java.util.Collection<LocalDate> candidates, int excludeBeatId) {if (candidates == null || candidates.isEmpty()) return null;Set<LocalDate> ds = new HashSet<>();for (LocalDate d : candidates) {if (d != null && d.getYear() != 9999) ds.add(d);}if (ds.isEmpty()) return null;for (Beat ub : beatRepository.selectActiveByAuthUserId(authUserId)) {if (ub.getId() == excludeBeatId) continue;for (BeatSchedule s : beatScheduleRepository.selectByBeatId(ub.getId())) {if (s.getStartDate() != null && ds.contains(s.getStartDate())) {Map<String, Object> conflict = new HashMap<>();conflict.put("date", s.getStartDate().toString());conflict.put("beatName", ub.getName() != null ? ub.getName() : "Beat #" + ub.getId());return conflict;}}}return null;}private String scheduleConflictMessage(Map<String, Object> conflict) {return "Cannot schedule on " + conflict.get("date")+ " — \"" + conflict.get("beatName") + "\" is already scheduled for this user on that day.";}@PostMapping(value = "/beatPlan/scheduleOnCalendar")public ResponseEntity<?> scheduleOnCalendar(HttpServletRequest request,@RequestParam String planGroupId,@RequestParam String dates,@RequestParam(required = false) String beatName,@RequestParam(required = false) String beatColor) throws Exception {int beatId = Integer.parseInt(planGroupId);Gson gson = new Gson();List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {}.getType());Beat beat = beatRepository.selectById(beatId);if (beat == null) return responseSender.badRequest("Beat not found");if (beatName != null) beat.setName(beatName);if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);// One-beat-per-day guard: reject if any of the requested dates already// has another beat scheduled for this user (excluding this beat itself).List<LocalDate> requested = new ArrayList<>();for (String s : dateList) {try {requested.add(LocalDate.parse(s));} catch (Exception ignored) {}}Map<String, Object> conflict = findScheduleConflict(beat.getAuthUserId(), requested, beatId);if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));// Delete old schedules and create newbeatScheduleRepository.deleteByBeatId(beatId);LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {int dayNumber = i + 1;String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";BeatSchedule schedule = new BeatSchedule();schedule.setBeatId(beatId);schedule.setStartDate(LocalDate.parse(dateList.get(i)));schedule.setEndDate(schEndDate);schedule.setDayNumber(dayNumber);schedule.setEndAction(endAction);// Fill total_distance_km / total_time_mins from beat_route so the new// schedule row isn't NULL (this was the bug — these were left unset).double[] totals = computeDayTotals(beatId, dayNumber, endAction);schedule.setTotalDistanceKm(totals[0]);schedule.setTotalTimeMins((int) totals[1]);schedule.setCreatedTimestamp(LocalDateTime.now());beatScheduleRepository.persist(schedule);}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("message", "Beat scheduled successfully");return responseSender.ok(response);}@GetMapping(value = "/beatPlan/calendar")public ResponseEntity<?> getCalendar(@RequestParam int authUserId,@RequestParam String month) {YearMonth ym = YearMonth.parse(month);LocalDate startDate = ym.atDay(1);LocalDate endDate = ym.atEndOfMonth();List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);List<Map<String, String>> holidayList = holidays.stream().map(h -> {Map<String, String> m = new HashMap<>();m.put("date", h.getDate().toString());m.put("occasion", h.getOccasion());return m;}).collect(Collectors.toList());List<Beat> allBeats = beatRepository.selectActiveByAuthUserId(authUserId);LocalDate today = LocalDate.now();List<Map<String, Object>> scheduledBeats = new ArrayList<>();for (Beat beat : allBeats) {List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beat.getId());List<BeatRoute> routes = beatRouteRepository.selectByBeatId(beat.getId());boolean allNullDates = schedules.isEmpty() || schedules.stream().allMatch(s -> s.getStartDate().getYear() == 9999);boolean hasToday = !allNullDates && schedules.stream().anyMatch(s -> s.getStartDate().equals(today));boolean allPast = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isBefore(today));boolean allFuture = !allNullDates && schedules.stream().filter(s -> s.getStartDate().getYear() != 9999).allMatch(s -> s.getStartDate().isAfter(today));String status;if (allNullDates) status = "unscheduled";else if (hasToday) status = "running";else if (allPast) status = "completed";else status = "scheduled";Map<String, Object> beatInfo = new HashMap<>();beatInfo.put("planGroupId", String.valueOf(beat.getId()));beatInfo.put("beatName", beat.getName() != null ? beat.getName() : "Beat");beatInfo.put("beatColor", beat.getBeatColor() != null ? beat.getBeatColor() : "#3498DB");beatInfo.put("status", status);beatInfo.put("totalDays", beat.getTotalDays());List<Map<String, Object>> dayInfoList = new ArrayList<>();for (BeatSchedule s : schedules) {Map<String, Object> dayInfo = new HashMap<>();dayInfo.put("dayNumber", s.getDayNumber());boolean isUnscheduled = s.getStartDate().getYear() == 9999;dayInfo.put("planDate", isUnscheduled ? null : s.getStartDate().toString());dayInfo.put("totalKm", s.getTotalDistanceKm());dayInfo.put("totalMins", s.getTotalTimeMins());// endAction tells the planner whether to draw the return-to-home line// for this day (HOME) or end at the last stop (DAYBREAK).dayInfo.put("endAction", s.getEndAction());long visitCount = routes.stream().filter(r -> r.getDayNumber() == s.getDayNumber()).count();dayInfo.put("visitCount", (int) visitCount);dayInfoList.add(dayInfo);}if (schedules.isEmpty()) {// No schedule at all — show from routesMap<Integer, Long> dayCounts = routes.stream().collect(Collectors.groupingBy(BeatRoute::getDayNumber, Collectors.counting()));for (int d = 1; d <= beat.getTotalDays(); d++) {Map<String, Object> dayInfo = new HashMap<>();dayInfo.put("dayNumber", d);dayInfo.put("planDate", null);dayInfo.put("totalKm", null);dayInfo.put("totalMins", null);dayInfo.put("visitCount", dayCounts.getOrDefault(d, 0L).intValue());dayInfoList.add(dayInfo);}}beatInfo.put("days", dayInfoList);scheduledBeats.add(beatInfo);}Set<String> blockedDates = new HashSet<>();for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blockedDates.add(d.toString());}for (PublicHolidays h : holidays) blockedDates.add(h.getDate().toString());Map<String, Object> response = new HashMap<>();response.put("holidays", holidayList);response.put("scheduledBeats", scheduledBeats);response.put("blockedDates", blockedDates);return responseSender.ok(response);}// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)@PostMapping(value = "/beatPlan/repeatBeat")public ResponseEntity<?> repeatBeat(HttpServletRequest request,@RequestParam String sourcePlanGroupId,@RequestParam int authUserId,@RequestParam String dates) throws Exception {int beatId = Integer.parseInt(sourcePlanGroupId);Gson gson = new Gson();List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {}.getType());Beat beat = beatRepository.selectById(beatId);if (beat == null) return responseSender.badRequest("Beat not found");// One-beat-per-day guard: reject if any of the new dates already has// another beat scheduled for this user (excluding this beat itself).List<LocalDate> repeatDates = new ArrayList<>();for (String s : dateList) {try {repeatDates.add(LocalDate.parse(s));} catch (Exception ignored) {}}Map<String, Object> repeatConflict = findScheduleConflict(beat.getAuthUserId(), repeatDates, beatId);if (repeatConflict != null) return responseSender.badRequest(scheduleConflictMessage(repeatConflict));// Remove placeholder (unscheduled) schedule rowsList<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);for (BeatSchedule s : existing) {if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {beatScheduleRepository.delete(s);}}// Add new real-date schedule rows for the existing beat — fill totals// from beat_route so total_distance_km / total_time_mins aren't NULL.LocalDate repeatEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));for (int i = 0; i < dateList.size(); i++) {int dayNumber = i + 1;String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";BeatSchedule schedule = new BeatSchedule();schedule.setBeatId(beatId);schedule.setStartDate(LocalDate.parse(dateList.get(i)));schedule.setEndDate(repeatEndDate);schedule.setDayNumber(dayNumber);schedule.setEndAction(endAction);double[] totals = computeDayTotals(beatId, dayNumber, endAction);schedule.setTotalDistanceKm(totals[0]);schedule.setTotalTimeMins((int) totals[1]);schedule.setCreatedTimestamp(LocalDateTime.now());beatScheduleRepository.persist(schedule);}Map<String, Object> response = new HashMap<>();response.put("status", true);response.put("planGroupId", String.valueOf(beatId));response.put("message", "Beat scheduled successfully");return responseSender.ok(response);}private static class ValidatedBulkBeat {final BulkBeatGroup g;final int authUserId;final List<Integer> sortedDays;final List<LocalDate> scheduleDates;ValidatedBulkBeat(BulkBeatGroup g, int authUserId, List<Integer> sortedDays, List<LocalDate> scheduleDates) {this.g = g;this.authUserId = authUserId;this.sortedDays = sortedDays;this.scheduleDates = scheduleDates;}}@GetMapping(value = "/beatPlan/availableSlots")public ResponseEntity<?> getAvailableSlots(@RequestParam int authUserId,@RequestParam String month,@RequestParam int daysNeeded) {YearMonth ym = YearMonth.parse(month);LocalDate startDate = ym.atDay(1);LocalDate endDate = ym.atEndOfMonth();LocalDate today = LocalDate.now();Set<LocalDate> blocked = new HashSet<>();for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);if (!d.isAfter(today)) blocked.add(d);}List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);for (PublicHolidays h : holidays) blocked.add(h.getDate());// Get all scheduled dates for this userList<Beat> userBeats = beatRepository.selectActiveByAuthUserId(authUserId);for (Beat b : userBeats) {List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(b.getId());for (BeatSchedule s : schedules) blocked.add(s.getStartDate());}List<String> available = new ArrayList<>();for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);!d.isAfter(endDate) && available.size() < daysNeeded;d = d.plusDays(1)) {if (!blocked.contains(d)) available.add(d.toString());}Map<String, Object> response = new HashMap<>();response.put("suggestedDates", available);response.put("totalAvailable", available.size());return responseSender.ok(response);}// --- Sorting helpers ---private List<Map<String, Object>> sortByNearestNeighborFromStart(List<Map<String, Object>> partners, double startLat, double startLng) {List<Map<String, Object>> withCoords = new ArrayList<>();List<Map<String, Object>> withoutCoords = new ArrayList<>();for (Map<String, Object> p : partners) {if (hasValidCoords(p)) withCoords.add(p);else withoutCoords.add(p);}List<Map<String, Object>> sorted = new ArrayList<>();double currentLat = startLat, currentLng = startLng;while (!withCoords.isEmpty()) {int nearestIdx = 0;double nearestDist = Double.MAX_VALUE;for (int i = 0; i < withCoords.size(); i++) {double dist = haversine(currentLat, currentLng,Double.parseDouble(withCoords.get(i).get("latitude").toString()),Double.parseDouble(withCoords.get(i).get("longitude").toString()));if (dist < nearestDist) {nearestDist = dist;nearestIdx = i;}}Map<String, Object> nearest = withCoords.remove(nearestIdx);sorted.add(nearest);currentLat = Double.parseDouble(nearest.get("latitude").toString());currentLng = Double.parseDouble(nearest.get("longitude").toString());}sorted.addAll(withoutCoords);return sorted;}private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {List<Map<String, Object>> withCoords = new ArrayList<>();List<Map<String, Object>> withoutCoords = new ArrayList<>();for (Map<String, Object> p : partners) {if (hasValidCoords(p)) withCoords.add(p);else withoutCoords.add(p);}List<Map<String, Object>> sorted = new ArrayList<>();if (!withCoords.isEmpty()) {sorted.add(withCoords.remove(0));while (!withCoords.isEmpty()) {Map<String, Object> last = sorted.get(sorted.size() - 1);double lastLat = Double.parseDouble(last.get("latitude").toString());double lastLng = Double.parseDouble(last.get("longitude").toString());int nearestIdx = 0;double nearestDist = Double.MAX_VALUE;for (int i = 0; i < withCoords.size(); i++) {double dist = haversine(lastLat, lastLng,Double.parseDouble(withCoords.get(i).get("latitude").toString()),Double.parseDouble(withCoords.get(i).get("longitude").toString()));if (dist < nearestDist) {nearestDist = dist;nearestIdx = i;}}sorted.add(withCoords.remove(nearestIdx));}}sorted.addAll(withoutCoords);return sorted;}private boolean hasValidCoords(Map<String, Object> p) {Object lat = p.get("latitude");Object lng = p.get("longitude");return lat != null && lng != null && !lat.toString().isEmpty() && !lng.toString().isEmpty();}private double haversine(double lat1, double lng1, double lat2, double lng2) {double R = 6371;double dLat = Math.toRadians(lat2 - lat1);double dLng = Math.toRadians(lng2 - lng1);double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))* Math.sin(dLng / 2) * Math.sin(dLng / 2);double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));return R * c;}}