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.
This commit is contained in:
mandreshope 2025-08-04 09:37:26 +03:00
parent 4c18b05e18
commit c231790f59
8 changed files with 224 additions and 21 deletions

View File

@ -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<int?> 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",

View File

@ -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 {

View File

@ -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);
},
),
),
],
),
),
],
),
),
);
}
}

View File

@ -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';

View File

@ -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)),
),

View File

@ -55,7 +55,14 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
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(

View File

@ -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;
}

View File

@ -15,7 +15,7 @@ T _$identity<T>(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<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)';
}
@ -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,
));
}