Files
crm_mobile/lib/services/qdrant_service.dart

236 lines
8.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/client.dart';
class QdrantService {
static const String _baseUrl = 'http://66.116.207.225:6333';
static const String _apiKey = 'Package@321#';
static const String collectionName = 'doormile_clients';
// 16-dim feature vector (city-agnostic — city stored in payload only)
//
// [0] parcelVolume (normalised 01 over max 1 200)
// [1] activeContracts (normalised 01 over max 40)
// [25] businessType one-hot (ecommerce, manufacturing, wholesale, retail)
// [69] status one-hot (newClient, pending, completed, updated)
// [10] surveySubmitted (0 / 1)
// [11] dataConsent basicOnly=0, full=1
// [12] hasPhone (0 / 1)
// [13] hasTransitRoute both from+to non-empty (0 / 1)
// [14] frequencyScore none=0, monthly=0.25, biweekly=0.5, weekly=0.75, daily=1
// [15] hasNeighbourhood (0 / 1)
static const int _vectorSize = 16;
final http.Client _http;
QdrantService({http.Client? client}) : _http = client ?? http.Client();
Map<String, String> get _headers => {
'Content-Type': 'application/json',
'api-key': _apiKey,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
};
/// Creates the collection if it does not already exist.
Future<void> ensureCollection() async {
final res = await _http.get(
Uri.parse('$_baseUrl/collections/$collectionName'),
headers: _headers,
);
if (res.statusCode == 200) return;
await _http.put(
Uri.parse('$_baseUrl/collections/$collectionName'),
headers: _headers,
body: jsonEncode({
'vectors': {'size': _vectorSize, 'distance': 'Cosine'},
}),
);
}
/// Upsert (insert or update) a single client.
Future<void> upsertClient(Client client) async {
await _http.put(
Uri.parse('$_baseUrl/collections/$collectionName/points'),
headers: _headers,
body: jsonEncode({
'points': [_toPoint(client)],
}),
);
}
/// Batch upsert a list of clients.
Future<void> upsertClients(List<Client> clients) async {
if (clients.isEmpty) return;
await _http.put(
Uri.parse('$_baseUrl/collections/$collectionName/points'),
headers: _headers,
body: jsonEncode({
'points': clients.map(_toPoint).toList(),
}),
);
}
/// Fetch all stored clients from Qdrant (up to 500).
Future<List<Client>> loadClients() async {
final res = await _http.post(
Uri.parse('$_baseUrl/collections/$collectionName/points/scroll'),
headers: _headers,
body: jsonEncode({
'limit': 500,
'with_payload': true,
'with_vector': false,
}),
);
if (res.statusCode != 200) return [];
final body = jsonDecode(res.body) as Map<String, dynamic>;
final points = (body['result']?['points'] as List?) ?? [];
return points.map((p) => _fromPoint(p as Map<String, dynamic>)).toList();
}
/// Drop and recreate the collection — used for staging resets.
Future<void> deleteCollection() async {
await _http.delete(
Uri.parse('$_baseUrl/collections/$collectionName'),
headers: _headers,
);
}
/// Delete a client by its string ID.
Future<void> deleteClient(String clientId) async {
await _http.post(
Uri.parse('$_baseUrl/collections/$collectionName/points/delete'),
headers: _headers,
body: jsonEncode({
'points': [_stableId(clientId)],
}),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
int _stableId(String id) {
int h = 0;
for (final c in id.codeUnits) {
h = (h * 31 + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
Map<String, dynamic> _toPoint(Client client) => {
'id': _stableId(client.id),
'vector': _toVector(client),
'payload': {
'clientId': client.id,
'name': client.name,
'city': client.city,
'businessType': client.businessType.name,
'parcelVolume': client.parcelVolume,
'activeContracts': client.activeContracts,
'provider': client.provider,
'efficiency': client.efficiency,
'status': client.status.name,
'lastUpdated': client.lastUpdated,
'surveySubmitted': client.surveySubmitted,
'notes': client.notes,
'dataConsent': client.dataConsent.name,
'phone': client.phone,
'frequency': client.frequency,
'businessState': client.businessState,
'transitType': client.transitType,
'transitFrom': client.transitFrom,
'transitTo': client.transitTo,
'neighbourhood': client.neighbourhood,
'logisticsSegment': client.logisticsSegment,
'pincode': client.pincode,
// GPS captured at submission — not shown in app, stored for field audit
'surveyLat': client.surveyLat,
'surveyLng': client.surveyLng,
'surveyAddress': client.surveyAddress,
'surveyZone': client.surveyZone,
'surveyPincode': client.surveyPincode,
// Field-agent attribution — who collected this survey (login session)
'recordedByName': client.recordedByName,
'recordedByEmail': client.recordedByEmail,
'recordedByRole': client.recordedByRole,
// Qdrant geo payload — enables geo-radius queries later
if (client.surveyLat != 0.0 || client.surveyLng != 0.0)
'surveyGeo': {'lat': client.surveyLat, 'lon': client.surveyLng},
},
};
List<double> _toVector(Client client) {
final v = List<double>.filled(_vectorSize, 0.0);
v[0] = (client.parcelVolume / 1200.0).clamp(0.0, 1.0);
v[1] = (client.activeContracts / 40.0).clamp(0.0, 1.0);
v[2 + client.businessType.index] = 1.0;
v[6 + client.status.index] = 1.0;
v[10] = client.surveySubmitted ? 1.0 : 0.0;
v[11] = client.dataConsent == DataConsent.full ? 1.0 : 0.0;
v[12] = client.phone.isNotEmpty ? 1.0 : 0.0;
v[13] = (client.transitFrom.isNotEmpty && client.transitTo.isNotEmpty)
? 1.0
: 0.0;
v[14] = _frequencyScore(client.frequency);
v[15] = client.neighbourhood.isNotEmpty ? 1.0 : 0.0;
return v;
}
double _frequencyScore(String freq) {
switch (freq.toLowerCase()) {
case 'daily':
return 1.0;
case 'weekly':
return 0.75;
case 'bi-weekly':
return 0.5;
case 'monthly':
return 0.25;
default:
return 0.0;
}
}
Client _fromPoint(Map<String, dynamic> point) {
final p = point['payload'] as Map<String, dynamic>;
return Client(
id: p['clientId'] as String,
name: p['name'] as String,
city: p['city'] as String,
businessType:
BusinessType.values.firstWhere((b) => b.name == p['businessType']),
parcelVolume: (p['parcelVolume'] as num?)?.toDouble() ?? 0.0,
activeContracts: p['activeContracts'] as int,
provider: p['provider'] as String,
efficiency: p['efficiency'] as String,
status: ClientStatus.values.firstWhere((s) => s.name == p['status']),
lastUpdated: p['lastUpdated'] as String,
surveySubmitted: p['surveySubmitted'] as bool,
notes: (p['notes'] as String?) ?? '',
dataConsent: DataConsent.values.firstWhere(
(d) => d.name == (p['dataConsent'] as String? ?? 'full'),
orElse: () => DataConsent.full,
),
phone: (p['phone'] as String?) ?? '',
frequency: (p['frequency'] as String?) ?? '',
businessState: (p['businessState'] as String?) ?? '',
transitType: (p['transitType'] as String?) ?? '',
transitFrom: (p['transitFrom'] as String?) ?? '',
transitTo: (p['transitTo'] as String?) ?? '',
neighbourhood: (p['neighbourhood'] as String?) ?? '',
logisticsSegment: (p['logisticsSegment'] as String?) ?? '',
pincode: (p['pincode'] as String?) ?? '',
surveyLat: (p['surveyLat'] as num?)?.toDouble() ?? 0.0,
surveyLng: (p['surveyLng'] as num?)?.toDouble() ?? 0.0,
surveyAddress: (p['surveyAddress'] as String?) ?? '',
surveyZone: (p['surveyZone'] as String?) ?? '',
surveyPincode: (p['surveyPincode'] as String?) ?? '',
recordedByName: (p['recordedByName'] as String?) ?? '',
recordedByEmail: (p['recordedByEmail'] as String?) ?? '',
recordedByRole: (p['recordedByRole'] as String?) ?? '',
);
}
}