Preisanzeige in der App entfernen und Auftragsbezeichnungen vereinheitlichen. Stationsabschlüsse und Benachrichtigungen absichern sowie Historie um Aufgabenbeschreibungen und ausführende Nutzer erweitern. Regressionstests ergänzen und App sowie Backend auf Version 0.9.24 anheben.
577 lines
19 KiB
Dart
577 lines
19 KiB
Dart
import 'cargo_item.dart';
|
|
import 'delivery_station.dart';
|
|
import 'task.dart';
|
|
|
|
class Job {
|
|
final String id; // Will store the timestamp as string
|
|
final String jobNumber;
|
|
final String status;
|
|
final DateTime createdAt;
|
|
final DateTime updatedAt;
|
|
final String createdBy;
|
|
final String customerSelection;
|
|
final String pickupCompany;
|
|
final String? pickupSalutation;
|
|
final String pickupFirstName;
|
|
final String pickupLastName;
|
|
final String pickupPhone;
|
|
final String pickupStreet;
|
|
final String pickupHouseNumber;
|
|
final String pickupAddressAddition;
|
|
final String pickupZip;
|
|
final String pickupCity;
|
|
final String deliveryCompany;
|
|
final String? deliverySalutation;
|
|
final String deliveryFirstName;
|
|
final String deliveryLastName;
|
|
final String deliveryPhone;
|
|
final String deliveryStreet;
|
|
final String deliveryHouseNumber;
|
|
final String deliveryAddressAddition;
|
|
final String deliveryZip;
|
|
final String deliveryCity;
|
|
final bool digitalProcessing;
|
|
final String appUser;
|
|
final String pickupDate;
|
|
final String pickupTime;
|
|
final String deliveryDate;
|
|
final String deliveryTime;
|
|
final String remark;
|
|
final double price;
|
|
final bool draft;
|
|
|
|
// New fields for cargoItems and tasks
|
|
final List<CargoItem> cargoItems;
|
|
final List<DeliveryStation> deliveryStations;
|
|
final List<Task> tasks;
|
|
final String deliveryCitiesDisplay;
|
|
final String firstDeliveryCity;
|
|
final String lastDeliveryCity;
|
|
|
|
// Legacy fields for backward compatibility
|
|
final String title;
|
|
final String description;
|
|
final String priority;
|
|
final DateTime? dueDate;
|
|
final String? assignedTo;
|
|
final String? location;
|
|
final Map<String, dynamic>? additionalData;
|
|
|
|
Job({
|
|
required this.id,
|
|
required this.jobNumber,
|
|
required this.status,
|
|
required this.createdAt,
|
|
required this.updatedAt,
|
|
required this.createdBy,
|
|
required this.customerSelection,
|
|
required this.pickupCompany,
|
|
this.pickupSalutation,
|
|
required this.pickupFirstName,
|
|
required this.pickupLastName,
|
|
required this.pickupPhone,
|
|
required this.pickupStreet,
|
|
required this.pickupHouseNumber,
|
|
required this.pickupAddressAddition,
|
|
required this.pickupZip,
|
|
required this.pickupCity,
|
|
required this.deliveryCompany,
|
|
this.deliverySalutation,
|
|
required this.deliveryFirstName,
|
|
required this.deliveryLastName,
|
|
required this.deliveryPhone,
|
|
required this.deliveryStreet,
|
|
required this.deliveryHouseNumber,
|
|
required this.deliveryAddressAddition,
|
|
required this.deliveryZip,
|
|
required this.deliveryCity,
|
|
required this.digitalProcessing,
|
|
required this.appUser,
|
|
required this.pickupDate,
|
|
required this.pickupTime,
|
|
required this.deliveryDate,
|
|
required this.deliveryTime,
|
|
required this.remark,
|
|
required this.price,
|
|
required this.draft,
|
|
// New fields for cargoItems and tasks
|
|
required this.cargoItems,
|
|
required this.tasks,
|
|
this.deliveryStations = const [],
|
|
this.deliveryCitiesDisplay = '',
|
|
this.firstDeliveryCity = '',
|
|
this.lastDeliveryCity = '',
|
|
// Legacy fields
|
|
String? title,
|
|
String? description,
|
|
this.priority = 'normal',
|
|
this.dueDate,
|
|
this.assignedTo,
|
|
this.location,
|
|
this.additionalData,
|
|
}) : title = title ?? 'Auftrag $jobNumber',
|
|
description =
|
|
description ?? 'Transport von $pickupCity nach $deliveryCity';
|
|
|
|
/// Parse DateTime from either string or array format
|
|
static DateTime? _parseDateTime(dynamic value) {
|
|
if (value == null) return null;
|
|
|
|
if (value is String) {
|
|
return DateTime.tryParse(value);
|
|
}
|
|
|
|
if (value is List && value.isNotEmpty) {
|
|
try {
|
|
// Array format: [year, month, day, hour, minute, second, nanosecond]
|
|
final year = value[0] as int;
|
|
final month = value.length > 1 ? value[1] as int : 1;
|
|
final day = value.length > 2 ? value[2] as int : 1;
|
|
final hour = value.length > 3 ? value[3] as int : 0;
|
|
final minute = value.length > 4 ? value[4] as int : 0;
|
|
final second = value.length > 5 ? value[5] as int : 0;
|
|
final nanosecond = value.length > 6 ? value[6] as int : 0;
|
|
|
|
// Convert nanoseconds to microseconds (divide by 1000)
|
|
final microsecond = nanosecond ~/ 1000;
|
|
|
|
return DateTime(year, month, day, hour, minute, second, microsecond);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Parse time string (for pickupTime/deliveryTime)
|
|
static String? _parseTimeString(dynamic value) {
|
|
if (value == null) return null;
|
|
if (value is String && value.isNotEmpty && value != 'null') {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Parse date string from either string or array format (for pickupDate/deliveryDate)
|
|
static String? _parseDateString(dynamic value) {
|
|
if (value == null) return null;
|
|
|
|
if (value is String) {
|
|
return value;
|
|
}
|
|
|
|
if (value is List && value.isNotEmpty) {
|
|
try {
|
|
// Array format: [year, month, day]
|
|
final year = value[0] as int;
|
|
final month = value.length > 1 ? value[1] as int : 1;
|
|
final day = value.length > 2 ? value[2] as int : 1;
|
|
|
|
// Format as ISO date string
|
|
return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return value.toString();
|
|
}
|
|
|
|
static String _readString(dynamic value, {String fallback = ''}) {
|
|
if (value is String) {
|
|
return value;
|
|
}
|
|
if (value is num || value is bool) {
|
|
return value.toString();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
factory Job.fromJson(Map<String, dynamic> json) {
|
|
// Support both flat structure and { job: {...}, cargoItems: [...], tasks: [...] }
|
|
final jobJson =
|
|
(json['job'] is Map)
|
|
? Map<String, dynamic>.from(json['job'] as Map)
|
|
: json;
|
|
|
|
// Determine the id robustly. Prefer the inner job.id if present.
|
|
String idValue = '';
|
|
final dynamic innerId = jobJson['id'];
|
|
if (innerId is Map<String, dynamic>) {
|
|
// Some backends send an object; try common fields
|
|
idValue =
|
|
innerId['timestamp']?.toString() ??
|
|
innerId[r'$oid']?.toString() ??
|
|
'';
|
|
} else if (innerId != null) {
|
|
idValue = innerId.toString();
|
|
}
|
|
if (idValue.isEmpty) {
|
|
// Fallback to outer json['id'] if provided
|
|
final dynamic outerId = json['id'];
|
|
if (outerId is Map<String, dynamic>) {
|
|
idValue =
|
|
outerId['timestamp']?.toString() ??
|
|
outerId[r'$oid']?.toString() ??
|
|
'';
|
|
} else if (outerId != null) {
|
|
idValue = outerId.toString();
|
|
}
|
|
}
|
|
|
|
// Parse cargoItems array
|
|
List<CargoItem> cargoItems = [];
|
|
if (json['cargoItems'] is List) {
|
|
cargoItems =
|
|
(json['cargoItems'] as List)
|
|
.map(
|
|
(item) =>
|
|
CargoItem.fromJson(Map<String, dynamic>.from(item as Map)),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
// Parse delivery stations and prefer their tasks over the legacy top-level tasks.
|
|
List<DeliveryStation> deliveryStations = [];
|
|
final deliveryStationsRaw =
|
|
jobJson['deliveryStations'] ?? json['deliveryStations'];
|
|
if (deliveryStationsRaw is List) {
|
|
deliveryStations =
|
|
deliveryStationsRaw
|
|
.map(
|
|
(station) => DeliveryStation.fromJson(
|
|
Map<String, dynamic>.from(station as Map),
|
|
),
|
|
)
|
|
.toList()
|
|
..sort((a, b) => a.stationOrder.compareTo(b.stationOrder));
|
|
}
|
|
|
|
int compareTasks(Task a, Task b) {
|
|
final stationCompare = (a.stationOrder ?? -1).compareTo(
|
|
b.stationOrder ?? -1,
|
|
);
|
|
if (stationCompare != 0) {
|
|
return stationCompare;
|
|
}
|
|
return (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0);
|
|
}
|
|
|
|
// The tasks embedded in deliveryStations are snapshots from job creation
|
|
// and never reflect later completions. The top-level tasks list comes from
|
|
// the server's task collection and carries the current completion state,
|
|
// so overlay it onto the station tasks by id.
|
|
final topLevelTasksRaw = json['tasks'] ?? jobJson['tasks'];
|
|
if (deliveryStations.isNotEmpty && topLevelTasksRaw is List) {
|
|
final freshTasksById = <String, Task>{};
|
|
for (final rawTask in topLevelTasksRaw) {
|
|
if (rawTask is! Map) {
|
|
continue;
|
|
}
|
|
try {
|
|
final task = Task.fromJson(Map<String, dynamic>.from(rawTask));
|
|
if (task.id.isNotEmpty) {
|
|
freshTasksById[task.id] = task;
|
|
}
|
|
} catch (_) {
|
|
// Ignore unparseable entries; the station snapshot stays in place.
|
|
}
|
|
}
|
|
for (final station in deliveryStations) {
|
|
for (var i = 0; i < station.tasks.length; i++) {
|
|
final fresh = freshTasksById[station.tasks[i].id];
|
|
if (fresh != null && fresh.completed && !station.tasks[i].completed) {
|
|
station.tasks[i] = station.tasks[i].copyWith(
|
|
completed: true,
|
|
completedAt: fresh.completedAt,
|
|
completedBy: fresh.completedBy,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse tasks array
|
|
List<Task> tasks = [];
|
|
if (deliveryStations.isNotEmpty) {
|
|
tasks =
|
|
deliveryStations.expand((station) => station.tasks).toList()
|
|
..sort(compareTasks);
|
|
} else if (json['tasks'] is List) {
|
|
tasks =
|
|
(json['tasks'] as List)
|
|
.map(
|
|
(task) => Task.fromJson(Map<String, dynamic>.from(task as Map)),
|
|
)
|
|
.toList()
|
|
..sort(compareTasks);
|
|
} else if (jobJson['tasks'] is List) {
|
|
tasks =
|
|
(jobJson['tasks'] as List)
|
|
.map(
|
|
(task) => Task.fromJson(Map<String, dynamic>.from(task as Map)),
|
|
)
|
|
.toList()
|
|
..sort(compareTasks);
|
|
}
|
|
|
|
// As a last resort, derive a deterministic id if still empty (avoid UNIQUE '' collisions)
|
|
if (idValue.isEmpty) {
|
|
final jobNumber = jobJson['jobNumber']?.toString();
|
|
final createdAt = jobJson['createdAt']?.toString();
|
|
idValue =
|
|
(jobNumber?.isNotEmpty == true)
|
|
? 'jobnum:$jobNumber'
|
|
: (createdAt?.isNotEmpty == true
|
|
? 'ts:${createdAt!}'
|
|
: DateTime.now().millisecondsSinceEpoch.toString());
|
|
}
|
|
|
|
return Job(
|
|
id: idValue,
|
|
jobNumber: jobJson['jobNumber']?.toString() ?? '',
|
|
status: jobJson['status']?.toString() ?? 'UNKNOWN',
|
|
createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
|
|
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
|
|
createdBy: jobJson['createdBy']?.toString() ?? '',
|
|
customerSelection: jobJson['customerSelection']?.toString() ?? '',
|
|
pickupCompany: jobJson['pickupCompany']?.toString() ?? '',
|
|
pickupSalutation: jobJson['pickupSalutation']?.toString(),
|
|
pickupFirstName: jobJson['pickupFirstName']?.toString() ?? '',
|
|
pickupLastName: jobJson['pickupLastName']?.toString() ?? '',
|
|
pickupPhone: jobJson['pickupPhone']?.toString() ?? '',
|
|
pickupStreet: jobJson['pickupStreet']?.toString() ?? '',
|
|
pickupHouseNumber: jobJson['pickupHouseNumber']?.toString() ?? '',
|
|
pickupAddressAddition: jobJson['pickupAddressAddition']?.toString() ?? '',
|
|
pickupZip: jobJson['pickupZip']?.toString() ?? '',
|
|
pickupCity: jobJson['pickupCity']?.toString() ?? '',
|
|
deliveryCompany: jobJson['deliveryCompany']?.toString() ?? '',
|
|
deliverySalutation: jobJson['deliverySalutation']?.toString(),
|
|
deliveryFirstName: jobJson['deliveryFirstName']?.toString() ?? '',
|
|
deliveryLastName: jobJson['deliveryLastName']?.toString() ?? '',
|
|
deliveryPhone: jobJson['deliveryPhone']?.toString() ?? '',
|
|
deliveryStreet: jobJson['deliveryStreet']?.toString() ?? '',
|
|
deliveryHouseNumber: jobJson['deliveryHouseNumber']?.toString() ?? '',
|
|
deliveryAddressAddition:
|
|
jobJson['deliveryAddressAddition']?.toString() ?? '',
|
|
deliveryZip: jobJson['deliveryZip']?.toString() ?? '',
|
|
deliveryCity: jobJson['deliveryCity']?.toString() ?? '',
|
|
digitalProcessing: jobJson['digitalProcessing'] == true,
|
|
appUser: jobJson['appUser']?.toString() ?? '',
|
|
pickupDate: _parseDateString(jobJson['pickupDate']) ?? '',
|
|
pickupTime: _parseTimeString(jobJson['pickupTime']) ?? '',
|
|
deliveryDate: _parseDateString(jobJson['deliveryDate']) ?? '',
|
|
deliveryTime: _parseTimeString(jobJson['deliveryTime']) ?? '',
|
|
remark: _readString(jobJson['remark']),
|
|
price: (jobJson['price'] is num) ? jobJson['price'].toDouble() : 0.0,
|
|
draft: jobJson['draft'] == true,
|
|
// New fields for cargoItems and tasks
|
|
cargoItems: cargoItems,
|
|
deliveryStations: deliveryStations,
|
|
tasks: tasks,
|
|
deliveryCitiesDisplay: _readString(jobJson['deliveryCitiesDisplay']),
|
|
firstDeliveryCity: _readString(jobJson['firstDeliveryCity']),
|
|
lastDeliveryCity: _readString(jobJson['lastDeliveryCity']),
|
|
// Legacy fields for backward compatibility
|
|
title: jobJson['title']?.toString(),
|
|
description: jobJson['description']?.toString(),
|
|
priority: jobJson['priority']?.toString() ?? 'normal',
|
|
dueDate: _parseDateTime(jobJson['dueDate']),
|
|
assignedTo: jobJson['assignedTo']?.toString(),
|
|
location: jobJson['location']?.toString(),
|
|
additionalData: jobJson['additionalData'] as Map<String, dynamic>?,
|
|
);
|
|
}
|
|
|
|
/// Return a normalized copy (trim strings, null→'')
|
|
Job normalized() {
|
|
String t(String? s) => (s ?? '').trim();
|
|
return Job(
|
|
id: id,
|
|
jobNumber: t(jobNumber),
|
|
status: t(status),
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt,
|
|
createdBy: t(createdBy),
|
|
customerSelection: t(customerSelection),
|
|
pickupCompany: t(pickupCompany),
|
|
pickupSalutation: t(pickupSalutation),
|
|
pickupFirstName: t(pickupFirstName),
|
|
pickupLastName: t(pickupLastName),
|
|
pickupPhone: t(pickupPhone),
|
|
pickupStreet: t(pickupStreet),
|
|
pickupHouseNumber: t(pickupHouseNumber),
|
|
pickupAddressAddition: t(pickupAddressAddition),
|
|
pickupZip: t(pickupZip),
|
|
pickupCity: t(pickupCity),
|
|
deliveryCompany: t(deliveryCompany),
|
|
deliverySalutation: t(deliverySalutation),
|
|
deliveryFirstName: t(deliveryFirstName),
|
|
deliveryLastName: t(deliveryLastName),
|
|
deliveryPhone: t(deliveryPhone),
|
|
deliveryStreet: t(deliveryStreet),
|
|
deliveryHouseNumber: t(deliveryHouseNumber),
|
|
deliveryAddressAddition: t(deliveryAddressAddition),
|
|
deliveryZip: t(deliveryZip),
|
|
deliveryCity: t(deliveryCity),
|
|
digitalProcessing: digitalProcessing,
|
|
appUser: t(appUser),
|
|
pickupDate: t(pickupDate),
|
|
pickupTime: t(pickupTime),
|
|
deliveryDate: t(deliveryDate),
|
|
deliveryTime: t(deliveryTime),
|
|
remark: t(remark),
|
|
price: price,
|
|
draft: draft,
|
|
cargoItems: cargoItems,
|
|
deliveryStations:
|
|
deliveryStations.map((station) => station.normalized()).toList(),
|
|
tasks: tasks,
|
|
deliveryCitiesDisplay: t(deliveryCitiesDisplay),
|
|
firstDeliveryCity: t(firstDeliveryCity),
|
|
lastDeliveryCity: t(lastDeliveryCity),
|
|
title: t(title),
|
|
description: t(description),
|
|
priority: t(priority),
|
|
dueDate: dueDate,
|
|
assignedTo: t(assignedTo),
|
|
location: t(location),
|
|
additionalData: additionalData,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'jobNumber': jobNumber,
|
|
'status': status,
|
|
'createdAt': createdAt.toIso8601String(),
|
|
'updatedAt': updatedAt.toIso8601String(),
|
|
'createdBy': createdBy,
|
|
'customerSelection': customerSelection,
|
|
'pickupCompany': pickupCompany,
|
|
'pickupSalutation': pickupSalutation,
|
|
'pickupFirstName': pickupFirstName,
|
|
'pickupLastName': pickupLastName,
|
|
'pickupPhone': pickupPhone,
|
|
'pickupStreet': pickupStreet,
|
|
'pickupHouseNumber': pickupHouseNumber,
|
|
'pickupAddressAddition': pickupAddressAddition,
|
|
'pickupZip': pickupZip,
|
|
'pickupCity': pickupCity,
|
|
'deliveryCompany': deliveryCompany,
|
|
'deliverySalutation': deliverySalutation,
|
|
'deliveryFirstName': deliveryFirstName,
|
|
'deliveryLastName': deliveryLastName,
|
|
'deliveryPhone': deliveryPhone,
|
|
'deliveryStreet': deliveryStreet,
|
|
'deliveryHouseNumber': deliveryHouseNumber,
|
|
'deliveryAddressAddition': deliveryAddressAddition,
|
|
'deliveryZip': deliveryZip,
|
|
'deliveryCity': deliveryCity,
|
|
'digitalProcessing': digitalProcessing,
|
|
'appUser': appUser,
|
|
'pickupDate': pickupDate,
|
|
'pickupTime': pickupTime,
|
|
'deliveryDate': deliveryDate,
|
|
'deliveryTime': deliveryTime,
|
|
'remark': remark,
|
|
'price': price,
|
|
'draft': draft,
|
|
// New fields for cargoItems and tasks
|
|
'cargoItems': cargoItems.map((item) => item.toJson()).toList(),
|
|
'deliveryStations':
|
|
deliveryStations.map((station) => station.toJson()).toList(),
|
|
'tasks': tasks.map((task) => task.toJson()).toList(),
|
|
'deliveryCitiesDisplay': deliveryCitiesDisplay,
|
|
'firstDeliveryCity': firstDeliveryCity,
|
|
'lastDeliveryCity': lastDeliveryCity,
|
|
// Legacy fields
|
|
'title': title,
|
|
'description': description,
|
|
'priority': priority,
|
|
'dueDate': dueDate?.toIso8601String(),
|
|
'assignedTo': assignedTo,
|
|
'location': location,
|
|
'additionalData': additionalData,
|
|
};
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'Job(id: $id, title: $title, status: $status, priority: $priority)';
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
if (identical(this, other)) return true;
|
|
return other is Job && other.id == id;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => id.hashCode;
|
|
|
|
/// Get status color for UI display
|
|
String get statusColor {
|
|
switch (status.toLowerCase()) {
|
|
case 'created':
|
|
return 'orange';
|
|
case 'pending':
|
|
case 'assigned':
|
|
return 'orange';
|
|
case 'in_progress':
|
|
case 'started':
|
|
return 'blue';
|
|
case 'completed':
|
|
case 'done':
|
|
return 'green';
|
|
case 'cancelled':
|
|
case 'failed':
|
|
return 'red';
|
|
default:
|
|
return 'grey';
|
|
}
|
|
}
|
|
|
|
/// Get status display text in German
|
|
String get statusDisplayText {
|
|
switch (status.toLowerCase()) {
|
|
case 'created':
|
|
return 'Erstellt';
|
|
case 'pending':
|
|
return 'Wartend';
|
|
case 'assigned':
|
|
return 'Zugewiesen';
|
|
case 'in_progress':
|
|
case 'started':
|
|
return 'In Bearbeitung';
|
|
case 'completed':
|
|
case 'done':
|
|
return 'Abgeschlossen';
|
|
case 'cancelled':
|
|
return 'Abgebrochen';
|
|
case 'failed':
|
|
return 'Fehlgeschlagen';
|
|
default:
|
|
return status; // Show the original status if not mapped
|
|
}
|
|
}
|
|
|
|
/// Get priority display text in German
|
|
String get priorityDisplayText {
|
|
switch (priority.toLowerCase()) {
|
|
case 'low':
|
|
return 'Niedrig';
|
|
case 'normal':
|
|
return 'Normal';
|
|
case 'high':
|
|
return 'Hoch';
|
|
case 'urgent':
|
|
return 'Dringend';
|
|
default:
|
|
return 'Normal';
|
|
}
|
|
}
|
|
}
|