Update: 2026-08-07 05:56:50
This commit is contained in:
@@ -423,6 +423,11 @@ class AppLink {
|
||||
static String get foodMerchantApprove =>
|
||||
"$server/food/admin/merchant_approve.php";
|
||||
static String get foodPayouts => "$server/food/admin/payouts.php";
|
||||
|
||||
/// تسوية أرباح سائق التوصيل — أجور التوصيل مقاصّةً مع ديون النقد.
|
||||
/// تعمل بوضعين: action=preview يحسب بلا أثر، وaction=execute يصرف.
|
||||
static String get foodCourierSettlement =>
|
||||
"$server/food/admin/courier_settlement.php";
|
||||
static String dashboardWalletV2 =
|
||||
"$paymentServerV2/Admin/v2/financial/dashboard_wallet.php";
|
||||
static String auditLogsV2 = "$server/Admin/v2/security/audit_logs.php";
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../views/widgets/snackbar.dart';
|
||||
import '../functions/crud.dart';
|
||||
|
||||
/// تسوية أرباح سائق التوصيل.
|
||||
///
|
||||
/// خطوتان لا واحدة: **معاينة** تحسب وتعرض بلا أثر، ثم **تنفيذ** يصرف.
|
||||
/// صرف المال يجب أن يسبقه اطّلاع، لا أن يكون أثراً جانبياً لفتح شاشة.
|
||||
class CourierSettlementController extends GetxController {
|
||||
final CRUD _crud = CRUD();
|
||||
|
||||
final courierCtrl = TextEditingController();
|
||||
final startCtrl = TextEditingController();
|
||||
final endCtrl = TextEditingController();
|
||||
|
||||
final isLoading = false.obs;
|
||||
final isExecuting = false.obs;
|
||||
|
||||
/// نتيجة المعاينة. فارغة = لم تُطلب بعد.
|
||||
final preview = Rxn<Map<String, dynamic>>();
|
||||
|
||||
/// نتيجة التنفيذ. وجودها يقفل زر الصرف — لا تُنفَّذ الدورة مرتين.
|
||||
final executed = Rxn<Map<String, dynamic>>();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// الافتراضي: الأسبوع المنتهي. أشيع فترة تسوية، ويوفّر كتابة يدوية.
|
||||
final now = DateTime.now();
|
||||
final weekAgo = now.subtract(const Duration(days: 7));
|
||||
startCtrl.text = _fmt(weekAgo, startOfDay: true);
|
||||
endCtrl.text = _fmt(now);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
courierCtrl.dispose();
|
||||
startCtrl.dispose();
|
||||
endCtrl.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
String _fmt(DateTime d, {bool startOfDay = false}) {
|
||||
final s = '${d.year}-${_2(d.month)}-${_2(d.day)}';
|
||||
return startOfDay ? '$s 00:00:00' : '$s ${_2(d.hour)}:${_2(d.minute)}:00';
|
||||
}
|
||||
|
||||
String _2(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
bool get _valid {
|
||||
if (courierCtrl.text.trim().isEmpty) {
|
||||
mySnackbarError('أدخل معرّف السائق');
|
||||
return false;
|
||||
}
|
||||
if (startCtrl.text.trim().isEmpty || endCtrl.text.trim().isEmpty) {
|
||||
mySnackbarError('حدّد الفترة');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<String, String> get _payload => {
|
||||
'courier_id': courierCtrl.text.trim(),
|
||||
'period_start': startCtrl.text.trim(),
|
||||
'period_end': endCtrl.text.trim(),
|
||||
};
|
||||
|
||||
/// يحسب الصافي بلا أي أثر على القاعدة أو المحفظة.
|
||||
Future<void> runPreview() async {
|
||||
if (!_valid || isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
// تغيير السائق أو الفترة يُبطل نتيجة تنفيذ سابقة — وإلا بقيت معروضة
|
||||
// فأوهمت الموظف أن الدورة الجديدة صُرفت.
|
||||
executed.value = null;
|
||||
preview.value = null;
|
||||
|
||||
try {
|
||||
final res = await _crud.post(
|
||||
link: AppLink.foodCourierSettlement,
|
||||
payload: {..._payload, 'action': 'preview'},
|
||||
);
|
||||
final data = _extract(res);
|
||||
if (data == null) return;
|
||||
preview.value = data;
|
||||
} catch (e) {
|
||||
mySnackbarError('تعذّر الاتصال بالخادم');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// يثبّت الدورة ويصرف إن كان الصافي موجباً.
|
||||
Future<void> runExecute() async {
|
||||
if (!_valid || isExecuting.value || preview.value == null) return;
|
||||
|
||||
isExecuting.value = true;
|
||||
try {
|
||||
final res = await _crud.post(
|
||||
link: AppLink.foodCourierSettlement,
|
||||
payload: {..._payload, 'action': 'execute'},
|
||||
);
|
||||
final data = _extract(res);
|
||||
if (data == null) return;
|
||||
|
||||
executed.value = data;
|
||||
|
||||
switch (data['status']) {
|
||||
case 'paid':
|
||||
mySnackbarSuccess('صُرفت التسوية #${data['settlement_id']}');
|
||||
break;
|
||||
case 'carried':
|
||||
mySnackbarSuccess('الصافي سالب — رُحّل للدورة القادمة');
|
||||
break;
|
||||
default:
|
||||
// فشل التحويل يُعرض صراحةً: الدورة مثبّتة والمال لم يصل.
|
||||
mySnackbarError('ثُبّتت الدورة لكن التحويل فشل — تحتاج تسوية يدوية');
|
||||
}
|
||||
} catch (e) {
|
||||
mySnackbarError('تعذّر الاتصال بالخادم');
|
||||
} finally {
|
||||
isExecuting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _extract(dynamic res) {
|
||||
final decoded = res is String ? jsonDecode(res) : res;
|
||||
if (decoded is! Map) {
|
||||
mySnackbarError('رد غير مفهوم من الخادم');
|
||||
return null;
|
||||
}
|
||||
if (decoded['status'] != 'success') {
|
||||
mySnackbarError(decoded['message']?.toString() ?? 'فشلت العملية');
|
||||
return null;
|
||||
}
|
||||
final data = decoded['message'] ?? decoded['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../../../controller/admin/courier_settlement_controller.dart';
|
||||
|
||||
/// شاشة تسوية أرباح سائق التوصيل.
|
||||
///
|
||||
/// المسار: إدخال → معاينة → تنفيذ. زر التنفيذ لا يظهر قبل المعاينة.
|
||||
class CourierSettlementPage extends StatelessWidget {
|
||||
const CourierSettlementPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = Get.put(CourierSettlementController());
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('تسوية أرباح التوصيل')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Card(
|
||||
title: 'الفترة والسائق',
|
||||
icon: Icons.tune,
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: c.courierCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'معرّف السائق',
|
||||
hintText: 'driver id',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: c.startCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'من',
|
||||
hintText: 'YYYY-MM-DD HH:MM:SS',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: c.endCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'إلى',
|
||||
hintText: 'YYYY-MM-DD HH:MM:SS',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Obx(() => SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: c.isLoading.value ? null : c.runPreview,
|
||||
icon: c.isLoading.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.calculate_outlined),
|
||||
label: Text(c.isLoading.value ? 'جارٍ الحساب…' : 'احسب الصافي'),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Obx(() => c.preview.value == null
|
||||
? const SizedBox.shrink()
|
||||
: _Result(c: c)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Result extends StatelessWidget {
|
||||
const _Result({required this.c});
|
||||
final CourierSettlementController c;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = c.preview.value!;
|
||||
final done = c.executed.value;
|
||||
|
||||
final num net = p['net_decimal'] ?? 0;
|
||||
final String cur = p['currency']?.toString() ?? '';
|
||||
final bool willPay = net > 0;
|
||||
|
||||
return _Card(
|
||||
title: 'الصافي',
|
||||
icon: Icons.receipt_long_outlined,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_line('الطلبات المسلَّمة', '${p['orders_count'] ?? 0}'),
|
||||
const Divider(height: 20),
|
||||
_line('أجور التوصيل', '+ ${_dec(p['payout_total'])} $cur',
|
||||
color: Colors.green.shade700),
|
||||
_line('دَين النقد', '− ${_dec(p['cash_debt_total'])} $cur',
|
||||
color: Colors.orange.shade800),
|
||||
if ((p['carried_over'] ?? 0) != 0)
|
||||
_line('مُرحَّل من دورة سابقة', '− ${_dec(p['carried_over'])} $cur',
|
||||
color: Colors.orange.shade800),
|
||||
const Divider(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('الصافي',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
Text('$net $cur',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
color: willPay ? Colors.green.shade700 : Colors.red.shade700,
|
||||
)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
willPay
|
||||
? 'سيُصرف هذا المبلغ لمحفظة السائق.'
|
||||
// السالب لا يُخصم من محفظته: قد تكون فارغة، والخصم القسري
|
||||
// يفاجئه ويوقفه عن العمل. الدَين لا يضيع — يُطرح لاحقاً.
|
||||
: 'الصافي سالب — يُرحَّل للدورة القادمة ولا يُخصم من محفظته.',
|
||||
style: const TextStyle(fontSize: 12, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (done == null)
|
||||
Obx(() => SizedBox(
|
||||
width: double.infinity,
|
||||
height: 46,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: c.isExecuting.value
|
||||
? null
|
||||
: () => _confirm(context, c, net, cur, willPay),
|
||||
icon: c.isExecuting.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Icon(willPay ? Icons.payments_outlined : Icons.archive_outlined),
|
||||
label: Text(willPay ? 'ثبّت واصرف' : 'ثبّت ورحّل'),
|
||||
),
|
||||
))
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: done['status'] == 'failed'
|
||||
? Colors.red.withOpacity(0.08)
|
||||
: Colors.green.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'تسوية #${done['settlement_id']} — ${_statusLabel(done['status'])}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// تأكيد قبل الصرف. التثبيت لا يُلغى، والدورة لا تُنفَّذ مرتين — فالضغطة
|
||||
/// الواحدة يجب أن تكون مقصودة.
|
||||
void _confirm(BuildContext context, CourierSettlementController c, num net,
|
||||
String cur, bool willPay) {
|
||||
Get.defaultDialog(
|
||||
title: 'تأكيد التسوية',
|
||||
middleText: willPay
|
||||
? 'سيُصرف $net $cur لمحفظة السائق. لا يمكن التراجع.'
|
||||
: 'سيُثبَّت دَين $net $cur ويُرحَّل للدورة القادمة.',
|
||||
textConfirm: 'تأكيد',
|
||||
textCancel: 'إلغاء',
|
||||
onConfirm: () {
|
||||
Get.back();
|
||||
c.runExecute();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _statusLabel(dynamic s) => switch (s) {
|
||||
'paid' => 'صُرفت بنجاح',
|
||||
'carried' => 'رُحّلت للدورة القادمة',
|
||||
'failed' => 'ثُبّتت والتحويل فشل — تحتاج تسوية يدوية',
|
||||
_ => '$s',
|
||||
};
|
||||
|
||||
String _dec(dynamic smallestUnit) {
|
||||
final v = int.tryParse('$smallestUnit') ?? 0;
|
||||
// الفلس الأردني: ثلاث خانات عشرية. يطابق FOOD_CURRENCY_DIVISOR=1000.
|
||||
return (v / 1000).toStringAsFixed(3);
|
||||
}
|
||||
|
||||
Widget _line(String label, String value, {Color? color}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13)),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.w600, color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.title, required this.icon, required this.child});
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user