Subversion Repositories SmartDukaan

Rev

Rev 36764 | Rev 36792 | Go to most recent revision | Show entire file | Ignore whitespace | Details | Blame | Last modification | View Log | RSS feed

Rev 36764 Rev 36785
Line 1781... Line 1781...
1781
		}
1781
		}
1782
 
1782
 
1783
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
1783
		String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
1784
		int totalDays = days.size();
1784
		int totalDays = days.size();
1785
 
1785
 
-
 
1786
		// One-beat-per-day guard: reject if any of the requested dates already
-
 
1787
		// has a beat scheduled for this user.
-
 
1788
		if (dates != null) {
-
 
1789
			List<LocalDate> candidateDates = new ArrayList<>();
-
 
1790
			for (String dStr : dates) {
-
 
1791
				if (dStr != null && !dStr.isEmpty()) {
-
 
1792
					try {
-
 
1793
						candidateDates.add(LocalDate.parse(dStr, DateTimeFormatter.ISO_DATE));
-
 
1794
					} catch (Exception ignored) {
-
 
1795
					}
-
 
1796
				}
-
 
1797
			}
-
 
1798
			Map<String, Object> conflict = findScheduleConflict(authUserId, candidateDates, 0);
-
 
1799
			if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
-
 
1800
		}
-
 
1801
 
1786
		// Create Beat master
1802
		// Create Beat master
1787
		Beat beat = new Beat();
1803
		Beat beat = new Beat();
1788
		beat.setName(beatName);
1804
		beat.setName(beatName);
1789
		beat.setAuthUserId(authUserId);
1805
		beat.setAuthUserId(authUserId);
1790
		beat.setBeatColor(beatColor);
1806
		beat.setBeatColor(beatColor);
Line 2073... Line 2089...
2073
 
2089
 
2074
		LocalDate holidayStart = LocalDate.now();
2090
		LocalDate holidayStart = LocalDate.now();
2075
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
2091
		List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(holidayStart, holidayStart.plusMonths(6));
2076
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
2092
		Set<LocalDate> holidayDates = holidays.stream().map(PublicHolidays::getDate).collect(Collectors.toSet());
2077
 
2093
 
-
 
2094
		// =====================================================================
-
 
2095
		// All-or-nothing import: validate every group first; only persist if
2078
		int beatsCreated = 0, errors = 0;
2096
		// the entire file passes. A single bad row blocks the whole upload
-
 
2097
		// so the user can fix and re-upload without partial creations.
-
 
2098
		// =====================================================================
-
 
2099
 
2079
		List<String> errorMessages = new ArrayList<>();
2100
		List<String> errorMessages = new ArrayList<>();
-
 
2101
		List<ValidatedBulkBeat> ready = new ArrayList<>();
2080
 
2102
 
-
 
2103
		// ----- Phase 1: validate every group, collect ALL errors -----
