436 lines
14 KiB
Dart
436 lines
14 KiB
Dart
import "package:bus_running_record/models/channels/base_channel.dart";
|
|
import "package:bus_running_record/models/operations/stop.dart";
|
|
import "package:supabase_flutter/supabase_flutter.dart";
|
|
|
|
class OperationsSchedule {
|
|
const OperationsSchedule({
|
|
required this.id,
|
|
required this.version,
|
|
required this.sourceFileName,
|
|
required this.uploadedAt,
|
|
});
|
|
|
|
final String id;
|
|
final int version;
|
|
final String sourceFileName;
|
|
final DateTime? uploadedAt;
|
|
|
|
factory OperationsSchedule.fromMap(Map<String, dynamic> map) {
|
|
return OperationsSchedule(
|
|
id: (map["id"] ?? "").toString(),
|
|
version: (map["version"] as num?)?.toInt() ?? 1,
|
|
sourceFileName: (map["source_file_name"] ?? "").toString(),
|
|
uploadedAt: DateTime.tryParse((map["uploaded_at"] ?? "").toString()),
|
|
);
|
|
}
|
|
}
|
|
|
|
class OperationsTrip {
|
|
const OperationsTrip({
|
|
required this.id,
|
|
required this.scheduleId,
|
|
required this.tripNumber,
|
|
required this.dutyNumber,
|
|
required this.busWorkNumber,
|
|
required this.direction,
|
|
required this.sortOrder,
|
|
});
|
|
|
|
final String id;
|
|
final String scheduleId;
|
|
final String tripNumber;
|
|
final String dutyNumber;
|
|
final String busWorkNumber;
|
|
final String direction;
|
|
final int sortOrder;
|
|
String get dutyKey => "$dutyNumber::$busWorkNumber";
|
|
|
|
factory OperationsTrip.fromMap(Map<String, dynamic> map) {
|
|
return OperationsTrip(
|
|
id: (map["id"] ?? "").toString(),
|
|
scheduleId: (map["schedule_id"] ?? "").toString(),
|
|
tripNumber: (map["trip_number"] ?? "").toString(),
|
|
dutyNumber: (map["duty_number"] ?? "").toString(),
|
|
busWorkNumber: (map["bus_work_number"] ?? "").toString(),
|
|
direction: (map["direction"] ?? "").toString(),
|
|
sortOrder: (map["sort_order"] as num?)?.toInt() ?? 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
class OperationsScheduledStop extends Stop {
|
|
const OperationsScheduledStop({
|
|
required this.id,
|
|
required this.tripId,
|
|
required this.sequence,
|
|
required super.name,
|
|
required this.scheduledTime,
|
|
super.alias,
|
|
super.aliasSource,
|
|
});
|
|
|
|
final String id;
|
|
final String tripId;
|
|
final int sequence;
|
|
final String? scheduledTime;
|
|
}
|
|
|
|
class OperationsStopUpdate {
|
|
const OperationsStopUpdate({
|
|
required this.status,
|
|
required this.actualTime,
|
|
required this.vehicleId,
|
|
required this.notes,
|
|
});
|
|
|
|
final String? status;
|
|
final String? actualTime;
|
|
final String? vehicleId;
|
|
final String? notes;
|
|
}
|
|
|
|
class OperationsStop {
|
|
const OperationsStop({required this.scheduledStop, this.update});
|
|
|
|
final OperationsScheduledStop scheduledStop;
|
|
final OperationsStopUpdate? update;
|
|
|
|
String get id => scheduledStop.id;
|
|
String get tripId => scheduledStop.tripId;
|
|
int get sequence => scheduledStop.sequence;
|
|
String get name => scheduledStop.name;
|
|
String get displayName => scheduledStop.displayName;
|
|
String? get aliasSource => scheduledStop.aliasSource;
|
|
String? get scheduledTime => scheduledStop.scheduledTime;
|
|
String? get status => update?.status;
|
|
String? get actualTime => update?.actualTime;
|
|
String? get vehicleId => update?.vehicleId;
|
|
String? get notes => update?.notes;
|
|
}
|
|
|
|
class OperationsTripSnapshot {
|
|
const OperationsTripSnapshot({
|
|
required this.trip,
|
|
required this.stops,
|
|
required this.scheduledStops,
|
|
});
|
|
|
|
final OperationsTrip trip;
|
|
final List<OperationsStop> stops;
|
|
final List<OperationsScheduledStop> scheduledStops;
|
|
}
|
|
|
|
class OperationsDutySnapshot {
|
|
const OperationsDutySnapshot({
|
|
required this.dutyNumber,
|
|
required this.busWorkNumber,
|
|
required this.trips,
|
|
});
|
|
|
|
final String dutyNumber;
|
|
final String busWorkNumber;
|
|
final List<OperationsTripSnapshot> trips;
|
|
}
|
|
|
|
class OperationsStopAlias {
|
|
const OperationsStopAlias({
|
|
required this.rawStopName,
|
|
required this.aliasStopName,
|
|
required this.source,
|
|
});
|
|
|
|
final String rawStopName;
|
|
final String aliasStopName;
|
|
final String source;
|
|
}
|
|
|
|
class OperationsSnapshot {
|
|
const OperationsSnapshot({
|
|
required this.schedule,
|
|
required this.trips,
|
|
required this.duties,
|
|
required this.scheduledStops,
|
|
required this.stopAliasesByRawName,
|
|
required this.stopAliases,
|
|
});
|
|
|
|
final OperationsSchedule? schedule;
|
|
final List<OperationsTripSnapshot> trips;
|
|
final List<OperationsDutySnapshot> duties;
|
|
final List<OperationsScheduledStop> scheduledStops;
|
|
final Map<String, String> stopAliasesByRawName;
|
|
final List<OperationsStopAlias> stopAliases;
|
|
}
|
|
|
|
class OperationsChannel extends BaseChannel {
|
|
OperationsChannel({
|
|
required super.client,
|
|
required super.id,
|
|
required super.organizationId,
|
|
required super.name,
|
|
required super.description,
|
|
required super.slug,
|
|
required super.isPrivate,
|
|
required super.position,
|
|
}) : super(kind: ChannelKind.operations);
|
|
|
|
factory OperationsChannel.fromApi({
|
|
required SupabaseClient client,
|
|
required Map<String, dynamic> map,
|
|
}) {
|
|
return OperationsChannel(
|
|
client: client,
|
|
id: (map["id"] ?? "").toString(),
|
|
organizationId: (map["organization_id"] ?? "").toString(),
|
|
name: (map["name"] ?? "").toString(),
|
|
description: (map["description"] ?? map["topic"] ?? "").toString(),
|
|
slug: (map["slug"] ?? "").toString(),
|
|
isPrivate: map["is_private"] == true,
|
|
position: (map["position"] as num?)?.toInt() ?? 0,
|
|
);
|
|
}
|
|
|
|
Future<OperationsSnapshot> loadSnapshot() async {
|
|
final scheduleRow = await client
|
|
.from("operations_schedules")
|
|
.select("id, version, source_file_name, uploaded_at")
|
|
.eq("channel_id", id)
|
|
.eq("is_active", true)
|
|
.order("uploaded_at", ascending: false)
|
|
.limit(1)
|
|
.maybeSingle();
|
|
|
|
if (scheduleRow == null) {
|
|
return const OperationsSnapshot(
|
|
schedule: null,
|
|
trips: <OperationsTripSnapshot>[],
|
|
duties: <OperationsDutySnapshot>[],
|
|
scheduledStops: <OperationsScheduledStop>[],
|
|
stopAliasesByRawName: <String, String>{},
|
|
stopAliases: <OperationsStopAlias>[],
|
|
);
|
|
}
|
|
|
|
final stopAliases = await listStopAliases();
|
|
final stopAliasesByRawName = _aliasesByRawStopName(stopAliases);
|
|
final aliasesByNormalizedName = <String, String>{};
|
|
final aliasSourceByNormalizedName = <String, String>{};
|
|
for (final entry in stopAliasesByRawName.entries) {
|
|
aliasesByNormalizedName[Stop.normalizeName(entry.key)] = entry.value;
|
|
}
|
|
for (final alias in stopAliases) {
|
|
aliasSourceByNormalizedName[Stop.normalizeName(alias.rawStopName)] =
|
|
alias.source;
|
|
}
|
|
|
|
final schedule = OperationsSchedule.fromMap(BaseChannel.asMap(scheduleRow));
|
|
final tripRows = await client
|
|
.from("operations_trips")
|
|
.select(
|
|
"id, schedule_id, trip_number, duty_number, bus_work_number, direction, sort_order",
|
|
)
|
|
.eq("schedule_id", schedule.id)
|
|
.order("sort_order", ascending: true);
|
|
|
|
final trips = BaseChannel.asList(
|
|
tripRows,
|
|
).map((row) => OperationsTrip.fromMap(BaseChannel.asMap(row))).toList();
|
|
|
|
if (trips.isEmpty) {
|
|
return OperationsSnapshot(
|
|
schedule: schedule,
|
|
trips: const <OperationsTripSnapshot>[],
|
|
duties: const <OperationsDutySnapshot>[],
|
|
scheduledStops: const <OperationsScheduledStop>[],
|
|
stopAliasesByRawName: stopAliasesByRawName,
|
|
stopAliases: stopAliases,
|
|
);
|
|
}
|
|
|
|
final tripIds = trips.map((trip) => trip.id).toList();
|
|
final stopRows = await client
|
|
.from("operations_trip_stops")
|
|
.select("id, trip_id, stop_sequence, stop_name, scheduled_time")
|
|
.filter("trip_id", "in", "(${tripIds.join(",")})")
|
|
.order("trip_id", ascending: true)
|
|
.order("stop_sequence", ascending: true);
|
|
|
|
final stopIds = BaseChannel.asList(stopRows)
|
|
.map((row) => (BaseChannel.asMap(row)["id"] ?? "").toString())
|
|
.where((value) => value.isNotEmpty)
|
|
.toList();
|
|
|
|
List<dynamic> updateRows = const <dynamic>[];
|
|
if (stopIds.isNotEmpty) {
|
|
updateRows = await client
|
|
.from("operations_stop_updates")
|
|
.select("trip_stop_id, status, actual_time, vehicle_id, notes")
|
|
.filter("trip_stop_id", "in", "(${stopIds.join(",")})");
|
|
}
|
|
|
|
final updatesByStopId = <String, Map<String, dynamic>>{};
|
|
for (final row in BaseChannel.asList(updateRows)) {
|
|
final map = BaseChannel.asMap(row);
|
|
final tripStopId = (map["trip_stop_id"] ?? "").toString();
|
|
if (tripStopId.isEmpty) continue;
|
|
updatesByStopId[tripStopId] = map;
|
|
}
|
|
|
|
final scheduledStopsByTripId = <String, List<OperationsScheduledStop>>{};
|
|
final allScheduledStops = <OperationsScheduledStop>[];
|
|
for (final row in BaseChannel.asList(stopRows)) {
|
|
final map = BaseChannel.asMap(row);
|
|
final stopId = (map["id"] ?? "").toString();
|
|
final tripId = (map["trip_id"] ?? "").toString();
|
|
final scheduledStop = OperationsScheduledStop(
|
|
id: stopId,
|
|
tripId: tripId,
|
|
sequence: (map["stop_sequence"] as num?)?.toInt() ?? 0,
|
|
name: (map["stop_name"] ?? "").toString().trim(),
|
|
alias: aliasesByNormalizedName[
|
|
Stop.normalizeName((map["stop_name"] ?? "").toString())],
|
|
aliasSource: aliasSourceByNormalizedName[
|
|
Stop.normalizeName((map["stop_name"] ?? "").toString())],
|
|
scheduledTime: (map["scheduled_time"] ?? "").toString().trim().isEmpty
|
|
? null
|
|
: (map["scheduled_time"] ?? "").toString(),
|
|
);
|
|
allScheduledStops.add(scheduledStop);
|
|
scheduledStopsByTripId
|
|
.putIfAbsent(tripId, () => <OperationsScheduledStop>[])
|
|
.add(scheduledStop);
|
|
}
|
|
|
|
final snapshots = trips.map((trip) {
|
|
final scheduledStops =
|
|
scheduledStopsByTripId[trip.id] ?? const <OperationsScheduledStop>[];
|
|
final stops = scheduledStops
|
|
.map((scheduledStop) {
|
|
final update = updatesByStopId[scheduledStop.id];
|
|
final hasUpdate = update != null;
|
|
return OperationsStop(
|
|
scheduledStop: scheduledStop,
|
|
update: !hasUpdate
|
|
? null
|
|
: OperationsStopUpdate(
|
|
status: (update["status"] ?? "").toString().trim().isEmpty
|
|
? null
|
|
: (update["status"] ?? "").toString(),
|
|
actualTime:
|
|
(update["actual_time"] ?? "")
|
|
.toString()
|
|
.trim()
|
|
.isEmpty
|
|
? null
|
|
: (update["actual_time"] ?? "").toString(),
|
|
vehicleId:
|
|
(update["vehicle_id"] ?? "").toString().trim().isEmpty
|
|
? null
|
|
: (update["vehicle_id"] ?? "").toString(),
|
|
notes: (update["notes"] ?? "").toString().trim().isEmpty
|
|
? null
|
|
: (update["notes"] ?? "").toString(),
|
|
),
|
|
);
|
|
})
|
|
.toList(growable: false);
|
|
return OperationsTripSnapshot(
|
|
trip: trip,
|
|
stops: stops,
|
|
scheduledStops: scheduledStops,
|
|
);
|
|
}).toList();
|
|
|
|
final groupedByDuty = <String, List<OperationsTripSnapshot>>{};
|
|
for (final snapshot in snapshots) {
|
|
groupedByDuty
|
|
.putIfAbsent(snapshot.trip.dutyKey, () => <OperationsTripSnapshot>[])
|
|
.add(snapshot);
|
|
}
|
|
final duties = groupedByDuty.entries
|
|
.map((entry) {
|
|
final dutyTrips = entry.value;
|
|
final firstTrip = dutyTrips.first.trip;
|
|
return OperationsDutySnapshot(
|
|
dutyNumber: firstTrip.dutyNumber,
|
|
busWorkNumber: firstTrip.busWorkNumber,
|
|
trips: dutyTrips,
|
|
);
|
|
})
|
|
.toList(growable: false);
|
|
|
|
return OperationsSnapshot(
|
|
schedule: schedule,
|
|
trips: snapshots,
|
|
duties: duties,
|
|
scheduledStops: allScheduledStops,
|
|
stopAliasesByRawName: stopAliasesByRawName,
|
|
stopAliases: stopAliases,
|
|
);
|
|
}
|
|
|
|
Future<List<OperationsStopAlias>> listStopAliases() async {
|
|
final rows = await client
|
|
.from("operations_stop_aliases")
|
|
.select("raw_stop_name, alias_stop_name, source")
|
|
.eq("channel_id", id);
|
|
|
|
final aliases = <OperationsStopAlias>[];
|
|
for (final row in BaseChannel.asList(rows)) {
|
|
final map = BaseChannel.asMap(row);
|
|
final raw = (map["raw_stop_name"] ?? "").toString().trim();
|
|
final alias = (map["alias_stop_name"] ?? "").toString().trim();
|
|
final source = (map["source"] ?? "").toString().trim();
|
|
if (raw.isEmpty || alias.isEmpty) continue;
|
|
aliases.add(
|
|
OperationsStopAlias(
|
|
rawStopName: raw,
|
|
aliasStopName: alias,
|
|
source: source.isEmpty ? "user" : source,
|
|
),
|
|
);
|
|
}
|
|
return aliases;
|
|
}
|
|
|
|
Map<String, String> _aliasesByRawStopName(List<OperationsStopAlias> aliases) {
|
|
final byRawStopName = <String, String>{};
|
|
for (final alias in aliases) {
|
|
byRawStopName[alias.rawStopName] = alias.aliasStopName;
|
|
}
|
|
return byRawStopName;
|
|
}
|
|
|
|
Future<void> markStopCompleted({
|
|
required String tripStopId,
|
|
required bool completed,
|
|
String? actualTime,
|
|
String? vehicleId,
|
|
String? notes,
|
|
}) async {
|
|
final userId = client.auth.currentUser?.id;
|
|
if (userId == null || userId.isEmpty) {
|
|
throw StateError(
|
|
"Cannot update stop status without an authenticated user.",
|
|
);
|
|
}
|
|
|
|
String? persistedActualTime = actualTime;
|
|
if (completed &&
|
|
(persistedActualTime == null || persistedActualTime.isEmpty)) {
|
|
final now = DateTime.now();
|
|
final hour = now.hour.toString().padLeft(2, "0");
|
|
final minute = now.minute.toString().padLeft(2, "0");
|
|
persistedActualTime = "$hour:$minute";
|
|
}
|
|
|
|
await client.from("operations_stop_updates").upsert({
|
|
"trip_stop_id": tripStopId,
|
|
"status": completed ? "completed" : null,
|
|
"actual_time": completed ? persistedActualTime : null,
|
|
"vehicle_id": vehicleId,
|
|
"notes": notes,
|
|
"updated_by": userId,
|
|
}, onConflict: "trip_stop_id");
|
|
}
|
|
}
|