feat: Implements reception validation process

Introduces the ability to validate a stock reception by sending updated move line quantities to the backend.

This includes:
- Adding a new API call to update stock picking move lines.
- Integrating a "Validate Reception" button within the quick actions component on the reception details page.
- Implementing the logic to gather move line data and call the new API endpoint.
- Enhancing error messages on the scan page for products not expected in the current reception.
- Improving type safety for API response data.
This commit is contained in:
your-name 2025-07-31 01:07:48 +03:00
parent ab4a56ed41
commit 61252a3aa9
10 changed files with 380 additions and 20 deletions

View File

@ -3,6 +3,7 @@ import 'package:e_scan/backend/objectbox/objectbox_manager.dart';
import 'package:e_scan/backend/schema/auth/auth_model.dart';
import 'package:e_scan/backend/schema/stock_picking/stock_picking_model.dart';
import 'package:e_scan/backend/schema/stock_picking/stock_picking_record_model.dart';
import 'package:e_scan/backend/schema/stock_picking/update_move_line_dto.dart';
import 'package:e_scan/provider_container.dart';
import 'package:e_scan/services/dio_service.dart';
import 'package:e_scan/services/token_provider.dart';
@ -156,7 +157,7 @@ class ApiCalls {
},
);
if (response.statusCode == 200) {
final data = response.data;
final data = response.data as Map<String, dynamic>;
if (data.containsKey('result')) {
final result = StockPickingResponseModel.fromJson(data);
final recordsModel =
@ -300,7 +301,7 @@ class ApiCalls {
},
);
if (response.statusCode == 200) {
final data = response.data;
final data = response.data as Map<String, dynamic>;
if (data.containsKey('result')) {
final datas = data['result'] as List;
if (datas.isNotEmpty) {
@ -322,4 +323,51 @@ class ApiCalls {
return Result.error(Error(e));
}
}
static Future<bool> updateAllMoveLineOnStockPiking({
required int stockPickingId,
required List<UpdateMoveLineDto> moveLineDto,
}) async {
try {
final response = await dioService.post(
path: '/web/dataset/call_kw/stock.picking/write',
data: {
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "stock.picking",
"method": "write",
"args": [
[
stockPickingId, //id stock piking
],
{
"move_line_ids": [...moveLineDto.map((e) => e.toList())],
},
],
"kwargs": {"context": {}},
},
},
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data.containsKey('result')) {
final datas = data['result'] as List;
if (datas.isNotEmpty) {
return true;
} else {
return false;
}
} else {
return false;
}
} else {
debugPrint('Erreur réseau: ${response.statusCode}');
return false;
}
} catch (e) {
debugPrint('Erreur lors de la requête: $e');
return false;
}
}
}

View File

@ -0,0 +1,43 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'update_move_line_dto.freezed.dart';
part 'update_move_line_dto.g.dart';
/// Exemple dutilisation :
///```dart
///final raw = [1, 116, {"quantity": 0}];
///final dto = UpdateMoveLineDto.fromList(raw);
///print(dto.moveLineId); // 116
///print(dto.toList()); // [1, 116, {quantity: 0}]
///
///```
@freezed
abstract class UpdateMoveLineDto with _$UpdateMoveLineDto {
const factory UpdateMoveLineDto({
required int operation, // ex: 1
required int moveLineId, // ex: 116
required Map<String, dynamic> values, // ex: {"quantity": 0}
}) = _UpdateMoveLineDto;
factory UpdateMoveLineDto.fromJson(Map<String, dynamic> json) =>
_$UpdateMoveLineDtoFromJson(json);
/// Constructeur depuis List
factory UpdateMoveLineDto.fromList(List<dynamic> list) {
if (list.length != 3) {
throw ArgumentError('La liste doit contenir exactement 3 éléments');
}
return UpdateMoveLineDto(
operation: list[0] as int,
moveLineId: list[1] as int,
values: Map<String, dynamic>.from(list[2] as Map),
);
}
/// Convertir vers liste (utile pour API Odoo)
List<dynamic> toList() {
return [operation, moveLineId, values];
}
}

View File