2081
		for (BulkBeatGroup g : beatGroups.values()) {
2104
		for (BulkBeatGroup g : beatGroups.values()) {
-
 
2105
			String beatName = g.beatName;
-
 
2106
 
-
 
2107
			int authUserId;
2082
			try {
2108
			try {
2083
				String beatName = g.beatName;
-
 
2084
				int authUserId;
-
 
2085
				try {
-
 
2086
					authUserId = Integer.parseInt(g.authUserId);
2109
				authUserId = Integer.parseInt(g.authUserId);
2087
				} catch (Exception e) {
2110
			} catch (Exception e) {
2088
					errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'. Skipped.");
2111
				errorMessages.add("Beat '" + beatName + "': invalid auth_user_id '" + g.authUserId + "'.");
2089
					errors++;
-
 
2090
					continue;
2112
				continue;
2091
				}
2113
			}
2092
 
2114
 
-
 
2115
			LocalDate startDate;
-
 
2116
			try {
-
 
2117
				startDate = (g.startDate == null || g.startDate.isEmpty())
2093
				LocalDate startDate = (g.startDate == null) ? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
2118
						? null : LocalDate.parse(g.startDate, DateTimeFormatter.ISO_DATE);
-
 
2119
			} catch (Exception e) {
-
 
2120
				errorMessages.add("Beat '" + beatName + "': invalid start_date '" + g.startDate + "'.");
-
 
2121
				continue;
-
 
2122
			}
2094
				if (startDate != null && startDate.isBefore(LocalDate.now())) {
2123
			if (startDate != null && startDate.isBefore(LocalDate.now())) {
2095
					errorMessages.add("Beat '" + beatName + "': start_date in past. Skipped.");
2124
				errorMessages.add("Beat '" + beatName + "': start_date in past.");
2096
					errors++;
-
 
2097
					continue;
2125
				continue;
2098
				}
2126
			}
2099
 
2127
 
2100
				List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
2128
			List<Integer> sortedDays = new ArrayList<>(g.dayToPartners.keySet());
2101
				Collections.sort(sortedDays);
2129
			Collections.sort(sortedDays);
2102
 
2130
 
2103
				List<LocalDate> scheduleDates = new ArrayList<>();
2131
			List<LocalDate> scheduleDates = new ArrayList<>();
2104
				if (startDate != null) {
2132
			if (startDate != null) {
2105
					LocalDate d = startDate;
2133
				LocalDate d = startDate;
2106
					while (scheduleDates.size() < sortedDays.size()) {
2134
				while (scheduleDates.size() < sortedDays.size()) {
2107
						if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
2135
					if (holidayDates.contains(d) || (d.getDayOfWeek() == DayOfWeek.SUNDAY && !includeSundays)) {
2108
							d = d.plusDays(1);
-
 
2109
							continue;
-
 
2110
						}
-
 
2111
						scheduleDates.add(d);
-
 
2112
						d = d.plusDays(1);
2136
						d = d.plusDays(1);
-
 
2137
						continue;
2113
					}
2138
					}
-
 
2139
					scheduleDates.add(d);
-
 
2140
					d = d.plusDays(1);
2114
				}
2141
				}
-
 
2142
			}
2115
 
2143
 
2116
				// Duplicate check — ACTIVE beats only (soft-deleted names are reusable)
2144
			// Duplicate beat-name check (ACTIVE only; soft-deleted names are reusable).
2117
				boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
2145
			boolean isDuplicate = beatRepository.selectActiveByAuthUserId(authUserId).stream()
2118
						.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
2146
					.anyMatch(b -> b.getName() != null && beatName.equalsIgnoreCase(b.getName().trim()));
2119
				if (isDuplicate) {
2147
			if (isDuplicate) {
2120
					errorMessages.add("Beat '" + beatName + "' already exists for user " + authUserId + ". Skipped.");
2148
				errorMessages.add("Beat '" + beatName + "': already exists for user " + authUserId + ".");
2121
					errors++;
-
 
2122
					continue;
2149
				continue;
2123
				}
2150
			}
2124
 
2151
 
2125
				String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
2152
			// One-beat-per-day guard (against existing beats).
2126
				AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
2153
			Map<String, Object> bulkConflict = findScheduleConflict(authUserId, scheduleDates, 0);
2127
 
-
 
2128
				Beat beat = new Beat();
2154
			if (bulkConflict != null) {
2129
				beat.setName(beatName);
-
 
2130
				beat.setAuthUserId(authUserId);
-
 
2131
				beat.setBeatColor(beatColor);
-
 
2132
				beat.setTotalDays(sortedDays.size());
-
 
2133
				beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
2155
				errorMessages.add("Beat '" + beatName + "': " + scheduleConflictMessage(bulkConflict));
2134
				beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
-
 
2135
				beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
-
 
2136
				beat.setActive(true);
2156
				continue;
2137
				beat.setCreatedBy(currentUser.getId());
-
 
2138
				beat.setCreatedTimestamp(LocalDateTime.now());
-
 
2139
				beatRepository.persist(beat);
-
 
2140
 
2157
			}
2141
				LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
-
 
2142
 
2158
 
2143
				for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
2159
			// Validate partner codes upfront so we don't half-persist.
2144
					int dayNumber = sortedDays.get(dayIdx);
-
 
2145
					LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
-
 
2146
 
-
 
2147
					BeatSchedule schedule = new BeatSchedule();
2160
			List<String> badCodes = new ArrayList<>();
2148
					schedule.setBeatId(beat.getId());
-
 
2149
					schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
-
 
2150
					schedule.setEndDate(bulkEndDate);
-
 
2151
					schedule.setDayNumber(dayNumber);
-
 
2152
					schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
-
 
2153
					schedule.setCreatedTimestamp(LocalDateTime.now());
-
 
2154
					beatScheduleRepository.persist(schedule);
-
 
2155
 
-
 
2156
					List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
2161
			for (List<BulkPartner> ps : g.dayToPartners.values()) {
2157
					// Sort by explicit sequence_order when present, else by row order
-
 
2158
					partners.sort((a, b) -> {
-
 
2159
						if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
-
 
2160
						if (a.seq >= 0) return -1;
-
 
2161
						if (b.seq >= 0) return 1;
-
 
2162
						return Integer.compare(a.rowNum, b.rowNum);
-
 
2163
					});
-
 
2164
 
-
 
2165
					int autoSeq = 0;
-
 
2166
					for (BulkPartner p : partners) {
2162
				for (BulkPartner p : ps) {
2167
						Integer fofoId = codeToId.get(p.code);
2163
					if (!codeToId.containsKey(p.code)) {
2168
						if (fofoId == null) {
-
 
2169
							errorMessages.add("Row " + p.rowNum + ": code not found '" + p.code + "'");
2164
						badCodes.add(p.code + " (row " + p.rowNum + ")");
2170
							errors++;
-
 
2171
							continue;
-
 
2172
						}
-
 
2173
						BeatRoute route = new BeatRoute();
-
 
2174
						route.setBeatId(beat.getId());
-
 
2175
						route.setFofoId(fofoId);
-
 
2176
						route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
-
 
2177
						route.setDayNumber(dayNumber);
-
 
2178
						route.setActive(true);
-
 
2179
						beatRouteRepository.persist(route);
-
 
2180
						autoSeq++;
-
 
2181
					}
2165
					}
2182
				}
2166
				}
2183
				beatsCreated++;
-
 
2184
			} catch (Exception e) {
-
 
2185
				errors++;
-
 
2186
				errorMessages.add("Error: " + g.beatName + " - " + e.getMessage());
-
 
2187
			}
2167
			}
