Subversion Repositories SmartDukaan

Rev

Rev 36802 | 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"
        };
        @Autowired
        private CsService csService;
        @Autowired
        private 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"
        ));
        @Autowired
        private com.spice.profitmandi.service.AuthService authService;
        @Autowired
        private com.spice.profitmandi.dao.repository.cs.PositionRepository positionRepository;
        @Autowired
        private RetailerService retailerService;
        @Autowired
        private BeatRepository beatRepository;
        @Autowired
        private BeatRouteRepository beatRouteRepository;
        @Autowired
        private BeatScheduleRepository beatScheduleRepository;
        @Autowired
        private LeadRouteRepository leadRouteRepository;
        @Autowired
        private com.spice.profitmandi.dao.service.BeatPlanQueryService beatPlanQueryService;
        @Autowired
        private AuthUserLocationRepository authUserLocationRepository;
        @Autowired
        private LeadRepository leadRepository;
        @Autowired
        private PublicHolidaysRepository publicHolidaysRepository;
        @Autowired
        private com.spice.profitmandi.service.GeocodingService geocodingService;
        @Autowired
        private CookiesProcessor cookiesProcessor;
        @Autowired
        private ResponseSender responseSender;
        @Autowired
        private FofoStoreRepository fofoStoreRepository;
        @Autowired
        private 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";
        }

        @Autowired
        private com.spice.profitmandi.dao.repository.dtr.LeadLiveLocationRepository leadLiveLocationRepositoryAuto;
        @Autowired
        private com.spice.profitmandi.dao.repository.dtr.LeadActivityRepository leadActivityRepositoryAuto;
        @Autowired
        private com.spice.profitmandi.dao.repository.dtr.UserRepository userRepositoryAuto;
        @Autowired
        private com.spice.profitmandi.common.web.client.RestClient restClientAuto;
        @Autowired
        private com.spice.profitmandi.dao.repository.auth.LocationTrackingRepository locationTrackingRepositoryAuto;
        @Autowired
        private 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/h
        int 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 email
                Integer 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 date
                LocalDate 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 date
                Set<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 wins
                                String 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 code
                        parties.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 change
                                existing.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_beat
                if (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)");
                }
                if (toDate.isBefore(LocalDate.now())) return responseSender.badRequest("Reschedule date cannot be in the past");

                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)");
                }
                if (date.isBefore(LocalDate.now())) return responseSender.badRequest("Pick an upcoming date");

                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/date
                        boolean 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 reorder
                                lr.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 upload
        private 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 call
                int updated = 0, kept = 0, noAddress = 0, noGeocode = 0, errored = 0;
                boolean stoppedOnTime = false;
                int nextIndex = from;     // absolute index of next unprocessed store
                List<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 DB
                result.put("offset", from);
                result.put("processed", total);                  // stores processed this call
                result.put("nextOffset", nextIndex);             // resume here next call
                result.put("done", nextIndex >= totalAvailable); // true when nothing left
                result.put("stoppedOnTimeBudget", stoppedOnTime);// true if we paused for time, not because we finished
                result.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 responses
                result.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");

                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 present
                Map<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 plan
                Set<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 exist
                if (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 beat
                                LeadRoute current = beatLeads.stream()
                                                .filter(r -> r.getLeadId() == leadId && "APPROVED".equals(r.getStatus()))
                                                .findFirst().orElse(null);
                                if (current == null) continue; // already removed/cancelled; nothing to do

                                if ("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 toDate
                                        Beat 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 beat
                                        current.setStatus("CANCELLED");
                                        current.setUpdatedTimestamp(LocalDateTime.now());

                                        // Create the new attachment on the target beat/date
                                        LeadRoute fresh = new LeadRoute();
                                        fresh.setBeatId(targetBeat.getId());
                                        fresh.setLeadId(leadId);
                                        fresh.setNearestStoreId(current.getNearestStoreId());
                                        fresh.setScheduleDate(toDate);
                                        fresh.setSequenceOrder(9999); // append; the planner can reorder
                                        fresh.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].
        @GetMapping(value = "/beatPlan/scheduledList")
        public ResponseEntity<?> scheduledList(
                        @RequestParam(required = false) String startDate,
                        @RequestParam(required = false) String endDate) {

                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");
                }

                List<com.spice.profitmandi.dao.model.BeatDayDetails> beats =
                                beatPlanQueryService.getAllScheduledBeats(start, end);

                // Resolve user names in bulk
                Set<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 bulk
                Set<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 + geo
                Map<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 stop
                List<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<>();
                        // Partners
                        for (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);
                        }
                        // Leads
                        for (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));
                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 date
                if (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 sequenceOrder
                result.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 master
                Beat 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 day
                if (!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 date
                LocalDate 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 day
                for (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 = DAYBREAK
                        String 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 unscheduled
                        schedule.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);
        }

        @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 style
                org.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 map
                org.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_order
                Map<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 fields
                                currentBeatName = 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 — skip
                        if (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 row

                        int 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 new
                beatScheduleRepository.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 routes
                                Map<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 rows
                List<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 user
                List<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;
        }
}