@ -0,0 +1,165 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'update_move_line_dto.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$UpdateMoveLineDto {
int get operation;// ex: 1
int get moveLineId;// ex: 116
Map<String, dynamic> get values;
/// Create a copy of UpdateMoveLineDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UpdateMoveLineDtoCopyWith<UpdateMoveLineDto> get copyWith => _$UpdateMoveLineDtoCopyWithImpl<UpdateMoveLineDto>(this as UpdateMoveLineDto, _$identity);
/// Serializes this UpdateMoveLineDto to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UpdateMoveLineDto&&(identical(other.operation, operation) || other.operation == operation)&&(identical(other.moveLineId, moveLineId) || other.moveLineId == moveLineId)&&const DeepCollectionEquality().equals(other.values, values));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,operation,moveLineId,const DeepCollectionEquality().hash(values));
@override
String toString() {
return 'UpdateMoveLineDto(operation: $operation, moveLineId: $moveLineId, values: $values)';
}
}
/// @nodoc
abstract mixin class $UpdateMoveLineDtoCopyWith<$Res> {
factory $UpdateMoveLineDtoCopyWith(UpdateMoveLineDto value, $Res Function(UpdateMoveLineDto) _then) = _$UpdateMoveLineDtoCopyWithImpl;
@useResult
$Res call({
int operation, int moveLineId, Map<String, dynamic> values
});
}
/// @nodoc
class _$UpdateMoveLineDtoCopyWithImpl<$Res>
implements $UpdateMoveLineDtoCopyWith<$Res> {
_$UpdateMoveLineDtoCopyWithImpl(this._self, this._then);
final UpdateMoveLineDto _self;
final $Res Function(UpdateMoveLineDto) _then;
/// Create a copy of UpdateMoveLineDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? operation = null,Object? moveLineId = null,Object? values = null,}) {
return _then(_self.copyWith(
operation: null == operation ? _self.operation : operation // ignore: cast_nullable_to_non_nullable
as int,moveLineId: null == moveLineId ? _self.moveLineId : moveLineId // ignore: cast_nullable_to_non_nullable
as int,values: null == values ? _self.values : values // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
));
}
}
/// @nodoc
@JsonSerializable()
class _UpdateMoveLineDto implements UpdateMoveLineDto {
const _UpdateMoveLineDto({required this.operation, required this.moveLineId, required final Map<String, dynamic> values}): _values = values;
factory _UpdateMoveLineDto.fromJson(Map<String, dynamic> json) => _$UpdateMoveLineDtoFromJson(json);
@override final int operation;
// ex: 1
@override final int moveLineId;
// ex: 116
final Map<String, dynamic> _values;
// ex: 116
@override Map<String, dynamic> get values {
if (_values is EqualUnmodifiableMapView) return _values;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_values);
}
/// Create a copy of UpdateMoveLineDto
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$UpdateMoveLineDtoCopyWith<_UpdateMoveLineDto> get copyWith => __$UpdateMoveLineDtoCopyWithImpl<_UpdateMoveLineDto>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$UpdateMoveLineDtoToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UpdateMoveLineDto&&(identical(other.operation, operation) || other.operation == operation)&&(identical(other.moveLineId, moveLineId) || other.moveLineId == moveLineId)&&const DeepCollectionEquality().equals(other._values, _values));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,operation,moveLineId,const DeepCollectionEquality().hash(_values));
@override
String toString() {
return 'UpdateMoveLineDto(operation: $operation, moveLineId: $moveLineId, values: $values)';
}
}
/// @nodoc
abstract mixin class _$UpdateMoveLineDtoCopyWith<$Res> implements $UpdateMoveLineDtoCopyWith<$Res> {
factory _$UpdateMoveLineDtoCopyWith(_UpdateMoveLineDto value, $Res Function(_UpdateMoveLineDto) _then) = __$UpdateMoveLineDtoCopyWithImpl;
@override @useResult
$Res call({
int operation, int moveLineId, Map<String, dynamic> values
});
}
/// @nodoc
class __$UpdateMoveLineDtoCopyWithImpl<$Res>
implements _$UpdateMoveLineDtoCopyWith<$Res> {
__$UpdateMoveLineDtoCopyWithImpl(this._self, this._then);
final _UpdateMoveLineDto _self;
final $Res Function(_UpdateMoveLineDto) _then;
/// Create a copy of UpdateMoveLineDto
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? operation = null,Object? moveLineId = null,Object? values = null,}) {
return _then(_UpdateMoveLineDto(
operation: null == operation ? _self.operation : operation // ignore: cast_nullable_to_non_nullable
as int,moveLineId: null == moveLineId ? _self.moveLineId : moveLineId // ignore: cast_nullable_to_non_nullable
as int,values: null == values ? _self._values : values // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
));
}
}
// dart format on

View File

@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'update_move_line_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_UpdateMoveLineDto _$UpdateMoveLineDtoFromJson(Map<String, dynamic> json) =>
_UpdateMoveLineDto(
operation: (json['operation'] as num).toInt(),
moveLineId: (json['moveLineId'] as num).toInt(),
values: json['values'] as Map<String, dynamic>,
);
Map<String, dynamic> _$UpdateMoveLineDtoToJson(_UpdateMoveLineDto instance) =>
<String, dynamic>{
'operation': instance.operation,
'moveLineId': instance.moveLineId,
'values': instance.values,
};