-
 
2168
			if (!badCodes.isEmpty()) {
-
 
2169
				errorMessages.add("Beat '" + beatName + "': unknown partner code(s) — " + String.join(", ", badCodes) + ".");
-
 
2170
				continue;
-
 
2171
			}
-
 
2172
 
-
 
2173
			ready.add(new ValidatedBulkBeat(g, authUserId, sortedDays, scheduleDates));
-
 
2174
		}
-
 
2175
 
-
 
2176
		// Intra-file conflict: two beats in the same upload requesting the same
-
 
2177
		// user + date. Caught here so the user fixes the file before re-uploading.
-
 
2178
		Set<String> seenUserDates = new HashSet<>();
-
 
2179
		for (ValidatedBulkBeat v : ready) {
-
 
2180
			for (LocalDate sd : v.scheduleDates) {
-
 
2181
				String key = v.authUserId + "|" + sd;
-
 
2182
				if (!seenUserDates.add(key)) {
-
 
2183
					errorMessages.add("Beat '" + v.g.beatName + "': date " + sd + " is also claimed by another beat in this file for the same user.");
-
 
2184
					break;
-
 
2185
				}
-
 
2186
			}
-
 
2187
		}
-
 
2188
 
-
 
2189
		// All-or-nothing: any error → return without persisting anything.
-
 
2190
		if (!errorMessages.isEmpty()) {
-
 
2191
			Map<String, Object> response = new HashMap<>();
-
 
2192
			response.put("status", false);
-
 
2193
			response.put("beatsCreated", 0);
-
 
2194
			response.put("errors", errorMessages.size());
-
 
2195
			response.put("errorMessages", errorMessages);
-
 
2196
			response.put("message", "No beats created. Fix the issues below and re-upload the file.");
-
 
2197
			return responseSender.ok(response);
-
 
2198
		}
-
 
2199
 
-
 
2200
		// ----- Phase 2: persist (only reached when every row was clean) -----
-
 
2201
		int beatsCreated = 0;
-
 
