236 lines
8.5 KiB
Dart
236 lines
8.5 KiB
Dart
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 0–1 over max 1 200)
|
||
// [1] activeContracts (normalised 0–1 over max 40)
|
||
// [2–5] businessType one-hot (ecommerce, manufacturing, wholesale, retail)
|
||
// [6–9] 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?) ?? '',
|
||
);
|
||
}
|
||
}
|