Adds the Flutter inventory app source, cleaned up for a shared repo: generic package name, no hardcoded ERP endpoints (moved to gitignored local config), no dead auth code, no debug logging of session data.
641 lines
19 KiB
Dart
641 lines
19 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:get/get.dart';
|
|
import 'package:cookie_jar/cookie_jar.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import 'package:inventory_app/constants.dart';
|
|
import 'package:inventory_app/models/famille_produit.dart';
|
|
import 'package:inventory_app/models/depot.dart';
|
|
import 'package:inventory_app/models/inventory_line.dart';
|
|
import 'package:inventory_app/models/product.dart';
|
|
import 'package:inventory_app/models/tracking_number.dart';
|
|
import 'package:inventory_app/utils.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
class AxelorClient {
|
|
late Dio dio;
|
|
late PersistCookieJar cookieJar;
|
|
|
|
static final GetStorage _storage = GetStorage();
|
|
|
|
AxelorClient._internal();
|
|
|
|
static Future<AxelorClient> create() async {
|
|
final client = AxelorClient._internal();
|
|
|
|
Directory appDocDir = await getApplicationDocumentsDirectory();
|
|
String cookiePath = '${appDocDir.path}/cookies';
|
|
|
|
client.cookieJar = PersistCookieJar(storage: FileStorage(cookiePath));
|
|
client.dio = Dio(BaseOptions(baseUrl: Utils.url));
|
|
client.dio.interceptors.add(CookieManager(client.cookieJar));
|
|
|
|
// Set saved session ID if available
|
|
final storedSessionId = _storage.read('sessionId');
|
|
if (storedSessionId != null) {
|
|
client.dio.options.headers['Cookie'] = 'JSESSIONID=$storedSessionId';
|
|
}
|
|
|
|
return client;
|
|
}
|
|
|
|
Future<bool> login(String username, String password) async {
|
|
try {
|
|
final response = await dio.post(
|
|
'${Utils.url}/login.jsp',
|
|
data: {'username': username, 'password': password},
|
|
options: Options(
|
|
contentType: Headers.formUrlEncodedContentType,
|
|
followRedirects: false,
|
|
// Make sure redirect is not automatically followed
|
|
validateStatus: (status) => status != null && status < 500,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Android; Flutter App)',
|
|
'Accept': '*/*',
|
|
},
|
|
),
|
|
);
|
|
|
|
// Handle 302 redirect: cookies should be in headers
|
|
final cookies = response.headers['set-cookie'];
|
|
if (cookies != null) {
|
|
// Parse JSESSIONID
|
|
String? sessionId;
|
|
for (var cookie in cookies) {
|
|
if (cookie.contains('JSESSIONID')) {
|
|
sessionId = cookie.split(';').first.split('=').last;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (sessionId != null && sessionId.isNotEmpty) {
|
|
// Set the cookie so the validation request is authenticated
|
|
dio.options.headers['Cookie'] = 'JSESSIONID=$sessionId';
|
|
|
|
// Verify the session is actually authenticated (not just an anonymous session)
|
|
final valid = await isSessionValid();
|
|
if (!valid) {
|
|
dio.options.headers.remove('Cookie');
|
|
return false;
|
|
}
|
|
|
|
await _storage.write('sessionId', sessionId);
|
|
await _storage.write('username', username);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
|
|
Future<bool> logout() async {
|
|
try {
|
|
final response = await dio.get('/logout');
|
|
if (response.statusCode == 200) {
|
|
await _storage.remove('sessionId');
|
|
await _storage.remove('username');
|
|
await cookieJar.deleteAll();
|
|
return true;
|
|
}
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
|
|
Future<List<Depot>?> fetchLocations() async {
|
|
try {
|
|
final response = await dio.post(
|
|
'/ws/rest/com.axelor.apps.stock.db.StockLocation/search',
|
|
data: {
|
|
"offset": 0,
|
|
"limit": 800,
|
|
"data": {
|
|
"criteria": [
|
|
{
|
|
"fieldName": "usableOnImmobilisation",
|
|
"operator": "=",
|
|
"value": true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
if (response.statusCode == 200 && response.data['data'] != null) {
|
|
List<Depot> depots = [];
|
|
for (var depot in response.data["data"]) {
|
|
depots.add(Depot.fromJson(depot));
|
|
}
|
|
return depots;
|
|
} else {
|
|
return [];
|
|
}
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
Future<void> saveInventoryLine({required InventoryLine inventoryLine}) async {
|
|
final String url =
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.InventoryLine';
|
|
String domain;
|
|
|
|
if (inventoryLine.trackingNumberId == null) {
|
|
domain =
|
|
"self.inventory.id = :inventoryId AND self.product.id = :product AND self.trackingNumber IS NULL";
|
|
} else {
|
|
domain =
|
|
"self.inventory.id = :inventoryId AND self.product.id = :product AND self.trackingNumber.id = :trackingNumberId";
|
|
}
|
|
|
|
try {
|
|
// 1. Check if InventoryLine already exists
|
|
final searchResponse = await dio.post(
|
|
'$url/search',
|
|
data: {
|
|
"data": {
|
|
"_domain": domain,
|
|
"_domainContext": {
|
|
"inventoryId": inventoryLine.inventoryId,
|
|
"product": inventoryLine.productId,
|
|
"trackingNumberId": inventoryLine.trackingNumberId,
|
|
},
|
|
"_archived": false,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (searchResponse.statusCode == 200 &&
|
|
searchResponse.data['status'] == 0) {
|
|
final existing = searchResponse.data['data'];
|
|
|
|
if (existing != null && existing.isNotEmpty) {
|
|
final existingLine = existing[0];
|
|
inventoryLine.id = existingLine['id'];
|
|
inventoryLine.version = existingLine['version'];
|
|
|
|
inventoryLine.firstCounting =
|
|
inventoryLine.firstCounting != 0
|
|
? inventoryLine.firstCounting
|
|
: double.tryParse(
|
|
existingLine['firstCounting']?.toString() ?? '',
|
|
) ??
|
|
0.0;
|
|
|
|
inventoryLine.secondCounting =
|
|
inventoryLine.secondCounting != 0
|
|
? inventoryLine.secondCounting
|
|
: double.tryParse(
|
|
existingLine['secondCounting']?.toString() ?? '',
|
|
) ??
|
|
0.0;
|
|
|
|
inventoryLine.thirdCounting =
|
|
inventoryLine.thirdCounting != 0
|
|
? inventoryLine.thirdCounting
|
|
: double.tryParse(
|
|
existingLine['thirdCounting']?.toString() ?? '',
|
|
) ??
|
|
0.0;
|
|
|
|
inventoryLine.firstCountingDate =
|
|
inventoryLine.firstCountingDate ??
|
|
existingLine['firstCountingDate'];
|
|
|
|
inventoryLine.secondCountingDate =
|
|
inventoryLine.secondCountingDate ??
|
|
existingLine['secondCountingDate'];
|
|
|
|
inventoryLine.thirdCountingDate =
|
|
inventoryLine.thirdCountingDate ??
|
|
existingLine['thirdCountingDate'];
|
|
|
|
inventoryLine.firstCountingByUser =
|
|
inventoryLine.firstCountingByUser ??
|
|
existingLine['firstCountingByUser'];
|
|
|
|
inventoryLine.secondCountingByUser =
|
|
inventoryLine.secondCountingByUser ??
|
|
existingLine['secondCountingByUser'];
|
|
|
|
inventoryLine.thirdCountingByUser =
|
|
inventoryLine.thirdCountingByUser ??
|
|
existingLine['thirdCountingByUser'];
|
|
} else {}
|
|
}
|
|
|
|
// 2. Create or update the InventoryLine
|
|
final saveResponse = await dio.post(
|
|
url,
|
|
data: jsonEncode({"data": inventoryLine.toJson()}),
|
|
);
|
|
|
|
if (saveResponse.statusCode == 200) {
|
|
final result = saveResponse.data;
|
|
if (result['status'] == 0) {
|
|
} else {}
|
|
} else {}
|
|
} on DioException catch (e) {
|
|
if (e.response != null) {}
|
|
} catch (e) {}
|
|
}
|
|
|
|
Future<Product?> fetchProductByCode(String code) async {
|
|
try {
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.base.db.Product/search',
|
|
data: {
|
|
"offset": 0,
|
|
"limit": 1,
|
|
"sortBy": ["code", "name", "unit"],
|
|
"data": {
|
|
"criteria": [
|
|
{"fieldName": "code", "operator": "=", "value": code},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 && response.data['status'] == 0) {
|
|
final list = response.data["data"];
|
|
if (list != null && list.isNotEmpty) {
|
|
return Product.fromJson(list[0]);
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<List<TrackingNumber>?> fetchTrackingNumberByProduct(int id) async {
|
|
try {
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.TrackingNumber/search',
|
|
data: {
|
|
"offset": 0,
|
|
"limit": 100,
|
|
"fields": ["trackingNumberSeq", "perishableExpirationDate"],
|
|
"data": {
|
|
"criteria": [
|
|
{"fieldName": "product.id", "operator": "=", "value": id},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 && response.data['data'] != null) {
|
|
List<TrackingNumber> trackingNumbers = [];
|
|
for (var trackingNumber in response.data["data"]) {
|
|
trackingNumbers.add(TrackingNumber.fromJson(trackingNumber));
|
|
}
|
|
return trackingNumbers;
|
|
} else {
|
|
return [];
|
|
}
|
|
} catch (e) {}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<List<InventoryLine>?> fetchInventoryLinesByLocation(
|
|
int locationId,
|
|
) async {
|
|
try {
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.InventoryLine/search',
|
|
data: {
|
|
"data": {
|
|
"_domain":
|
|
"self.stockLocation.id = :locationId and self.inventory.id = :inventoryId",
|
|
"_domainContext": {
|
|
"locationId": locationId,
|
|
"inventoryId": kInventoryId,
|
|
},
|
|
"_archived": false,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 && response.data['data'] != null) {
|
|
List<InventoryLine> lines = [];
|
|
for (var line in response.data["data"]) {
|
|
lines.add(InventoryLine.fromJson(line));
|
|
}
|
|
return lines;
|
|
}
|
|
return [];
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> isSessionValid() async {
|
|
try {
|
|
final res = await dio.get(
|
|
'/ws/rest/com.axelor.apps.stock.db.StockLocation?offset=0&limit=1',
|
|
data: {
|
|
"offset": 0,
|
|
"limit": 1,
|
|
"data": {
|
|
"criteria": [
|
|
{
|
|
"fieldName": "usableOnImmobilisation",
|
|
"operator": "=",
|
|
"value": true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
return res.statusCode == 200 && res.data['status'] == 0;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> fetchUserProfile() async {
|
|
final username = _storage.read('username');
|
|
if (username == null) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
final response = await dio.post(
|
|
'/ws/rest/com.axelor.auth.db.User/search',
|
|
data: {
|
|
"offset": 0,
|
|
"limit": 1,
|
|
"data": {
|
|
"criteria": [
|
|
{"fieldName": "code", "operator": "=", "value": username},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 &&
|
|
response.data['status'] == 0 &&
|
|
response.data['data'] != null &&
|
|
response.data['data'].isNotEmpty) {
|
|
final user = response.data['data'][0];
|
|
return user;
|
|
} else {}
|
|
} catch (e) {}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> fetchStockLocationByName(String name) async {
|
|
try {
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.StockLocation/search',
|
|
data: {
|
|
"offset": 0,
|
|
"limit": 10000,
|
|
"data": {
|
|
"criteria": [
|
|
{"fieldName": "name", "operator": "=", "value": "$name"},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 &&
|
|
response.data['status'] == 0 &&
|
|
response.data['data'] != null &&
|
|
response.data['data'].isNotEmpty) {
|
|
final location = response.data['data'][0];
|
|
return location;
|
|
} else {}
|
|
} catch (e) {}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<Uint8List?> fetchImageBytes(int imageId) async {
|
|
final url =
|
|
'${Utils.url}/ws/rest/com.axelor.meta.db.MetaFile/$imageId/content/download';
|
|
|
|
try {
|
|
final response = await dio.get(
|
|
url,
|
|
options: Options(responseType: ResponseType.bytes),
|
|
);
|
|
|
|
final contentType = response.headers.value('content-type');
|
|
|
|
if (contentType == null ||
|
|
(!contentType.startsWith('image/') &&
|
|
contentType != 'application/octet-stream')) {
|
|
return null;
|
|
}
|
|
|
|
return Uint8List.fromList(response.data);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<Product?> createProducts(Product product, Depot? depot) async {
|
|
final String url = '${Utils.url}/ws/rest/com.axelor.apps.base.db.Product';
|
|
|
|
try {
|
|
// Create or update the InventoryLine
|
|
final saveResponse = await dio.post(
|
|
url,
|
|
data: jsonEncode({"data": product.toJson()}),
|
|
);
|
|
|
|
if (saveResponse.statusCode == 200) {
|
|
final result = saveResponse.data;
|
|
if (result['status'] == 0) {
|
|
Product newProduct = Product.fromJson(result["data"][0]);
|
|
TrackingNumber? trackingNumber = await createTrackingNumber(
|
|
trackingNumber: TrackingNumber(
|
|
trackingNumberSeq: product.internalDescription,
|
|
product: Product(id: newProduct.id),
|
|
),
|
|
);
|
|
|
|
// Create initial inventory line
|
|
await createInitialInventoryLine(newProduct, trackingNumber, depot);
|
|
|
|
return newProduct;
|
|
} else {}
|
|
} else {}
|
|
} on DioException catch (e) {
|
|
if (e.response != null) {}
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
Future<List<FamilleProduit>?> getFamilleProduit() async {
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.base.db.FamilleProduit/search',
|
|
data: {
|
|
"data": {
|
|
"criteria": [
|
|
{
|
|
"operator": "and",
|
|
"criteria": [
|
|
{"fieldName": "niveau", "operator": "=", "value": 0},
|
|
{
|
|
"fieldName": "usableOnImmobilisation",
|
|
"operator": "=",
|
|
"value": true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 && response.data['data'] != null) {
|
|
List<FamilleProduit> familleProduits = [];
|
|
for (var familleProduit in response.data["data"]) {
|
|
familleProduits.add(FamilleProduit.fromJson(familleProduit));
|
|
}
|
|
return familleProduits;
|
|
} else {
|
|
return [];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<List<FamilleProduit>?> getSousFamilleProduit(parentId) async {
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.base.db.FamilleProduit/search',
|
|
data: {
|
|
"data": {
|
|
"_domain": "self.parente.id = :parente",
|
|
"_domainContext": {"parente": parentId},
|
|
"_archived": false,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 && response.data['data'] != null) {
|
|
List<FamilleProduit> sousfamilleProduits = [];
|
|
for (var sousfamilleProduit in response.data["data"]) {
|
|
sousfamilleProduits.add(FamilleProduit.fromJson(sousfamilleProduit));
|
|
}
|
|
return sousfamilleProduits;
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<List<InventoryLine>?> getMyInventoryLines() async {
|
|
final user = await fetchUserProfile();
|
|
|
|
final response = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.InventoryLine/search',
|
|
data: {
|
|
"data": {
|
|
"_domain":
|
|
"self.createdBy.id = :createdBy and self.inventory.id = :inventoryId",
|
|
"_domainContext": {
|
|
"inventoryId": kInventoryId,
|
|
"createdBy": user!['id'],
|
|
},
|
|
"_archived": false,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (response.statusCode == 200 && response.data['data'] != null) {
|
|
List<InventoryLine>? inventoryLines = [];
|
|
for (var inventoryLine in response.data["data"]) {
|
|
inventoryLines.add(InventoryLine.fromJson(inventoryLine));
|
|
}
|
|
return inventoryLines;
|
|
} else {
|
|
return [];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<TrackingNumber?> createTrackingNumber({
|
|
required TrackingNumber trackingNumber,
|
|
}) async {
|
|
final String url =
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.TrackingNumber';
|
|
|
|
try {
|
|
// 1. Check if product already exists
|
|
final searchResponse = await dio.post(
|
|
'${Utils.url}/ws/rest/com.axelor.apps.stock.db.TrackingNumber/search',
|
|
data: {
|
|
"data": {
|
|
"_domain":
|
|
"self.trackingNumberSeq = :trackingNumberSeq and self.product.id = :productId",
|
|
"_domainContext": {
|
|
"trackingNumberSeq": trackingNumber.trackingNumberSeq,
|
|
"productId": trackingNumber.product,
|
|
},
|
|
"_archived": false,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (searchResponse.statusCode == 200 &&
|
|
searchResponse.data['status'] == 0) {
|
|
final existing = searchResponse.data['data'];
|
|
|
|
if (existing != null && existing.isNotEmpty) {
|
|
Get.snackbar('Erreur', "N° de serie existe deja");
|
|
return null;
|
|
} else {}
|
|
}
|
|
|
|
// 2. Create or update the InventoryLine
|
|
final saveResponse = await dio.post(
|
|
url,
|
|
data: jsonEncode({"data": trackingNumber.toJson()}),
|
|
);
|
|
|
|
if (saveResponse.statusCode == 200) {
|
|
final result = saveResponse.data;
|
|
if (result['status'] == 0) {
|
|
return TrackingNumber.fromJson(result["data"][0]);
|
|
} else {}
|
|
} else {}
|
|
} on DioException catch (e) {
|
|
if (e.response != null) {}
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
Future<void> createInitialInventoryLine(
|
|
Product product,
|
|
TrackingNumber? trackingNumber,
|
|
Depot? depot,
|
|
) async {
|
|
final user = await fetchUserProfile();
|
|
|
|
final line = InventoryLine(
|
|
inventoryId: kInventoryId,
|
|
productId: product.id!,
|
|
productName: product.name ?? '',
|
|
currentQty: 1,
|
|
realQty: 1,
|
|
description: "Initial entry from mobile",
|
|
unitId: product.unit?.id ?? 4,
|
|
countingTypeSelect: 1,
|
|
firstCounting: 1,
|
|
firstCountingDate: DateTime.now().toIso8601String(),
|
|
firstCountingByUser: user,
|
|
stockLocationId: depot!.id!,
|
|
ticketId: product.internalDescription ?? '',
|
|
secondCounting: null,
|
|
thirdCounting: null,
|
|
trackingNumberId: trackingNumber?.id,
|
|
);
|
|
|
|
await saveInventoryLine(inventoryLine: line);
|
|
}
|
|
}
|