2202
		for (ValidatedBulkBeat v : ready) {
-
 
2203
			BulkBeatGroup g = v.g;
-
 
2204
			String beatName = g.beatName;
-
 
2205
			int authUserId = v.authUserId;
-
 
2206
			List<Integer> sortedDays = v.sortedDays;
-
 
2207
			List<LocalDate> scheduleDates = v.scheduleDates;
-
 
2208
 
-
 
2209
			String beatColor = BEAT_COLORS[Math.abs(beatName.hashCode()) % BEAT_COLORS.length];
-
 
2210
			AuthUserLocation homeLoc = authUserLocationRepository.selectDefaultByAuthUserIdAndType(authUserId, "BASE");
-
 
2211
 
-
 
2212
			Beat beat = new Beat();
-
 
2213
			beat.setName(beatName);
-
 
2214
			beat.setAuthUserId(authUserId);
-
 
2215
			beat.setBeatColor(beatColor);
-
 
2216
			beat.setTotalDays(sortedDays.size());
-
 
2217
			beat.setStartLocationName(homeLoc != null ? homeLoc.getLocationName() : "Home");
-
 
2218
			beat.setStartLatitude(homeLoc != null ? homeLoc.getLatitude() : null);
-
 
2219
			beat.setStartLongitude(homeLoc != null ? homeLoc.getLongitude() : null);
-
 
2220
			beat.setActive(true);
-
 
2221
			beat.setCreatedBy(currentUser.getId());
-
 
2222
			beat.setCreatedTimestamp(LocalDateTime.now());
-
 
2223
			beatRepository.persist(beat);
-
 
2224
 
-
 
2225
			LocalDate bulkEndDate = scheduleDates.isEmpty() ? null : scheduleDates.get(scheduleDates.size() - 1);
-
 
2226
 
-
 
2227
			for (int dayIdx = 0; dayIdx < sortedDays.size(); dayIdx++) {
-
 
2228
				int dayNumber = sortedDays.get(dayIdx);
-
 
2229
				LocalDate planDate = (dayIdx < scheduleDates.size()) ? scheduleDates.get(dayIdx) : null;
-
 
2230
 
-
 
2231
				BeatSchedule schedule = new BeatSchedule();
-
 
2232
				schedule.setBeatId(beat.getId());
-
 
2233
				schedule.setStartDate(planDate != null ? planDate : LocalDate.of(9999, 12, 31));
-
 
2234
				schedule.setEndDate(bulkEndDate);
-
 
2235
				schedule.setDayNumber(dayNumber);
-
 
2236
				schedule.setEndAction(dayIdx == sortedDays.size() - 1 ? "HOME" : "DAYBREAK");
-
 
2237
				schedule.setCreatedTimestamp(LocalDateTime.now());
-
 
2238
				beatScheduleRepository.persist(schedule);
-
 
2239
 
-
 
2240
				List<BulkPartner> partners = g.dayToPartners.get(dayNumber);
-
 
2241
				partners.sort((a, b) -> {
-
 
2242
					if (a.seq >= 0 && b.seq >= 0) return Integer.compare(a.seq, b.seq);
-
 
2243
					if (a.seq >= 0) return -1;
-
 
2244
					if (b.seq >= 0) return 1;
-
 
2245
					return Integer.compare(a.rowNum, b.rowNum);
-
 
2246
				});
-
 
2247
 
-
 
2248
				int autoSeq = 0;
-
 
2249
				for (BulkPartner p : partners) {
-
 
2250
					Integer fofoId = codeToId.get(p.code);
-
 
2251
					// Codes were validated in Phase 1, this is just a safety net.
-
 
2252
					if (fofoId == null) continue;
-
 
2253
					BeatRoute route = new BeatRoute();
-
 
2254
					route.setBeatId(beat.getId());
-
 
2255
					route.setFofoId(fofoId);
-
 
2256
					route.setSequenceOrder(p.seq >= 0 ? p.seq : autoSeq);
-
 
2257
					route.setDayNumber(dayNumber);
-
 
2258
					route.setActive(true);
-
 
2259
					beatRouteRepository.persist(route);
-
 
2260
					autoSeq++;
-
 
2261
				}
-
 
2262
			}
-
 
2263
			beatsCreated++;
2188
		}
2264
		}
2189
 
2265
 
2190
		Map<String, Object> response = new HashMap<>();
2266
		Map<String, Object> response = new HashMap<>();
2191
		response.put("status", true);
2267
		response.put("status", true);
2192
		response.put("beatsCreated", beatsCreated);
2268
		response.put("beatsCreated", beatsCreated);
2193
		response.put("errors", errors);
2269
		response.put("errors", 0);
2194
		response.put("errorMessages", errorMessages);
2270
		response.put("errorMessages", errorMessages);
-
 
2271
		response.put("message", beatsCreated + " beat(s) created.");
-
 
2272
		return responseSender.ok(response);
-
 
2273
	}
-
 
2274
 
-
 
2275
	// Move a beat from one date to another — used by calendar drag-and-drop.
-
 
2276
	// Behaviour: if the target date already has ANOTHER beat scheduled (for the
-
 
2277
	// same sales user), the two schedules swap — the other beat slides onto
-
 
2278
	// the source date. If the target date is empty, the source date becomes empty.
-
 
2279
	@PostMapping(value = "/beatPlan/moveScheduleDate")
-
 
