From c231790f59996efc75223b068831fc26b821accb Mon Sep 17 00:00:00 2001 From: mandreshope Date: Mon, 4 Aug 2025 09:37:26 +0300 Subject: [PATCH] feat: Implements backorder confirmation flow for receptions Corrects API endpoints for creating, processing with, and processing without backorder confirmations. Adds a new `isBackorder` getter to stock picking records for identifying backordered items, and improves the robustness of the `isDone` getter. Integrates backorder detection into the reception validation process, prompting a dedicated confirmation dialog when a backorder is present. Introduces new state management and methods (`withBackorder`, `withoutBackorder`) in the reception details model to handle the backorder confirmation logic. Enhances the primary button component with a `backgroundColor` property for greater customization. --- lib/backend/api/api_calls.dart | 9 +- .../stock_picking_record_entity.dart | 5 +- .../backorder_dialog_component.dart | 115 ++++++++++++++++++ lib/components/components.dart | 1 + lib/components/primary_button_component.dart | 4 +- .../reception/reception_details_page.dart | 9 +- .../reception_details_page_model.dart | 68 +++++++++++ .../reception_details_page_model.freezed.dart | 34 +++--- 8 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 lib/components/backorder_dialog_component.dart diff --git a/lib/backend/api/api_calls.dart b/lib/backend/api/api_calls.dart index 5e53122..a5de2de 100644 --- a/lib/backend/api/api_calls.dart +++ b/lib/backend/api/api_calls.dart @@ -410,13 +410,13 @@ class ApiCalls { /// Therefore, this function must be called to generate this confirmation. /// This function returns the `ID of the backorder` confirmation popup, /// which must be used to perform the next action, - /// either with or without a backorder. + /// either `withBackorder()` or `withoutBackorder()`. static Future createBackorderConfirmation({ required int stockPickingId, }) async { try { final response = await dioService.post( - path: '/web/dataset/call_kw/stock.picking/button_validate', + path: '/web/dataset/call_kw/stock.backorder.confirmation/create', data: { "jsonrpc": "2.0", "method": "call", @@ -473,7 +473,7 @@ class ApiCalls { }) async { try { final response = await dioService.post( - path: '/web/dataset/call_kw/stock.picking/button_validate', + path: '/web/dataset/call_kw/stock.backorder.confirmation/process', data: { "jsonrpc": "2.0", "method": "call", @@ -518,7 +518,8 @@ class ApiCalls { }) async { try { final response = await dioService.post( - path: '/web/dataset/call_kw/stock.picking/button_validate', + path: + '/web/dataset/call_kw/stock.backorder.confirmation/process_cancel_backorder', data: { "jsonrpc": "2.0", "method": "call", diff --git a/lib/backend/objectbox/entities/stock_picking/stock_picking_record_entity.dart b/lib/backend/objectbox/entities/stock_picking/stock_picking_record_entity.dart index a08ab2e..9d07e6f 100644 --- a/lib/backend/objectbox/entities/stock_picking/stock_picking_record_entity.dart +++ b/lib/backend/objectbox/entities/stock_picking/stock_picking_record_entity.dart @@ -123,7 +123,10 @@ class MoveWithoutPackageEntity { } extension StockPickingRecordEntityExt on StockPickingRecordEntity { - bool get isDone => state == "done"; + bool get isDone => (state ?? '').toLowerCase() == 'done'; + bool get isBackorder => moveIdsWithoutPackage.any( + (e) => (e.productUomQty ?? 0) > (e.quantity ?? 0), + ); } extension MoveWithoutPackageEntityExt on MoveWithoutPackageEntity { diff --git a/lib/components/backorder_dialog_component.dart b/lib/components/backorder_dialog_component.dart new file mode 100644 index 0000000..fa2349b --- /dev/null +++ b/lib/components/backorder_dialog_component.dart @@ -0,0 +1,115 @@ +import 'package:e_scan/pages/operation/reception/reception_details_page_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:e_scan/components/components.dart'; +import 'package:e_scan/themes/app_theme.dart'; +import 'package:flutter/material.dart'; + +class DeleteCategorieDialogComponentWidget extends ConsumerWidget { + const DeleteCategorieDialogComponentWidget({ + super.key, + required this.stockPickingId, + }); + final int stockPickingId; + + static void show(BuildContext context, {required int stockPickingId}) { + showDialog( + context: context, + builder: (dialogContext) { + return Dialog( + elevation: 0, + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + alignment: AlignmentDirectional( + 0.0, + 0.0, + ).resolve(Directionality.of(context)), + child: GestureDetector( + onTap: () { + FocusScope.of(dialogContext).unfocus(); + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: SizedBox( + width: MediaQuery.of(context).size.width * .8, + child: DeleteCategorieDialogComponentWidget( + stockPickingId: stockPickingId, + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppTheme.of(context).primaryBackground, + borderRadius: BorderRadius.circular(8.0), + ), + child: Padding( + padding: EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Vous avez traité moins de produits que la demande initiale.', + style: AppTheme.of(context).titleSmall, + textAlign: TextAlign.center, + ), + Padding( + padding: EdgeInsetsDirectional.fromSTEB(0.0, 20.0, 0.0, 0.0), + child: Text( + 'Créer un reliquat si vous vous attendez à traiter la quantité de produits restante. Ne créez pas de reliquat si vous ne voulez pas traiter la quantité de produits restante.', + textAlign: TextAlign.center, + style: AppTheme.of(context).bodyMedium, + ), + ), + Padding( + padding: EdgeInsetsDirectional.fromSTEB(0.0, 40.0, 0.0, 0.0), + child: Row( + spacing: 5, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: PrimaryButtonComponent( + text: 'Créer un reliquat', + onPressed: () async { + await ref + .read(receptionDetailsPageModelProvider.notifier) + .withBackorder( + receptionId: stockPickingId, + onSuccess: () { + Navigator.of(context).pop(); + }, + ); + }, + ), + ), + Expanded( + child: PrimaryButtonComponent( + backgroundColor: Colors.red, + text: 'Aucun reliquat', + onPressed: () async { + await ref + .read(receptionDetailsPageModelProvider.notifier) + .withoutBackorder( + receptionId: stockPickingId, + onSuccess: () { + Navigator.of(context).pop(); + }, + ); + // Navigator.pop(context); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/components.dart b/lib/components/components.dart index 2af3e5e..c2c79bf 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -7,3 +7,4 @@ export 'quick_action_component.dart'; export 'main_appbar_component.dart'; export 'stock_picking_card_component.dart'; export 'flash_button_component.dart'; +export 'backorder_dialog_component.dart'; diff --git a/lib/components/primary_button_component.dart b/lib/components/primary_button_component.dart index a1390a3..174bac6 100644 --- a/lib/components/primary_button_component.dart +++ b/lib/components/primary_button_component.dart @@ -10,12 +10,14 @@ class PrimaryButtonComponent extends StatelessWidget { this.loading = false, this.leading, this.centered = false, + this.backgroundColor, }); final void Function()? onPressed; final String text; final bool loading; final Widget? leading; final bool centered; + final Color? backgroundColor; @override Widget build(BuildContext context) { @@ -28,7 +30,7 @@ class PrimaryButtonComponent extends StatelessWidget { ); return ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.of(context).primary, + backgroundColor: backgroundColor ?? AppTheme.of(context).primary, padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), diff --git a/lib/pages/operation/reception/reception_details_page.dart b/lib/pages/operation/reception/reception_details_page.dart index 20a83fb..ebfca38 100644 --- a/lib/pages/operation/reception/reception_details_page.dart +++ b/lib/pages/operation/reception/reception_details_page.dart @@ -55,7 +55,14 @@ class _ReceptionDetailsPageState extends ConsumerState { const SizedBox(height: 16), if (reception?.isDone == false) ...[ QuickActionComponent( - onTapValidateReception: () { + onTapValidateReception: () async { + if (reception?.isBackorder == true) { + DeleteCategorieDialogComponentWidget.show( + context, + stockPickingId: reception!.id, + ); + return; + } ref .read(receptionDetailsPageModelProvider.notifier) .validate( diff --git a/lib/pages/operation/reception/reception_details_page_model.dart b/lib/pages/operation/reception/reception_details_page_model.dart index 7c9abc4..e9d2356 100644 --- a/lib/pages/operation/reception/reception_details_page_model.dart +++ b/lib/pages/operation/reception/reception_details_page_model.dart @@ -101,6 +101,72 @@ class ReceptionDetailsPageModel state = state.copyWith(validateLoading: false); } } + + Future withBackorder({ + required int receptionId, + VoidCallback? onSuccess, + VoidCallback? onError, + }) async { + await save(receptionId: receptionId); + try { + state = state.copyWith(withBackorderLoading: true); + final createBackorderConfirmationId = + await ApiCalls.createBackorderConfirmation( + stockPickingId: receptionId, + ); + if (createBackorderConfirmationId != null) { + final res = await ApiCalls.withBackorder( + stockPickingId: receptionId, + createBackorderConfirmationId: createBackorderConfirmationId, + ); + if (res) { + await getReceptionById(id: receptionId); + onSuccess?.call(); + } else { + onError?.call(); + } + } else { + onError?.call(); + } + state = state.copyWith(withBackorderLoading: false); + } catch (e) { + onError?.call(); + state = state.copyWith(withBackorderLoading: false); + } + } + + Future withoutBackorder({ + required int receptionId, + VoidCallback? onSuccess, + VoidCallback? onError, + }) async { + await save(receptionId: receptionId); + try { + state = state.copyWith(withBackorderLoading: true); + final createBackorderConfirmationId = + await ApiCalls.createBackorderConfirmation( + stockPickingId: receptionId, + ); + if (createBackorderConfirmationId != null) { + final res = await ApiCalls.withoutBackorder( + stockPickingId: receptionId, + createBackorderConfirmationId: createBackorderConfirmationId, + ); + if (res) { + await getReceptionById(id: receptionId); + onSuccess?.call(); + } else { + onError?.call(); + } + } else { + onError?.call(); + } + state = state.copyWith(withBackorderLoading: false); + } catch (e) { + onError?.call(); + state = state.copyWith(withBackorderLoading: false); + } + } } @freezed @@ -110,5 +176,7 @@ abstract class ReceptionDetailsPageState with _$ReceptionDetailsPageState { @Default(false) bool loading, @Default(false) bool validateLoading, @Default(false) bool saveLoading, + @Default(false) bool withBackorderLoading, + @Default(false) bool withoutBackorderLoading, }) = _ReceptionDetailsPageState; } diff --git a/lib/pages/operation/reception/reception_details_page_model.freezed.dart b/lib/pages/operation/reception/reception_details_page_model.freezed.dart index b52e8dc..d623903 100644 --- a/lib/pages/operation/reception/reception_details_page_model.freezed.dart +++ b/lib/pages/operation/reception/reception_details_page_model.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$ReceptionDetailsPageState implements DiagnosticableTreeMixin { - StockPickingRecordEntity? get reception; bool get loading; bool get validateLoading; bool get saveLoading; + StockPickingRecordEntity? get reception; bool get loading; bool get validateLoading; bool get saveLoading; bool get withBackorderLoading; bool get withoutBackorderLoading; /// 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 get copyWith => _$ void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('type', 'ReceptionDetailsPageState')) - ..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading))..add(DiagnosticsProperty('saveLoading', saveLoading)); + ..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading))..add(DiagnosticsProperty('saveLoading', saveLoading))..add(DiagnosticsProperty('withBackorderLoading', withBackorderLoading))..add(DiagnosticsProperty('withoutBackorderLoading', withoutBackorderLoading)); } @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)&&(identical(other.validateLoading, validateLoading) || other.validateLoading == validateLoading)&&(identical(other.saveLoading, saveLoading) || other.saveLoading == saveLoading)); + 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)&&(identical(other.saveLoading, saveLoading) || other.saveLoading == saveLoading)&&(identical(other.withBackorderLoading, withBackorderLoading) || other.withBackorderLoading == withBackorderLoading)&&(identical(other.withoutBackorderLoading, withoutBackorderLoading) || other.withoutBackorderLoading == withoutBackorderLoading)); } @override -int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading,saveLoading); +int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading,saveLoading,withBackorderLoading,withoutBackorderLoading); @override String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { - return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading, saveLoading: $saveLoading)'; + return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading, saveLoading: $saveLoading, withBackorderLoading: $withBackorderLoading, withoutBackorderLoading: $withoutBackorderLoading)'; } @@ -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, bool validateLoading, bool saveLoading + StockPickingRecordEntity? reception, bool loading, bool validateLoading, bool saveLoading, bool withBackorderLoading, bool withoutBackorderLoading }); @@ -69,12 +69,14 @@ 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,Object? validateLoading = null,Object? saveLoading = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? reception = freezed,Object? loading = null,Object? validateLoading = null,Object? saveLoading = null,Object? withBackorderLoading = null,Object? withoutBackorderLoading = 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,saveLoading: null == saveLoading ? _self.saveLoading : saveLoading // ignore: cast_nullable_to_non_nullable +as bool,withBackorderLoading: null == withBackorderLoading ? _self.withBackorderLoading : withBackorderLoading // ignore: cast_nullable_to_non_nullable +as bool,withoutBackorderLoading: null == withoutBackorderLoading ? _self.withoutBackorderLoading : withoutBackorderLoading // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -86,13 +88,15 @@ as bool, class _ReceptionDetailsPageState with DiagnosticableTreeMixin implements ReceptionDetailsPageState { - const _ReceptionDetailsPageState({this.reception, this.loading = false, this.validateLoading = false, this.saveLoading = false}); + const _ReceptionDetailsPageState({this.reception, this.loading = false, this.validateLoading = false, this.saveLoading = false, this.withBackorderLoading = false, this.withoutBackorderLoading = false}); @override final StockPickingRecordEntity? reception; @override@JsonKey() final bool loading; @override@JsonKey() final bool validateLoading; @override@JsonKey() final bool saveLoading; +@override@JsonKey() final bool withBackorderLoading; +@override@JsonKey() final bool withoutBackorderLoading; /// Create a copy of ReceptionDetailsPageState /// with the given fields replaced by the non-null parameter values. @@ -105,21 +109,21 @@ _$ReceptionDetailsPageStateCopyWith<_ReceptionDetailsPageState> get copyWith => void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('type', 'ReceptionDetailsPageState')) - ..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading))..add(DiagnosticsProperty('saveLoading', saveLoading)); + ..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading))..add(DiagnosticsProperty('saveLoading', saveLoading))..add(DiagnosticsProperty('withBackorderLoading', withBackorderLoading))..add(DiagnosticsProperty('withoutBackorderLoading', withoutBackorderLoading)); } @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)&&(identical(other.validateLoading, validateLoading) || other.validateLoading == validateLoading)&&(identical(other.saveLoading, saveLoading) || other.saveLoading == saveLoading)); + 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)&&(identical(other.saveLoading, saveLoading) || other.saveLoading == saveLoading)&&(identical(other.withBackorderLoading, withBackorderLoading) || other.withBackorderLoading == withBackorderLoading)&&(identical(other.withoutBackorderLoading, withoutBackorderLoading) || other.withoutBackorderLoading == withoutBackorderLoading)); } @override -int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading,saveLoading); +int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading,saveLoading,withBackorderLoading,withoutBackorderLoading); @override String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { - return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading, saveLoading: $saveLoading)'; + return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading, saveLoading: $saveLoading, withBackorderLoading: $withBackorderLoading, withoutBackorderLoading: $withoutBackorderLoading)'; } @@ -130,7 +134,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, bool validateLoading, bool saveLoading + StockPickingRecordEntity? reception, bool loading, bool validateLoading, bool saveLoading, bool withBackorderLoading, bool withoutBackorderLoading }); @@ -147,12 +151,14 @@ 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,Object? validateLoading = null,Object? saveLoading = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? reception = freezed,Object? loading = null,Object? validateLoading = null,Object? saveLoading = null,Object? withBackorderLoading = null,Object? withoutBackorderLoading = 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,saveLoading: null == saveLoading ? _self.saveLoading : saveLoading // ignore: cast_nullable_to_non_nullable +as bool,withBackorderLoading: null == withBackorderLoading ? _self.withBackorderLoading : withBackorderLoading // ignore: cast_nullable_to_non_nullable +as bool,withoutBackorderLoading: null == withoutBackorderLoading ? _self.withoutBackorderLoading : withoutBackorderLoading // ignore: cast_nullable_to_non_nullable as bool, )); }