View File

@ -9,11 +9,13 @@ class MainAppbarComponent extends StatelessWidget
this.subTitle,
this.scaffoledKey,
this.leading,
this.actions,
});
final GlobalKey<ScaffoldState>? scaffoledKey;
final String? title;
final String? subTitle;
final Widget? leading;
final List<Widget>? actions;
@override
Widget build(BuildContext context) {
@ -50,6 +52,7 @@ class MainAppbarComponent extends StatelessWidget
),
],
),
actions: actions,
toolbarHeight: 60,
backgroundColor: AppTheme.of(context).primary,
elevation: 4,

View File

@ -8,10 +8,12 @@ class QuickActionComponent extends StatelessWidget {
this.onTapAdd,
this.onTapScan,
this.onTapSearch,
this.onTapValidateReception,
});
final VoidCallback? onTapAdd;
final VoidCallback? onTapScan;
final VoidCallback? onTapSearch;
final VoidCallback? onTapValidateReception;
@override
Widget build(BuildContext context) {
@ -52,6 +54,18 @@ class QuickActionComponent extends StatelessWidget {
// ),
],
),
if (onTapValidateReception != null) ...[
const SizedBox(height: 16),
SizedBox(
width: double.maxFinite,
child: PrimaryButtonComponent(
centered: true,
leading: Icon(Icons.save, color: AppTheme.of(context).white),
text: 'Valider la réception',
onPressed: onTapValidateReception,
),
),
],
if (onTapAdd != null) ...[
const SizedBox(height: 16),
SizedBox(

View File

@ -3,6 +3,7 @@ import 'package:e_scan/components/components.dart';
import 'package:e_scan/pages/operation/reception/reception_details_page_model.dart';
import 'package:e_scan/router/go_secure_router_builder.dart';
import 'package:e_scan/themes/app_theme.dart';
import 'package:e_scan/utils/utils.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
@ -54,6 +55,28 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
const SizedBox(height: 16),
if (reception?.isDone == false) ...[
QuickActionComponent(
onTapValidateReception: reception?.isDraft == true
? () {
ref
.read(
receptionDetailsPageModelProvider
.notifier,
)
.save(
receptionId: widget.receptionId,
onSuccess: () {
Toast.showSuccess(
'Réception validée avec succès.',
);
},
onError: () {
Toast.showError(
'Connexion impossible. Les données seront synchronisées plus tard.',
);
},
);
}
: null,
onTapScan: () {
final id = reception?.id;
if (id == null) return;

View File

@ -1,5 +1,7 @@
import 'package:e_scan/backend/api/api_calls.dart';
import 'package:e_scan/backend/objectbox/entities/stock_picking/stock_picking_record_entity.dart';
import 'package:e_scan/backend/objectbox/objectbox_manager.dart';
import 'package:e_scan/backend/schema/stock_picking/update_move_line_dto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -29,6 +31,43 @@ class ReceptionDetailsPageModel
state = state.copyWith(loading: false);
}
}
Future save({
required int receptionId,
VoidCallback? onSuccess,
VoidCallback? onError,
}) async {
try {
state = state.copyWith(validateLoading: true);
final stockPickingRecords = objectboxManager.store
.box<StockPickingRecordEntity>();
final stockPikingEntity = stockPickingRecords.get(receptionId);
final moveLinesDto =
stockPikingEntity?.moveLineIdsWithoutPackage
.map(
(m) => UpdateMoveLineDto(
operation: 1,
moveLineId: m.id,
values: {"quantity": m.quantity},
),
)
.toList() ??
[];
final res = await ApiCalls.updateAllMoveLineOnStockPiking(
stockPickingId: receptionId,
moveLineDto: moveLinesDto,
);
if (res) {
onSuccess?.call();
} else {
onError?.call();
}
state = state.copyWith(validateLoading: false);
} catch (e) {
onError?.call();
state = state.copyWith(validateLoading: false);
}
}
}
@freezed
@ -36,5 +75,6 @@ abstract class ReceptionDetailsPageState with _$ReceptionDetailsPageState {
const factory ReceptionDetailsPageState({
StockPickingRecordEntity? reception,
@Default(false) bool loading,
@Default(false) bool validateLoading,
}) = _ReceptionDetailsPageState;
}

View File

@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ReceptionDetailsPageState implements DiagnosticableTreeMixin {
StockPickingRecordEntity? get reception; bool get loading;
StockPickingRecordEntity? get reception; bool get loading; bool get validateLoading;
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -27,21 +27,21 @@ $ReceptionDetailsPageStateCopyWith<ReceptionDetailsPageState> get copyWith => _$
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ReceptionDetailsPageState'))
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading));
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ReceptionDetailsPageState&&(identical(other.reception, reception) || other.reception == reception)&&(identical(other.loading, loading) || other.loading == loading));
return identical(this, other) || (other.runtimeType == runtimeType&&other is ReceptionDetailsPageState&&(identical(other.reception, reception) || other.reception == reception)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.validateLoading, validateLoading) || other.validateLoading == validateLoading));
}
@override
int get hashCode => Object.hash(runtimeType,reception,loading);
int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading)';
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading)';
}
@ -52,7 +52,7 @@ abstract mixin class $ReceptionDetailsPageStateCopyWith<$Res> {
factory $ReceptionDetailsPageStateCopyWith(ReceptionDetailsPageState value, $Res Function(ReceptionDetailsPageState) _then) = _$ReceptionDetailsPageStateCopyWithImpl;
@useResult
$Res call({
StockPickingRecordEntity? reception, bool loading
StockPickingRecordEntity? reception, bool loading, bool validateLoading
});
@ -69,10 +69,11 @@ class _$ReceptionDetailsPageStateCopyWithImpl<$Res>
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? reception = freezed,Object? loading = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? reception = freezed,Object? loading = null,Object? validateLoading = null,}) {
return _then(_self.copyWith(
reception: freezed == reception ? _self.reception : reception // ignore: cast_nullable_to_non_nullable
as StockPickingRecordEntity?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,validateLoading: null == validateLoading ? _self.validateLoading : validateLoading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@ -84,11 +85,12 @@ as bool,
class _ReceptionDetailsPageState with DiagnosticableTreeMixin implements ReceptionDetailsPageState {
const _ReceptionDetailsPageState({this.reception, this.loading = false});
const _ReceptionDetailsPageState({this.reception, this.loading = false, this.validateLoading = false});
@override final StockPickingRecordEntity? reception;
@override@JsonKey() final bool loading;
@override@JsonKey() final bool validateLoading;
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@ -101,21 +103,21 @@ _$ReceptionDetailsPageStateCopyWith<_ReceptionDetailsPageState> get copyWith =>
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ReceptionDetailsPageState'))
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading));
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReceptionDetailsPageState&&(identical(other.reception, reception) || other.reception == reception)&&(identical(other.loading, loading) || other.loading == loading));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReceptionDetailsPageState&&(identical(other.reception, reception) || other.reception == reception)&&(identical(other.loading, loading) || other.loading == loading)&&(identical(other.validateLoading, validateLoading) || other.validateLoading == validateLoading));
}
@override
int get hashCode => Object.hash(runtimeType,reception,loading);
int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading)';
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading)';
}
@ -126,7 +128,7 @@ abstract mixin class _$ReceptionDetailsPageStateCopyWith<$Res> implements $Recep
factory _$ReceptionDetailsPageStateCopyWith(_ReceptionDetailsPageState value, $Res Function(_ReceptionDetailsPageState) _then) = __$ReceptionDetailsPageStateCopyWithImpl;
@override @useResult
$Res call({
StockPickingRecordEntity? reception, bool loading
StockPickingRecordEntity? reception, bool loading, bool validateLoading
});
@ -143,10 +145,11 @@ class __$ReceptionDetailsPageStateCopyWithImpl<$Res>
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? reception = freezed,Object? loading = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? reception = freezed,Object? loading = null,Object? validateLoading = null,}) {
return _then(_ReceptionDetailsPageState(
reception: freezed == reception ? _self.reception : reception // ignore: cast_nullable_to_non_nullable
as StockPickingRecordEntity?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,validateLoading: null == validateLoading ? _self.validateLoading : validateLoading // ignore: cast_nullable_to_non_nullable
as bool,
));
}

View File

@ -98,8 +98,8 @@ class _ReceptionScanPageState extends ConsumerState<ReceptionScanPage>
final model = ref.read(receptionScanPageModelProvider.notifier);
if (qrcodeValue == null) {
debugPrint("Qrcode non valide");
Toast.showError('Aucun produit trouvé.');
debugPrint("Produit non attendu dans cette réception");
Toast.showError('Produit non attendu dans cette réception');
return;
}
// find product in local database
@ -151,8 +151,8 @@ class _ReceptionScanPageState extends ConsumerState<ReceptionScanPage>
},
);
} else {
Toast.showError('Aucun produit trouvé.');
debugPrint('Aucun produit trouvé.');
Toast.showError('Produit non attendu dans cette réception');
debugPrint('Produit non attendu dans cette réception');
}
}
}