2280
	public ResponseEntity<?> moveScheduleDate(
-
 
2281
			@RequestParam String planGroupId,
-
 
2282
			@RequestParam String fromDate,
-
 
2283
			@RequestParam String toDate) {
-
 
2284
		int beatId = Integer.parseInt(planGroupId);
-
 
2285
		LocalDate from = LocalDate.parse(fromDate);
-
 
2286
		LocalDate to = LocalDate.parse(toDate);
-
 
2287
 
-
 
2288
		if (from.equals(to)) {
-
 
2289
			Map<String, Object> ok = new HashMap<>();
-
 
2290
			ok.put("status", true);
-
 
2291
			ok.put("message", "Same date — no change");
-
 
2292
			return responseSender.ok(ok);
-
 
2293
		}
-
 
2294
 
-
 
2295
		// Today is the live/running slot — a beat already running today can't be
-
 
2296
		// bumped, and a future beat can't be moved onto today.
-
 
2297
		LocalDate today = LocalDate.now();
-
 
2298
		if (to.equals(today) || from.equals(today) || from.isBefore(today) || to.isBefore(today)) {
-
 
2299
			return responseSender.badRequest("Cannot move to or from today / a past date — today's beat is live. Use future dates only.");
-
 
2300
		}
-
 
2301
 
-
 
2302
		Beat beat = beatRepository.selectById(beatId);
-
 
2303
		if (beat == null) return responseSender.badRequest("Beat not found");
-
 
2304
 
-
 
2305
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
-
 
2306
 
-
 
2307
		// Reject if THIS beat already has a different schedule row on the target date
-
 
2308
		// (it would create two schedule rows of the same beat on one day).
-
 
2309
		boolean selfConflict = schedules.stream()
-
 
2310
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
-
 
2311
		if (selfConflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
-
 
2312
 
-
 
2313
		BeatSchedule match = schedules.stream()
-
 
2314
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
-
 
2315
				.findFirst().orElse(null);
-
 
2316
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
-
 
2317
 
-
 
2318
		// Look for ANY OTHER beat (same sales user) whose schedule sits on the target
-
 
2319
		// date — if found we'll swap it onto the source date.
-
 
2320
		BeatSchedule otherSchedule = null;
-
 
2321
		List<BeatSchedule> otherSchedules = null;
-
 
2322
		Beat otherBeat = null;
-
 
2323
		for (Beat ub : beatRepository.selectActiveByAuthUserId(beat.getAuthUserId())) {
-
 
2324
			if (ub.getId() == beatId) continue;
-
 
2325
			List<BeatSchedule> ubSchedules = beatScheduleRepository.selectByBeatId(ub.getId());
-
 
2326
			BeatSchedule hit = ubSchedules.stream()
-
 
2327
					.filter(s -> s.getStartDate() != null && s.getStartDate().equals(to))
-
 
2328
					.findFirst().orElse(null);
-
 
2329
			if (hit != null) {
-
 
2330
				otherSchedule = hit;
-
 
2331
				otherSchedules = ubSchedules;
-
 
2332
				otherBeat = ub;
-
 
2333
				break;
-
 
2334
			}
-
 
2335
		}
-
 
2336
 
-
 
2337
		// Move the dragged beat onto the target date.
-
 
2338
		match.setStartDate(to);
-
 
2339
		// If another beat occupied the target, slide it onto the source date (swap).
-
 
2340
		if (otherSchedule != null) {
-
 
2341
			otherSchedule.setStartDate(from);
-
 
2342
		}
-
 
2343
 
-
 
2344
		// Recompute endDate as the max across each affected beat's schedules so
-
 
2345
		// the [startDate, endDate] envelope stays consistent.
-
 
2346
		recomputeEndDate(schedules);
-
 
2347
		if (otherSchedules != null) {
-
 
2348
			recomputeEndDate(otherSchedules);
-
 
2349
		}
-
 
2350
 
-
 
2351
		Map<String, Object> response = new HashMap<>();
-
 
2352
		response.put("status", true);
-
 
2353
		response.put("message", otherBeat != null
-
 
2354
				? "Swapped with \"" + (otherBeat.getName() != null ? otherBeat.getName() : "beat") + "\" on " + toDate
-
 
2355
				: "Moved from " + fromDate + " to " + toDate);
-
 
2356
		response.put("swapped", otherBeat != null);
2195
		return responseSender.ok(response);
2357
		return responseSender.ok(response);
2196
	}
2358
	}
2197
 
2359
 
2198
	private static class BulkBeatGroup {
2360
	private static class BulkBeatGroup {
2199
		final String beatName;
2361
		final String beatName;
Line 2293... Line 2455...
2293
		response.put("status", true);
2455
		response.put("status", true);
2294
		response.put("message", "Unscheduled from " + date);
2456
		response.put("message", "Unscheduled from " + date);
2295
		return responseSender.ok(response);
2457
		return responseSender.ok(response);
2296
	}
2458
	}
2297
 
2459
 
2298
	// Move a beat from one date to another — used by calendar drag-and-drop.
-
 
2299
	// Just updates the start_date of the matching beat_schedule row.
