feat: Implement explicit synchronization and validation

Refactors the reception process to provide clearer control over data synchronization and validation.

- Replaces the `isDraft` property with `synchronized` in `StockPickingRecordEntity` to accurately represent the local data's sync status with the backend.
- Introduces a new API endpoint and corresponding logic for `validateStockPicking`, allowing finalization of receptions.
- Splits the "Save" action into "Synchronize data" and "Validate reception" on the details page. "Validate" now implicitly performs a synchronization first.
- Updates UI elements (cards, quick actions) to reflect the new synchronization state and expose the new actions.
- Corrects a typo in the `updateAllMoveLineOnStockPicking` API method name.
- Improves local data update logic for scanned items, marking them as unsynchronized.
This commit is contained in:
your-name 2025-07-31 02:34:50 +03:00
parent 288b57d4cf
commit 18f74daae4
11 changed files with 181 additions and 74 deletions

View File

@ -324,7 +324,7 @@ class ApiCalls {
}
}
static Future<bool> updateAllMoveLineOnStockPiking({
static Future<bool> updateAllMoveLineOnStockPicking({
required int stockPickingId,
required List<UpdateMoveLineDto> moveLineDto,
}) async {
@ -352,12 +352,43 @@ class ApiCalls {
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;
}
return true;
} else {
return false;
}
} else {
debugPrint('Erreur réseau: ${response.statusCode}');
return false;
}
} catch (e) {
debugPrint('Erreur lors de la requête: $e');
return false;
}
}
static Future<bool> validateStockPicking({
required int stockPickingId,
}) async {
try {
final response = await dioService.post(
path: '/web/dataset/call_kw/stock.picking/button_validate',
data: {
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "stock.picking",
"method": "button_validate",
"args": [
[stockPickingId],
],
"kwargs": {"context": {}},
},
},
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data.containsKey('result')) {
return true;
} else {
return false;
}

View File

@ -5,7 +5,7 @@ import 'package:objectbox/objectbox.dart';
class StockPickingRecordEntity {
StockPickingRecordEntity({
this.id = 0,
this.isDraft = false,
this.synchronized = true,
this.priority,
this.name,
this.pickingTypeCode,
@ -29,7 +29,7 @@ class StockPickingRecordEntity {
});
@Id(assignable: true)
int id;
bool isDraft;
bool synchronized;
String? priority;
String? name;
String? pickingTypeCode;

View File

@ -156,7 +156,7 @@
},
{
"id": "7:7263194599189060077",
"lastPropertyId": "27:7762235054004255701",
"lastPropertyId": "28:1976672364117660903",
"name": "StockPickingRecordEntity",
"properties": [
{
@ -241,8 +241,8 @@
"relationTarget": "StockPickingTypeEntity"
},
{
"id": "27:7762235054004255701",
"name": "isDraft",
"id": "28:1976672364117660903",
"name": "synchronized",
"type": 1
}
],
@ -295,7 +295,8 @@
2889425908429352139,
934877054574553245,
7012525525648469072,
4739641817802949530
4739641817802949530,
7762235054004255701
],
"retiredRelationUids": [],
"version": 1

View File

@ -193,7 +193,7 @@ final _entities = <obx_int.ModelEntity>[
obx_int.ModelEntity(
id: const obx_int.IdUid(7, 7263194599189060077),
name: 'StockPickingRecordEntity',
lastPropertyId: const obx_int.IdUid(27, 7762235054004255701),
lastPropertyId: const obx_int.IdUid(28, 1976672364117660903),
flags: 0,
properties: <obx_int.ModelProperty>[
obx_int.ModelProperty(
@ -285,8 +285,8 @@ final _entities = <obx_int.ModelEntity>[
relationTarget: 'StockPickingTypeEntity',
),
obx_int.ModelProperty(
id: const obx_int.IdUid(27, 7762235054004255701),
name: 'isDraft',
id: const obx_int.IdUid(28, 1976672364117660903),
name: 'synchronized',
type: 1,
flags: 0,
),
@ -393,6 +393,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
934877054574553245,
7012525525648469072,
4739641817802949530,
7762235054004255701,
],
retiredRelationUids: const [],
modelVersion: 5,
@ -713,7 +714,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
final originOffset = object.origin == null
? null
: fbb.writeString(object.origin!);
fbb.startTable(28);
fbb.startTable(29);
fbb.addInt64(0, object.id);
fbb.addOffset(1, priorityOffset);
fbb.addOffset(2, nameOffset);
@ -727,7 +728,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
fbb.addInt64(23, object.locationId.targetId);
fbb.addInt64(24, object.locationDestId.targetId);
fbb.addInt64(25, object.pickingTypeId.targetId);
fbb.addBool(26, object.isDraft);
fbb.addBool(27, object.synchronized);
fbb.finish(fbb.endTable());
return object.id;
},
@ -740,10 +741,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
4,
0,
);
final isDraftParam = const fb.BoolReader().vTableGet(
final synchronizedParam = const fb.BoolReader().vTableGet(
buffer,
rootOffset,
56,
58,
false,
);
final priorityParam = const fb.StringReader(
@ -773,7 +774,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
);
final object = StockPickingRecordEntity(
id: idParam,
isDraft: isDraftParam,
synchronized: synchronizedParam,
priority: priorityParam,
name: nameParam,
pickingTypeCode: pickingTypeCodeParam,
@ -1071,10 +1072,11 @@ class StockPickingRecordEntity_ {
_entities[6].properties[12],
);
/// See [StockPickingRecordEntity.isDraft].
static final isDraft = obx.QueryBooleanProperty<StockPickingRecordEntity>(
_entities[6].properties[13],
);
/// See [StockPickingRecordEntity.synchronized].
static final synchronized =
obx.QueryBooleanProperty<StockPickingRecordEntity>(
_entities[6].properties[13],
);
/// see [StockPickingRecordEntity.moveLineIdsWithoutPackage]
static final moveLineIdsWithoutPackage =

View File

@ -9,11 +9,17 @@ class QuickActionComponent extends StatelessWidget {
this.onTapScan,
this.onTapSearch,
this.onTapValidateReception,
this.onTapSyncReception,
this.syncReceptionLoading = false,
this.validateReceptionLoading = false,
});
final VoidCallback? onTapAdd;
final VoidCallback? onTapScan;
final VoidCallback? onTapSearch;
final VoidCallback? onTapValidateReception;
final VoidCallback? onTapSyncReception;
final bool syncReceptionLoading;
final bool validateReceptionLoading;
@override
Widget build(BuildContext context) {
@ -59,6 +65,7 @@ class QuickActionComponent extends StatelessWidget {
SizedBox(
width: double.maxFinite,
child: PrimaryButtonComponent(
loading: validateReceptionLoading,
centered: true,
leading: Icon(Icons.save, color: AppTheme.of(context).white),
text: 'Valider la réception',
@ -66,6 +73,22 @@ class QuickActionComponent extends StatelessWidget {
),
),
],
if (onTapSyncReception != null) ...[
const SizedBox(height: 16),
SizedBox(
width: double.maxFinite,
child: PrimaryButtonComponent(
loading: syncReceptionLoading,
centered: true,
leading: Icon(
Icons.cloud_upload_outlined,
color: AppTheme.of(context).white,
),
text: 'Synchroniser les données',
onPressed: onTapSyncReception,
),
),
],
if (onTapAdd != null) ...[
const SizedBox(height: 16),
SizedBox(

View File

@ -11,11 +11,11 @@ class StockPickingCard extends StatelessWidget {
required this.origin,
required this.status,
required this.isDone,
this.isDraft = false,
this.synchronized = true,
this.margin,
});
final bool isDone;
final bool isDraft;
final bool synchronized;
final String? reference;
final String? from;
final String? to;
@ -56,7 +56,7 @@ class StockPickingCard extends StatelessWidget {
],
),
),
if (isDraft)
if (!synchronized)
Chip(
backgroundColor: Colors.red,
label: Row(
@ -64,7 +64,7 @@ class StockPickingCard extends StatelessWidget {
children: [
Icon(Icons.cloud_off_outlined, color: Colors.white),
Text(
'Brouillon',
'Non synchronisé',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,

View File

@ -55,7 +55,31 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
const SizedBox(height: 16),
if (reception?.isDone == false) ...[
QuickActionComponent(
onTapValidateReception: reception?.isDraft == true
onTapValidateReception: reception?.synchronized == false
? () {
ref
.read(
receptionDetailsPageModelProvider
.notifier,
)
.validate(
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,
validateReceptionLoading: state.validateLoading,
syncReceptionLoading: state.saveLoading,
onTapSyncReception: reception?.synchronized == false
? () {
ref
.read(
@ -66,7 +90,7 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
receptionId: widget.receptionId,
onSuccess: () {
Toast.showSuccess(
'Réception validée avec succès.',
'Les données sont synchronisées.',
);
},
onError: () {
@ -102,7 +126,7 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
),
SizedBox(height: 10),
StockPickingCard(
isDraft: reception?.isDraft == true,
synchronized: reception?.synchronized == true,
isDone: reception?.isDone == true,
margin: EdgeInsets.symmetric(horizontal: 5),
reference: reception?.name ?? '',

View File

@ -38,7 +38,7 @@ class ReceptionDetailsPageModel
VoidCallback? onError,
}) async {
try {
state = state.copyWith(validateLoading: true);
state = state.copyWith(saveLoading: true);
final stockPickingRecords = objectboxManager.store
.box<StockPickingRecordEntity>();
final stockPikingEntity = stockPickingRecords.get(receptionId);
@ -53,15 +53,48 @@ class ReceptionDetailsPageModel
)
.toList() ??
[];
final res = await ApiCalls.updateAllMoveLineOnStockPiking(
final res = await ApiCalls.updateAllMoveLineOnStockPicking(
stockPickingId: receptionId,
moveLineDto: moveLinesDto,
);
if (res) {
stockPikingEntity?.synchronized = true;
stockPickingRecords.put(stockPikingEntity!);
getReceptionById(id: receptionId);
onSuccess?.call();
} else {
onError?.call();
}
state = state.copyWith(saveLoading: false);
} catch (e) {
onError?.call();
state = state.copyWith(saveLoading: false);
}
}
Future validate({
required int receptionId,
VoidCallback? onSuccess,
VoidCallback? onError,
}) async {
await save(receptionId: receptionId);
try {
final stockPickingRecords = objectboxManager.store
.box<StockPickingRecordEntity>();
final stockPikingEntity = stockPickingRecords.get(receptionId);
state = state.copyWith(validateLoading: true);
final res = await ApiCalls.validateStockPicking(
stockPickingId: receptionId,
);
if (res) {
onSuccess?.call();
stockPikingEntity?.synchronized = true;
stockPikingEntity?.state = 'done';
stockPickingRecords.put(stockPikingEntity!);
getReceptionById(id: receptionId);
} else {
onError?.call();
}
state = state.copyWith(validateLoading: false);
} catch (e) {
onError?.call();
@ -76,5 +109,6 @@ abstract class ReceptionDetailsPageState with _$ReceptionDetailsPageState {
StockPickingRecordEntity? reception,
@Default(false) bool loading,
@Default(false) bool validateLoading,
@Default(false) bool saveLoading,
}) = _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;
StockPickingRecordEntity? get reception; bool get loading; bool get validateLoading; bool get saveLoading;
/// 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('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading))..add(DiagnosticsProperty('saveLoading', saveLoading));
}
@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));
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));
}
@override
int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading);
int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading,saveLoading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading)';
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading, saveLoading: $saveLoading)';
}
@ -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
StockPickingRecordEntity? reception, bool loading, bool validateLoading, bool saveLoading
});
@ -69,11 +69,12 @@ 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,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? reception = freezed,Object? loading = null,Object? validateLoading = null,Object? saveLoading = 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,
));
}
@ -85,12 +86,13 @@ as bool,
class _ReceptionDetailsPageState with DiagnosticableTreeMixin implements ReceptionDetailsPageState {
const _ReceptionDetailsPageState({this.reception, this.loading = false, this.validateLoading = false});
const _ReceptionDetailsPageState({this.reception, this.loading = false, this.validateLoading = false, this.saveLoading = false});
@override final StockPickingRecordEntity? reception;
@override@JsonKey() final bool loading;
@override@JsonKey() final bool validateLoading;
@override@JsonKey() final bool saveLoading;
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@ -103,21 +105,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('reception', reception))..add(DiagnosticsProperty('loading', loading))..add(DiagnosticsProperty('validateLoading', validateLoading))..add(DiagnosticsProperty('saveLoading', saveLoading));
}
@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));
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));
}
@override
int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading);
int get hashCode => Object.hash(runtimeType,reception,loading,validateLoading,saveLoading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading)';
return 'ReceptionDetailsPageState(reception: $reception, loading: $loading, validateLoading: $validateLoading, saveLoading: $saveLoading)';
}
@ -128,7 +130,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
StockPickingRecordEntity? reception, bool loading, bool validateLoading, bool saveLoading
});
@ -145,11 +147,12 @@ 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,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? reception = freezed,Object? loading = null,Object? validateLoading = null,Object? saveLoading = 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,
));
}

