| Line 22... |
Line 22... |
| 22 |
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
|
22 |
import com.spice.profitmandi.dao.repository.cs.RegionRepository;
|
| 23 |
import com.spice.profitmandi.dao.entity.user.LeadCallAnswer;
|
23 |
import com.spice.profitmandi.dao.entity.user.LeadCallAnswer;
|
| 24 |
import com.spice.profitmandi.dao.entity.user.LeadCallQuestion;
|
24 |
import com.spice.profitmandi.dao.entity.user.LeadCallQuestion;
|
| 25 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallChecklistRepository;
|
25 |
import com.spice.profitmandi.dao.repository.dtr.LeadCallChecklistRepository;
|
| 26 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
26 |
import com.spice.profitmandi.dao.repository.dtr.LeadRepository;
|
| - |
|
27 |
import com.spice.profitmandi.dao.model.lms.LeadDispositionRequest;
|
| 27 |
import com.spice.profitmandi.service.LmsAssignmentService;
|
28 |
import com.spice.profitmandi.service.LmsAssignmentService;
|
| - |
|
29 |
import com.spice.profitmandi.service.lms.LmsDispositionService;
|
| - |
|
30 |
import com.spice.profitmandi.service.lms.LmsOutcome;
|
| 28 |
import com.spice.profitmandi.web.model.LoginDetails;
|
31 |
import com.spice.profitmandi.web.model.LoginDetails;
|
| 29 |
import com.spice.profitmandi.web.util.CookiesProcessor;
|
32 |
import com.spice.profitmandi.web.util.CookiesProcessor;
|
| 30 |
import org.apache.logging.log4j.LogManager;
|
33 |
import org.apache.logging.log4j.LogManager;
|
| 31 |
import org.apache.logging.log4j.Logger;
|
34 |
import org.apache.logging.log4j.Logger;
|
| 32 |
import org.springframework.beans.factory.annotation.Autowired;
|
35 |
import org.springframework.beans.factory.annotation.Autowired;
|
| Line 95... |
Line 98... |
| 95 |
private LeadRepository leadRepository;
|
98 |
private LeadRepository leadRepository;
|
| 96 |
|
99 |
|
| 97 |
@Autowired
|
100 |
@Autowired
|
| 98 |
private LeadActivityRepository leadActivityRepository;
|
101 |
private LeadActivityRepository leadActivityRepository;
|
| 99 |
|
102 |
|
| - |
|
103 |
/**
|
| - |
|
104 |
* The disposition write — shared with the field app's post-call sheet in profitmandi-web. It is
|
| - |
|
105 |
* the most consequential write in the LMS (SLA stamp, stage, trail, DND), so it lives in one
|
| - |
|
106 |
* place rather than once per front end.
|
| - |
|
107 |
*/
|
| - |
|
108 |
@Autowired
|
| - |
|
109 |
private LmsDispositionService lmsDispositionService;
|
| - |
|
110 |
|
| 100 |
@Autowired
|
111 |
@Autowired
|
| 101 |
private LeadLiveLocationRepository leadLiveLocationRepository;
|
112 |
private LeadLiveLocationRepository leadLiveLocationRepository;
|
| 102 |
|
113 |
|
| 103 |
@Autowired
|
114 |
@Autowired
|
| 104 |
private LeadCallRepository leadCallRepository;
|
115 |
private LeadCallRepository leadCallRepository;
|
| Line 264... |
Line 275... |
| 264 |
*/
|
275 |
*/
|
| 265 |
@RequestMapping(value = "/lms/disposition", method = RequestMethod.POST,
|
276 |
@RequestMapping(value = "/lms/disposition", method = RequestMethod.POST,
|
| 266 |
consumes = MediaType.APPLICATION_JSON_VALUE)
|
277 |
consumes = MediaType.APPLICATION_JSON_VALUE)
|
| 267 |
@ResponseBody
|
278 |
@ResponseBody
|
| 268 |
public ResponseEntity<?> disposition(HttpServletRequest request,
|
279 |
public ResponseEntity<?> disposition(HttpServletRequest request,
|
| 269 |
@RequestBody DispositionRequest body) {
|
280 |
@RequestBody LeadDispositionRequest body) {
|
| 270 |
if (body == null || body.disposition == null || body.disposition.trim().isEmpty()) {
|
281 |
LmsOutcome outcome = lmsDispositionService.apply(currentUser(request), body);
|
| 271 |
return responseSender.badRequest("A disposition is required");
|
- |
|
| 272 |
}
|
- |
|
| 273 |
Lead lead = leadRepository.selectById(body.leadId);
|
- |
|
| 274 |
if (lead == null) {
|
282 |
switch (outcome.status) {
|
| 275 |
return responseSender.notFound("Lead not found");
|
- |
|
| 276 |
}
|
- |
|
| 277 |
|
- |
|
| 278 |
LeadDisposition disposition;
|
- |
|
| 279 |
try {
|
283 |
case OK:
|
| 280 |
disposition = LeadDisposition.valueOf(body.disposition);
|
- |
|
| 281 |
} catch (IllegalArgumentException e) {
|
- |
|
| 282 |
return responseSender.badRequest("Unknown disposition: " + body.disposition);
|
284 |
return responseSender.ok(stateOf((Lead) outcome.data.get("lead")));
|
| 283 |
}
|
- |
|
| 284 |
if (disposition == LeadDisposition.INTERESTED && (body.value == null || body.value <= 0)) {
|
- |
|
| 285 |
return responseSender.badRequest("Business value is required on an INTERESTED disposition");
|
- |
|
| 286 |
}
|
- |
|
| 287 |
|
- |
|
| 288 |
AuthUser actor = currentUser(request);
|
- |
|
| 289 |
LeadStage before = lead.getEffectiveStage();
|
- |
|
| 290 |
LeadCall call = resolveCall(body, lead, actor);
|
- |
|
| 291 |
|
- |
|
| 292 |
String subReason = latin1Safe(trimToNull(body.subReason));
|
- |
|
| 293 |
if (subReason != null && subReason.length() > 64) {
|
- |
|
| 294 |
return responseSender.badRequest("Sub-reason is too long - keep it to 64 characters");
|
- |
|
| 295 |
}
|
- |
|
| 296 |
lead.setDisposition(disposition);
|
- |
|
| 297 |
lead.setDispositionSubReason(subReason);
|
- |
|
| 298 |
// Interim manual path — drops out when the dialer starts filling recordings from the webhook.
|
- |
|
| 299 |
if (body.recordingUrl != null && !body.recordingUrl.trim().isEmpty()) {
|
- |
|
| 300 |
lead.setRecordingUrl(body.recordingUrl.trim());
|
- |
|
| 301 |
}
|
- |
|
| 302 |
// Reaching the retailer IS the first contact — stamp once, never move it. NOT_REACHABLE (nobody
|
- |
|
| 303 |
// answered) and WRONG_NUMBER (not the retailer) are not contact: they must not satisfy the SLA,
|
- |
|
| 304 |
// and they must not disturb a stamp an earlier real contact already set.
|
- |
|
| 305 |
if (lead.getFirstContactedAt() == null && countsAsContact(disposition)) {
|
- |
|
| 306 |
// Prefer the moment the retailer actually picked up over "now" — the agent may sit on the
|
- |
|
| 307 |
// disposition modal for minutes, and that gap would silently eat into the 5-hr SLA.
|
- |
|
| 308 |
LocalDateTime contactedAt = (call != null && call.getAnsweredAt() != null)
|
- |
|
| 309 |
? call.getAnsweredAt() : LocalDateTime.now();
|
- |
|
| 310 |
lead.setFirstContactedAt(contactedAt);
|
- |
|
| 311 |
}
|
- |
|
| 312 |
|
- |
|
| 313 |
LocalDateTime scheduled = parseLocal(body.callbackAt != null ? body.callbackAt : body.followUpAt);
|
- |
|
| 314 |
applyDispositionStage(lead, disposition, body);
|
- |
|
| 315 |
leadRepository.persist(lead);
|
- |
|
| 316 |
|
- |
|
| 317 |
CommunicationType type = "MEETING".equalsIgnoreCase(body.followUpType)
|
- |
|
| 318 |
? CommunicationType.VISIT : CommunicationType.TELEPHONIC;
|
- |
|
| 319 |
LeadActivity activity = appendTrail(body.leadId, actor,
|
- |
|
| 320 |
latin1Safe(dispositionRemark(disposition, before, lead, body)), type, scheduled,
|
- |
|
| 321 |
call != null ? call.getId() : null);
|
- |
|
| 322 |
|
- |
|
| 323 |
// Bind both ways: the trail entry knows its call (for duration + a recording link), and the
|
- |
|
| 324 |
// call knows the disposition it produced (so an unactioned call is findable).
|
- |
|
| 325 |
if (call != null) {
|
285 |
case NOT_FOUND:
|
| 326 |
call.setLeadActivityId(activity.getId());
|
- |
|
| 327 |
leadCallRepository.persist(call);
|
- |
|
| 328 |
}
|
- |
|
| 329 |
|
- |
|
| 330 |
if (disposition == LeadDisposition.DO_NOT_CALL) {
|
- |
|
| 331 |
blockFurtherCalls(lead, actor, body);
|
- |
|
| 332 |
}
|
- |
|
| 333 |
|
- |
|
| 334 |
LOGGER.info("LMS disposition {} on lead {} ({} -> {}) by {}, call {}", disposition, body.leadId, before,
|
- |
|
| 335 |
lead.getEffectiveStage(), actor != null ? actor.getId() : 0, call != null ? call.getId() : null);
|
- |
|
| 336 |
return responseSender.ok(stateOf(lead));
|
286 |
return responseSender.notFound(outcome.message);
|
| 337 |
}
|
- |
|
| 338 |
|
- |
|
| 339 |
/**
|
- |
|
| 340 |
* Which call this disposition is about. The browser normally passes {@code leadCallId} straight
|
- |
|
| 341 |
* from the dialer; when it cannot (agent dialled from their own handset, or the page reloaded
|
- |
|
| 342 |
* mid-call) we fall back to this agent's most recent call on this lead that no disposition has
|
- |
|
| 343 |
* claimed yet. Deliberately narrow — binding the wrong call is worse than binding none, because
|
- |
|
| 344 |
* it would attach someone else's recording to this lead's trail.
|
- |
|
| 345 |
*/
|
- |
|
| 346 |
private LeadCall resolveCall(DispositionRequest body, Lead lead, AuthUser actor) {
|
- |
|
| 347 |
if (body.leadCallId != null && body.leadCallId > 0) {
|
- |
|
| 348 |
LeadCall call = leadCallRepository.selectById(body.leadCallId);
|
- |
|
| 349 |
if (call != null && call.getLeadId() == lead.getId()) {
|
- |
|
| 350 |
return call;
|
- |
|
| 351 |
}
|
287 |
default:
|
| 352 |
LOGGER.warn("Ignoring leadCallId {} on lead {} — missing or belongs to another lead",
|
- |
|
| 353 |
body.leadCallId, lead.getId());
|
288 |
return responseSender.badRequest(outcome.message);
|
| 354 |
return null;
|
- |
|
| 355 |
}
|
- |
|
| 356 |
if (actor == null) {
|
- |
|
| 357 |
return null;
|
- |
|
| 358 |
}
|
- |
|
| 359 |
LeadCall latest = leadCallRepository.selectLatestByLeadIdAndAuthId(lead.getId(), actor.getId());
|
- |
|
| 360 |
if (latest == null || latest.getLeadActivityId() != null) {
|
- |
|
| 361 |
return null;
|
- |
|
| 362 |
}
|
- |
|
| 363 |
LocalDateTime startedAt = latest.getStartedAt() != null ? latest.getStartedAt() : latest.getCreatedTimestamp();
|
- |
|
| 364 |
if (startedAt == null || startedAt.isBefore(LocalDateTime.now().minusMinutes(CALL_BIND_WINDOW_MINUTES))) {
|
- |
|
| 365 |
return null;
|
- |
|
| 366 |
}
|
289 |
}
|
| 367 |
return latest;
|
- |
|
| 368 |
}
|
- |
|
| 369 |
|
- |
|
| 370 |
/**
|
- |
|
| 371 |
* DO_NOT_CALL means never ring this retailer again (SOP §17). Blocks the number, not the lead —
|
- |
|
| 372 |
* the same person recurs as several leads and a per-lead block would not hold. Before this the
|
- |
|
| 373 |
* disposition only moved the stage, and nothing stopped a re-dial.
|
- |
|
| 374 |
*/
|
- |
|
| 375 |
private void blockFurtherCalls(Lead lead, AuthUser actor, DispositionRequest body) {
|
- |
|
| 376 |
LeadDnd dnd = new LeadDnd();
|
- |
|
| 377 |
dnd.setMobile(lead.getLeadMobile());
|
- |
|
| 378 |
dnd.setLeadId(lead.getId());
|
- |
|
| 379 |
dnd.setAuthId(actor != null ? actor.getId() : null);
|
- |
|
| 380 |
dnd.setSource("DISPOSITION");
|
- |
|
| 381 |
dnd.setReason(latin1Safe(trimToNull(body.note) != null
|
- |
|
| 382 |
? trimToNull(body.note) : "Retailer asked not to be contacted"));
|
- |
|
| 383 |
leadDndRepository.block(dnd);
|
- |
|
| 384 |
LOGGER.info("LMS DND block on lead {} by {}", lead.getId(), actor != null ? actor.getId() : 0);
|
- |
|
| 385 |
}
|
290 |
}
|
| 386 |
|
291 |
|
| 387 |
/**
|
292 |
/**
|
| 388 |
* Correct the record's own fields (SOP §6).
|
293 |
* Correct the record's own fields (SOP §6).
|
| 389 |
*
|
294 |
*
|
| Line 1146... |
Line 1051... |
| 1146 |
return responseSender.ok(out);
|
1051 |
return responseSender.ok(out);
|
| 1147 |
}
|
1052 |
}
|
| 1148 |
|
1053 |
|
| 1149 |
// ---- Stage rules ------------------------------------------------------------------------
|
1054 |
// ---- Stage rules ------------------------------------------------------------------------
|
| 1150 |
|
1055 |
|
| 1151 |
/**
|
- |
|
| 1152 |
* Stage effect of each disposition (SOP §12.2). Only INTERESTED moves the lead forward; the
|
- |
|
| 1153 |
* negative outcomes are terminal, and NOT_REACHABLE drops the lead once the retry budget is
|
- |
|
| 1154 |
* spent. Anything else just records that contact happened.
|
- |
|
| 1155 |
*/
|
- |
|
| 1156 |
private void applyDispositionStage(Lead lead, LeadDisposition disposition, DispositionRequest body) {
|
- |
|
| 1157 |
switch (disposition) {
|
- |
|
| 1158 |
case INTERESTED:
|
- |
|
| 1159 |
if (body.value != null && body.value > 0) {
|
- |
|
| 1160 |
lead.setPotential(body.value);
|
- |
|
| 1161 |
}
|
- |
|
| 1162 |
advanceIfForward(lead, LeadStage.QUALIFIED);
|
- |
|
| 1163 |
break;
|
- |
|
| 1164 |
case NOT_INTERESTED:
|
- |
|
| 1165 |
case DO_NOT_CALL:
|
- |
|
| 1166 |
applyStage(lead, LeadStage.NOT_INTERESTED);
|
- |
|
| 1167 |
break;
|
- |
|
| 1168 |
case WRONG_NUMBER:
|
- |
|
| 1169 |
// A corrected number keeps the lead alive at its current stage — the retailer still
|
- |
|
| 1170 |
// has not been spoken to, so nothing advances. A blank one closes the lead.
|
- |
|
| 1171 |
String corrected = digitsOnly(body.correctedNumber);
|
- |
|
| 1172 |
if (corrected != null && corrected.length() == 10) {
|
- |
|
| 1173 |
lead.setLeadMobile(corrected);
|
- |
|
| 1174 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
- |
|
| 1175 |
} else {
|
- |
|
| 1176 |
applyStage(lead, LeadStage.DROPPED);
|
- |
|
| 1177 |
}
|
- |
|
| 1178 |
break;
|
- |
|
| 1179 |
case NOT_REACHABLE:
|
- |
|
| 1180 |
int tries = (lead.getUnreachableCount() == null ? 0 : lead.getUnreachableCount()) + 1;
|
- |
|
| 1181 |
lead.setUnreachableCount(tries);
|
- |
|
| 1182 |
if (tries >= LmsAssignmentService.MAX_UNREACHABLE) {
|
- |
|
| 1183 |
applyStage(lead, LeadStage.DROPPED);
|
- |
|
| 1184 |
}
|
- |
|
| 1185 |
break;
|
- |
|
| 1186 |
case CALLBACK:
|
- |
|
| 1187 |
case FOLLOW_UP:
|
- |
|
| 1188 |
default:
|
- |
|
| 1189 |
advanceIfForward(lead, LeadStage.CONTACTED);
|
- |
|
| 1190 |
break;
|
- |
|
| 1191 |
}
|
- |
|
| 1192 |
}
|
- |
|
| 1193 |
|
- |
|
| 1194 |
/** Did the retailer actually get spoken to? Only those outcomes may close the first-contact SLA. */
|
- |
|
| 1195 |
private boolean countsAsContact(LeadDisposition disposition) {
|
- |
|
| 1196 |
return disposition != LeadDisposition.NOT_REACHABLE && disposition != LeadDisposition.WRONG_NUMBER;
|
- |
|
| 1197 |
}
|
- |
|
| 1198 |
|
- |
|
| 1199 |
/** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */
|
1056 |
/** Set stage + keep the legacy status column in lockstep. Every stage write goes through here. */
|
| 1200 |
private void applyStage(Lead lead, LeadStage stage) {
|
1057 |
private void applyStage(Lead lead, LeadStage stage) {
|
| 1201 |
lead.setStage(stage);
|
1058 |
lead.setStage(stage);
|
| 1202 |
lead.setStatus(stage.toLegacyStatus());
|
1059 |
lead.setStatus(stage.toLegacyStatus());
|
| 1203 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
1060 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
| Line 1233... |
Line 1090... |
| 1233 |
activity.setCreatedTimestamp(LocalDateTime.now());
|
1090 |
activity.setCreatedTimestamp(LocalDateTime.now());
|
| 1234 |
leadActivityRepository.persist(activity);
|
1091 |
leadActivityRepository.persist(activity);
|
| 1235 |
return activity;
|
1092 |
return activity;
|
| 1236 |
}
|
1093 |
}
|
| 1237 |
|
1094 |
|
| 1238 |
/** Human-readable trail line for a disposition, including the stage move it caused. */
|
- |
|
| 1239 |
private String dispositionRemark(LeadDisposition disposition, LeadStage before, Lead lead,
|
- |
|
| 1240 |
DispositionRequest body) {
|
- |
|
| 1241 |
StringBuilder sb = new StringBuilder(pretty(disposition.name()));
|
- |
|
| 1242 |
if (body.subReason != null && !body.subReason.trim().isEmpty()) {
|
- |
|
| 1243 |
sb.append(" · ").append(body.subReason.trim());
|
- |
|
| 1244 |
}
|
- |
|
| 1245 |
if (disposition == LeadDisposition.NOT_REACHABLE) {
|
- |
|
| 1246 |
sb.append(" · attempt ").append(lead.getUnreachableCount());
|
- |
|
| 1247 |
if (body.retrySchedule != null && !body.retrySchedule.trim().isEmpty()) {
|
- |
|
| 1248 |
sb.append(", retry ").append(body.retrySchedule.trim());
|
- |
|
| 1249 |
}
|
- |
|
| 1250 |
}
|
- |
|
| 1251 |
LeadStage after = lead.getEffectiveStage();
|
- |
|
| 1252 |
if (after != before) {
|
- |
|
| 1253 |
sb.append(" · stage ").append(pretty(before)).append(" -> ").append(pretty(after));
|
- |
|
| 1254 |
}
|
- |
|
| 1255 |
if (body.note != null && !body.note.trim().isEmpty()) {
|
- |
|
| 1256 |
sb.append(" - ").append(body.note.trim());
|
- |
|
| 1257 |
}
|
- |
|
| 1258 |
return sb.toString();
|
- |
|
| 1259 |
}
|
- |
|
| 1260 |
|
- |
|
| 1261 |
/** What the caller needs to refresh its row without re-reading the whole record. */
|
1095 |
/** What the caller needs to refresh its row without re-reading the whole record. */
|
| 1262 |
private Map<String, Object> stateOf(Lead lead) {
|
1096 |
private Map<String, Object> stateOf(Lead lead) {
|
| 1263 |
LeadStage stage = lead.getEffectiveStage();
|
1097 |
LeadStage stage = lead.getEffectiveStage();
|
| 1264 |
Map<String, Object> out = new HashMap<>();
|
1098 |
Map<String, Object> out = new HashMap<>();
|
| 1265 |
out.put("leadId", lead.getId());
|
1099 |
out.put("leadId", lead.getId());
|
| Line 1321... |
Line 1155... |
| 1321 |
|
1155 |
|
| 1322 |
private String trimToNull(String s) {
|
1156 |
private String trimToNull(String s) {
|
| 1323 |
return (s == null || s.trim().isEmpty()) ? null : s.trim();
|
1157 |
return (s == null || s.trim().isEmpty()) ? null : s.trim();
|
| 1324 |
}
|
1158 |
}
|
| 1325 |
|
1159 |
|
| - |
|
1160 |
private static boolean positive(Integer n) {
|
| - |
|
1161 |
return n != null && n > 0;
|
| - |
|
1162 |
}
|
| - |
|
1163 |
|
| 1326 |
private String digitsOnly(String s) {
|
1164 |
private String digitsOnly(String s) {
|
| 1327 |
return s == null ? null : s.replaceAll("\\D", "");
|
1165 |
return s == null ? null : s.replaceAll("\\D", "");
|
| 1328 |
}
|
1166 |
}
|
| 1329 |
|
1167 |
|
| 1330 |
/** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */
|
1168 |
/** Accepts the {@code datetime-local} value the modal posts ({@code 2026-08-27T14:30}); null-safe. */
|
| Line 1338... |
Line 1176... |
| 1338 |
LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);
|
1176 |
LOGGER.warn("Ignoring unparseable LMS schedule timestamp: {}", s);
|
| 1339 |
return null;
|
1177 |
return null;
|
| 1340 |
}
|
1178 |
}
|
| 1341 |
}
|
1179 |
}
|
| 1342 |
|
1180 |
|
| 1343 |
/** JSON body of the disposition modal — one flat shape, sub-fields set per disposition. */
|
- |
|
| 1344 |
public static class DispositionRequest {
|
- |
|
| 1345 |
public int leadId;
|
- |
|
| 1346 |
public String disposition;
|
- |
|
| 1347 |
public String note;
|
- |
|
| 1348 |
/** The {@code user.lead_call} row this outcome belongs to, supplied by the dialer. */
|
- |
|
| 1349 |
public Integer leadCallId;
|
- |
|
| 1350 |
/**
|
- |
|
| 1351 |
* Interim: the free-text recording URL agents paste today. Superseded by {@code leadCallId}
|
- |
|
| 1352 |
* once the dialer captures recordings automatically — kept until then so removing the box
|
- |
|
| 1353 |
* does not leave leads with no recording path at all.
|
- |
|
| 1354 |
*/
|
- |
|
| 1355 |
public String recordingUrl;
|
- |
|
| 1356 |
public Double value; // INTERESTED
|
- |
|
| 1357 |
public String callbackAt; // CALLBACK
|
- |
|
| 1358 |
public String retrySchedule; // NOT_REACHABLE
|
- |
|
| 1359 |
public String correctedNumber;// WRONG_NUMBER
|
- |
|
| 1360 |
public String followUpType; // FOLLOW_UP — CALL | MEETING
|
- |
|
| 1361 |
public String followUpAt; // FOLLOW_UP
|
- |
|
| 1362 |
public String subReason; // NOT_INTERESTED
|
- |
|
| 1363 |
}
|
- |
|
| 1364 |
|
- |
|
| 1365 |
/**
|
1181 |
/**
|
| 1366 |
* Create a lead from the LMS dashboard.
|
1182 |
* Create a lead from the LMS dashboard.
|
| 1367 |
*
|
1183 |
*
|
| 1368 |
* <p><b>Deliberately not {@code /createLead}.</b> That endpoint is shared with the legacy Leads
|
1184 |
* <p><b>Deliberately not {@code /createLead}.</b> That endpoint is shared with the legacy Leads
|
| 1369 |
* screen and must keep behaving exactly as it does. It also silently ignores the {@code regionId}
|
1185 |
* screen and must keep behaving exactly as it does. It also silently ignores the {@code regionId}
|
| Line 1393... |
Line 1209... |
| 1393 |
}
|
1209 |
}
|
| 1394 |
mobile = mobile.substring(mobile.length() - 10);
|
1210 |
mobile = mobile.substring(mobile.length() - 10);
|
| 1395 |
if (body.regionId == null || body.regionId <= 0) {
|
1211 |
if (body.regionId == null || body.regionId <= 0) {
|
| 1396 |
return responseSender.badRequest("Pick a region — it drives auto-assignment");
|
1212 |
return responseSender.badRequest("Pick a region — it drives auto-assignment");
|
| 1397 |
}
|
1213 |
}
|
| - |
|
1214 |
// Narrow columns + STRICT_TRANS_TABLES: name the field instead of failing at flush.
|
| - |
|
1215 |
String tooLong = firstTooLong(
|
| - |
|
1216 |
"Address", body.address, 128,
|
| - |
|
1217 |
"City", body.city, 30,
|
| - |
|
1218 |
"State", body.state, 30);
|
| - |
|
1219 |
if (tooLong != null) {
|
| - |
|
1220 |
return responseSender.badRequest(tooLong);
|
| - |
|
1221 |
}
|
| 1398 |
|
1222 |
|
| 1399 |
// Same rule the dialer uses: a number on the do-not-call register never becomes a lead.
|
1223 |
// Same rule the dialer uses: a number on the do-not-call register never becomes a lead.
|
| 1400 |
if (leadDndRepository.selectByMobile(mobile) != null) {
|
1224 |
if (leadDndRepository.selectByMobile(mobile) != null) {
|
| 1401 |
return responseSender.badRequest("This number is on the do-not-call register.");
|
1225 |
return responseSender.badRequest("This number is on the do-not-call register.");
|
| 1402 |
}
|
1226 |
}
|
| Line 1416... |
Line 1240... |
| 1416 |
// Split on the last space so "Jitu Katara" keeps a surname; user.lead.last_name is NOT NULL.
|
1240 |
// Split on the last space so "Jitu Katara" keeps a surname; user.lead.last_name is NOT NULL.
|
| 1417 |
int space = name.lastIndexOf(' ');
|
1241 |
int space = name.lastIndexOf(' ');
|
| 1418 |
lead.setFirstName(space > 0 ? name.substring(0, space) : name);
|
1242 |
lead.setFirstName(space > 0 ? name.substring(0, space) : name);
|
| 1419 |
lead.setLastName(space > 0 ? name.substring(space + 1) : "");
|
1243 |
lead.setLastName(space > 0 ? name.substring(space + 1) : "");
|
| 1420 |
lead.setLeadMobile(mobile);
|
1244 |
lead.setLeadMobile(mobile);
|
| 1421 |
// address / city / state are NOT NULL but are no longer asked for: the geo link supplies the
|
1245 |
// address / city / state are NOT NULL. They are optional on this form (the geo link supplies
|
| 1422 |
// real location. Coalesced rather than left to fail the insert.
|
1246 |
// the real location), so whatever was typed is kept and blanks are coalesced rather than
|
| 1423 |
lead.setAddress("");
|
1247 |
// left to fail the insert.
|
| - |
|
1248 |
lead.setAddress(latin1Safe(body.address == null ? "" : body.address.trim()));
|
| 1424 |
lead.setCity("");
|
1249 |
lead.setCity(latin1Safe(body.city == null ? "" : body.city.trim()));
|
| 1425 |
lead.setState("");
|
1250 |
lead.setState(latin1Safe(body.state == null ? "" : body.state.trim()));
|
| - |
|
1251 |
// Legacy "conversion probability": the Leads screen colours a High lead Green, else Yellow.
|
| - |
|
1252 |
lead.setColor(Boolean.TRUE.equals(body.conversionHigh) ? "Green" : "Yellow");
|
| 1426 |
lead.setSource(latin1Safe(body.source == null || body.source.trim().isEmpty()
|
1253 |
lead.setSource(latin1Safe(body.source == null || body.source.trim().isEmpty()
|
| 1427 |
? "LMS Dashboard" : body.source.trim()));
|
1254 |
? "LMS Dashboard" : body.source.trim()));
|
| 1428 |
lead.setCreatedTimestamp(LocalDateTime.now());
|
1255 |
lead.setCreatedTimestamp(LocalDateTime.now());
|
| 1429 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
1256 |
lead.setUpdatedTimestamp(LocalDateTime.now());
|
| 1430 |
lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
|
1257 |
lead.setCreatedBy(me.getFirstName() + " " + me.getLastName());
|
| Line 1450... |
Line 1277... |
| 1450 |
// lms_code needs the generated id, so it can only be stamped after the insert.
|
1277 |
// lms_code needs the generated id, so it can only be stamped after the insert.
|
| 1451 |
lead.setLmsCode(lmsAssignmentService.generateLmsCode(lead));
|
1278 |
lead.setLmsCode(lmsAssignmentService.generateLmsCode(lead));
|
| 1452 |
leadRepository.persist(lead);
|
1279 |
leadRepository.persist(lead);
|
| 1453 |
|
1280 |
|
| 1454 |
boolean hasBrands = body.leadBrands != null && !body.leadBrands.isEmpty();
|
1281 |
boolean hasBrands = body.leadBrands != null && !body.leadBrands.isEmpty();
|
| - |
|
1282 |
boolean hasPhotos = positive(body.frontp) || positive(body.frontWithMarket) || positive(body.internalLongShot)
|
| - |
|
1283 |
|| positive(body.internalLeftWall) || positive(body.internalRightWall);
|
| - |
|
1284 |
boolean hasCounter = positive(body.counterSize);
|
| 1455 |
if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands) {
|
1285 |
if ((body.outletName != null && !body.outletName.trim().isEmpty()) || hasBrands || hasPhotos || hasCounter) {
|
| 1456 |
try {
|
1286 |
try {
|
| 1457 |
LeadDetailModel detail = new LeadDetailModel();
|
1287 |
LeadDetailModel detail = new LeadDetailModel();
|
| 1458 |
detail.setLeadId(lead.getId());
|
1288 |
detail.setLeadId(lead.getId());
|
| 1459 |
detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());
|
1289 |
detail.setOutletName(body.outletName == null ? "" : body.outletName.trim());
|
| - |
|
1290 |
if (hasCounter) {
|
| - |
|
1291 |
detail.setCounterSize(body.counterSize);
|
| - |
|
1292 |
}
|
| - |
|
1293 |
// Whatever photos came with the form; none are required here (see persistLeadDetail's
|
| - |
|
1294 |
// lenient overload) because this lead is usually created on a call, before a visit.
|
| - |
|
1295 |
if (positive(body.frontp)) detail.setFrontp(body.frontp);
|
| - |
|
1296 |
if (positive(body.frontWithMarket)) detail.setFrontWithMarket(body.frontWithMarket);
|
| - |
|
1297 |
if (positive(body.internalLongShot)) detail.setInternalLongShot(body.internalLongShot);
|
| - |
|
1298 |
if (positive(body.internalLeftWall)) detail.setInternalLeftWall(body.internalLeftWall);
|
| - |
|
1299 |
if (positive(body.internalRightWall)) detail.setInternalRightWall(body.internalRightWall);
|
| 1460 |
if (hasBrands) {
|
1300 |
if (hasBrands) {
|
| 1461 |
List<LeadBrandModel> brands = new ArrayList<>();
|
1301 |
List<LeadBrandModel> brands = new ArrayList<>();
|
| 1462 |
for (BrandValue bv : body.leadBrands) {
|
1302 |
for (BrandValue bv : body.leadBrands) {
|
| 1463 |
if (bv == null || bv.brand == null || bv.brand.trim().isEmpty() || bv.value == null
|
1303 |
if (bv == null || bv.brand == null || bv.brand.trim().isEmpty() || bv.value == null
|
| 1464 |
|| bv.value <= 0) {
|
1304 |
|| bv.value <= 0) {
|
| Line 1471... |
Line 1311... |
| 1471 |
brand.setValue(bv.value.intValue());
|
1311 |
brand.setValue(bv.value.intValue());
|
| 1472 |
brands.add(brand);
|
1312 |
brands.add(brand);
|
| 1473 |
}
|
1313 |
}
|
| 1474 |
detail.setLeadBrands(brands);
|
1314 |
detail.setLeadBrands(brands);
|
| 1475 |
}
|
1315 |
}
|
| 1476 |
leadRepository.persistLeadDetail(detail, me);
|
1316 |
leadRepository.persistLeadDetail(detail, me, false);
|
| 1477 |
} catch (Exception e) {
|
1317 |
} catch (Exception e) {
|
| 1478 |
// Shop name and brand split are not worth losing an otherwise-good lead over.
|
1318 |
// Shop name and brand split are not worth losing an otherwise-good lead over.
|
| 1479 |
LOGGER.warn("Could not save the lead detail for lead {}", lead.getId(), e);
|
1319 |
LOGGER.warn("Could not save the lead detail for lead {}", lead.getId(), e);
|
| 1480 |
}
|
1320 |
}
|
| 1481 |
}
|
1321 |
}
|
| Line 1493... |
Line 1333... |
| 1493 |
}
|
1333 |
}
|
| 1494 |
created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
|
1334 |
created.append(bv.brand.trim()).append(' ').append(bv.value.longValue());
|
| 1495 |
first = false;
|
1335 |
first = false;
|
| 1496 |
}
|
1336 |
}
|
| 1497 |
}
|
1337 |
}
|
| - |
|
1338 |
if (hasPhotos) {
|
| - |
|
1339 |
created.append(" · store photos attached");
|
| - |
|
1340 |
}
|
| - |
|
1341 |
String remark = body.remark == null ? "" : body.remark.trim();
|
| - |
|
1342 |
if (!remark.isEmpty()) {
|
| - |
|
1343 |
created.append(" · remark: ").append(remark.length() > 500 ? remark.substring(0, 500) : remark);
|
| - |
|
1344 |
}
|
| 1498 |
trail(lead.getId(), me.getId(), latin1Safe(created.toString()
|
1345 |
trail(lead.getId(), me.getId(), latin1Safe(created.toString()
|
| 1499 |
+ (assignment != null && "HOLD".equals(assignment.assignmentStatus)
|
1346 |
+ (assignment != null && "HOLD".equals(assignment.assignmentStatus)
|
| 1500 |
? " - region has no active BM/RSM, parked in the HOLD queue" : "")));
|
1347 |
? " - region has no active BM/RSM, parked in the HOLD queue" : "")));
|
| 1501 |
|
1348 |
|
| 1502 |
// Auto-generate the geo capture link so the agent can send it during the same call.
|
1349 |
// Auto-generate the geo capture link so the agent can send it during the same call.
|
| Line 1601... |
Line 1448... |
| 1601 |
public Integer regionId; // drives auto-assignment
|
1448 |
public Integer regionId; // drives auto-assignment
|
| 1602 |
public String creationPath; // A = BGC outbound, B = field encounter
|
1449 |
public String creationPath; // A = BGC outbound, B = field encounter
|
| 1603 |
public Double potential; // monthly business value — the sum of leadBrands
|
1450 |
public Double potential; // monthly business value — the sum of leadBrands
|
| 1604 |
/** Per-brand monthly business. What a BM actually needs to size the counter. */
|
1451 |
/** Per-brand monthly business. What a BM actually needs to size the counter. */
|
| 1605 |
public List<BrandValue> leadBrands;
|
1452 |
public List<BrandValue> leadBrands;
|
| - |
|
1453 |
// Shop details the legacy Leads screen also captures. All optional here.
|
| - |
|
1454 |
public String address;
|
| - |
|
1455 |
public String city;
|
| - |
|
1456 |
public String state;
|
| - |
|
1457 |
public String remark; // becomes the first trail entry's note
|
| - |
|
1458 |
public Boolean conversionHigh; // legacy "conversion probability": High -> Green, else Yellow
|
| - |
|
1459 |
public Integer counterSize; // user.lead_detail.counter_size
|
| - |
|
1460 |
// Store photos as document ids from /document-upload (user.lead_detail columns).
|
| - |
|
1461 |
public Integer frontp;
|
| - |
|
1462 |
public Integer frontWithMarket;
|
| - |
|
1463 |
public Integer internalLongShot;
|
| - |
|
1464 |
public Integer internalLeftWall;
|
| - |
|
1465 |
public Integer internalRightWall;
|
| 1606 |
}
|
1466 |
}
|
| 1607 |
|
1467 |
|
| 1608 |
/** One brand's monthly business value. */
|
1468 |
/** One brand's monthly business value. */
|
| 1609 |
public static class BrandValue {
|
1469 |
public static class BrandValue {
|
| 1610 |
public String brand;
|
1470 |
public String brand;
|