2460
	private void recomputeEndDate(List<BeatSchedule> schedules) {
2300
	@PostMapping(value = "/beatPlan/moveScheduleDate")
2461
		LocalDate newEnd = schedules.stream()
2301
	public ResponseEntity<?> moveScheduleDate(
2462
				.map(BeatSchedule::getStartDate)
2302
			@RequestParam String planGroupId,
2463
				.filter(d -> d != null && d.getYear() != 9999)
2303
			@RequestParam String fromDate,
2464
				.max(LocalDate::compareTo).orElse(null);
2304
			@RequestParam String toDate) {
2465
		if (newEnd == null) return;
2305
		int beatId = Integer.parseInt(planGroupId);
2466
		for (BeatSchedule s : schedules) {
2306
		LocalDate from = LocalDate.parse(fromDate);
2467
			if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {
2307
		LocalDate to = LocalDate.parse(toDate);
2468
				s.setEndDate(newEnd);
-
 
2469
			}
-
 
2470
		}
-
 
2471
	}
2308
 
2472
 
-
 
2473
	/**
-
 
2474
	 * Per-user one-beat-per-day guard. Walks every active beat the sales user
-
 
2475
	 * already has and looks for a schedule row on any of the candidate dates.
-
 
2476
	 * Returns null if the candidate dates are clear, otherwise a {date,beatName}
-
 
2477
	 * map describing the first collision so the caller can surface a clean error.
-
 
2478
	 * Pass `excludeBeatId` so callers that are re-scheduling an existing beat
-
 
2479
	 * don't trip on their own pre-existing schedule rows; pass 0 for new beats.
-
 
2480
	 */
-
 
2481
	private Map<String, Object> findScheduleConflict(int authUserId, java.util.Collection<LocalDate> candidates, int excludeBeatId) {
-
 
2482
		if (candidates == null || candidates.isEmpty()) return null;
-
 
2483
		Set<LocalDate> ds = new HashSet<>();
-
 
2484
		for (LocalDate d : candidates) {
-
 
2485
			if (d != null && d.getYear() != 9999) ds.add(d);
-
 
2486
		}
2309
		if (from.equals(to)) {
2487
		if (ds.isEmpty()) return null;
-
 
2488
		for (Beat ub : beatRepository.selectActiveByAuthUserId(authUserId)) {
-
 
2489
			if (ub.getId() == excludeBeatId) continue;
-
 
2490
			for (BeatSchedule s : beatScheduleRepository.selectByBeatId(ub.getId())) {
-
 
2491
				if (s.getStartDate() != null && ds.contains(s.getStartDate())) {
2310
			Map<String, Object> ok = new HashMap<>();
2492
					Map<String, Object> conflict = new HashMap<>();
2311
			ok.put("status", true);
2493
					conflict.put("date", s.getStartDate().toString());
2312
			ok.put("message", "Same date — no change");
2494
					conflict.put("beatName", ub.getName() != null ? ub.getName() : "Beat #" + ub.getId());
2313
			return responseSender.ok(ok);
2495
					return conflict;
-
 
2496
				}
-
 
2497
			}
2314
		}
2498
		}
-
 
2499
		return null;
-
 
2500
	}
2315
 
2501
 
-
 
2502
	private String scheduleConflictMessage(Map<String, Object> conflict) {
2316
		Beat beat = beatRepository.selectById(beatId);
2503
		return "Cannot schedule on " + conflict.get("date")
2317
		if (beat == null) return responseSender.badRequest("Beat not found");
2504
				+ " — \"" + conflict.get("beatName") + "\" is already scheduled for this user on that day.";
-
 
2505
	}
2318
 
2506
 
-
 
2507
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
-
 
2508
	public ResponseEntity<?> scheduleOnCalendar(
-
 
2509
			HttpServletRequest request,
-
 
2510
			@RequestParam String planGroupId,
-
 
2511
			@RequestParam String dates,
-
 
2512
			@RequestParam(required = false) String beatName,
2319
		List<BeatSchedule> schedules = beatScheduleRepository.selectByBeatId(beatId);
2513
			@RequestParam(required = false) String beatColor) throws Exception {
2320
 
2514
 
2321
		// Reject if the beat is already on the target date
2515
		int beatId = Integer.parseInt(planGroupId);
2322
		boolean conflict = schedules.stream()
2516
		Gson gson = new Gson();
2323
				.anyMatch(s -> s.getStartDate() != null && s.getStartDate().equals(to));
2517
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
2324
		if (conflict) return responseSender.badRequest("Beat is already scheduled on " + toDate);
2518
		}.getType());
2325
 
2519
 
2326
		BeatSchedule match = schedules.stream()
2520
		Beat beat = beatRepository.selectById(beatId);
2327
				.filter(s -> s.getStartDate() != null && s.getStartDate().equals(from))
-
 
2328
				.findFirst().orElse(null);
-
 
2329
		if (match == null) return responseSender.badRequest("No schedule found for " + fromDate);
2521
		if (beat == null) return responseSender.badRequest("Beat not found");
2330
 
2522
 
2331
		match.setStartDate(to);
2523
		if (beatName != null) beat.setName(beatName);
2332
		// Recompute endDate as the max across all (post-update) schedules
2524
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
2333
		LocalDate newEnd = schedules.stream()
-
 
-
 
2525
 
2334
				.map(s -> s == match ? to : s.getStartDate())
2526
		// One-beat-per-day guard: reject if any of the requested dates already
2335
				.filter(d -> d != null && d.getYear() != 9999)
2527
		// has another beat scheduled for this user (excluding this beat itself).
2336
				.max(LocalDate::compareTo).orElse(to);
2528
		List<LocalDate> requested = new ArrayList<>();