View File

@ -79,7 +79,7 @@ class _ReceptionPageState extends ConsumerState<ReceptionPage> {
).push(context);
},
child: StockPickingCard(
isDraft: reception.isDraft == true,
synchronized: reception.synchronized == true,
margin: EdgeInsets.symmetric(
horizontal: 5,
vertical: 5,

View File

@ -57,32 +57,21 @@ class ReceptionScanPageModel
final stockPickingRecordBox = objectboxManager.store
.box<StockPickingRecordEntity>();
final moveBox = objectboxManager.store.box<MoveWithoutPackageEntity>();
final productBox = objectboxManager.store.box<ProductEntity>();
final productEntity = productBox
.query(ProductEntity_.barcode.equals(barcode))
.build()
.findFirst();
final productId = productEntity?.id;
final stockPickingRecord = stockPickingRecordBox.get(receptionId);
if (productId != null) {
final moveLineEntity = moveLineBox
.query(MoveLineWithoutPackageEntity_.productId.equals(productId))
.build()
.findFirst();
final moveEntity = moveBox
.query(MoveWithoutPackageEntity_.productId.equals(productId))
.build()
.findFirst();
if (moveLineEntity != null &&
moveEntity != null &&
stockPickingRecord != null) {
moveLineEntity.quantity = (moveLineEntity.quantity ?? 0) + 1;
moveEntity.quantity = (moveEntity.quantity ?? 0) + 1;
stockPickingRecord.isDraft = true;
moveLineBox.put(moveLineEntity);
moveBox.put(moveEntity);
stockPickingRecordBox.put(stockPickingRecord);
}
final moveLineEntity = stockPickingRecord?.moveLineIdsWithoutPackage
.firstWhere((e) => e.productId.target?.barcode == barcode);
final moveEntity = stockPickingRecord?.moveIdsWithoutPackage.firstWhere(
(e) => e.productId.target?.barcode == barcode,
);
if (moveLineEntity != null &&
moveEntity != null &&
stockPickingRecord != null) {
moveLineEntity.quantity = (moveLineEntity.quantity ?? 0) + 1;
moveEntity.quantity = (moveEntity.quantity ?? 0) + 1;
stockPickingRecord.synchronized = false;
moveLineBox.put(moveLineEntity);
moveBox.put(moveEntity);
stockPickingRecordBox.put(stockPickingRecord);
}
}
}