import 'package:dio/dio.dart'; import 'package:sysfarma_mobile/src/core/network/mobile_api_response.dart'; import 'package:sysfarma_mobile/src/core/storage/tenant_session_store.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; final dioProvider = Provider( (ref) => Dio( BaseOptions( headers: const { 'Accept': 'application/json', 'Content-Type': 'application/json', }, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 20), sendTimeout: const Duration(seconds: 20), ), ), ); final apiClientProvider = Provider( (ref) => ApiClient( dio: ref.watch(dioProvider), sessionStore: ref.watch(tenantSessionStoreProvider), ), ); class ApiClient { const ApiClient({ required Dio dio, required TenantSessionStore sessionStore, }) : _dio = dio, _sessionStore = sessionStore; final Dio _dio; final TenantSessionStore _sessionStore; /// Default discovery URL used when no session exists yet (first-time RUC lookup). /// Production tenant accessible from any network (used for real-device testing). static const String defaultDiscoveryUrl = String.fromEnvironment( 'DISCOVERY_URL', defaultValue: 'https://sysfarma.pe', ); static const bool mockMode = bool.fromEnvironment( 'MOBILE_WEB_TEST_MODE', defaultValue: false, ); static String normalizeBaseUrl(String raw) { var value = raw.trim(); if (!value.startsWith('http://') && !value.startsWith('https://')) { value = 'https://$value'; } return value.replaceAll(RegExp(r'/$'), ''); } Future>> bootstrap({String? baseUrl}) { return _requestMap( 'GET', path: '/api/mobile/v1/bootstrap', baseUrl: baseUrl, // Si hay sesión activa, enviar token para recibir session.user. authorized: true, ); } Future>> resolveRuc({ required String ruc, String? baseUrl, }) { return _requestMap( 'POST', path: '/api/mobile/v1/resolve-ruc', baseUrl: baseUrl, authorized: false, data: {'ruc': ruc}, ); } Future>> login({ required String baseUrl, required String email, required String password, }) { return _requestMap( 'POST', path: '/api/mobile/v1/auth/login', baseUrl: baseUrl, authorized: false, data: { 'email': email, 'password': password, }, ); } Future>> dashboardSummary({ String period = 'date', String? dateStart, String? dateEnd, String? monthStart, String? monthEnd, }) { final params = {'period': period}; if (dateStart != null) params['date_start'] = dateStart; if (dateEnd != null) params['date_end'] = dateEnd; if (monthStart != null) params['month_start'] = monthStart; if (monthEnd != null) params['month_end'] = monthEnd; return _requestMap( 'GET', path: '/api/mobile/v1/dashboard/summary', authorized: true, queryParameters: params, ); } Future>> documentsTables() { return documentsTablesByEstablishment(); } Future>> documentsTablesByEstablishment({ int? establishmentId, }) { return _requestMap( 'GET', path: '/api/mobile/v1/documents/tables', authorized: true, queryParameters: { if (establishmentId != null) 'establishment_id': establishmentId, }, ); } Future>> createDocument({ required Map payload, String? idempotencyKey, }) { final data = Map.from(payload); if (idempotencyKey != null && idempotencyKey.trim().isNotEmpty) { data['idempotency_key'] = idempotencyKey.trim(); } return _requestMap( 'POST', path: '/api/mobile/v1/documents', authorized: true, data: data, ); } Future>> updateActiveEstablishment({ required int establishmentId, }) { return _requestMap( 'POST', path: '/api/mobile/v1/session/establishment', authorized: true, data: {'establishment_id': establishmentId}, ); } Future>> registerPushToken({ required String token, String platform = 'android', String? appVersion, String? deviceName, }) { return _requestMap( 'POST', path: '/api/mobile/v1/devices/push-token', authorized: true, data: { 'token': token, 'platform': platform, if (appVersion != null && appVersion.isNotEmpty) 'app_version': appVersion, if (deviceName != null && deviceName.isNotEmpty) 'device_name': deviceName, }, ); } Future>> revokePushToken({ String? token, String platform = 'android', String? deviceName, }) { return _requestMap( 'POST', path: '/api/mobile/v1/devices/push-token/revoke', authorized: true, data: { if (token != null && token.isNotEmpty) 'token': token, 'platform': platform, if (deviceName != null && deviceName.isNotEmpty) 'device_name': deviceName, }, ); } Future>> documentDetail({ required int id, String model = 'document', }) { return _requestMap( 'GET', path: '/api/mobile/v1/documents/$id', authorized: true, queryParameters: { 'model': model, }, ); } Future>> documentPdf({ required int id, String model = 'document', String format = 'a4', }) { return _requestMap( 'GET', path: '/api/mobile/v1/documents/$id/pdf', authorized: true, queryParameters: { 'model': model, 'format': format, }, ); } Future>> sendDocumentToSunat({ required int id, }) { return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/send-sunat', authorized: true, ); } Future>> anularDocument({ required int id, String model = 'document', }) { return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/anular', authorized: true, queryParameters: {'model': model}, ); } Future>> emitCreditNote({ required int id, String reason = 'Anulación de la operación', List>? items, String? noteCreditTypeId, double? discountAmount, }) { final data = {'reason': reason}; if (items != null && items.isNotEmpty) { data['items'] = items; } if (noteCreditTypeId != null && noteCreditTypeId.isNotEmpty) { data['note_credit_type_id'] = noteCreditTypeId; } if (discountAmount != null && discountAmount > 0) { data['discount_amount'] = discountAmount; } return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/credit-note', authorized: true, data: data, ); } Future>> emitDebitNote({ required int id, required List> items, String noteDebitTypeId = '01', String? reason, }) { final data = { 'items': items, 'note_debit_type_id': noteDebitTypeId, }; if (reason != null && reason.isNotEmpty) { data['reason'] = reason; } return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/debit-note', authorized: true, data: data, ); } Future>> confirmAnularDocument({ required int id, }) { return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/anular/confirm', authorized: true, ); } Future>> cancelAnularDocument({ required int id, }) { return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/anular/cancel', authorized: true, ); } Future>> voidedSunatDocument({ required int id, String description = 'Anulación de la operación', }) { return _requestMap( 'POST', path: '/api/mobile/v1/documents/$id/voided-sunat', authorized: true, data: {'description': description}, ); } Future>> documentPrintPayload({ required int id, String model = 'document', }) { return _requestMap( 'GET', path: '/api/mobile/v1/documents/$id/print-payload', authorized: true, queryParameters: { 'model': model, }, ); } Future>> salesReport({ String period = 'month', String? dateStart, String? dateEnd, }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/sales', authorized: true, queryParameters: { 'period': period, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, }, ); } Future>> inventoryReport() { return _requestMap( 'GET', path: '/api/mobile/v1/reports/inventory', authorized: true, queryParameters: const {'per_page': 5}, ); } Future>> criticalStockReport({ String kind = 'all', }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/critical-stock', authorized: true, queryParameters: {'kind': kind, 'per_page': 100}, ); } Future>> expiringLotsReport({ int days = 60, }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/expiring-lots', authorized: true, queryParameters: {'days': days, 'per_page': 100}, ); } Future>> lotsOverviewReport({ String kind = 'all', }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/lots-overview', authorized: true, queryParameters: {'kind': kind, 'per_page': 200}, ); } Future>> bySellerReport({ String period = 'month', String? dateStart, String? dateEnd, }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/by-seller', authorized: true, queryParameters: { 'period': period, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, }, ); } Future>> byCategoryReport({ String period = 'month', String? dateStart, String? dateEnd, }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/by-category', authorized: true, queryParameters: { 'period': period, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, }, ); } Future>> byHourReport({ String period = 'month', String? dateStart, String? dateEnd, }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/by-hour', authorized: true, queryParameters: { 'period': period, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, }, ); } Future>> topProductsReport({ String period = 'month', String? dateStart, String? dateEnd, int limit = 10, }) { return _requestMap( 'GET', path: '/api/mobile/v1/reports/top-products', authorized: true, queryParameters: { 'period': period, 'limit': limit, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, }, ); } Future>> quickSummary() { return _requestMap( 'GET', path: '/api/mobile/v1/quick/summary', authorized: true, ); } Future>>> suppliers({ int perPage = 8, int page = 1, String? search, }) { return _requestList( 'GET', path: '/api/mobile/v1/suppliers', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), }, ); } Future>> supplierDetail({ required int id, }) { return _requestMap( 'GET', path: '/api/mobile/v1/suppliers/$id', authorized: true, ); } Future>> upsertSupplier({ int? id, required Map payload, }) { return _requestMap( id == null ? 'POST' : 'PUT', path: id == null ? '/api/mobile/v1/suppliers' : '/api/mobile/v1/suppliers/$id', authorized: true, data: payload, ); } Future>> lookupPersonByDocument({ required String type, required String number, }) { return _requestMap( 'GET', path: '/api/mobile/v1/persons/lookup/$type/$number', authorized: true, ); } Future>> customerDetail({ required int id, }) { return _requestMap( 'GET', path: '/api/mobile/v1/customers/$id', authorized: true, ); } Future>> upsertCustomer({ int? id, required Map payload, }) { return _requestMap( id == null ? 'POST' : 'PUT', path: id == null ? '/api/mobile/v1/customers' : '/api/mobile/v1/customers/$id', authorized: true, data: payload, ); } Future>> changePassword({ required String currentPassword, required String newPassword, }) { return _requestMap( 'POST', path: '/api/mobile/v1/me/password', authorized: true, data: { 'current_password': currentPassword, 'new_password': newPassword, }, ); } Future>> updateItemBasic({ required int id, required Map payload, }) { return _requestMap( 'PUT', path: '/api/mobile/v1/items/$id', authorized: true, data: payload, ); } Future>> cashStatus() { return _requestMap( 'GET', path: '/api/mobile/v1/cash/status', authorized: true, ); } Future>> openCash({ required double beginningBalance, }) { return _requestMap( 'POST', path: '/api/mobile/v1/cash/open', authorized: true, data: { 'beginning_balance': beginningBalance, }, ); } Future>> closeCash({ required int cashId, required double cash, required double transfers, List>? denominations, }) { final payload = { 'cash': cash, 'transfers': transfers, }; if (denominations != null && denominations.isNotEmpty) { payload['denominations'] = denominations; } return _requestMap( 'POST', path: '/api/mobile/v1/cash/$cashId/close', authorized: true, data: payload, ); } Future>> cashReport({ required int cashId, }) { return _requestMap( 'GET', path: '/api/mobile/v1/cash/$cashId/report', authorized: true, ); } Future>>> cashes({ int perPage = 30, int page = 1, String? search, }) { return _requestList( 'GET', path: '/api/mobile/v1/cash', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), }, ); } /// Download a PDF (or any binary) from an authenticated endpoint. /// Returns the raw bytes, or null on failure. Future?> downloadAuthenticatedFile(String url) async { if (mockMode) return null; try { // ignore: avoid_print print('[EnterFarma] downloadAuthenticatedFile url=$url'); final session = await _sessionStore.readSession(); final token = session?.token; final headers = {'Accept': '*/*'}; if (token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; } final response = await _dio.get>( url, options: Options( method: 'GET', headers: headers, responseType: ResponseType.bytes, ), ); // ignore: avoid_print print('[EnterFarma] downloadAuthenticatedFile status=${response.statusCode} bytes=${response.data?.length ?? 0}'); return response.data; } catch (e) { // ignore: avoid_print print('[EnterFarma] downloadAuthenticatedFile EXCEPTION: $e'); return null; } } Future>> inventoryOptions() { return _requestMap( 'GET', path: '/api/mobile/v1/inventory/options', authorized: true, ); } Future>> createInventoryMovement({ String? itemCode, int? itemId, required int warehouseId, required Object inventoryTransactionId, // String o int — el backend acepta ambos required double quantity, required String type, String? lotCode, String? dateOfDue, List>? lots, }) { return _requestMap( 'POST', path: '/api/mobile/v1/inventory', authorized: true, data: { if (itemCode != null && itemCode.isNotEmpty) 'item_code': itemCode, if (itemId != null) 'item_id': itemId, 'warehouse_id': warehouseId, 'inventory_transaction_id': inventoryTransactionId, 'quantity': quantity, 'type': type, if (lotCode != null && lotCode.isNotEmpty) 'lot_code': lotCode, if (dateOfDue != null && dateOfDue.isNotEmpty) 'date_of_due': dateOfDue, if (lots != null && lots.isNotEmpty) 'lots': lots, }, ); } /// Busca lotes ya registrados de un producto (autocompletado de código de /// lote), igual que el buscador de la web. Future>>> itemLots({ required int itemId, String query = '', }) { return _requestList( 'GET', path: '/api/mobile/v1/inventory/items/$itemId/lots', authorized: true, queryParameters: { if (query.isNotEmpty) 'query': query, }, ); } Future>>> items({ int perPage = 8, int page = 1, String context = 'sale', String? search, String kind = 'all', bool favorite = false, bool searchByBarcode = false, int? warehouseId, int? establishmentId, }) { return _requestList( 'GET', path: '/api/mobile/v1/items', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, 'context': context, 'kind': kind, if (favorite) 'favorite': 1, if (searchByBarcode) 'search_by_barcode': 1, if (warehouseId != null) 'warehouse_id': warehouseId, if (establishmentId != null) 'establishment_id': establishmentId, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), }, ); } Future>> itemDetail({ required int id, String context = 'inventory', }) { return _requestMap( 'GET', path: '/api/mobile/v1/items/$id', authorized: true, queryParameters: { 'context': context, }, ); } Future>> itemOptions() { return _requestMap( 'GET', path: '/api/mobile/v1/items/options', authorized: true, ); } Future>> createItem({ required Map payload, }) { return _requestMap( 'POST', path: '/api/mobile/v1/items', authorized: true, data: payload, ); } Future>>> customers({ int perPage = 8, int page = 1, String? search, int? establishmentId, }) { return _requestList( 'GET', path: '/api/mobile/v1/customers', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, if (establishmentId != null) 'establishment_id': establishmentId, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), }, ); } Future>>> purchases({ int perPage = 10, int page = 1, String? search, String? status, int? supplierId, String? dateStart, String? dateEnd, double? totalMin, double? totalMax, }) { return _requestList( 'GET', path: '/api/mobile/v1/purchases', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), if (status != null) 'status': status, if (supplierId != null) 'supplier_id': supplierId, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, if (totalMin != null) 'total_min': totalMin, if (totalMax != null) 'total_max': totalMax, }, ); } Future>> purchaseDetail({ required int id, }) { return _requestMap( 'GET', path: '/api/mobile/v1/purchases/$id', authorized: true, ); } Future>> createPurchase({ required Map payload, }) { return _requestMap( 'POST', path: '/api/mobile/v1/purchases', authorized: true, data: payload, ); } Future>> purchasePayments({ required int id, }) { return _requestMap( 'GET', path: '/api/mobile/v1/purchases/$id/payments', authorized: true, ); } Future>> purchaseFees({ required int id, }) { return _requestMap( 'GET', path: '/api/mobile/v1/purchases/$id/fees', authorized: true, ); } Future>> anularPurchase({ required int id, }) { return _requestMap( 'POST', path: '/api/mobile/v1/purchases/$id/anular', authorized: true, ); } Future>> createPurchasePayment({ required int id, required Map payload, }) { return _requestMap( 'POST', path: '/api/mobile/v1/purchases/$id/payments', authorized: true, data: payload, ); } Future>>> receivables({ int perPage = 10, int page = 1, }) { return _requestList( 'GET', path: '/api/mobile/v1/receivables', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, }, ); } Future>>> payables({ int perPage = 10, int page = 1, }) { return _requestList( 'GET', path: '/api/mobile/v1/payables', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, }, ); } Future>>> inventoryStock({ int perPage = 12, int page = 1, }) { return _requestList( 'GET', path: '/api/mobile/v1/inventory/stock', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, }, ); } Future>>> inventoryMovements({ int perPage = 8, int page = 1, String movementType = 'all', }) { return _requestList( 'GET', path: '/api/mobile/v1/inventory/movements', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, 'movement_type': movementType, }, ); } Future>>> physicalCounts({ int perPage = 15, int page = 1, String status = 'all', }) { return _requestList( 'GET', path: '/api/mobile/v1/physical-counts', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, 'status': status, }, ); } Future>> createPhysicalCount({ required int warehouseId, String? notes, }) { return _requestMap( 'POST', path: '/api/mobile/v1/physical-counts', authorized: true, data: { 'warehouse_id': warehouseId, if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(), }, ); } Future>> physicalCount(int id) { return _requestMap( 'GET', path: '/api/mobile/v1/physical-counts/$id', authorized: true, ); } Future>>> searchPhysicalCountItems( int countId, { required String search, int perPage = 20, }) { return _requestList( 'GET', path: '/api/mobile/v1/physical-counts/$countId/items/search', authorized: true, queryParameters: { 'search': search.trim(), 'per_page': perPage, }, ); } Future>> addPhysicalCountItem( int countId, { String? barcode, int? itemId, int? itemUnitTypeId, int? lotGroupId, String? lotCode, double quantity = 1, String source = 'scan', String? notes, }) { return _requestMap( 'POST', path: '/api/mobile/v1/physical-counts/$countId/items', authorized: true, data: { if (barcode != null && barcode.trim().isNotEmpty) 'barcode': barcode.trim(), if (itemId != null) 'item_id': itemId, if (itemUnitTypeId != null) 'item_unit_type_id': itemUnitTypeId, if (lotGroupId != null) 'lot_group_id': lotGroupId, if (lotCode != null && lotCode.trim().isNotEmpty) 'lot_code': lotCode.trim(), 'quantity': quantity, 'source': source, if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(), }, ); } Future>> updatePhysicalCountItem( int countId, int lineId, { required double countedQtyBase, int? lotGroupId, String? lotCode, String? notes, }) { return _requestMap( 'PUT', path: '/api/mobile/v1/physical-counts/$countId/items/$lineId', authorized: true, data: { 'counted_qty_base': countedQtyBase, if (lotGroupId != null) 'lot_group_id': lotGroupId, if (lotCode != null && lotCode.trim().isNotEmpty) 'lot_code': lotCode.trim(), if (notes != null && notes.trim().isNotEmpty) 'notes': notes.trim(), }, ); } Future>> removePhysicalCountItem( int countId, int lineId, ) { return _requestMap( 'DELETE', path: '/api/mobile/v1/physical-counts/$countId/items/$lineId', authorized: true, ); } Future>> submitPhysicalCount(int id) { return _requestMap( 'POST', path: '/api/mobile/v1/physical-counts/$id/submit', authorized: true, ); } Future>> physicalCountReport(int id) { return _requestMap( 'GET', path: '/api/mobile/v1/physical-counts/$id/report', authorized: true, ); } Future>>> physicalCountItemKardex( int countId, int itemId, ) { return _requestList( 'GET', path: '/api/mobile/v1/physical-counts/$countId/items/$itemId/kardex', authorized: true, ); } Future>>> documents({ int perPage = 10, int page = 1, String? search, String model = 'document', // 'document' o 'sale_note' String? stateTypeId, String? dateStart, String? dateEnd, int? sellerId, double? totalMin, double? totalMax, }) { return _requestList( 'GET', path: '/api/mobile/v1/documents', authorized: true, queryParameters: { 'per_page': perPage, 'page': page, 'model': model, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), if (stateTypeId != null) 'state_type_id': stateTypeId, if (dateStart != null) 'date_start': dateStart, if (dateEnd != null) 'date_end': dateEnd, if (sellerId != null) 'seller_id': sellerId, if (totalMin != null) 'total_min': totalMin, if (totalMax != null) 'total_max': totalMax, }, ); } Future>> _requestMap( String method, { required String path, String? baseUrl, bool authorized = false, Map? data, Map? queryParameters, }) async { if (mockMode) { return _mockMap(path); } try { final resolvedBaseUrl = await _resolveBaseUrl(baseUrl); // ignore: avoid_print print('[EnterFarma] HTTP $method $resolvedBaseUrl$path'); final response = await _dio.request( '$resolvedBaseUrl$path', data: data, queryParameters: queryParameters, options: await _buildOptions(method: method, authorized: authorized), ); // ignore: avoid_print print('[EnterFarma] HTTP $method $path -> ${response.statusCode}'); return _toMapResponse(response.data); } on DioException catch (error) { final statusCode = error.response?.statusCode ?? 0; // ignore: avoid_print print('[EnterFarma] HTTP $method $path FAIL status=$statusCode type=${error.type}'); // ignore: avoid_print print('[EnterFarma] HTTP $method $path BODY=${error.response?.data}'); if (statusCode == 401 || statusCode == 419) { await _sessionStore.clear(); return MobileApiResponse>.failure( message: 'Sesion vencida. Vuelve a iniciar sesion.', errors: const {'auth': true}, ); } final payload = error.response?.data; if (payload is Map) { return MobileApiResponse>( success: payload['success'] == true, data: payload['data'] is Map ? Map.from(payload['data'] as Map) : const {}, message: payload['message']?.toString() ?? 'No fue posible completar la solicitud.', errors: payload['errors'] is Map ? Map.from(payload['errors'] as Map) : const {}, meta: payload['meta'] is Map ? Map.from(payload['meta'] as Map) : const {}, ); } return MobileApiResponse>.failure( message: error.message ?? 'Error de red al conectar con el API movil.', ); } on Exception catch (error) { // ignore: avoid_print print('[EnterFarma] HTTP $method $path EXCEPTION: $error'); return MobileApiResponse>.failure( message: error.toString(), ); } } Future>>> _requestList( String method, { required String path, String? baseUrl, bool authorized = false, Map? data, Map? queryParameters, }) async { if (mockMode) { return _mockList(path); } try { final resolvedBaseUrl = await _resolveBaseUrl(baseUrl); final response = await _dio.request( '$resolvedBaseUrl$path', data: data, queryParameters: queryParameters, options: await _buildOptions(method: method, authorized: authorized), ); return MobileApiResponse>>.fromJson( response.data as Map? ?? const {}, (value) => List>.from(value as List? ?? const []), ); } on DioException catch (error) { final statusCode = error.response?.statusCode ?? 0; if (statusCode == 401 || statusCode == 419) { await _sessionStore.clear(); return MobileApiResponse>>.failure( message: 'Sesion vencida. Vuelve a iniciar sesion.', errors: const {'auth': true}, ); } final payload = error.response?.data; if (payload is Map) { return MobileApiResponse>>( success: payload['success'] == true, data: List>.from(payload['data'] as List? ?? const []), message: payload['message']?.toString() ?? 'No fue posible completar la solicitud.', errors: payload['errors'] is Map ? Map.from(payload['errors'] as Map) : const {}, meta: payload['meta'] is Map ? Map.from(payload['meta'] as Map) : const {}, ); } return MobileApiResponse>>.failure( message: error.message ?? 'Error de red al conectar con el API movil.', ); } on Exception catch (error) { return MobileApiResponse>>.failure( message: error.toString(), ); } } Future _buildOptions({ required String method, required bool authorized, }) async { final headers = {}; if (authorized) { final session = await _sessionStore.readSession(); final token = session?.token; if (token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; } } return Options(method: method, headers: headers); } Future _resolveBaseUrl(String? baseUrl) async { if (baseUrl != null && baseUrl.trim().isNotEmpty) { return _sanitizeBaseUrl(normalizeBaseUrl(baseUrl)); } final session = await _sessionStore.readSession(); if (session == null) { throw const FormatException('No hay una sesion o URL base configurada.'); } return _sanitizeBaseUrl(normalizeBaseUrl(session.baseUrl)); } String _sanitizeBaseUrl(String baseUrl) { final normalized = normalizeBaseUrl(baseUrl); if (normalized.contains('enterfarmaplus.test') || normalized.contains('.test')) { return normalizeBaseUrl(defaultDiscoveryUrl); } return normalized; } MobileApiResponse> _toMapResponse(dynamic raw) { final payload = raw is Map ? raw : {}; return MobileApiResponse>.fromJson( payload, (value) => value is Map ? Map.from(value) : const {}, ); } MobileApiResponse> _mockMap(String path) { if (path.startsWith('/api/mobile/v1/cash/') && path.endsWith('/close')) { return const MobileApiResponse>( success: true, data: {'id': 1}, message: 'Caja cerrada (mock).', ); } if (path.startsWith('/api/mobile/v1/cash/') && path.endsWith('/report')) { return const MobileApiResponse>( success: true, data: { 'income': 980.25, 'expenses': 120.5, 'balance': 320.5, }, message: 'Reporte caja mock.', ); } switch (path) { case '/api/mobile/v1/resolve-ruc': return const MobileApiResponse>( success: true, data: { 'ruc': '00000000000', 'company_name': 'Demo', 'base_url': 'http://demo.enterfarmaplus.test', 'host': 'demo.enterfarmaplus.test', }, message: 'Empresa encontrada.', ); case '/api/mobile/v1/bootstrap': return const MobileApiResponse>( success: true, data: { 'tenant': { 'host': 'demo.enterfarmaplus.test', 'api_base_url': 'http://demo.enterfarmaplus.test', }, 'company': { 'trade_name': 'Demo Farmacia', 'name': 'Demo Farmacia SAC', }, 'capabilities': { 'online_only': true, 'android_classic_bluetooth_print': true, 'ios_pdf_airprint': true, }, 'theme': { 'colors': { 'primary': '#0d9488', }, }, 'session': { 'user': { 'name': 'Administrador', 'email': 'admin@gmail.com', }, }, 'navigation': [ {'key': 'home', 'label': 'Inicio'}, {'key': 'sales', 'label': 'Ventas'}, {'key': 'inventory', 'label': 'Inventario'}, {'key': 'reports', 'label': 'Reportes'}, {'key': 'settings', 'label': 'Mas'}, ], }, message: 'Bootstrap mock.', ); case '/api/mobile/v1/auth/login': return const MobileApiResponse>( success: true, data: { 'token': 'mock-token', 'token_type': 'Bearer', 'company': { 'trade_name': 'Demo Farmacia', 'name': 'Demo Farmacia SAC', }, }, message: 'Usuario autenticado.', ); case '/api/mobile/v1/quick/summary': return const MobileApiResponse>( success: true, data: { 'documents': { 'count': 2, 'total': 180.5, 'records': [ { 'number_full': 'B001-123', 'customer_name': 'Clientes - Varios', 'total': 45.5, }, { 'number_full': 'F001-224', 'customer_name': 'Juan Perez', 'total': 135.0, }, ], }, 'purchases': { 'count': 1, 'total': 150.0, 'records': [ { 'id': 1, 'number_full': 'F001-55', 'total': 150.0, 'print_a4': 'http://demo.enterfarmaplus.test/purchases/print/demo/a4', 'supplier': {'name': 'Proveedor Demo'}, }, ], }, 'receivables': { 'count': 1, 'total': 35.8, 'records': [ {'number_full': 'F001-120', 'customer_name': 'Juan Perez', 'total_to_pay': 35.8}, ], }, 'payables': { 'count': 1, 'total': 100.0, 'records': [ {'number_full': 'F001-55', 'supplier_name': 'Proveedor Demo', 'total_to_pay': 100.0}, ], }, 'low_stock': { 'count': 2, 'records': [ {'description': 'Ibuprofeno 400mg', 'stock': 5, 'stock_min': 10, 'internal_id': 'IBU400'}, {'description': 'Alcohol 70%', 'stock': 0, 'stock_min': 6, 'internal_id': 'ALC70'}, ], }, }, message: 'Resumen rapido mock.', ); case '/api/mobile/v1/customers/1': case '/api/mobile/v1/customers/2': return const MobileApiResponse>( success: true, data: { 'id': 2, 'name': 'Juan Perez', 'number': '12345678', 'identity_document_type_id': '1', 'identity_document_type_description': 'DNI', 'telephone': '999888777', 'email': 'juan@example.com', 'address': 'Av. Lima 123', }, message: 'Cliente mock.', ); case '/api/mobile/v1/suppliers/10': case '/api/mobile/v1/suppliers/11': return const MobileApiResponse>( success: true, data: { 'id': 10, 'name': 'Proveedor Demo', 'number': '20111111111', 'identity_document_type_id': '6', 'identity_document_type_description': 'RUC', 'telephone': '999999999', 'email': 'proveedor@example.com', 'address': 'Parque Industrial 55', }, message: 'Proveedor mock.', ); case '/api/mobile/v1/purchases/1': return const MobileApiResponse>( success: true, data: { 'id': 1, 'number_full': 'F001-55', 'total': 150.0, 'total_pending': 100.0, 'date_of_issue': '2026-04-11', 'print_a4': 'http://demo.enterfarmaplus.test/purchases/print/demo/a4', 'supplier': {'name': 'Proveedor Demo', 'number': '20111111111'}, 'items': [ { 'quantity': 5, 'unit_price': 10.0, 'item': {'description': 'Paracetamol 500mg'}, }, ], 'payments': [ { 'payment_method_type_description': 'Efectivo', 'date_of_payment': '2026-04-11', 'payment': 50.0, }, ], }, message: 'Compra mock.', ); case '/api/mobile/v1/purchases/1/payments': return const MobileApiResponse>( success: true, data: { 'payment_method_types': [ {'id': '01', 'description': 'Efectivo'}, {'id': '02', 'description': 'Transferencia'}, ], 'payment_destinations': [ {'id': 'cash', 'description': 'Caja principal'}, {'id': 'bank', 'description': 'Banco principal'}, ], }, message: 'Pagos mock.', ); case '/api/mobile/v1/dashboard/summary': return const MobileApiResponse>( success: true, data: { 'totals': { 'total': 1250.75, 'total_documents': 18, 'total_sale_notes': 4, 'total_orders': 2, 'total_paid': 980.25, 'total_pending': 270.5, }, 'notifications': { 'documents_not_sent': 0, 'notifications_count': 1, }, 'cash': { 'id': 1, 'final_balance': 320.5, 'income': 980.25, }, 'period': { 'date_start': '2026-04-11', 'date_end': '2026-04-11', }, 'graph': { 'labels': ['08:00', '10:00', '12:00', '14:00', '16:00'], 'series': [120, 220, 180, 260, 300], }, 'reports': { 'products_url': '-', 'income_summary_url': '-', }, }, message: 'Resumen mock.', ); case '/api/mobile/v1/documents/tables': return const MobileApiResponse>( success: true, data: { 'sale_detail': { 'document_types': [ {'id': '03', 'description': 'Boleta'}, {'id': '01', 'description': 'Factura'}, {'id': '80', 'description': 'Nota de venta'}, ], }, 'sale_payment': { 'series': [ {'id': 1, 'document_type_id': '03', 'number': 'B001', 'is_default': true}, {'id': 2, 'document_type_id': '01', 'number': 'F001', 'is_default': true}, {'id': 3, 'document_type_id': '80', 'number': 'NV01', 'is_default': true}, ], 'payment_conditions': [ {'id': '01', 'description': 'Contado'}, {'id': '02', 'description': 'Credito'}, ], 'payment_method_types': [ {'id': '01', 'description': 'Efectivo'}, {'id': '02', 'description': 'Transferencia'}, ], 'payment_destinations': [ {'id': 'cash', 'description': 'Caja principal'}, {'id': 'bank', 'description': 'Banco'}, ], }, }, message: 'Tablas mock.', ); case '/api/mobile/v1/reports/sales': return const MobileApiResponse>( success: true, data: { 'totals': { 'total': 4200.5, 'total_documents': 32, 'total_sale_notes': 7, }, 'period': { 'period': 'month', 'date_start': '2026-04-01', 'date_end': '2026-04-11', }, 'graph': { 'labels': ['01', '03', '05', '07', '09', '11'], 'series': [320, 540, 460, 700, 620, 830], }, }, message: 'Reporte ventas mock.', ); case '/api/mobile/v1/reports/inventory': return const MobileApiResponse>( success: true, data: { 'totals': { 'stock_cost_value': 21500, 'stock_sale_value': 29800, 'potential_profit': 8300, }, 'items': [ { 'description': 'Paracetamol 500mg', 'stock': 25, 'stock_cost_value': 350, 'stock_sale_value': 520, }, { 'description': 'Ibuprofeno 400mg', 'stock': 12, 'stock_cost_value': 280, 'stock_sale_value': 410, }, ], }, message: 'Reporte inventario mock.', ); case '/api/mobile/v1/inventory/options': return const MobileApiResponse>( success: true, data: { 'warehouses': [ {'id': 1, 'description': 'Almacen principal'}, {'id': 2, 'description': 'Almacen secundario'}, ], 'transactions': [ {'id': 1, 'name': 'Stock inicial', 'type': 'input'}, {'id': 2, 'name': 'Compra', 'type': 'input'}, {'id': 3, 'name': 'Venta', 'type': 'output'}, {'id': 4, 'name': 'Merma', 'type': 'output'}, ], }, message: 'Opciones inventario mock.', ); case '/api/mobile/v1/inventory': case '/api/mobile/v1/customers': case '/api/mobile/v1/suppliers': case '/api/mobile/v1/purchases': return const MobileApiResponse>( success: true, data: {}, message: 'Registro mock guardado.', ); case '/api/mobile/v1/cash/status': return const MobileApiResponse>( success: true, data: { 'id': 1, 'final_balance': 320.5, 'income': 980.25, }, message: 'Caja mock.', ); case '/api/mobile/v1/cash/open': return const MobileApiResponse>( success: true, data: {'id': 1}, message: 'Caja aperturada (mock).', ); default: return const MobileApiResponse>( success: true, data: {}, message: 'Respuesta mock.', ); } } MobileApiResponse>> _mockList(String path) { switch (path) { case '/api/mobile/v1/items': return const MobileApiResponse>>( success: true, data: [ { 'id': 1, 'description': 'Paracetamol 500mg', 'full_description': 'Paracetamol 500mg tabletas', 'internal_id': 'PARA500', 'barcode': '789100000001', 'sale_unit_price': 2.5, 'sale_affectation_igv_type_id': '10', 'has_igv': true, }, { 'id': 2, 'description': 'Ibuprofeno 400mg', 'full_description': 'Ibuprofeno 400mg capsulas', 'internal_id': 'IBU400', 'barcode': '789100000002', 'sale_unit_price': 3.5, 'sale_affectation_igv_type_id': '10', 'has_igv': true, }, ], message: 'Items mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/customers': return const MobileApiResponse>>( success: true, data: [ {'id': 1, 'name': 'Clientes - Varios', 'number': '99999999'}, {'id': 2, 'name': 'Juan Perez', 'number': '12345678'}, ], message: 'Clientes mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/suppliers': return const MobileApiResponse>>( success: true, data: [ {'id': 10, 'name': 'Proveedor Demo', 'number': '20111111111', 'telephone': '999999999'}, {'id': 11, 'name': 'Botica Proveedor', 'number': '20555555555'}, ], message: 'Proveedores mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/inventory/stock': return const MobileApiResponse>>( success: true, data: [ { 'id': 1, 'description': 'Paracetamol 500mg', 'internal_id': 'PARA500', 'stock': 25, 'stock_min': 8, 'stock_cost_value': 350, 'stock_sale_value': 520, }, { 'id': 2, 'description': 'Ibuprofeno 400mg', 'internal_id': 'IBU400', 'stock': 5, 'stock_min': 10, 'stock_cost_value': 140, 'stock_sale_value': 210, }, { 'id': 3, 'description': 'Alcohol 70%', 'internal_id': 'ALC70', 'stock': 0, 'stock_min': 6, 'stock_cost_value': 0, 'stock_sale_value': 0, }, ], message: 'Stock mock.', meta: { 'has_more': false, 'totals': { 'stock': 30, 'stock_cost_value': 490, 'stock_sale_value': 730, }, }, ); case '/api/mobile/v1/inventory/movements': return const MobileApiResponse>>( success: true, data: [ { 'description': 'Stock inicial', 'item_description': 'Paracetamol 500mg', 'quantity': 50, 'input': 50, 'output': '-', 'date_time': '2026-04-11 09:00:00', 'warehouse_name': 'Almacen principal', }, { 'description': 'Venta', 'item_description': 'Ibuprofeno 400mg', 'quantity': 2, 'input': '-', 'output': -2, 'date_time': '2026-04-11 10:30:00', 'warehouse_name': 'Almacen principal', }, ], message: 'Movimientos mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/documents': return const MobileApiResponse>>( success: true, data: [ { 'id': 1, 'description': 'B001-123', 'date_of_issue': '2026-04-11', 'customer_name': 'Clientes - Varios', 'total': 45.5, }, ], message: 'Documentos mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/purchases': return const MobileApiResponse>>( success: true, data: [ { 'id': 1, 'number_full': 'F001-55', 'date_of_issue': '2026-04-11', 'supplier': {'name': 'Proveedor Demo', 'number': '20111111111'}, 'total': 150.0, 'total_paid': 50.0, 'total_pending': 100.0, }, ], message: 'Compras mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/receivables': return const MobileApiResponse>>( success: true, data: [ { 'id': 1, 'number_full': 'F001-120', 'customer_name': 'Juan Perez', 'total_to_pay': 35.8, 'currency_type_id': 'PEN', }, ], message: 'CxC mock.', meta: {'has_more': false}, ); case '/api/mobile/v1/payables': return const MobileApiResponse>>( success: true, data: [ { 'id': 1, 'number_full': 'F001-55', 'supplier_name': 'Proveedor Demo', 'total_to_pay': 100.0, 'currency_type_id': 'PEN', }, ], message: 'CxP mock.', meta: {'has_more': false}, ); default: return const MobileApiResponse>>( success: true, data: [], message: 'Lista mock.', meta: {'has_more': false}, ); } } }