2337
		for (BeatSchedule s : schedules) {
2529
		for (String s : dateList) {
-
 
2530
			try {
2338
			if (s.getStartDate() != null && s.getStartDate().getYear() != 9999) {
2531
				requested.add(LocalDate.parse(s));
2339
				s.setEndDate(newEnd);
2532
			} catch (Exception ignored) {
2340
			}
2533
			}
2341
		}
2534
		}
-
 
2535
		Map<String, Object> conflict = findScheduleConflict(beat.getAuthUserId(), requested, beatId);
-
 
2536
		if (conflict != null) return responseSender.badRequest(scheduleConflictMessage(conflict));
-
 
2537
 
-
 
2538
		// Delete old schedules and create new
-
 
2539
		beatScheduleRepository.deleteByBeatId(beatId);
-
 
2540
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
-
 
2541
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
-
 
2542
            int dayNumber = i + 1;
-
 
2543
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
-
 
2544
			BeatSchedule schedule = new BeatSchedule();
-
 
2545
			schedule.setBeatId(beatId);
-
 
2546
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
-
 
2547
			schedule.setEndDate(schEndDate);
-
 
2548
            schedule.setDayNumber(dayNumber);
-
 
2549
            schedule.setEndAction(endAction);
-
 
2550
            // Fill total_distance_km / total_time_mins from beat_route so the new
-
 
2551
            // schedule row isn't NULL (this was the bug — these were left unset).
-
 
2552
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
-
 
2553
            schedule.setTotalDistanceKm(totals[0]);
-
 
2554
            schedule.setTotalTimeMins((int) totals[1]);
-
 
2555
			schedule.setCreatedTimestamp(LocalDateTime.now());
-
 
2556
			beatScheduleRepository.persist(schedule);
-
 
2557
		}
2342
 
2558
 
2343
		Map<String, Object> response = new HashMap<>();
2559
		Map<String, Object> response = new HashMap<>();
2344
		response.put("status", true);
2560
		response.put("status", true);
2345
		response.put("message", "Moved from " + fromDate + " to " + toDate);
2561
		response.put("message", "Beat scheduled successfully");
2346
		return responseSender.ok(response);
2562
		return responseSender.ok(response);
2347
	}
2563
	}
2348
 
2564
 
2349
	@GetMapping(value = "/beatPlan/calendar")
2565
	@GetMapping(value = "/beatPlan/calendar")
2350
	public ResponseEntity<?> getCalendar(
2566
	public ResponseEntity<?> getCalendar(
Line 2433... Line 2649...
2433
		response.put("scheduledBeats", scheduledBeats);
2649
		response.put("scheduledBeats", scheduledBeats);
2434
		response.put("blockedDates", blockedDates);
2650
		response.put("blockedDates", blockedDates);
2435
		return responseSender.ok(response);
2651
		return responseSender.ok(response);
2436
	}
2652
	}
2437
 
2653
 
2438
	@PostMapping(value = "/beatPlan/scheduleOnCalendar")
-
 
2439
	public ResponseEntity<?> scheduleOnCalendar(
-
 
2440
			HttpServletRequest request,
-
 
2441
			@RequestParam String planGroupId,
-
 
2442
			@RequestParam String dates,
-
 
2443
			@RequestParam(required = false) String beatName,
-
 
2444
			@RequestParam(required = false) String beatColor) throws Exception {
-
 
2445
 
-
 
2446
		int beatId = Integer.parseInt(planGroupId);
-
 
2447
		Gson gson = new Gson();
-
 
2448
		List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
-
 
2449
		}.getType());
-
 
2450
 
-
 
2451
		Beat beat = beatRepository.selectById(beatId);
-
 
2452
		if (beat == null) return responseSender.badRequest("Beat not found");
-
 
2453
 
-
 
2454
		if (beatName != null) beat.setName(beatName);
-
 
2455
		if (beatColor != null && !beatColor.isEmpty()) beat.setBeatColor(beatColor);
-
 
2456
 
-
 
2457
		// Delete old schedules and create new
-
 
2458
		beatScheduleRepository.deleteByBeatId(beatId);
-
 
2459
		LocalDate schEndDate = dateList.isEmpty() ? null : LocalDate.parse(dateList.get(dateList.size() - 1));
-
 
2460
		for (int i = 0; i < dateList.size() && i < beat.getTotalDays(); i++) {
-
 
2461
            int dayNumber = i + 1;
-
 
2462
            String endAction = (i == dateList.size() - 1) ? "HOME" : "DAYBREAK";
-
 
2463
			BeatSchedule schedule = new BeatSchedule();
-
 
2464
			schedule.setBeatId(beatId);
-
 
2465
			schedule.setStartDate(LocalDate.parse(dateList.get(i)));
-
 
2466
			schedule.setEndDate(schEndDate);
-
 
2467
            schedule.setDayNumber(dayNumber);
-
 
2468
            schedule.setEndAction(endAction);
-
 
2469
            // Fill total_distance_km / total_time_mins from beat_route so the new
-
 
2470
            // schedule row isn't NULL (this was the bug — these were left unset).
-
 
2471
            double[] totals = computeDayTotals(beatId, dayNumber, endAction);
-
 
2472
            schedule.setTotalDistanceKm(totals[0]);
-
 
2473
            schedule.setTotalTimeMins((int) totals[1]);
-
 
2474
			schedule.setCreatedTimestamp(LocalDateTime.now());
-
 
2475
			beatScheduleRepository.persist(schedule);
-
 
2476
		}
