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, ¤t); 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)