feat: Frachtanzeige und Stationsabschluss für Version 0.9.23
Fracht mit Maßen und Gewicht anzeigen und Stationsabschlüsse gegen Wiederholung absichern. Backend-Abschluss atomar ausführen und mit Regressionstests abdecken. Optionale Aufgaben beim Kopieren erhalten und Kunden mit Adressen anzeigen. App und Backend auf Version 0.9.23 aktualisieren.
This commit is contained in:
1 parent
d7c0173493
commit
7325c77739
11 files changed
+277
-65
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:votianlt_app/models/cargo_item.dart';
|
||||||
|
|
||||||
import 'app_theme.dart';
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
@@ -81,8 +82,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: ListView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// Job summary card
|
// Job summary card
|
||||||
Card(
|
Card(
|
||||||
@@ -122,11 +122,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
color: Colors.green[600],
|
color: Colors.green[600],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Flexible(
|
||||||
widget.job.pickupCity,
|
child: Text(
|
||||||
style: TextStyle(
|
widget.job.pickupCity,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
color: Colors.grey[700],
|
fontSize: 12,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -142,13 +144,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Flexible(
|
||||||
widget.job.deliveryCitiesDisplay.isNotEmpty
|
child: Text(
|
||||||
? widget.job.deliveryCitiesDisplay
|
widget.job.deliveryCitiesDisplay.isNotEmpty
|
||||||
: widget.job.deliveryCity,
|
? widget.job.deliveryCitiesDisplay
|
||||||
style: TextStyle(
|
: widget.job.deliveryCity,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
color: Colors.grey[700],
|
fontSize: 12,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -157,6 +161,30 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.job.cargoItems.isNotEmpty) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 24,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.cargo,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...widget.job.cargoItems.map(_buildCargoItemCard),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
// Delivery stations section header
|
// Delivery stations section header
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -166,17 +194,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
l10n.deliveryStationsCount(_deliveryStations.length),
|
child: Text(
|
||||||
style: const TextStyle(
|
l10n.deliveryStationsCount(_deliveryStations.length),
|
||||||
fontSize: 20,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(child: _buildDeliveryStationsList()),
|
_buildDeliveryStationsList(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -186,6 +216,39 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCargoItemCard(CargoItem item) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.description.trim().isNotEmpty
|
||||||
|
? item.description
|
||||||
|
: l10n.cargo,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('${l10n.quantity}: ${item.quantity}'),
|
||||||
|
if (item.hasDimensions) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${l10n.dimensions}: ${item.formattedDimensions}'),
|
||||||
|
],
|
||||||
|
if (item.weightKg.isFinite && item.weightKg > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${l10n.weight}: ${item.formattedWeight}'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<DeliveryStation> get _deliveryStations {
|
List<DeliveryStation> get _deliveryStations {
|
||||||
if (widget.job.deliveryStations.isNotEmpty) {
|
if (widget.job.deliveryStations.isNotEmpty) {
|
||||||
return widget.job.deliveryStations;
|
return widget.job.deliveryStations;
|
||||||
@@ -238,12 +301,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return Column(
|
||||||
itemCount: _deliveryStations.length,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
itemBuilder: (context, index) {
|
children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
|
||||||
final station = _deliveryStations[index];
|
|
||||||
return _buildDeliveryStationCard(station);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,9 +87,18 @@ class CargoItem {
|
|||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode;
|
int get hashCode => id.hashCode;
|
||||||
|
|
||||||
|
bool get hasDimensions =>
|
||||||
|
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
|
||||||
|
|
||||||
/// Get formatted dimensions string for display
|
/// Get formatted dimensions string for display
|
||||||
String get formattedDimensions {
|
String get formattedDimensions {
|
||||||
return '${lengthCm.toInt()} × ${widthCm.toInt()} × ${heightCm.toInt()} cm';
|
final dimensions = [lengthCm, widthCm, heightCm].map((value) {
|
||||||
|
if (!value.isFinite || value <= 0) return '–';
|
||||||
|
return value == value.truncateToDouble()
|
||||||
|
? value.toStringAsFixed(0)
|
||||||
|
: value.toString();
|
||||||
|
});
|
||||||
|
return '${dimensions.join(' × ')} cm';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get formatted weight string for display
|
/// Get formatted weight string for display
|
||||||
|
|||||||
@@ -1216,6 +1216,7 @@ class WebSocketService {
|
|||||||
name: 'WebSocketService',
|
name: 'WebSocketService',
|
||||||
);
|
);
|
||||||
developer.log('Stack: $st', name: 'WebSocketService');
|
developer.log('Stack: $st', name: 'WebSocketService');
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-20
@@ -42,6 +42,9 @@ class TaskView extends StatefulWidget {
|
|||||||
class _TaskViewState extends State<TaskView> {
|
class _TaskViewState extends State<TaskView> {
|
||||||
final Set<String> _completedTasks = {};
|
final Set<String> _completedTasks = {};
|
||||||
final DatabaseService _databaseService = DatabaseService();
|
final DatabaseService _databaseService = DatabaseService();
|
||||||
|
bool _statusesLoaded = false;
|
||||||
|
bool _stationCompleted = false;
|
||||||
|
bool _isCompletingStation = false;
|
||||||
// Store SVG representations of signatures per task for later use
|
// Store SVG representations of signatures per task for later use
|
||||||
final Map<String, String> _signatureSvgByTask = {};
|
final Map<String, String> _signatureSvgByTask = {};
|
||||||
|
|
||||||
@@ -61,10 +64,19 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get _stationCompletionKey =>
|
||||||
|
'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}';
|
||||||
|
|
||||||
/// Load task completion and skipped statuses from database and merge with JSON task states
|
/// Load task completion and skipped statuses from database and merge with JSON task states
|
||||||
Future<void> _loadTaskStatuses() async {
|
Future<void> _loadTaskStatuses() async {
|
||||||
final statuses = await _databaseService.loadAllTaskStatuses();
|
final statuses = await _databaseService.loadAllTaskStatuses();
|
||||||
|
final stationCompleted =
|
||||||
|
await _databaseService.loadKeyValue(_stationCompletionKey) == '1';
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_stationCompleted =
|
||||||
|
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED';
|
||||||
|
_statusesLoaded = true;
|
||||||
_completedTasks.clear();
|
_completedTasks.clear();
|
||||||
// 1) Add all completed from DB
|
// 1) Add all completed from DB
|
||||||
for (final entry in statuses.entries) {
|
for (final entry in statuses.entries) {
|
||||||
@@ -148,9 +160,11 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get _canCompleteStation {
|
bool get _canCompleteStation {
|
||||||
|
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
||||||
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
|
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
|
||||||
// immer aktiv.
|
|
||||||
for (final t in _visibleTasks) {
|
for (final t in _visibleTasks) {
|
||||||
if (!t.optional && !_completedTasks.contains(t.id)) {
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -184,6 +198,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onCompleteStationPressed() {
|
void _onCompleteStationPressed() {
|
||||||
|
if (!_canCompleteStation) return;
|
||||||
if (_hasIncompleteOptionalTasks) {
|
if (_hasIncompleteOptionalTasks) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -214,21 +229,30 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendStationCompleted() {
|
Future<void> _sendStationCompleted() async {
|
||||||
|
if (!_canCompleteStation) return;
|
||||||
|
setState(() => _isCompletingStation = true);
|
||||||
final stationOrder = widget.stationOrder ?? 0;
|
final stationOrder = widget.stationOrder ?? 0;
|
||||||
try {
|
try {
|
||||||
StompService().sendStationCompleted(
|
await StompService().sendStationCompleted(
|
||||||
jobId: widget.job.id,
|
jobId: widget.job.id,
|
||||||
jobNumber: widget.job.jobNumber,
|
jobNumber: widget.job.jobNumber,
|
||||||
stationOrder: stationOrder,
|
stationOrder: stationOrder,
|
||||||
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_stationCompleted = true;
|
||||||
const SnackBar(content: Text('Station abgeschlossen')),
|
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
|
||||||
);
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Station abgeschlossen')));
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Error sending station completion: $e', name: 'TaskView');
|
developer.log('Error sending station completion: $e', name: 'TaskView');
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isCompletingStation = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,18 +396,18 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.warningSoft,
|
color: AppColors.warningSoft,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.warning.withValues(alpha: 0.5),
|
color: AppColors.warning.withValues(alpha: 0.5),
|
||||||
width: 1,
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Optional',
|
'Optional',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -756,7 +780,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
||||||
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted && _canCompleteStation) {
|
||||||
_showLastMandatoryCompletedDialog();
|
_showLastMandatoryCompletedDialog();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -899,7 +923,9 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
? localizeKnownText(context, task.description!)
|
? localizeKnownText(context, task.description!)
|
||||||
: null;
|
: null;
|
||||||
final String? signatureNote =
|
final String? signatureNote =
|
||||||
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
|
(task is SignatureTask &&
|
||||||
|
task.note != null &&
|
||||||
|
task.note!.trim().isNotEmpty)
|
||||||
? task.note!.trim()
|
? task.note!.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.9.22+922
|
version: 0.9.23+923
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
sdk: ^3.7.0
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.9.22</revision>
|
<revision>0.9.23</revision>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import de.assecutor.votianlt.model.JobStatus;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
|
import org.springframework.data.mongodb.core.query.Criteria;
|
||||||
|
import org.springframework.data.mongodb.core.query.Query;
|
||||||
|
import org.springframework.data.mongodb.core.query.Update;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -53,6 +57,7 @@ public class MessageController {
|
|||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
|
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
|
private final MongoTemplate mongoTemplate;
|
||||||
|
|
||||||
private final CargoItemRepository cargoItemRepository;
|
private final CargoItemRepository cargoItemRepository;
|
||||||
|
|
||||||
@@ -72,11 +77,12 @@ public class MessageController {
|
|||||||
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||||
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
|
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
|
||||||
MessageService messageService, TaskAssignmentService taskAssignmentService) {
|
MessageService messageService, TaskAssignmentService taskAssignmentService, MongoTemplate mongoTemplate) {
|
||||||
this.messagingPublisher = messagingPublisher;
|
this.messagingPublisher = messagingPublisher;
|
||||||
this.appUserRepository = appUserRepository;
|
this.appUserRepository = appUserRepository;
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
|
this.mongoTemplate = mongoTemplate;
|
||||||
this.cargoItemRepository = cargoItemRepository;
|
this.cargoItemRepository = cargoItemRepository;
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.photoRepository = photoRepository;
|
this.photoRepository = photoRepository;
|
||||||
@@ -420,7 +426,9 @@ public class MessageController {
|
|||||||
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
|
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
|
||||||
|
|
||||||
if (allCompleted) {
|
if (allCompleted) {
|
||||||
updateJobStatusToCompleted(jobId);
|
if (!updateJobStatusToCompleted(jobId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emailService.sendJobCompletionNotification(jobId, completedBy);
|
emailService.sendJobCompletionNotification(jobId, completedBy);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -438,23 +446,18 @@ public class MessageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateJobStatusToCompleted(ObjectId jobId) {
|
private boolean updateJobStatusToCompleted(ObjectId jobId) {
|
||||||
try {
|
try {
|
||||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
Query query = Query.query(Criteria.where("_id").is(jobId).and("status").ne(JobStatus.COMPLETED));
|
||||||
if (jobOpt.isEmpty()) {
|
Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now());
|
||||||
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
|
boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0;
|
||||||
return;
|
if (completed) {
|
||||||
}
|
|
||||||
|
|
||||||
Job job = jobOpt.get();
|
|
||||||
if (job.getStatus() != JobStatus.COMPLETED) {
|
|
||||||
job.setStatus(JobStatus.COMPLETED);
|
|
||||||
job.setUpdatedAt(LocalDateTime.now());
|
|
||||||
jobRepository.save(job);
|
|
||||||
log.info("[JOB] Job {} marked as COMPLETED", jobId);
|
log.info("[JOB] Job {} marked as COMPLETED", jobId);
|
||||||
}
|
}
|
||||||
|
return completed;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e);
|
log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -1600,6 +1600,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
|
|
||||||
if (copy != null) {
|
if (copy != null) {
|
||||||
copy.setDescription(original.getDescription());
|
copy.setDescription(original.getDescription());
|
||||||
|
copy.setOptional(original.isOptional());
|
||||||
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
||||||
copy.setCompleted(original.isCompleted());
|
copy.setCompleted(original.isCompleted());
|
||||||
copy.setCompletedAt(original.getCompletedAt());
|
copy.setCompletedAt(original.getCompletedAt());
|
||||||
|
|||||||
+1
-1
@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
|
|
||||||
customerLabelMap.clear();
|
customerLabelMap.clear();
|
||||||
for (Customer c : customers) {
|
for (Customer c : customers) {
|
||||||
CustomerAddressLabelHelper.putName(customerLabelMap, c,
|
CustomerAddressLabelHelper.put(customerLabelMap, c,
|
||||||
translationHelper.getTranslation("addjob.customer.unnamed"));
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
}
|
}
|
||||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
||||||
customerLabelToEntity.clear();
|
customerLabelToEntity.clear();
|
||||||
for (Customer c : ownerCustomers) {
|
for (Customer c : ownerCustomers) {
|
||||||
CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
CustomerAddressLabelHelper.put(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
||||||
}
|
}
|
||||||
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.assecutor.votianlt.controller;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.mongodb.client.result.UpdateResult;
|
||||||
|
import de.assecutor.votianlt.model.Job;
|
||||||
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
|
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||||
|
import de.assecutor.votianlt.repository.JobRepository;
|
||||||
|
import de.assecutor.votianlt.service.EmailService;
|
||||||
|
import de.assecutor.votianlt.service.JobUpdateBroadcaster;
|
||||||
|
import de.assecutor.votianlt.service.TaskAssignmentService;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.bson.Document;
|
||||||
|
import org.bson.types.ObjectId;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
|
import org.springframework.data.mongodb.core.query.Query;
|
||||||
|
import org.springframework.data.mongodb.core.query.Update;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class StationCompletionTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Mock
|
||||||
|
private TaskAssignmentService taskAssignmentService;
|
||||||
|
@Mock
|
||||||
|
private EmailService emailService;
|
||||||
|
@Mock
|
||||||
|
private JobUpdateBroadcaster jobUpdateBroadcaster;
|
||||||
|
@Mock
|
||||||
|
private MongoTemplate mongoTemplate;
|
||||||
|
@InjectMocks
|
||||||
|
private MessageController controller;
|
||||||
|
|
||||||
|
private final ObjectId jobId = new ObjectId();
|
||||||
|
private ConfirmationTask mandatoryTask;
|
||||||
|
private Map<String, Object> payload;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Job job = new Job();
|
||||||
|
job.setId(jobId);
|
||||||
|
job.setJobNumber("JOB-001");
|
||||||
|
when(jobRepository.findById(jobId)).thenReturn(Optional.of(job));
|
||||||
|
mandatoryTask = new ConfirmationTask("Lieferung bestätigen");
|
||||||
|
mandatoryTask.setCompleted(true);
|
||||||
|
when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask));
|
||||||
|
payload = Map.of("jobId", jobId.toHexString(), "jobNumber", "JOB-001", "stationOrder", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedStationCompletionSendsOnlyOneEmail() {
|
||||||
|
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||||
|
.thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null));
|
||||||
|
|
||||||
|
controller.handleStationCompleted("driver-1", payload);
|
||||||
|
controller.handleStationCompleted("driver-1", payload);
|
||||||
|
|
||||||
|
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alreadyCompletedJobDoesNotSendAnotherEmail() {
|
||||||
|
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||||
|
.thenReturn(UpdateResult.acknowledged(0, 0L, null));
|
||||||
|
|
||||||
|
controller.handleStationCompleted("driver-1", payload);
|
||||||
|
|
||||||
|
verifyNoInteractions(emailService);
|
||||||
|
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
|
||||||
|
verify(mongoTemplate).updateFirst(query.capture(), any(Update.class), eq(Job.class));
|
||||||
|
assertThat(query.getValue().getQueryObject()).containsEntry("_id", jobId)
|
||||||
|
.containsEntry("status", new Document("$ne", JobStatus.COMPLETED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteMandatoryTaskPreventsJobCompletion() {
|
||||||
|
mandatoryTask.setCompleted(false);
|
||||||
|
|
||||||
|
controller.handleStationCompleted("driver-1", payload);
|
||||||
|
|
||||||
|
verifyNoInteractions(mongoTemplate, emailService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unfinishedOptionalTaskDoesNotPreventExplicitCompletion() {
|
||||||
|
ConfirmationTask optionalTask = new ConfirmationTask("Zusätzliche Bestätigung");
|
||||||
|
optionalTask.setOptional(true);
|
||||||
|
when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask, optionalTask));
|
||||||
|
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||||
|
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
|
||||||
|
|
||||||
|
controller.handleStationCompleted("driver-1", payload);
|
||||||
|
|
||||||
|
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user