-
 
2477
 
-
 
2478
		Map<String, Object> response = new HashMap<>();
-
 
2479
		response.put("status", true);
-
 
2480
		response.put("message", "Beat scheduled successfully");
-
 
2481
		return responseSender.ok(response);
-
 
2482
	}
-
 
2483
 
-
 
2484
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
2654
	// Drag-drop scheduling — adds schedule dates to the EXISTING beat (no new beat created)
2485
	@PostMapping(value = "/beatPlan/repeatBeat")
2655
	@PostMapping(value = "/beatPlan/repeatBeat")
2486
	public ResponseEntity<?> repeatBeat(
2656
	public ResponseEntity<?> repeatBeat(
2487
			HttpServletRequest request,
2657
			HttpServletRequest request,
2488
			@RequestParam String sourcePlanGroupId,
2658
			@RequestParam String sourcePlanGroupId,
Line 2495... Line 2665...
2495
		}.getType());
2665
		}.getType());
2496
 
2666
 
2497
		Beat beat = beatRepository.selectById(beatId);
2667
		Beat beat = beatRepository.selectById(beatId);
2498
		if (beat == null) return responseSender.badRequest("Beat not found");
2668
		if (beat == null) return responseSender.badRequest("Beat not found");
2499
 
2669
 
-
 
2670
		// One-beat-per-day guard: reject if any of the new dates already has
-
 
2671
		// another beat scheduled for this user (excluding this beat itself).
-
 
2672
		List<LocalDate> repeatDates = new ArrayList<>();
-
 
2673
		for (String s : dateList) {
-
 
2674
			try {
-
 
2675
				repeatDates.add(LocalDate.parse(s));
-
 
2676
			} catch (Exception ignored) {
-
 
2677
			}
-
 
2678
		}
-
 
2679
		Map<String, Object> repeatConflict = findScheduleConflict(beat.getAuthUserId(), repeatDates, beatId);
-
 
2680
		if (repeatConflict != null) return responseSender.badRequest(scheduleConflictMessage(repeatConflict));
-
 
2681
 
2500
		// Remove placeholder (unscheduled) schedule rows
2682
		// Remove placeholder (unscheduled) schedule rows
2501
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
2683
		List<BeatSchedule> existing = beatScheduleRepository.selectByBeatId(beatId);
2502
		for (BeatSchedule s : existing) {
2684
		for (BeatSchedule s : existing) {
2503
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
2685
			if (s.getStartDate() != null && s.getStartDate().getYear() == 9999) {
2504
				beatScheduleRepository.delete(s);
2686
				beatScheduleRepository.delete(s);
Line 2529... Line 2711...
2529
		response.put("planGroupId", String.valueOf(beatId));
2711
		response.put("planGroupId", String.valueOf(beatId));
2530
		response.put("message", "Beat scheduled successfully");
2712
		response.put("message", "Beat scheduled successfully");
2531
		return responseSender.ok(response);
2713
		return responseSender.ok(response);
2532
	}
2714
	}
2533
 
2715
 
-
 
2716
	private static class ValidatedBulkBeat {
-
 
2717
		final BulkBeatGroup g;
-
 
2718
		final int authUserId;
-
 
2719
		final List<Integer> sortedDays;
-
 
2720
		final List<LocalDate> scheduleDates;
-
 
2721
 
-
 
2722
		ValidatedBulkBeat(BulkBeatGroup g, int authUserId, List<Integer> sortedDays, List<LocalDate> scheduleDates) {
-
 
2723
			this.g = g;
-
 
2724
			this.authUserId = authUserId;
-
 
2725
			this.sortedDays = sortedDays;
-
 
2726
			this.scheduleDates = scheduleDates;
-
 
2727
		}
-
 
2728
	}
-
 
2729
 
2534
	@GetMapping(value = "/beatPlan/availableSlots")
2730
	@GetMapping(value = "/beatPlan/availableSlots")
2535
	public ResponseEntity<?> getAvailableSlots(
2731
	public ResponseEntity<?> getAvailableSlots(
2536
			@RequestParam int authUserId,
2732
			@RequestParam int authUserId,
2537
			@RequestParam String month,
2733
			@RequestParam String month,
2538
			@RequestParam int daysNeeded) {
2734
			@RequestParam int daysNeeded) {