Preuve · code, extrait

backend/ringover-call-events.go

Dépôt jeremy-flexylead/flexylead-crm · commit ccbbaa07870e0f786729003dd6f8862e4bcc8256 · dossier de production synchronisé le 14 septembre 2026 à 08 h 46

Extrait tel qu'à ce commit : lignes 595–667, 1381–1441, 3000–3066, sur 4 566 (numérotation d'origine, 30 lignes de contexte autour de chaque citation) · fichier intégral sur GitHub (dépôt privé, accès requis)

Lignes citées par l'audit : 625–637 1411 3030–3036 3032–3033

201 lignes reproduites · ← retour à l'audit · catalogue des preuves

595		routing.CommercialGroupID = groupID
596	}
597	routing.MatchSource = appendMatchReason(routing.MatchSource, "smart_routing_decision")
598	return routing, nil
599}
600
601func routingDeliveryConfirmed(payload ringoverPayload, commercialID string) bool {
602	// Ringover can mark the root IVR leg as answered even though no agent ever
603	// picked up. A routed delivery only exists when the provider event can be
604	// tied to the commercial that actually answered.
605	if strings.TrimSpace(commercialID) == "" {
606		return false
607	}
608	if isUnattributedIVRAnswer(payload, commercialID) {
609		return false
610	}
611	event := strings.ToLower(strings.TrimSpace(payload.Event))
612	if event == "answered" {
613		return true
614	}
615	if !isTerminalCallEvent(event) {
616		return false
617	}
618	if firstTime(payload.Data, "answered_at", "answer_time", "answered_date_time_atom") != "" {
619		return true
620	}
621	talk := firstNumber(payload.Data, "talk_duration", "talk_duration_seconds", "conversation_duration")
622	return talk != nil
623}
624
625func isUnattributedIVRAnswer(payload ringoverPayload, commercialID string) bool {
626	// A commercial inherited from a ringing event is a target, not proof of
627	// pickup. Only the provider identity on this event can identify its actor.
628	if ringoverUserID(payload.Data) != "" || ringoverUserEmail(payload.Data) != "" || !truthy(payload.Data["is_ivr"]) {
629		return false
630	}
631	event := strings.ToLower(strings.TrimSpace(payload.Event))
632	if event == "answered" {
633		return true
634	}
635	return isTerminalCallEvent(event) && (firstTime(payload.Data, "answered_at", "answer_time", "answered_date_time_atom") != "" ||
636		firstNumber(payload.Data, "talk_duration", "talk_duration_seconds", "conversation_duration") != nil)
637}
638
639func confirmRoutingDecisionDelivery(ctx *Context, callID, centreID, commercialID, now string) error {
640	callID = strings.TrimSpace(callID)
641	centreID = strings.TrimSpace(centreID)
642	commercialID = strings.TrimSpace(commercialID)
643	if callID == "" || centreID == "" || commercialID == "" {
644		return nil
645	}
646	rows, err := search(ctx, "call_routing_decisions", []any{
647		map[string]any{"col": "provider_call_id", "op": "eq", "value": callID},
648		map[string]any{"col": "status", "op": "eq", "value": "routed"},
649	}, 5)
650	if err != nil {
651		return err
652	}
653	for _, decision := range rows.Rows {
654		if truthyField(decision, "repeated") {
655			continue
656		}
657		id := rowIDInt(decision)
658		if id <= 0 {
659			continue
660		}
661		fields := map[string]any{
662			"quota_status":              "delivered",
663			"counted":                   true,
664			"delivered_centre_id":       centreID,
665			"delivered_commercial_id":   commercialID,
666			"delivery_confirmed_at_iso": now,
667			"updated_at_iso":            now,
···
1381	prospect, err := getRowByID(ctx, "prospects", prospectID)
1382	if err != nil || prospect == nil {
1383		return err
1384	}
1385	if lock := activeProspectCentreLock(prospect, now); lock != nil {
1386		return nil
1387	}
1388	if centreID == "" {
1389		owner, ownerErr := getRowByID(ctx, "commerciaux", commercialID)
1390		if ownerErr != nil {
1391			return ownerErr
1392		}
1393		centreID = stringField(owner, "centre_appel_id")
1394	}
1395	if centreID == "" {
1396		return fmt.Errorf("answered commercial %s has no centre", commercialID)
1397	}
1398	id := rowIDInt(prospect)
1399	if id <= 0 {
1400		return fmt.Errorf("answered prospect %s has no numeric id", prospectID)
1401	}
1402	_, err = ctx.Call("tables", "rows_update", map[string]any{
1403		"table": "prospects",
1404		"id":    id,
1405		"fields": map[string]any{
1406			"assigned_commercial_id": commercialID,
1407			"assigned_at":            firstNonEmpty(answeredAt, now),
1408			"assignment_source":      "answered_call",
1409			"centre_lock_id":         centreID,
1410			"centre_lock_until":      nil,
1411			"centre_lock_source":     "answered_call",
1412			"updated_at_iso":         now,
1413		},
1414	})
1415	return err
1416}
1417
1418func finalizeAnsweredAppelProjection(ctx *Context, appelRowID int64, commercialID, centreID, userID, username, userEmail, answeredAt string) error {
1419	if appelRowID <= 0 {
1420		return fmt.Errorf("answered call has no numeric id")
1421	}
1422	raw, err := ctx.Call("tables", "rows_get", map[string]any{"table": "appels", "id": appelRowID})
1423	if err != nil {
1424		return err
1425	}
1426	var current struct {
1427		Found bool           `json:"found"`
1428		Row   map[string]any `json:"row"`
1429	}
1430	if err = json.Unmarshal(raw, &current); err != nil {
1431		return err
1432	}
1433	if !current.Found {
1434		return fmt.Errorf("answered call disappeared")
1435	}
1436	fields := map[string]any{
1437		"commercial_id":       commercialID,
1438		"centre_appel_id":     centreID,
1439		"provider_user_id":    userID,
1440		"provider_username":   username,
1441		"provider_user_email": userEmail,
···
3000		row["ringover_summary_updated_at"] = now
3001	}
3002	if existing != nil {
3003		if v := stringField(existing, "local_id"); v != "" {
3004			row["local_id"] = v
3005		}
3006		if v := stringField(existing, "source"); v != "" {
3007			row["source"] = v
3008		}
3009		for _, k := range []string{"parent_appel_id", "callback_reason", "callback_requested_at", "followup_appel_id", "reservation_id"} {
3010			if v := stringField(existing, k); v != "" {
3011				row[k] = v
3012			}
3013		}
3014		if stringField(existing, "callback_status") != "" {
3015			row["callback_status"] = callbackStatusForEvent(payload)
3016			row["callback_provider_request_id"] = callID
3017		}
3018		if v := stringField(existing, "issue"); v != "" {
3019			row["issue"] = v
3020		}
3021		if v := stringField(existing, "handled_by"); v != "" {
3022			row["handled_by"] = v
3023		}
3024		if row["hangup_initiator"] == "" {
3025			if v := stringField(existing, "hangup_initiator"); v != "" {
3026				row["hangup_initiator"] = v
3027			}
3028		}
3029	}
3030	if isUnattributedIVRAnswer(payload, commercialID) && stringField(existing, "handled_by") != "ai" &&
3031		(eventName == "answered" || stringField(existing, "answered_at") == "" || stringField(existing, "handled_by") == "ivr") {
3032		// Preserve answered_at as the provider fact, but make the business
3033		// ownership explicit so Dashboard/Acquisition do not report a pickup.
3034		row["handled_by"] = "ivr"
3035		row["issue"] = "ivr_connected_unattributed"
3036		row["routing_warning"] = appendWarning(stringField(row, "routing_warning"), "ivr_answered_without_commercial")
3037	}
3038	if eventName == "answered" && (ringoverUserID(payload.Data) != "" || ringoverUserEmail(payload.Data) != "") && commercialID != "" && stringField(existing, "handled_by") == "ivr" {
3039		row["handled_by"] = "human"
3040		if stringField(existing, "issue") == "ivr_connected_unattributed" {
3041			row["issue"] = ""
3042		}
3043	}
3044	applyMissedPoolFields(row, existing, status, direction, startedAt, endedAt, now, routing.isAIForwarded(direction))
3045	// Réparation d'un « manque » deviné par le reconciler : l'appel fantôme
3046	// quitte la file des appels manqués et la ligne garde une trace de la
3047	// correction (le marqueur du reconciler est remplacé, pas cumulé).
3048	if existing != nil && isMissedStatus(stringField(existing, "status")) && !isMissedStatus(status) && reconcilerGuessedMissed(existing) {
3049		row["missed_pool_status"] = "routed"
3050		row["status_error"] = "reconciled_missed_repaired_by_terminal_event"
3051	}
3052	if manualParent != nil {
3053		if v := stringField(manualParent, "local_id"); v != "" {
3054			row["parent_appel_id"] = v
3055		}
3056		row["callback_reason"] = "manual_ringover"
3057		row["callback_requested_at"] = firstNonEmpty(startedAt, eventAt, now)
3058		row["issue"] = "rappel"
3059	}
3060	for k, v := range row {
3061		if isEmpty(v) {
3062			delete(row, k)
3063		}
3064	}
3065	if existing != nil {
3066		id := rowIDInt(existing)