Subversion Repositories SmartDukaan

Rev

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

Rev 37737 Rev 37749
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;