1
0

2 Komitmen 4cd330c3f9 ... 47418a2ca0

Pembuat SHA1 Pesan Tanggal
  lxz 47418a2ca0 ✨ feat(finance): 新增费用返还申请及类别管理功能 2 minggu lalu
  lxz 20d5b69335 ✨ feat(finance): 新增提现申请及类别管理功能 2 minggu lalu
30 mengubah file dengan 2538 tambahan dan 2 penghapusan
  1. 2 2
      app/common.php
  2. 7 0
      app/finance/common.php
  3. 135 0
      app/finance/controller/Feerefund.php
  4. 76 0
      app/finance/controller/Feerefundcate.php
  5. 132 0
      app/finance/controller/Withdraw.php
  6. 76 0
      app/finance/controller/Withdrawcate.php
  7. 97 0
      app/finance/model/FeeRefund.php
  8. 71 0
      app/finance/model/FeeRefundCate.php
  9. 88 0
      app/finance/model/Withdraw.php
  10. 71 0
      app/finance/model/WithdrawCate.php
  11. 34 0
      app/finance/validate/FeeRefundValidate.php
  12. 30 0
      app/finance/validate/WithdrawValidate.php
  13. 196 0
      app/finance/view/feerefund/add.html
  14. 115 0
      app/finance/view/feerefund/datalist.html
  15. 83 0
      app/finance/view/feerefund/view.html
  16. 144 0
      app/finance/view/feerefundcate/datalist.html
  17. 147 0
      app/finance/view/withdraw/add.html
  18. 115 0
      app/finance/view/withdraw/datalist.html
  19. 73 0
      app/finance/view/withdraw/view.html
  20. 143 0
      app/finance/view/withdrawcate/datalist.html
  21. 44 0
      database/migrations/20260527100001_create_withdraw.php
  22. 26 0
      database/migrations/20260527100002_create_withdraw_cate.php
  23. 38 0
      database/migrations/20260527100003_seed_withdraw_cate_data.php
  24. 151 0
      database/migrations/20260527100004_seed_withdraw_data.php
  25. 87 0
      database/migrations/20260527100005_seed_withdraw_cate_rule.php
  26. 46 0
      database/migrations/20260527110001_create_fee_refund.php
  27. 26 0
      database/migrations/20260527110002_create_fee_refund_cate.php
  28. 47 0
      database/migrations/20260527110003_seed_fee_refund_cate_data.php
  29. 151 0
      database/migrations/20260527110004_seed_fee_refund_data.php
  30. 87 0
      database/migrations/20260527110005_seed_fee_refund_cate_rule.php

+ 2 - 2
app/common.php

@@ -271,7 +271,7 @@ function add_log($type, $param_id = 0, $param = [] ,$subject='')
 function get_message_link($template_id,$action_id){
 	$content='';
 	$template = Db::name('Template')->where('id',$template_id)->find();
-	$link = $template['msg_link'];
+	$link = empty($template) ? '' : ($template['msg_link'] ?? '');
 	if(!empty($link)){
 		$content = str_replace('{action_id}', $action_id, $link);
 	}
@@ -281,7 +281,7 @@ function get_message_link($template_id,$action_id){
 function get_message_mobile($template_id,$action_id){
 	$content='';
 	$template = Db::name('Template')->where('id',$template_id)->find();
-	$link = $template['msg_link'];
+	$link = empty($template) ? '' : ($template['msg_link'] ?? '');
 	if(!empty($link)){
 		$content = str_replace('{action_id}', $action_id, $link);
 	}

+ 7 - 0
app/finance/common.php

@@ -84,3 +84,10 @@ function price_adjust_yn_name($val=0)
 	$arr = [0=>'否', 1=>'是'];
 	return $arr[$val] ?? '否';
 }
+
+//提现申请-是否加急处理
+function withdraw_urgent_name($val=0)
+{
+	$arr = [0=>'否(按系统周期)', 1=>'是(人工核算)'];
+	return $arr[$val] ?? '否(按系统周期)';
+}

+ 135 - 0
app/finance/controller/Feerefund.php

@@ -0,0 +1,135 @@
+<?php
+declare(strict_types=1);
+
+namespace app\finance\controller;
+
+use app\base\BaseController;
+use app\finance\model\FeeRefund as FeeRefundModel;
+use app\finance\model\FeeRefundCate;
+use app\finance\validate\FeeRefundValidate;
+use think\exception\ValidateException;
+use think\facade\Db;
+use think\facade\View;
+
+class Feerefund extends BaseController
+{
+    protected $model;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->model = new FeeRefundModel();
+    }
+
+    public function datalist()
+    {
+        $param = get_params();
+        if (request()->isAjax()) {
+            $uid      = $this->uid;
+            $where    = [];
+            $whereOr  = [];
+            $where[]  = ['delete_time', '=', 0];
+
+            $tab = isset($param['tab']) ? (int) $param['tab'] : 0;
+            if ($tab === 0) {
+                $whereOr[] = ['admin_id', '=', $uid];
+                $whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
+                $whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
+                $whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];
+            } elseif ($tab === 1) {
+                $where[] = ['admin_id', '=', $uid];
+            } elseif ($tab === 2) {
+                $where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
+            } elseif ($tab === 3) {
+                $where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
+            } elseif ($tab === 4) {
+                $where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];
+            }
+
+            if (!empty($param['keywords'])) {
+                $where[] = ['customer_id', 'like', '%' . $param['keywords'] . '%'];
+            }
+            if (isset($param['check_status']) && $param['check_status'] !== '') {
+                $where[] = ['check_status', '=', $param['check_status']];
+            }
+            if (!empty($param['diff_time'])) {
+                $diff = explode('~', $param['diff_time']);
+                $where[] = ['create_time', 'between', [strtotime(urldecode($diff[0])), strtotime(urldecode($diff[1] . ' 23:59:59'))]];
+            }
+
+            $list = $this->model->datalist($param, $where, $whereOr);
+            return table_assign(0, '', $list);
+        }
+        return view();
+    }
+
+    public function add()
+    {
+        $param = get_params();
+        if (request()->isAjax()) {
+            if (!empty($param['id']) && $param['id'] > 0) {
+                try {
+                    validate(FeeRefundValidate::class)->scene('edit')->check($param);
+                } catch (ValidateException $e) {
+                    return to_assign(1, $e->getError());
+                }
+                $this->model->edit($param);
+            } else {
+                try {
+                    validate(FeeRefundValidate::class)->scene('add')->check($param);
+                } catch (ValidateException $e) {
+                    return to_assign(1, $e->getError());
+                }
+                $param['admin_id'] = $this->uid;
+                $param['did']      = $this->did;
+                $this->model->add($param);
+            }
+        } else {
+            $id = isset($param['id']) ? (int) $param['id'] : 0;
+            $detail = [
+                'id'                   => 0,
+                'customer_id'          => '',
+                'fee_dimension'        => '',
+                'card_no'              => '',
+                'refund_item'          => 0,
+                'refund_item_remark'   => '',
+                'refund_reason'        => 0,
+                'agreement_no'         => '',
+                'refund_reason_detail' => '',
+                'remark'               => '',
+                'file_ids'             => '',
+                'check_status'         => 0,
+                'check_flow_id'        => 0,
+            ];
+            if ($id > 0) {
+                $detail = $this->model->getById($id);
+            }
+            View::assign('detail', $detail);
+            View::assign('fee_dimensions', FeeRefundCate::getByType(1));
+            View::assign('refund_items', FeeRefundCate::getByType(2));
+            View::assign('refund_reasons', FeeRefundCate::getByType(3));
+            return view();
+        }
+    }
+
+    public function view($id)
+    {
+        $detail = $this->model->getById($id);
+        if (!empty($detail)) {
+            View::assign('create_user', get_admin($detail['admin_id']));
+            View::assign('detail', $detail);
+            return view();
+        }
+        return view(EEEOR_REPORTING, ['code' => 404, 'warning' => '找不到页面']);
+    }
+
+    public function del()
+    {
+        $param = get_params();
+        $id    = isset($param['id']) ? (int) $param['id'] : 0;
+        if (request()->isDelete()) {
+            return $this->model->delById($id);
+        }
+        return to_assign(1, '错误的请求');
+    }
+}

+ 76 - 0
app/finance/controller/Feerefundcate.php

@@ -0,0 +1,76 @@
+<?php
+declare(strict_types=1);
+
+namespace app\finance\controller;
+
+use app\base\BaseController;
+use app\finance\model\FeeRefundCate as FeeRefundCateModel;
+use think\facade\View;
+
+class Feerefundcate extends BaseController
+{
+    protected $model;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->model = new FeeRefundCateModel();
+    }
+
+    public function datalist()
+    {
+        if (request()->isAjax()) {
+            $param = get_params();
+            $type  = isset($param['type']) ? (int) $param['type'] : 0;
+            $where = [['delete_time', '=', 0]];
+            if ($type > 0) {
+                $where[] = ['type', '=', $type];
+            }
+            $list = $this->model->where($where)->order('sort asc, id asc')->select();
+            return to_assign(0, '', $list);
+        }
+        return view();
+    }
+
+    public function add()
+    {
+        $param = get_params();
+        if (request()->isAjax()) {
+            if (!empty($param['id']) && $param['id'] > 0) {
+                $this->model->edit($param);
+            } else {
+                $this->model->add($param);
+            }
+        } else {
+            $id = isset($param['id']) ? (int) $param['id'] : 0;
+            if ($id > 0) {
+                View::assign('detail', $this->model->getById($id));
+            }
+            return view();
+        }
+    }
+
+    public function set()
+    {
+        if (request()->isAjax()) {
+            $param = get_params();
+            $res   = $this->model->strict(false)->field('id,status')->update($param);
+            if ($res) {
+                add_log($param['status'] == 1 ? 'recovery' : 'disable', $param['id'], $param);
+                return to_assign();
+            }
+            return to_assign(0, '操作失败');
+        }
+        return to_assign(1, '错误的请求');
+    }
+
+    public function del()
+    {
+        if (request()->isDelete()) {
+            $param = get_params();
+            $id    = isset($param['id']) ? (int) $param['id'] : 0;
+            return $this->model->delById($id);
+        }
+        return to_assign(1, '错误的请求');
+    }
+}

+ 132 - 0
app/finance/controller/Withdraw.php

@@ -0,0 +1,132 @@
+<?php
+declare(strict_types=1);
+
+namespace app\finance\controller;
+
+use app\base\BaseController;
+use app\finance\model\Withdraw as WithdrawModel;
+use app\finance\model\WithdrawCate;
+use app\finance\validate\WithdrawValidate;
+use think\exception\ValidateException;
+use think\facade\Db;
+use think\facade\View;
+
+class Withdraw extends BaseController
+{
+    protected $model;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->model = new WithdrawModel();
+    }
+
+    public function datalist()
+    {
+        $param = get_params();
+        if (request()->isAjax()) {
+            $uid      = $this->uid;
+            $where    = [];
+            $whereOr  = [];
+            $where[]  = ['delete_time', '=', 0];
+
+            $tab = isset($param['tab']) ? (int) $param['tab'] : 0;
+            if ($tab === 0) {
+                $whereOr[] = ['admin_id', '=', $uid];
+                $whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
+                $whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
+                $whereOr[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];
+            } elseif ($tab === 1) {
+                $where[] = ['admin_id', '=', $uid];
+            } elseif ($tab === 2) {
+                $where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_uids)")];
+            } elseif ($tab === 3) {
+                $where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_history_uids)")];
+            } elseif ($tab === 4) {
+                $where[] = ['', 'exp', Db::raw("FIND_IN_SET('{$uid}',check_copy_uids)")];
+            }
+
+            if (!empty($param['keywords'])) {
+                $where[] = ['customer_id', 'like', '%' . $param['keywords'] . '%'];
+            }
+            if (isset($param['check_status']) && $param['check_status'] !== '') {
+                $where[] = ['check_status', '=', $param['check_status']];
+            }
+            if (!empty($param['diff_time'])) {
+                $diff = explode('~', $param['diff_time']);
+                $where[] = ['create_time', 'between', [strtotime(urldecode($diff[0])), strtotime(urldecode($diff[1] . ' 23:59:59'))]];
+            }
+
+            $list = $this->model->datalist($param, $where, $whereOr);
+            return table_assign(0, '', $list);
+        }
+        return view();
+    }
+
+    public function add()
+    {
+        $param = get_params();
+        if (request()->isAjax()) {
+            if (!empty($param['id']) && $param['id'] > 0) {
+                try {
+                    validate(WithdrawValidate::class)->scene('edit')->check($param);
+                } catch (ValidateException $e) {
+                    return to_assign(1, $e->getError());
+                }
+                $this->model->edit($param);
+            } else {
+                try {
+                    validate(WithdrawValidate::class)->scene('add')->check($param);
+                } catch (ValidateException $e) {
+                    return to_assign(1, $e->getError());
+                }
+                $param['admin_id'] = $this->uid;
+                $param['did']      = $this->did;
+                $this->model->add($param);
+            }
+        } else {
+            $id = isset($param['id']) ? (int) $param['id'] : 0;
+            $detail = [
+                'id'                 => 0,
+                'customer_id'        => '',
+                'trade_scene'        => 0,
+                'trade_scene_remark' => '',
+                'withdraw_type'      => 0,
+                'withdraw_fee'       => '',
+                'is_urgent'          => 0,
+                'remark'             => '',
+                'file_ids'           => '',
+                'check_status'       => 0,
+                'check_flow_id'      => 0,
+            ];
+            if ($id > 0) {
+                $detail = $this->model->getById($id);
+            }
+            View::assign('detail', $detail);
+            View::assign('trade_scenes', WithdrawCate::getByType(1));
+            View::assign('withdraw_types', WithdrawCate::getByType(2));
+            return view();
+        }
+    }
+
+    public function view($id)
+    {
+        $detail = $this->model->getById($id);
+        if (!empty($detail)) {
+            View::assign('create_user', get_admin($detail['admin_id']));
+            View::assign('detail', $detail);
+            return view();
+        }
+        return view(EEEOR_REPORTING, ['code' => 404, 'warning' => '找不到页面']);
+    }
+
+    public function del()
+    {
+        $param = get_params();
+        $id    = isset($param['id']) ? (int) $param['id'] : 0;
+        if (request()->isDelete()) {
+            return $this->model->delById($id);
+        }
+        return to_assign(1, '错误的请求');
+    }
+}

+ 76 - 0
app/finance/controller/Withdrawcate.php

@@ -0,0 +1,76 @@
+<?php
+declare(strict_types=1);
+
+namespace app\finance\controller;
+
+use app\base\BaseController;
+use app\finance\model\WithdrawCate as WithdrawCateModel;
+use think\facade\View;
+
+class Withdrawcate extends BaseController
+{
+    protected $model;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->model = new WithdrawCateModel();
+    }
+
+    public function datalist()
+    {
+        if (request()->isAjax()) {
+            $param = get_params();
+            $type  = isset($param['type']) ? (int) $param['type'] : 0;
+            $where = [['delete_time', '=', 0]];
+            if ($type > 0) {
+                $where[] = ['type', '=', $type];
+            }
+            $list = $this->model->where($where)->order('sort asc, id asc')->select();
+            return to_assign(0, '', $list);
+        }
+        return view();
+    }
+
+    public function add()
+    {
+        $param = get_params();
+        if (request()->isAjax()) {
+            if (!empty($param['id']) && $param['id'] > 0) {
+                $this->model->edit($param);
+            } else {
+                $this->model->add($param);
+            }
+        } else {
+            $id = isset($param['id']) ? (int) $param['id'] : 0;
+            if ($id > 0) {
+                View::assign('detail', $this->model->getById($id));
+            }
+            return view();
+        }
+    }
+
+    public function set()
+    {
+        if (request()->isAjax()) {
+            $param = get_params();
+            $res   = $this->model->strict(false)->field('id,status')->update($param);
+            if ($res) {
+                add_log($param['status'] == 1 ? 'recovery' : 'disable', $param['id'], $param);
+                return to_assign();
+            }
+            return to_assign(0, '操作失败');
+        }
+        return to_assign(1, '错误的请求');
+    }
+
+    public function del()
+    {
+        if (request()->isDelete()) {
+            $param = get_params();
+            $id    = isset($param['id']) ? (int) $param['id'] : 0;
+            return $this->model->delById($id);
+        }
+        return to_assign(1, '错误的请求');
+    }
+}

+ 97 - 0
app/finance/model/FeeRefund.php

@@ -0,0 +1,97 @@
+<?php
+namespace app\finance\model;
+
+use think\Model;
+use think\facade\Db;
+
+class FeeRefund extends Model
+{
+    public function datalist($param = [], $where = [], $whereOr = [])
+    {
+        $rows  = empty($param['limit']) ? get_config('app.page_size') : $param['limit'];
+        $order = empty($param['order']) ? 'id desc' : $param['order'];
+        try {
+            $list = self::where($where)
+                ->where(function ($query) use ($whereOr) {
+                    if (!empty($whereOr)) {
+                        $query->whereOr($whereOr);
+                    }
+                })
+                ->order($order)
+                ->paginate(['list_rows' => $rows])
+                ->each(function ($item) {
+                    $item->check_status_str = check_status_name($item->check_status);
+                    $item->admin_name       = Db::name('Admin')->where('id', $item->admin_id)->value('name');
+                    $item->department       = Db::name('Department')->where('id', $item->did)->value('title');
+                    $item->create_time_str  = to_date($item->create_time);
+                });
+            return $list;
+        } catch (\Exception $e) {
+            return ['code' => 1, 'data' => [], 'msg' => $e->getMessage()];
+        }
+    }
+
+    public function add($param)
+    {
+        try {
+            $param['create_time'] = time();
+            $insertId = self::strict(false)->field(true)->insertGetId($param);
+            add_log('add', $insertId, $param, '费用返还申请');
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $insertId]);
+    }
+
+    public function edit($param)
+    {
+        try {
+            $param['update_time'] = time();
+            self::where('id', $param['id'])->strict(false)->field(true)->update($param);
+            add_log('edit', $param['id'], $param, '费用返还申请');
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $param['id']]);
+    }
+
+    public function getById($id)
+    {
+        $info = self::find($id);
+        if (empty($info)) {
+            return [];
+        }
+        $info['admin_name']        = Db::name('Admin')->where('id', $info['admin_id'])->value('name');
+        $info['department']        = Db::name('Department')->where('id', $info['did'])->value('title');
+        // 费项维度为多选,存逗号分隔的 value,翻译为名称拼接
+        $info['fee_dimension_name'] = '';
+        if ($info['fee_dimension'] !== '') {
+            $values = array_filter(explode(',', $info['fee_dimension']), static fn($v) => $v !== '');
+            if (!empty($values)) {
+                $names = Db::name('FeeRefundCate')->where('type', 1)->where('value', 'in', $values)->where('delete_time', 0)->column('title');
+                $info['fee_dimension_name'] = implode('、', $names);
+            }
+        }
+        $info['refund_item_name']   = Db::name('FeeRefundCate')->where(['type' => 2, 'value' => $info['refund_item'], 'delete_time' => 0])->value('title') ?? '';
+        $info['refund_reason_name'] = Db::name('FeeRefundCate')->where(['type' => 3, 'value' => $info['refund_reason'], 'delete_time' => 0])->value('title') ?? '';
+        if (!empty($info['file_ids'])) {
+            $info['file_array'] = Db::name('File')->where('id', 'in', $info['file_ids'])->select();
+        }
+        return $info;
+    }
+
+    public function delById($id, $type = 0)
+    {
+        try {
+            if ($type == 0) {
+                self::where('id', $id)->update(['delete_time' => time()]);
+            } else {
+                self::destroy($id);
+            }
+            add_log('delete', $id);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign();
+    }
+}

+ 71 - 0
app/finance/model/FeeRefundCate.php

@@ -0,0 +1,71 @@
+<?php
+namespace app\finance\model;
+
+use think\Model;
+
+class FeeRefundCate extends Model
+{
+    public function add($param)
+    {
+        if ($this->valueExists((int) $param['type'], (int) $param['value'])) {
+            return to_assign(1, '该类型下「值」已存在,请使用其它值');
+        }
+        try {
+            $param['create_time'] = time();
+            $insertId = self::strict(false)->field(true)->insertGetId($param);
+            add_log('add', $insertId, $param);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $insertId]);
+    }
+
+    public function edit($param)
+    {
+        if ($this->valueExists((int) $param['type'], (int) $param['value'], (int) $param['id'])) {
+            return to_assign(1, '该类型下「值」已存在,请使用其它值');
+        }
+        try {
+            self::where('id', $param['id'])->strict(false)->field(true)->update($param);
+            add_log('edit', $param['id'], $param);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $param['id']]);
+    }
+
+    /**
+     * 校验同 type 下 value 是否已被占用(编辑时排除自身)
+     */
+    protected function valueExists(int $type, int $value, int $excludeId = 0): bool
+    {
+        $query = self::where(['type' => $type, 'value' => $value, 'delete_time' => 0]);
+        if ($excludeId > 0) {
+            $query->where('id', '<>', $excludeId);
+        }
+        return $query->count() > 0;
+    }
+
+    public function getById($id)
+    {
+        return self::find($id);
+    }
+
+    public function delById($id)
+    {
+        try {
+            self::where('id', $id)->update(['delete_time' => time()]);
+            add_log('delete', $id);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign();
+    }
+
+    public static function getByType($type)
+    {
+        return self::where(['type' => $type, 'status' => 1, 'delete_time' => 0])
+            ->order('sort asc, id asc')
+            ->select();
+    }
+}

+ 88 - 0
app/finance/model/Withdraw.php

@@ -0,0 +1,88 @@
+<?php
+namespace app\finance\model;
+
+use think\Model;
+use think\facade\Db;
+
+class Withdraw extends Model
+{
+    public function datalist($param = [], $where = [], $whereOr = [])
+    {
+        $rows  = empty($param['limit']) ? get_config('app.page_size') : $param['limit'];
+        $order = empty($param['order']) ? 'id desc' : $param['order'];
+        try {
+            $list = self::where($where)
+                ->where(function ($query) use ($whereOr) {
+                    if (!empty($whereOr)) {
+                        $query->whereOr($whereOr);
+                    }
+                })
+                ->order($order)
+                ->paginate(['list_rows' => $rows])
+                ->each(function ($item) {
+                    $item->check_status_str = check_status_name($item->check_status);
+                    $item->admin_name       = Db::name('Admin')->where('id', $item->admin_id)->value('name');
+                    $item->department       = Db::name('Department')->where('id', $item->did)->value('title');
+                    $item->create_time_str  = to_date($item->create_time);
+                });
+            return $list;
+        } catch (\Exception $e) {
+            return ['code' => 1, 'data' => [], 'msg' => $e->getMessage()];
+        }
+    }
+
+    public function add($param)
+    {
+        try {
+            $param['create_time'] = time();
+            $insertId = self::strict(false)->field(true)->insertGetId($param);
+            add_log('add', $insertId, $param, '提现申请');
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $insertId]);
+    }
+
+    public function edit($param)
+    {
+        try {
+            $param['update_time'] = time();
+            self::where('id', $param['id'])->strict(false)->field(true)->update($param);
+            add_log('edit', $param['id'], $param, '提现申请');
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $param['id']]);
+    }
+
+    public function getById($id)
+    {
+        $info = self::find($id);
+        if (empty($info)) {
+            return [];
+        }
+        $info['admin_name']        = Db::name('Admin')->where('id', $info['admin_id'])->value('name');
+        $info['department']        = Db::name('Department')->where('id', $info['did'])->value('title');
+        $info['trade_scene_name']  = Db::name('WithdrawCate')->where(['type' => 1, 'value' => $info['trade_scene'], 'delete_time' => 0])->value('title') ?? '';
+        $info['withdraw_type_name'] = Db::name('WithdrawCate')->where(['type' => 2, 'value' => $info['withdraw_type'], 'delete_time' => 0])->value('title') ?? '';
+        if (!empty($info['file_ids'])) {
+            $info['file_array'] = Db::name('File')->where('id', 'in', $info['file_ids'])->select();
+        }
+        return $info;
+    }
+
+    public function delById($id, $type = 0)
+    {
+        try {
+            if ($type == 0) {
+                self::where('id', $id)->update(['delete_time' => time()]);
+            } else {
+                self::destroy($id);
+            }
+            add_log('delete', $id);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign();
+    }
+}

+ 71 - 0
app/finance/model/WithdrawCate.php

@@ -0,0 +1,71 @@
+<?php
+namespace app\finance\model;
+
+use think\Model;
+
+class WithdrawCate extends Model
+{
+    public function add($param)
+    {
+        if ($this->valueExists((int) $param['type'], (int) $param['value'])) {
+            return to_assign(1, '该类型下「值」已存在,请使用其它值');
+        }
+        try {
+            $param['create_time'] = time();
+            $insertId = self::strict(false)->field(true)->insertGetId($param);
+            add_log('add', $insertId, $param);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $insertId]);
+    }
+
+    public function edit($param)
+    {
+        if ($this->valueExists((int) $param['type'], (int) $param['value'], (int) $param['id'])) {
+            return to_assign(1, '该类型下「值」已存在,请使用其它值');
+        }
+        try {
+            self::where('id', $param['id'])->strict(false)->field(true)->update($param);
+            add_log('edit', $param['id'], $param);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign(0, '操作成功', ['return_id' => $param['id']]);
+    }
+
+    /**
+     * 校验同 type 下 value 是否已被占用(编辑时排除自身)
+     */
+    protected function valueExists(int $type, int $value, int $excludeId = 0): bool
+    {
+        $query = self::where(['type' => $type, 'value' => $value, 'delete_time' => 0]);
+        if ($excludeId > 0) {
+            $query->where('id', '<>', $excludeId);
+        }
+        return $query->count() > 0;
+    }
+
+    public function getById($id)
+    {
+        return self::find($id);
+    }
+
+    public function delById($id)
+    {
+        try {
+            self::where('id', $id)->update(['delete_time' => time()]);
+            add_log('delete', $id);
+        } catch (\Exception $e) {
+            return to_assign(1, '操作失败,原因:' . $e->getMessage());
+        }
+        return to_assign();
+    }
+
+    public static function getByType($type)
+    {
+        return self::where(['type' => $type, 'status' => 1, 'delete_time' => 0])
+            ->order('sort asc, id asc')
+            ->select();
+    }
+}

+ 34 - 0
app/finance/validate/FeeRefundValidate.php

@@ -0,0 +1,34 @@
+<?php
+namespace app\finance\validate;
+
+use think\Validate;
+
+class FeeRefundValidate extends Validate
+{
+    protected $rule = [
+        'customer_id'          => 'require|max:100',
+        'fee_dimension'        => 'require',
+        'card_no'              => 'require|max:255',
+        'refund_item'          => 'require|in:1,2,3,4,5,6,7,9',
+        'refund_item_remark'   => 'requireIf:refund_item,9',
+        'refund_reason'        => 'require|in:1,2',
+        'agreement_no'         => 'requireIf:refund_reason,1|max:100',
+        'refund_reason_detail' => 'require|max:500',
+    ];
+
+    protected $message = [
+        'customer_id.require'        => '客户ID不能为空',
+        'fee_dimension.require'      => '请选择费项维度',
+        'card_no.require'            => '卡号不能为空',
+        'refund_item.require'        => '请选择退款项',
+        'refund_item_remark.requireIf' => '退款项选择【其他】时,退款项备注不能为空',
+        'refund_reason.require'      => '请选择退款原因',
+        'agreement_no.requireIf'     => '退款原因为【约定,已过审批】时,约定审批编号不能为空',
+        'refund_reason_detail.require' => '具体退款原因不能为空',
+    ];
+
+    protected $scene = [
+        'add'  => ['customer_id', 'fee_dimension', 'card_no', 'refund_item', 'refund_item_remark', 'refund_reason', 'agreement_no', 'refund_reason_detail'],
+        'edit' => ['customer_id', 'fee_dimension', 'card_no', 'refund_item', 'refund_item_remark', 'refund_reason', 'agreement_no', 'refund_reason_detail'],
+    ];
+}

+ 30 - 0
app/finance/validate/WithdrawValidate.php

@@ -0,0 +1,30 @@
+<?php
+namespace app\finance\validate;
+
+use think\Validate;
+
+class WithdrawValidate extends Validate
+{
+    protected $rule = [
+        'customer_id'        => 'require|max:100',
+        'trade_scene'        => 'require|in:1,9',
+        'trade_scene_remark' => 'requireIf:trade_scene,9',
+        'withdraw_type'      => 'require|in:1,2',
+        'withdraw_fee'       => 'require|max:100',
+        'is_urgent'          => 'require|in:0,1',
+    ];
+
+    protected $message = [
+        'customer_id.require'          => '客户ID不能为空',
+        'trade_scene.require'          => '请选择客户交易场景',
+        'trade_scene_remark.requireIf' => '选择【其他】场景时,交易场景备注不能为空',
+        'withdraw_type.require'        => '请选择提现类型',
+        'withdraw_fee.require'         => '提现手续费不能为空',
+        'is_urgent.require'            => '请选择是否加急处理',
+    ];
+
+    protected $scene = [
+        'add'  => ['customer_id', 'trade_scene', 'trade_scene_remark', 'withdraw_type', 'withdraw_fee', 'is_urgent'],
+        'edit' => ['customer_id', 'trade_scene', 'trade_scene_remark', 'withdraw_type', 'withdraw_fee', 'is_urgent'],
+    ];
+}

+ 196 - 0
app/finance/view/feerefund/add.html

@@ -0,0 +1,196 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<form class="layui-form p-page" lay-filter="form-feerefund">
+    <h3 class="pb-2">费用返还申请</h3>
+    <table class="layui-table layui-table-form">
+        <tr>
+            <td class="layui-td-gray-2">客户ID<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="customer_id" class="layui-input"
+                       value="{$detail.customer_id|default=''}"
+                       lay-verify="required" lay-reqText="请输入客户ID"
+                       placeholder="请输入客户ID">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">费项维度<font>*</font></td>
+            <td colspan="3">
+                {volist name="$fee_dimensions" id="vo"}
+                <input type="checkbox" name="fee_dim_cb" lay-skin="primary" lay-filter="fee_dim"
+                       value="{$vo.value}" title="{$vo.title}"
+                       {in name="$vo.value" value="$detail.fee_dimension"}checked{/in}>
+                {/volist}
+                <input type="hidden" name="fee_dimension" value="{$detail.fee_dimension|default=''}">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">卡号<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="card_no" class="layui-input"
+                       value="{$detail.card_no|default=''}"
+                       lay-verify="required" lay-reqText="请输入卡号"
+                       placeholder="单张卡填卡ID,多张卡的情况下可以填写卡BIN">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">退款项<font>*</font></td>
+            <td colspan="3">
+                <select name="refund_item" lay-verify="required" lay-reqText="请选择退款项" lay-filter="refund_item">
+                    <option value="">-- 请选择 --</option>
+                    {volist name="$refund_items" id="vo"}
+                    <option value="{$vo.value}" data-is-other="{$vo.is_other}" {eq name="$detail.refund_item" value="$vo.value"} selected{/eq}>{$vo.title}</option>
+                    {/volist}
+                </select>
+            </td>
+        </tr>
+        <tr id="row_refund_item_remark" style="display:none;">
+            <td class="layui-td-gray-2">退款项备注<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="refund_item_remark" class="layui-input"
+                       value="{$detail.refund_item_remark|default=''}"
+                       placeholder="选择【其他】时必填,请说明具体退款项">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">退款原因<font>*</font></td>
+            <td colspan="3">
+                <select name="refund_reason" lay-verify="required" lay-reqText="请选择退款原因" lay-filter="refund_reason">
+                    <option value="">-- 请选择 --</option>
+                    {volist name="$refund_reasons" id="vo"}
+                    <option value="{$vo.value}" {eq name="$detail.refund_reason" value="$vo.value"} selected{/eq}>{$vo.title}</option>
+                    {/volist}
+                </select>
+            </td>
+        </tr>
+        <tr id="row_agreement_no" style="display:none;">
+            <td class="layui-td-gray-2">约定审批编号<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="agreement_no" class="layui-input"
+                       value="{$detail.agreement_no|default=''}"
+                       placeholder="退款原因为【约定,已过审批】时必填">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">具体退款原因<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="refund_reason_detail" class="layui-input"
+                       value="{$detail.refund_reason_detail|default=''}"
+                       lay-verify="required" lay-reqText="请填写具体退款原因"
+                       placeholder="请填写具体退款原因">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">备注</td>
+            <td colspan="3">
+                <textarea name="remark" class="layui-textarea"
+                          placeholder="选填">{$detail.remark|default=''}</textarea>
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">附件 <button type="button" class="layui-btn layui-btn-xs" id="uploadBtn"><i class="layui-icon layui-icon-upload"></i></button></td>
+            <td colspan="3">
+                <div class="layui-row" id="uploadBox">
+                    <input data-type="file" type="hidden" name="file_ids" value="{$detail.file_ids|default=''}">
+                    {notempty name="$detail.file_ids"}
+                    {volist name="$detail.file_array" id="vo"}
+                    <div class="layui-col-md4">{:file_card($vo)}</div>
+                    {/volist}
+                    {/notempty}
+                </div>
+            </td>
+        </tr>
+    </table>
+
+    <div id="checkBox"
+         data-status="{$detail.check_status|default=0}"
+         data-id="{$detail.id|default=0}"
+         data-checkflowid="{$detail.check_flow_id|default=0}"
+         class="pt-3"></div>
+
+    <div class="pt-4">
+        <input type="hidden" name="id" value="{$detail.id|default=0}">
+        <button class="layui-btn layui-btn-normal" lay-submit="" lay-filter="webform">立即提交</button>
+        <button type="reset" class="layui-btn layui-btn-primary">重置</button>
+    </div>
+</form>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool', 'uploadPlus', 'oaCheck'];
+    function gouguInit() {
+        var form = layui.form, tool = layui.tool,
+            uploadPlus = layui.uploadPlus, oaCheck = layui.oaCheck;
+
+        oaCheck.init({ check_name: 'fee_refund', check_btn: 0 });
+        var fileUp = new uploadPlus();
+
+        // 费项维度(多选):将选中的 value 收集为逗号串写入隐藏域
+        function syncFeeDimension() {
+            var arr = [];
+            $('input[name=fee_dim_cb]:checked').each(function () {
+                arr.push($(this).val());
+            });
+            $('input[name=fee_dimension]').val(arr.join(','));
+        }
+        form.on('checkbox(fee_dim)', syncFeeDimension);
+
+        // 退款项选择【其他】时显示备注(由 is_other 字段控制)
+        function checkRefundItemRemark() {
+            var isOther = $('select[name=refund_item] option:selected').data('is-other');
+            if (isOther == 1) {
+                $('#row_refund_item_remark').show();
+                $('input[name=refund_item_remark]').attr('lay-verify', 'required').attr('lay-reqText', '请填写退款项备注');
+            } else {
+                $('#row_refund_item_remark').hide();
+                $('input[name=refund_item_remark]').removeAttr('lay-verify').removeAttr('lay-reqText');
+            }
+        }
+        checkRefundItemRemark();
+        form.on('select(refund_item)', function () {
+            checkRefundItemRemark();
+            form.render('select');
+        });
+
+        // 退款原因为【约定,已过审批】(value=1)时显示并必填约定审批编号
+        function checkAgreementNo() {
+            var reason = $('select[name=refund_reason]').val();
+            if (reason == 1) {
+                $('#row_agreement_no').show();
+                $('input[name=agreement_no]').attr('lay-verify', 'required').attr('lay-reqText', '请填写约定审批编号');
+            } else {
+                $('#row_agreement_no').hide();
+                $('input[name=agreement_no]').removeAttr('lay-verify').removeAttr('lay-reqText');
+            }
+        }
+        checkAgreementNo();
+        form.on('select(refund_reason)', function () {
+            checkAgreementNo();
+            form.render('select');
+        });
+
+        form.on('submit(webform)', function (data) {
+            syncFeeDimension();
+            if (!data.field.fee_dimension) {
+                layer.msg('请选择费项维度');
+                return false;
+            }
+            let callback = function (e) {
+                layer.msg(e.msg);
+                if (e.code == 0) {
+                    let checkCallback = function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) tool.sideClose(1000);
+                    };
+                    data.field.check_name = 'fee_refund';
+                    data.field.action_id  = e.data.return_id;
+                    oaCheck.submit(data.field, checkCallback);
+                }
+            };
+            let clickbtn = $(this);
+            tool.post('/finance/feerefund/add', data.field, callback, clickbtn);
+            return false;
+        });
+    }
+</script>
+{/block}

+ 115 - 0
app/finance/view/feerefund/datalist.html

@@ -0,0 +1,115 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<div class="p-page">
+    <div class="layui-card">
+        <div class="layui-card-header">
+            <div class="layui-form" lay-filter="form-search">
+                <div class="layui-inline">
+                    <input class="layui-input" name="keywords" id="keywords" placeholder="搜索客户ID">
+                </div>
+                <div class="layui-inline">
+                    <select name="check_status" id="check_status">
+                        <option value="">全部状态</option>
+                        <option value="0">待审核</option>
+                        <option value="1">审核中</option>
+                        <option value="2">已通过</option>
+                        <option value="3">已驳回</option>
+                        <option value="4">已撤销</option>
+                    </select>
+                </div>
+                <div class="layui-inline">
+                    <button class="layui-btn layui-btn-sm" id="btnSearch">搜索</button>
+                    <button type="button" class="layui-btn layui-btn-sm layui-btn-normal tool-add"
+                            data-href="/finance/feerefund/add">新建费用返还申请
+                    </button>
+                </div>
+            </div>
+        </div>
+        <div class="layui-card-body">
+            <div class="layui-tab layui-tab-brief">
+                <ul class="layui-tab-title">
+                    <li class="layui-this" data-tab="0">全部</li>
+                    <li data-tab="1">我创建的</li>
+                    <li data-tab="2">待我审核</li>
+                    <li data-tab="3">我已审核</li>
+                    <li data-tab="4">抄送给我</li>
+                </ul>
+            </div>
+            <table id="table_feerefund" lay-filter="table_feerefund"></table>
+        </div>
+    </div>
+</div>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool', 'tablePlus'];
+    function gouguInit() {
+        var tool = layui.tool, table = layui.tablePlus, form = layui.form;
+
+        var tab = 0;
+        var tableIns = table.render({
+            elem: '#table_feerefund',
+            url: '/finance/feerefund/datalist',
+            where: { tab: tab },
+            page: true,
+            cols: [[
+                { field: 'id', title: 'ID', width: 80 },
+                { field: 'customer_id', title: '客户ID', minWidth: 120 },
+                { field: 'admin_name', title: '申请人', width: 100 },
+                { field: 'department', title: '部门', width: 120 },
+                { field: 'check_status_str', title: '审批状态', width: 100 },
+                { field: 'create_time_str', title: '申请时间', width: 160 },
+                {
+                    title: '操作', width: 160, align: 'center', fixed: 'right',
+                    templet: function (d) {
+                        var html = '<div class="layui-btn-group">';
+                        html += '<span class="layui-btn layui-btn-normal layui-btn-xs" lay-event="view">查看</span>';
+                        html += '<span class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</span>';
+                        html += '</div>';
+                        return html;
+                    }
+                }
+            ]]
+        });
+
+        table.on('tool(table_feerefund)', function (obj) {
+            var data = obj.data;
+            if (obj.event === 'view') {
+                tool.side('/finance/feerefund/view?id=' + data.id);
+                return;
+            }
+            if (obj.event === 'del') {
+                layer.confirm('确认删除该费用返还申请?', { icon: 3, title: '提示' }, function (index) {
+                    tool.delete('/finance/feerefund/del', { id: data.id }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) {
+                            obj.del();
+                        }
+                    });
+                    layer.close(index);
+                });
+                return;
+            }
+        });
+
+        // Tab 切换
+        $('body').on('click', '.layui-tab-title li', function () {
+            tab = $(this).data('tab');
+            tableIns.reload({ where: { tab: tab }, page: { curr: 1 } });
+        });
+
+        // 搜索
+        $('#btnSearch').on('click', function () {
+            tableIns.reload({
+                where: {
+                    tab: tab,
+                    keywords: $('#keywords').val(),
+                    check_status: $('#check_status').val()
+                },
+                page: { curr: 1 }
+            });
+        });
+    }
+</script>
+{/block}

+ 83 - 0
app/finance/view/feerefund/view.html

@@ -0,0 +1,83 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<div class="p-page">
+    <h3 class="pb-2">费用返还申请详情</h3>
+    <table class="layui-table layui-table-form">
+        <tr>
+            <td class="layui-td-gray-2">申请人</td>
+            <td>{$create_user.name|default=''} ({$create_user.department|default=''})</td>
+            <td class="layui-td-gray">申请时间</td>
+            <td>{:to_date($detail['create_time'])}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">客户ID</td>
+            <td colspan="3">{$detail.customer_id|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">费项维度</td>
+            <td colspan="3">{$detail.fee_dimension_name|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">卡号</td>
+            <td colspan="3">{$detail.card_no|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">退款项</td>
+            <td colspan="3">{$detail.refund_item_name|default=''}</td>
+        </tr>
+        {notempty name="$detail.refund_item_remark"}
+        <tr>
+            <td class="layui-td-gray-2">退款项备注</td>
+            <td colspan="3">{$detail.refund_item_remark}</td>
+        </tr>
+        {/notempty}
+        <tr>
+            <td class="layui-td-gray-2">退款原因</td>
+            <td colspan="3">{$detail.refund_reason_name|default=''}</td>
+        </tr>
+        {notempty name="$detail.agreement_no"}
+        <tr>
+            <td class="layui-td-gray-2">约定审批编号</td>
+            <td colspan="3">{$detail.agreement_no}</td>
+        </tr>
+        {/notempty}
+        <tr>
+            <td class="layui-td-gray-2">具体退款原因</td>
+            <td colspan="3">{$detail.refund_reason_detail|default=''}</td>
+        </tr>
+        {notempty name="$detail.remark"}
+        <tr>
+            <td class="layui-td-gray-2">备注</td>
+            <td colspan="3">{$detail.remark}</td>
+        </tr>
+        {/notempty}
+        {notempty name="$detail.file_ids"}
+        <tr>
+            <td class="layui-td-gray-2">相关附件</td>
+            <td colspan="3">
+                <div class="layui-row">
+                    {volist name="$detail.file_array" id="vo"}
+                    <div class="layui-col-md4">{:file_card($vo,'view')}</div>
+                    {/volist}
+                </div>
+            </td>
+        </tr>
+        {/notempty}
+    </table>
+    <div id="checkBox"
+         data-status="{$detail.check_status}"
+         data-id="{$detail.id}"
+         data-checkflowid="{$detail.check_flow_id}"
+         class="pt-3"></div>
+</div>
+{/block}
+
+{block name="script"}
+<script>
+    var moduleInit = ['tool', 'oaCheck'];
+    function gouguInit() {
+        var oaCheck = layui.oaCheck;
+        oaCheck.init({ check_name: 'fee_refund' });
+    }
+</script>
+{/block}

+ 144 - 0
app/finance/view/feerefundcate/datalist.html

@@ -0,0 +1,144 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<div class="p-page">
+    <div class="layui-tab layui-tab-brief">
+        <ul class="layui-tab-title">
+            <li class="layui-this" data-type="1">费项维度</li>
+            <li data-type="2">退款项</li>
+            <li data-type="3">退款原因</li>
+        </ul>
+    </div>
+    <table class="layui-hide" id="table_fee_refund_cate" lay-filter="table_fee_refund_cate"></table>
+</div>
+
+<script type="text/html" id="toolbarDemo">
+    <div class="layui-btn-container">
+        <button class="layui-btn layui-btn-sm add-new" type="button">+ 添加</button>
+    </div>
+</script>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool'];
+    var currentType = 1;
+
+    function gouguInit() {
+        var table = layui.table, tool = layui.tool;
+
+        layui.pageTable = table.render({
+            elem: '#table_fee_refund_cate',
+            toolbar: '#toolbarDemo',
+            url: '/finance/feerefundcate/datalist',
+            where: { type: currentType },
+            page: false,
+            limit: 999,
+            cellMinWidth: 80,
+            cols: [[
+                { field: 'id', width: 80, title: 'ID', align: 'center' },
+                { field: 'title', title: '名称' },
+                { field: 'value', width: 80, title: '值', align: 'center' },
+                { field: 'is_other', width: 90, title: '其他项', align: 'center', templet: function (d) {
+                    return d.is_other == 1 ? '<span class="layui-badge layui-bg-blue">是</span>' : '-';
+                }},
+                { field: 'sort', width: 80, title: '排序', align: 'center' },
+                { field: 'status', title: '状态', width: 80, align: 'center', templet: function (d) {
+                    return d.status == 1 ? '<span class="green">启用</span>' : '<span class="yellow">禁用</span>';
+                }},
+                { width: 160, title: '操作', align: 'center', templet: function (d) {
+                    var edit = '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="edit">编辑</a>';
+                    var disable = '<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="disable">禁用</a>';
+                    var enable = '<a class="layui-btn layui-btn-xs" lay-event="open">启用</a>';
+                    var del = '<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>';
+                    var toggle = d.status == 1 ? disable : enable;
+                    return '<div class="layui-btn-group">' + edit + toggle + del + '</div>';
+                }}
+            ]]
+        });
+
+        table.on('tool(table_fee_refund_cate)', function (obj) {
+            if (obj.event === 'edit') {
+                editCate(obj.data);
+            }
+            if (obj.event === 'disable') {
+                layer.confirm('确定要禁用该记录吗?', { icon: 3 }, function (index) {
+                    tool.post('/finance/feerefundcate/set', { id: obj.data.id, status: 0 }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) layui.pageTable.reload();
+                    });
+                    layer.close(index);
+                });
+            }
+            if (obj.event === 'open') {
+                layer.confirm('确定要启用该记录吗?', { icon: 3 }, function (index) {
+                    tool.post('/finance/feerefundcate/set', { id: obj.data.id, status: 1 }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) layui.pageTable.reload();
+                    });
+                    layer.close(index);
+                });
+            }
+            if (obj.event === 'del') {
+                layer.confirm('确定要删除该记录吗?', { icon: 3 }, function (index) {
+                    tool.delete('/finance/feerefundcate/del', { id: obj.data.id }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) layui.pageTable.reload();
+                    });
+                    layer.close(index);
+                });
+            }
+        });
+
+        // Tab 切换
+        $('body').on('click', '.layui-tab-title li', function () {
+            currentType = $(this).data('type');
+            layui.pageTable.reload({ where: { type: currentType }, page: { curr: 1 } });
+        });
+
+        $('body').on('click', '.add-new', function () {
+            editCate();
+        });
+
+        function editCate(row) {
+            row = row || {};
+            var isEdit  = row.id > 0;
+            var id      = row.id || 0;
+            var title   = row.title || '';
+            var value   = (row.value === undefined || row.value === null) ? '' : row.value;
+            var sort    = row.sort || 0;
+            var isOther = row.is_other == 1;
+            layer.open({
+                type: 1,
+                title: isEdit ? '编辑' : '添加',
+                area: ['360px', 'auto'],
+                content: '<div style="padding:20px">'
+                    + '<div class="layui-form-item"><label>名称</label>'
+                    + '<input id="cate_title" class="layui-input" value="' + title + '" placeholder="请输入名称"></div>'
+                    + '<div class="layui-form-item" style="margin-top:10px"><label>值(同类型内唯一)</label>'
+                    + '<input id="cate_value" class="layui-input" type="number" value="' + value + '" placeholder="请输入数字值"></div>'
+                    + '<div class="layui-form-item" style="margin-top:10px"><label>排序</label>'
+                    + '<input id="cate_sort" class="layui-input" type="number" value="' + sort + '"></div>'
+                    + '<div class="layui-form-item" style="margin-top:10px"><label><input id="cate_is_other" type="checkbox"' + (isOther ? ' checked' : '') + '> 标记为「其他」项(申请时选中需填备注)</label></div>'
+                    + '</div>',
+                btn: ['确定', '取消'],
+                yes: function (index) {
+                    var val   = $('#cate_title').val();
+                    var vval  = $('#cate_value').val();
+                    var sval  = $('#cate_sort').val();
+                    var other = $('#cate_is_other').prop('checked') ? 1 : 0;
+                    if (!val) { layer.msg('请填写名称'); return; }
+                    if (vval === '' || isNaN(vval)) { layer.msg('请填写数字值'); return; }
+                    var tool = layui.tool;
+                    tool.post('/finance/feerefundcate/add',
+                        { id: id, type: currentType, title: val, value: vval, sort: sval, is_other: other },
+                        function (e) {
+                            layer.msg(e.msg);
+                            if (e.code == 0) { layer.close(index); layui.pageTable.reload(); }
+                        }
+                    );
+                }
+            });
+        }
+    }
+</script>
+{/block}

+ 147 - 0
app/finance/view/withdraw/add.html

@@ -0,0 +1,147 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<form class="layui-form p-page" lay-filter="form-withdraw">
+    <h3 class="pb-2">提现申请</h3>
+    <table class="layui-table layui-table-form">
+        <tr>
+            <td class="layui-td-gray-2">客户ID<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="customer_id" class="layui-input"
+                       value="{$detail.customer_id|default=''}"
+                       lay-verify="required" lay-reqText="请输入客户ID"
+                       placeholder="请输入客户ID">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">客户交易场景<font>*</font></td>
+            <td colspan="3">
+                <select name="trade_scene" lay-verify="required" lay-reqText="请选择客户交易场景" lay-filter="trade_scene">
+                    <option value="">-- 请选择 --</option>
+                    {volist name="$trade_scenes" id="vo"}
+                    <option value="{$vo.value}" data-is-other="{$vo.is_other}" {eq name="$detail.trade_scene" value="$vo.value"} selected{/eq}>{$vo.title}</option>
+                    {/volist}
+                </select>
+            </td>
+        </tr>
+        <tr id="row_trade_scene_remark" style="display:none;">
+            <td class="layui-td-gray-2">交易场景备注<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="trade_scene_remark" class="layui-input"
+                       value="{$detail.trade_scene_remark|default=''}"
+                       placeholder="选择【其他】时必填,请说明具体场景">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">提现类型<font>*</font></td>
+            <td colspan="3">
+                <select name="withdraw_type" lay-verify="required" lay-reqText="请选择提现类型">
+                    <option value="">-- 请选择 --</option>
+                    {volist name="$withdraw_types" id="vo"}
+                    <option value="{$vo.value}" {eq name="$detail.withdraw_type" value="$vo.value"} selected{/eq}>{$vo.title}</option>
+                    {/volist}
+                </select>
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">提现手续费<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="withdraw_fee" class="layui-input"
+                       value="{$detail.withdraw_fee|default=''}"
+                       lay-verify="required" lay-reqText="请填写提现手续费"
+                       placeholder="请填写提现手续费">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">是否加急处理<font>*</font></td>
+            <td colspan="3">
+                <input type="radio" name="is_urgent" value="1" title="是(人工核算)"
+                    {eq name="$detail.is_urgent" value="1"} checked{/eq}>
+                <input type="radio" name="is_urgent" value="0" title="否(按系统周期)"
+                    {eq name="$detail.is_urgent" value="0"} checked{/eq}
+                    {empty name="$detail"} checked{/empty}>
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">备注</td>
+            <td colspan="3">
+                <textarea name="remark" class="layui-textarea"
+                          placeholder="选填">{$detail.remark|default=''}</textarea>
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">附件 <button type="button" class="layui-btn layui-btn-xs" id="uploadBtn"><i class="layui-icon layui-icon-upload"></i></button></td>
+            <td colspan="3">
+                <div class="layui-row" id="uploadBox">
+                    <input data-type="file" type="hidden" name="file_ids" value="{$detail.file_ids|default=''}">
+                    {notempty name="$detail.file_ids"}
+                    {volist name="$detail.file_array" id="vo"}
+                    <div class="layui-col-md4">{:file_card($vo)}</div>
+                    {/volist}
+                    {/notempty}
+                </div>
+            </td>
+        </tr>
+    </table>
+
+    <div id="checkBox"
+         data-status="{$detail.check_status|default=0}"
+         data-id="{$detail.id|default=0}"
+         data-checkflowid="{$detail.check_flow_id|default=0}"
+         class="pt-3"></div>
+
+    <div class="pt-4">
+        <input type="hidden" name="id" value="{$detail.id|default=0}">
+        <button class="layui-btn layui-btn-normal" lay-submit="" lay-filter="webform">立即提交</button>
+        <button type="reset" class="layui-btn layui-btn-primary">重置</button>
+    </div>
+</form>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool', 'uploadPlus', 'oaCheck'];
+    function gouguInit() {
+        var form = layui.form, tool = layui.tool,
+            uploadPlus = layui.uploadPlus, oaCheck = layui.oaCheck;
+
+        oaCheck.init({ check_name: 'withdraw', check_btn: 0 });
+        var fileUp = new uploadPlus();
+
+        // 交易场景选择【其他】时显示备注(由 is_other 字段控制)
+        function checkTradeSceneRemark() {
+            var isOther = $('select[name=trade_scene] option:selected').data('is-other');
+            if (isOther == 1) {
+                $('#row_trade_scene_remark').show();
+                $('input[name=trade_scene_remark]').attr('lay-verify', 'required').attr('lay-reqText', '请填写交易场景备注');
+            } else {
+                $('#row_trade_scene_remark').hide();
+                $('input[name=trade_scene_remark]').removeAttr('lay-verify').removeAttr('lay-reqText');
+            }
+        }
+        checkTradeSceneRemark();
+
+        form.on('select(trade_scene)', function () {
+            checkTradeSceneRemark();
+            form.render('select');
+        });
+
+        form.on('submit(webform)', function (data) {
+            let callback = function (e) {
+                layer.msg(e.msg);
+                if (e.code == 0) {
+                    let checkCallback = function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) tool.sideClose(1000);
+                    };
+                    data.field.check_name = 'withdraw';
+                    data.field.action_id  = e.data.return_id;
+                    oaCheck.submit(data.field, checkCallback);
+                }
+            };
+            let clickbtn = $(this);
+            tool.post('/finance/withdraw/add', data.field, callback, clickbtn);
+            return false;
+        });
+    }
+</script>
+{/block}

+ 115 - 0
app/finance/view/withdraw/datalist.html

@@ -0,0 +1,115 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<div class="p-page">
+    <div class="layui-card">
+        <div class="layui-card-header">
+            <div class="layui-form" lay-filter="form-search">
+                <div class="layui-inline">
+                    <input class="layui-input" name="keywords" id="keywords" placeholder="搜索客户ID">
+                </div>
+                <div class="layui-inline">
+                    <select name="check_status" id="check_status">
+                        <option value="">全部状态</option>
+                        <option value="0">待审核</option>
+                        <option value="1">审核中</option>
+                        <option value="2">已通过</option>
+                        <option value="3">已驳回</option>
+                        <option value="4">已撤销</option>
+                    </select>
+                </div>
+                <div class="layui-inline">
+                    <button class="layui-btn layui-btn-sm" id="btnSearch">搜索</button>
+                    <button type="button" class="layui-btn layui-btn-sm layui-btn-normal tool-add"
+                            data-href="/finance/withdraw/add">新建提现申请
+                    </button>
+                </div>
+            </div>
+        </div>
+        <div class="layui-card-body">
+            <div class="layui-tab layui-tab-brief">
+                <ul class="layui-tab-title">
+                    <li class="layui-this" data-tab="0">全部</li>
+                    <li data-tab="1">我创建的</li>
+                    <li data-tab="2">待我审核</li>
+                    <li data-tab="3">我已审核</li>
+                    <li data-tab="4">抄送给我</li>
+                </ul>
+            </div>
+            <table id="table_withdraw" lay-filter="table_withdraw"></table>
+        </div>
+    </div>
+</div>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool', 'tablePlus'];
+    function gouguInit() {
+        var tool = layui.tool, table = layui.tablePlus, form = layui.form;
+
+        var tab = 0;
+        var tableIns = table.render({
+            elem: '#table_withdraw',
+            url: '/finance/withdraw/datalist',
+            where: { tab: tab },
+            page: true,
+            cols: [[
+                { field: 'id', title: 'ID', width: 80 },
+                { field: 'customer_id', title: '客户ID', minWidth: 120 },
+                { field: 'admin_name', title: '申请人', width: 100 },
+                { field: 'department', title: '部门', width: 120 },
+                { field: 'check_status_str', title: '审批状态', width: 100 },
+                { field: 'create_time_str', title: '申请时间', width: 160 },
+                {
+                    title: '操作', width: 160, align: 'center', fixed: 'right',
+                    templet: function (d) {
+                        var html = '<div class="layui-btn-group">';
+                        html += '<span class="layui-btn layui-btn-normal layui-btn-xs" lay-event="view">查看</span>';
+                        html += '<span class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</span>';
+                        html += '</div>';
+                        return html;
+                    }
+                }
+            ]]
+        });
+
+        table.on('tool(table_withdraw)', function (obj) {
+            var data = obj.data;
+            if (obj.event === 'view') {
+                tool.side('/finance/withdraw/view?id=' + data.id);
+                return;
+            }
+            if (obj.event === 'del') {
+                layer.confirm('确认删除该提现申请?', { icon: 3, title: '提示' }, function (index) {
+                    tool.delete('/finance/withdraw/del', { id: data.id }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) {
+                            obj.del();
+                        }
+                    });
+                    layer.close(index);
+                });
+                return;
+            }
+        });
+
+        // Tab 切换
+        $('body').on('click', '.layui-tab-title li', function () {
+            tab = $(this).data('tab');
+            tableIns.reload({ where: { tab: tab }, page: { curr: 1 } });
+        });
+
+        // 搜索
+        $('#btnSearch').on('click', function () {
+            tableIns.reload({
+                where: {
+                    tab: tab,
+                    keywords: $('#keywords').val(),
+                    check_status: $('#check_status').val()
+                },
+                page: { curr: 1 }
+            });
+        });
+    }
+</script>
+{/block}

+ 73 - 0
app/finance/view/withdraw/view.html

@@ -0,0 +1,73 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<div class="p-page">
+    <h3 class="pb-2">提现申请详情</h3>
+    <table class="layui-table layui-table-form">
+        <tr>
+            <td class="layui-td-gray-2">申请人</td>
+            <td>{$create_user.name|default=''} ({$create_user.department|default=''})</td>
+            <td class="layui-td-gray">申请时间</td>
+            <td>{:to_date($detail['create_time'])}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">客户ID</td>
+            <td colspan="3">{$detail.customer_id|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">客户交易场景</td>
+            <td colspan="3">{$detail.trade_scene_name|default=''}</td>
+        </tr>
+        {notempty name="$detail.trade_scene_remark"}
+        <tr>
+            <td class="layui-td-gray-2">交易场景备注</td>
+            <td colspan="3">{$detail.trade_scene_remark}</td>
+        </tr>
+        {/notempty}
+        <tr>
+            <td class="layui-td-gray-2">提现类型</td>
+            <td colspan="3">{$detail.withdraw_type_name|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">提现手续费</td>
+            <td colspan="3">{$detail.withdraw_fee|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">是否加急处理</td>
+            <td colspan="3">{:withdraw_urgent_name($detail['is_urgent'])}</td>
+        </tr>
+        {notempty name="$detail.remark"}
+        <tr>
+            <td class="layui-td-gray-2">备注</td>
+            <td colspan="3">{$detail.remark}</td>
+        </tr>
+        {/notempty}
+        {notempty name="$detail.file_ids"}
+        <tr>
+            <td class="layui-td-gray-2">相关附件</td>
+            <td colspan="3">
+                <div class="layui-row">
+                    {volist name="$detail.file_array" id="vo"}
+                    <div class="layui-col-md4">{:file_card($vo,'view')}</div>
+                    {/volist}
+                </div>
+            </td>
+        </tr>
+        {/notempty}
+    </table>
+    <div id="checkBox"
+         data-status="{$detail.check_status}"
+         data-id="{$detail.id}"
+         data-checkflowid="{$detail.check_flow_id}"
+         class="pt-3"></div>
+</div>
+{/block}
+
+{block name="script"}
+<script>
+    var moduleInit = ['tool', 'oaCheck'];
+    function gouguInit() {
+        var oaCheck = layui.oaCheck;
+        oaCheck.init({ check_name: 'withdraw' });
+    }
+</script>
+{/block}

+ 143 - 0
app/finance/view/withdrawcate/datalist.html

@@ -0,0 +1,143 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<div class="p-page">
+    <div class="layui-tab layui-tab-brief">
+        <ul class="layui-tab-title">
+            <li class="layui-this" data-type="1">客户交易场景</li>
+            <li data-type="2">提现类型</li>
+        </ul>
+    </div>
+    <table class="layui-hide" id="table_withdraw_cate" lay-filter="table_withdraw_cate"></table>
+</div>
+
+<script type="text/html" id="toolbarDemo">
+    <div class="layui-btn-container">
+        <button class="layui-btn layui-btn-sm add-new" type="button">+ 添加</button>
+    </div>
+</script>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool'];
+    var currentType = 1;
+
+    function gouguInit() {
+        var table = layui.table, tool = layui.tool;
+
+        layui.pageTable = table.render({
+            elem: '#table_withdraw_cate',
+            toolbar: '#toolbarDemo',
+            url: '/finance/withdrawcate/datalist',
+            where: { type: currentType },
+            page: false,
+            limit: 999,
+            cellMinWidth: 80,
+            cols: [[
+                { field: 'id', width: 80, title: 'ID', align: 'center' },
+                { field: 'title', title: '名称' },
+                { field: 'value', width: 80, title: '值', align: 'center' },
+                { field: 'is_other', width: 90, title: '其他项', align: 'center', templet: function (d) {
+                    return d.is_other == 1 ? '<span class="layui-badge layui-bg-blue">是</span>' : '-';
+                }},
+                { field: 'sort', width: 80, title: '排序', align: 'center' },
+                { field: 'status', title: '状态', width: 80, align: 'center', templet: function (d) {
+                    return d.status == 1 ? '<span class="green">启用</span>' : '<span class="yellow">禁用</span>';
+                }},
+                { width: 160, title: '操作', align: 'center', templet: function (d) {
+                    var edit = '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="edit">编辑</a>';
+                    var disable = '<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="disable">禁用</a>';
+                    var enable = '<a class="layui-btn layui-btn-xs" lay-event="open">启用</a>';
+                    var del = '<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>';
+                    var toggle = d.status == 1 ? disable : enable;
+                    return '<div class="layui-btn-group">' + edit + toggle + del + '</div>';
+                }}
+            ]]
+        });
+
+        table.on('tool(table_withdraw_cate)', function (obj) {
+            if (obj.event === 'edit') {
+                editCate(obj.data);
+            }
+            if (obj.event === 'disable') {
+                layer.confirm('确定要禁用该记录吗?', { icon: 3 }, function (index) {
+                    tool.post('/finance/withdrawcate/set', { id: obj.data.id, status: 0 }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) layui.pageTable.reload();
+                    });
+                    layer.close(index);
+                });
+            }
+            if (obj.event === 'open') {
+                layer.confirm('确定要启用该记录吗?', { icon: 3 }, function (index) {
+                    tool.post('/finance/withdrawcate/set', { id: obj.data.id, status: 1 }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) layui.pageTable.reload();
+                    });
+                    layer.close(index);
+                });
+            }
+            if (obj.event === 'del') {
+                layer.confirm('确定要删除该记录吗?', { icon: 3 }, function (index) {
+                    tool.delete('/finance/withdrawcate/del', { id: obj.data.id }, function (e) {
+                        layer.msg(e.msg);
+                        if (e.code == 0) layui.pageTable.reload();
+                    });
+                    layer.close(index);
+                });
+            }
+        });
+
+        // Tab 切换
+        $('body').on('click', '.layui-tab-title li', function () {
+            currentType = $(this).data('type');
+            layui.pageTable.reload({ where: { type: currentType }, page: { curr: 1 } });
+        });
+
+        $('body').on('click', '.add-new', function () {
+            editCate();
+        });
+
+        function editCate(row) {
+            row = row || {};
+            var isEdit  = row.id > 0;
+            var id      = row.id || 0;
+            var title   = row.title || '';
+            var value   = (row.value === undefined || row.value === null) ? '' : row.value;
+            var sort    = row.sort || 0;
+            var isOther = row.is_other == 1;
+            layer.open({
+                type: 1,
+                title: isEdit ? '编辑' : '添加',
+                area: ['360px', 'auto'],
+                content: '<div style="padding:20px">'
+                    + '<div class="layui-form-item"><label>名称</label>'
+                    + '<input id="cate_title" class="layui-input" value="' + title + '" placeholder="请输入名称"></div>'
+                    + '<div class="layui-form-item" style="margin-top:10px"><label>值(同类型内唯一)</label>'
+                    + '<input id="cate_value" class="layui-input" type="number" value="' + value + '" placeholder="请输入数字值"></div>'
+                    + '<div class="layui-form-item" style="margin-top:10px"><label>排序</label>'
+                    + '<input id="cate_sort" class="layui-input" type="number" value="' + sort + '"></div>'
+                    + '<div class="layui-form-item" style="margin-top:10px"><label><input id="cate_is_other" type="checkbox"' + (isOther ? ' checked' : '') + '> 标记为「其他」项(申请时选中需填备注)</label></div>'
+                    + '</div>',
+                btn: ['确定', '取消'],
+                yes: function (index) {
+                    var val   = $('#cate_title').val();
+                    var vval  = $('#cate_value').val();
+                    var sval  = $('#cate_sort').val();
+                    var other = $('#cate_is_other').prop('checked') ? 1 : 0;
+                    if (!val) { layer.msg('请填写名称'); return; }
+                    if (vval === '' || isNaN(vval)) { layer.msg('请填写数字值'); return; }
+                    var tool = layui.tool;
+                    tool.post('/finance/withdrawcate/add',
+                        { id: id, type: currentType, title: val, value: vval, sort: sval, is_other: other },
+                        function (e) {
+                            layer.msg(e.msg);
+                            if (e.code == 0) { layer.close(index); layui.pageTable.reload(); }
+                        }
+                    );
+                }
+            });
+        }
+    }
+</script>
+{/block}

+ 44 - 0
database/migrations/20260527100001_create_withdraw.php

@@ -0,0 +1,44 @@
+<?php
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use think\migration\Migrator;
+
+class CreateWithdraw extends Migrator
+{
+    public function change(): void {
+        $table = $this->table('withdraw', [
+            'id'          => 'id',
+            'engine'      => 'InnoDB',
+            'collation'   => 'utf8mb4_general_ci',
+            'comment'     => '提现申请表',
+            'auto_id'     => true,
+        ]);
+
+        $table
+            // 业务字段
+            ->addColumn('customer_id', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '客户ID'])
+            ->addColumn('trade_scene', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '客户交易场景:1Facebook,9其他'])
+            ->addColumn('trade_scene_remark', 'text', ['null' => true, 'comment' => '交易场景备注(场景为其他时必填)'])
+            ->addColumn('withdraw_type', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '提现类型:1清退提现(全额提现),2部分提现'])
+            ->addColumn('withdraw_fee', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '提现手续费'])
+            ->addColumn('is_urgent', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '是否加急处理:0否(按系统周期),1是(人工核算)'])
+            ->addColumn('remark', 'text', ['null' => true, 'comment' => '备注'])
+            ->addColumn('file_ids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '附件id,id,id'])
+            // 审批必填字段
+            ->addColumn('check_status', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '审核状态:0待审核,1审核中,2通过,3不通过,4撤销'])
+            ->addColumn('check_flow_id', 'integer', ['null' => false, 'default' => 0, 'comment' => '审核流程id'])
+            ->addColumn('check_step_sort', 'integer', ['null' => false, 'default' => 0, 'comment' => '当前审批步骤'])
+            ->addColumn('check_uids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '当前审批人ID'])
+            ->addColumn('check_last_uid', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '上一审批人'])
+            ->addColumn('check_history_uids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '历史审批人ID'])
+            ->addColumn('check_copy_uids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '抄送人ID'])
+            ->addColumn('check_time', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false, 'comment' => '审核通过时间'])
+            // 用户/部门/时间戳
+            ->addColumn('admin_id', 'integer', ['null' => false, 'default' => 0, 'comment' => '创建人ID'])
+            ->addColumn('did', 'integer', ['null' => false, 'default' => 0, 'comment' => '创建人部门ID'])
+            ->addColumn('create_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '创建时间'])
+            ->addColumn('update_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '更新时间'])
+            ->addColumn('delete_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '删除时间'])
+            ->create();
+    }
+}

+ 26 - 0
database/migrations/20260527100002_create_withdraw_cate.php

@@ -0,0 +1,26 @@
+<?php
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use think\migration\Migrator;
+
+class CreateWithdrawCate extends Migrator
+{
+    public function change(): void {
+        $table = $this->table('withdraw_cate', [
+            'engine'    => 'InnoDB',
+            'collation' => 'utf8mb4_general_ci',
+            'comment'   => '提现申请类型字典表',
+        ]);
+
+        $table
+            ->addColumn('type', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '类型:1客户交易场景,2提现类型'])
+            ->addColumn('title', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '名称'])
+            ->addColumn('value', 'integer', ['null' => false, 'default' => 0, 'comment' => '业务值(同type内唯一,申请单存此值)'])
+            ->addColumn('is_other', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '是否为其他选项:1是(选中后需填备注)'])
+            ->addColumn('sort', 'integer', ['null' => false, 'default' => 0, 'comment' => '排序'])
+            ->addColumn('status', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 1, 'comment' => '状态:0禁用,1启用'])
+            ->addColumn('create_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '创建时间'])
+            ->addColumn('delete_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '删除时间'])
+            ->create();
+    }
+}

+ 38 - 0
database/migrations/20260527100003_seed_withdraw_cate_data.php

@@ -0,0 +1,38 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedWithdrawCateData extends Migrator
+{
+    public function up(): void
+    {
+        $now = time();
+        foreach ($this->data() as [$type, $title, $value, $isOther, $sort]) {
+            $this->execute("INSERT INTO `oa_withdraw_cate`
+                (`type`, `title`, `value`, `is_other`, `sort`, `status`, `create_time`, `delete_time`)
+                VALUES ({$type}, '{$title}', {$value}, {$isOther}, {$sort}, 1, {$now}, 0)");
+        }
+    }
+
+    public function down(): void
+    {
+        foreach ($this->data() as [$type, $title]) {
+            $this->execute("DELETE FROM `oa_withdraw_cate` WHERE `type` = {$type} AND `title` = '{$title}'");
+        }
+    }
+
+    /**
+     * @return array<int, array{0:int,1:string,2:int,3:int,4:int}> [type, title, value, is_other, sort]
+     */
+    protected function data(): array
+    {
+        return [
+            // 客户交易场景(type=1)
+            [1, 'Facebook', 1, 0, 1],
+            [1, '其他-需在备注中说明', 9, 1, 9],
+            // 提现类型(type=2)
+            [2, '清退提现(全额提现)', 1, 0, 1],
+            [2, '部分提现', 2, 0, 2],
+        ];
+    }
+}

+ 151 - 0
database/migrations/20260527100004_seed_withdraw_data.php

@@ -0,0 +1,151 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedWithdrawData extends Migrator
+{
+    public function up(): void
+    {
+        $now = time();
+        $db  = $this->getAdapter()->getConnection();
+
+        // 动态查找财务管理菜单节点 ID
+        $financeMenu = $db->query("SELECT id FROM `oa_admin_rule` WHERE title='财务管理' AND module='finance' AND pid=0 LIMIT 1")
+                          ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($financeMenu)) {
+            throw new \RuntimeException('找不到财务管理菜单节点,请确认基础数据已安装');
+        }
+        $financePid = $financeMenu[0]['id'];
+
+        // 动态查找审批流程财务模块 ID(oa_flow_module.title='财务')
+        $flowModule = $db->query("SELECT id FROM `oa_flow_module` WHERE title='财务' LIMIT 1")
+                         ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($flowModule)) {
+            throw new \RuntimeException('找不到审批流程财务模块,请确认基础数据已安装');
+        }
+        $flowModuleId = $flowModule[0]['id'];
+
+        // 动态查找超级权限角色 ID
+        $superGroup = $db->query("SELECT id FROM `oa_admin_group` WHERE title='超级权限角色' LIMIT 1")
+                         ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($superGroup)) {
+            throw new \RuntimeException('找不到超级权限角色,请确认基础数据已安装');
+        }
+        $superGroupId = $superGroup[0]['id'];
+
+        // 1. 注册审批类型
+        $this->execute("INSERT INTO `oa_flow_cate`
+            (`title`, `name`, `module_id`, `check_table`, `icon`,
+             `department_ids`, `sort`, `is_copy`, `is_file`, `is_export`, `is_back`, `is_reversed`,
+             `form`, `add_url`, `view_url`, `form_id`, `is_list`, `status`,
+             `template_id`, `create_time`, `update_time`)
+            VALUES
+            ('提现申请', 'withdraw', {$flowModuleId}, 'withdraw', 'icon-zhangbu',
+             '', 52, 1, 1, 0, 1, 0,
+             1, '/finance/withdraw/add', '/finance/withdraw/view', 0, 1, 1,
+             0, {$now}, 0)");
+
+        $cateId = $db->lastInsertId();
+
+        // 2. 创建默认审批流程(自由审批流)
+        $this->execute("INSERT INTO `oa_flow`
+            (`title`, `cate_id`, `check_type`, `department_ids`, `copy_uids`, `flow_list`,
+             `status`, `remark`, `admin_id`, `create_time`)
+            VALUES
+            ('提现申请审批', {$cateId}, 1, '', '', '', 1, '', 1, {$now})");
+
+        // 3. 注册消息模板
+        $this->execute("INSERT INTO `oa_template`
+            (`title`, `name`, `types`, `check_types`, `remark`, `msg_link`,
+             `msg_title_0`, `msg_content_0`,
+             `msg_title_1`, `msg_content_1`,
+             `msg_title_2`, `msg_content_2`,
+             `msg_title_3`, `msg_content_3`,
+             `email_link`, `status`, `admin_id`, `create_time`, `update_time`)
+            VALUES
+            ('提现申请审批', 'withdraw', 2, 0, '', '/finance/withdraw/view/id/{action_id}',
+             '{from_user}提交了一个『提现申请』,请及时审批',
+             '您有一个新的『提现申请』需要处理。',
+             '您提交的『提现申请』已被审批通过。',
+             '您在{create_time}提交的『提现申请』已于{date}被审批通过。',
+             '您提交的『提现申请』已被驳回拒绝。',
+             '您在{create_time}提交的『提现申请』已于{date}被驳回拒绝。',
+             '{from_user}提交的『提现申请』已被审批通过并抄送给你',
+             '{from_user}在{create_time}提交的『提现申请』已被审批通过并抄送给你,请及时查看详情。',
+             '', 1, 1, {$now}, 0)");
+
+        $tplId = $db->lastInsertId();
+        $this->execute("UPDATE `oa_flow_cate` SET `template_id` = {$tplId} WHERE `id` = {$cateId}");
+
+        // 4. 菜单 + 权限节点
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$financePid}, 'finance/withdraw/datalist', '提现申请', '提现申请', 'finance', 'icon-zhangbu', 1, 52, 1, {$now})");
+
+        $ruleId = $db->lastInsertId();
+
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$ruleId}, 'finance/withdraw/datalist', '列表',      '提现申请-列表',     'finance', '', 2, 1, 1, {$now}),
+            ({$ruleId}, 'finance/withdraw/add',      '添加/编辑', '提现申请-添加编辑', 'finance', '', 2, 2, 1, {$now}),
+            ({$ruleId}, 'finance/withdraw/view',     '详情',      '提现申请-详情',     'finance', '', 2, 3, 1, {$now}),
+            ({$ruleId}, 'finance/withdraw/del',      '删除',      '提现申请-删除',     'finance', '', 2, 4, 1, {$now})");
+
+        // 5. 授权给超级权限角色
+        $this->execute("UPDATE `oa_admin_group`
+            SET `rules` = CONCAT(`rules`, ',', (
+                SELECT GROUP_CONCAT(t.id) FROM (
+                    SELECT id FROM `oa_admin_rule` WHERE id = {$ruleId} OR pid = {$ruleId}
+                ) t
+            ))
+            WHERE `id` = {$superGroupId}");
+    }
+
+    public function down(): void
+    {
+        $db = $this->getAdapter()->getConnection();
+
+        // 1. 回收权限节点:先从超级权限角色 rules 中剔除,再删除节点
+        $ruleIds = $db->query("SELECT id FROM `oa_admin_rule` WHERE src LIKE 'finance/withdraw/%'")
+                      ->fetchAll(\PDO::FETCH_COLUMN);
+        if (!empty($ruleIds)) {
+            $this->removeRulesFromGroups($ruleIds);
+            $in = implode(',', array_map('intval', $ruleIds));
+            $this->execute("DELETE FROM `oa_admin_rule` WHERE id IN ({$in})");
+        }
+
+        // 2. 删除审批流程(依赖审批类型 cate_id,需先删)
+        $cateIds = $db->query("SELECT id FROM `oa_flow_cate` WHERE name='withdraw'")
+                      ->fetchAll(\PDO::FETCH_COLUMN);
+        if (!empty($cateIds)) {
+            $in = implode(',', array_map('intval', $cateIds));
+            $this->execute("DELETE FROM `oa_flow` WHERE `cate_id` IN ({$in})");
+        }
+
+        // 3. 删除消息模板与审批类型
+        $this->execute("DELETE FROM `oa_template` WHERE `name` = 'withdraw'");
+        $this->execute("DELETE FROM `oa_flow_cate` WHERE `name` = 'withdraw'");
+    }
+
+    /**
+     * 从所有角色的 rules 字段中剔除指定权限节点 ID
+     *
+     * @param array<int, int|string> $ruleIds
+     */
+    protected function removeRulesFromGroups(array $ruleIds): void
+    {
+        $db      = $this->getAdapter()->getConnection();
+        $ruleIds = array_map('strval', $ruleIds);
+        $groups  = $db->query("SELECT id, rules FROM `oa_admin_group`")->fetchAll(\PDO::FETCH_ASSOC);
+        foreach ($groups as $group) {
+            $rules = array_filter(explode(',', (string) $group['rules']), static fn($v) => $v !== '');
+            $kept  = array_values(array_diff($rules, $ruleIds));
+            if (count($kept) !== count($rules)) {
+                $newRules = implode(',', $kept);
+                $this->execute("UPDATE `oa_admin_group` SET `rules` = '{$newRules}' WHERE `id` = {$group['id']}");
+            }
+        }
+    }
+}

+ 87 - 0
database/migrations/20260527100005_seed_withdraw_cate_rule.php

@@ -0,0 +1,87 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedWithdrawCateRule extends Migrator
+{
+    public function up(): void
+    {
+        $now = time();
+        $db  = $this->getAdapter()->getConnection();
+
+        // 动态查找「财务模块」(基础数据-财务管理)节点 ID
+        $financeMenu = $db->query("SELECT id FROM `oa_admin_rule` WHERE title='财务模块' AND module='finance' LIMIT 1")
+                          ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($financeMenu)) {
+            throw new \RuntimeException('找不到财务模块菜单节点,请确认基础数据已安装');
+        }
+        $financePid = $financeMenu[0]['id'];
+
+        // 动态查找超级权限角色 ID
+        $superGroup = $db->query("SELECT id FROM `oa_admin_group` WHERE title='超级权限角色' LIMIT 1")
+                         ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($superGroup)) {
+            throw new \RuntimeException('找不到超级权限角色,请确认基础数据已安装');
+        }
+        $superGroupId = $superGroup[0]['id'];
+
+        // 菜单节点
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$financePid}, 'finance/withdrawcate/datalist', '提现类型', '提现类型管理', 'finance', '', 1, 53, 1, {$now})");
+
+        $ruleId = $db->lastInsertId();
+
+        // 子权限节点
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$ruleId}, 'finance/withdrawcate/datalist', '列表',      '提现类型-列表',     'finance', '', 2, 1, 1, {$now}),
+            ({$ruleId}, 'finance/withdrawcate/add',      '添加/编辑', '提现类型-添加编辑', 'finance', '', 2, 2, 1, {$now}),
+            ({$ruleId}, 'finance/withdrawcate/del',      '删除',      '提现类型-删除',     'finance', '', 2, 3, 1, {$now}),
+            ({$ruleId}, 'finance/withdrawcate/set',      '设置',      '提现类型-设置',     'finance', '', 2, 4, 1, {$now})");
+
+        // 授权给超级权限角色
+        $this->execute("UPDATE `oa_admin_group`
+            SET `rules` = CONCAT(`rules`, ',', (
+                SELECT GROUP_CONCAT(t.id) FROM (
+                    SELECT id FROM `oa_admin_rule` WHERE id = {$ruleId} OR pid = {$ruleId}
+                ) t
+            ))
+            WHERE `id` = {$superGroupId}");
+    }
+
+    public function down(): void
+    {
+        $db = $this->getAdapter()->getConnection();
+
+        $ruleIds = $db->query("SELECT id FROM `oa_admin_rule` WHERE src LIKE 'finance/withdrawcate/%'")
+                      ->fetchAll(\PDO::FETCH_COLUMN);
+        if (!empty($ruleIds)) {
+            $this->removeRulesFromGroups($ruleIds);
+            $in = implode(',', array_map('intval', $ruleIds));
+            $this->execute("DELETE FROM `oa_admin_rule` WHERE id IN ({$in})");
+        }
+    }
+
+    /**
+     * 从所有角色的 rules 字段中剔除指定权限节点 ID
+     *
+     * @param array<int, int|string> $ruleIds
+     */
+    protected function removeRulesFromGroups(array $ruleIds): void
+    {
+        $db      = $this->getAdapter()->getConnection();
+        $ruleIds = array_map('strval', $ruleIds);
+        $groups  = $db->query("SELECT id, rules FROM `oa_admin_group`")->fetchAll(\PDO::FETCH_ASSOC);
+        foreach ($groups as $group) {
+            $rules = array_filter(explode(',', (string) $group['rules']), static fn($v) => $v !== '');
+            $kept  = array_values(array_diff($rules, $ruleIds));
+            if (count($kept) !== count($rules)) {
+                $newRules = implode(',', $kept);
+                $this->execute("UPDATE `oa_admin_group` SET `rules` = '{$newRules}' WHERE `id` = {$group['id']}");
+            }
+        }
+    }
+}

+ 46 - 0
database/migrations/20260527110001_create_fee_refund.php

@@ -0,0 +1,46 @@
+<?php
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use think\migration\Migrator;
+
+class CreateFeeRefund extends Migrator
+{
+    public function change(): void {
+        $table = $this->table('fee_refund', [
+            'id'          => 'id',
+            'engine'      => 'InnoDB',
+            'collation'   => 'utf8mb4_general_ci',
+            'comment'     => '费用返还申请表',
+            'auto_id'     => true,
+        ]);
+
+        $table
+            // 业务字段
+            ->addColumn('customer_id', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '客户ID'])
+            ->addColumn('fee_dimension', 'string', ['limit' => 50, 'null' => false, 'default' => '', 'comment' => '费项维度(多选,逗号分隔value):1账户,2卡'])
+            ->addColumn('card_no', 'string', ['limit' => 255, 'null' => false, 'default' => '', 'comment' => '卡号(单张填卡ID,多张填卡BIN)'])
+            ->addColumn('refund_item', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '退款项:1小额手续费,2失败手续费,3开卡手续费,4充值手续费,5退款手续费,6撤销手续费,7销卡退值金额,9其他'])
+            ->addColumn('refund_item_remark', 'text', ['null' => true, 'comment' => '退款项备注(退款项为其他时必填)'])
+            ->addColumn('refund_reason', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '退款原因:1约定已过审批,2特殊情况'])
+            ->addColumn('agreement_no', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '约定审批编号'])
+            ->addColumn('refund_reason_detail', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '具体退款原因'])
+            ->addColumn('remark', 'text', ['null' => true, 'comment' => '备注'])
+            ->addColumn('file_ids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '附件id,id,id'])
+            // 审批必填字段
+            ->addColumn('check_status', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '审核状态:0待审核,1审核中,2通过,3不通过,4撤销'])
+            ->addColumn('check_flow_id', 'integer', ['null' => false, 'default' => 0, 'comment' => '审核流程id'])
+            ->addColumn('check_step_sort', 'integer', ['null' => false, 'default' => 0, 'comment' => '当前审批步骤'])
+            ->addColumn('check_uids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '当前审批人ID'])
+            ->addColumn('check_last_uid', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '上一审批人'])
+            ->addColumn('check_history_uids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '历史审批人ID'])
+            ->addColumn('check_copy_uids', 'string', ['limit' => 500, 'null' => false, 'default' => '', 'comment' => '抄送人ID'])
+            ->addColumn('check_time', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false, 'comment' => '审核通过时间'])
+            // 用户/部门/时间戳
+            ->addColumn('admin_id', 'integer', ['null' => false, 'default' => 0, 'comment' => '创建人ID'])
+            ->addColumn('did', 'integer', ['null' => false, 'default' => 0, 'comment' => '创建人部门ID'])
+            ->addColumn('create_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '创建时间'])
+            ->addColumn('update_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '更新时间'])
+            ->addColumn('delete_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '删除时间'])
+            ->create();
+    }
+}

+ 26 - 0
database/migrations/20260527110002_create_fee_refund_cate.php

@@ -0,0 +1,26 @@
+<?php
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use think\migration\Migrator;
+
+class CreateFeeRefundCate extends Migrator
+{
+    public function change(): void {
+        $table = $this->table('fee_refund_cate', [
+            'engine'    => 'InnoDB',
+            'collation' => 'utf8mb4_general_ci',
+            'comment'   => '费用返还申请类型字典表',
+        ]);
+
+        $table
+            ->addColumn('type', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '类型:1费项维度,2退款项,3退款原因'])
+            ->addColumn('title', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '名称'])
+            ->addColumn('value', 'integer', ['null' => false, 'default' => 0, 'comment' => '业务值(同type内唯一,申请单存此值)'])
+            ->addColumn('is_other', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 0, 'comment' => '是否为其他选项:1是(选中后需填备注)'])
+            ->addColumn('sort', 'integer', ['null' => false, 'default' => 0, 'comment' => '排序'])
+            ->addColumn('status', 'integer', ['limit' => MysqlAdapter::INT_TINY, 'null' => false, 'default' => 1, 'comment' => '状态:0禁用,1启用'])
+            ->addColumn('create_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '创建时间'])
+            ->addColumn('delete_time', 'biginteger', ['null' => false, 'default' => 0, 'comment' => '删除时间'])
+            ->create();
+    }
+}

+ 47 - 0
database/migrations/20260527110003_seed_fee_refund_cate_data.php

@@ -0,0 +1,47 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedFeeRefundCateData extends Migrator
+{
+    public function up(): void
+    {
+        $now = time();
+        foreach ($this->data() as [$type, $title, $value, $isOther, $sort]) {
+            $this->execute("INSERT INTO `oa_fee_refund_cate`
+                (`type`, `title`, `value`, `is_other`, `sort`, `status`, `create_time`, `delete_time`)
+                VALUES ({$type}, '{$title}', {$value}, {$isOther}, {$sort}, 1, {$now}, 0)");
+        }
+    }
+
+    public function down(): void
+    {
+        foreach ($this->data() as [$type, $title]) {
+            $this->execute("DELETE FROM `oa_fee_refund_cate` WHERE `type` = {$type} AND `title` = '{$title}'");
+        }
+    }
+
+    /**
+     * @return array<int, array{0:int,1:string,2:int,3:int,4:int}> [type, title, value, is_other, sort]
+     */
+    protected function data(): array
+    {
+        return [
+            // 费项维度(type=1,多选)
+            [1, '账户', 1, 0, 1],
+            [1, '卡', 2, 0, 2],
+            // 退款项(type=2,单选)
+            [2, '小额手续费', 1, 0, 1],
+            [2, '失败手续费', 2, 0, 2],
+            [2, '开卡手续费', 3, 0, 3],
+            [2, '充值手续费', 4, 0, 4],
+            [2, '退款手续费', 5, 0, 5],
+            [2, '撤销手续费', 6, 0, 6],
+            [2, '销卡退值金额', 7, 0, 7],
+            [2, '其他-需备注说明', 9, 1, 9],
+            // 退款原因(type=3,单选)
+            [3, '约定,已过审批', 1, 0, 1],
+            [3, '特殊情况', 2, 0, 2],
+        ];
+    }
+}

+ 151 - 0
database/migrations/20260527110004_seed_fee_refund_data.php

@@ -0,0 +1,151 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedFeeRefundData extends Migrator
+{
+    public function up(): void
+    {
+        $now = time();
+        $db  = $this->getAdapter()->getConnection();
+
+        // 动态查找财务管理菜单节点 ID
+        $financeMenu = $db->query("SELECT id FROM `oa_admin_rule` WHERE title='财务管理' AND module='finance' AND pid=0 LIMIT 1")
+                          ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($financeMenu)) {
+            throw new \RuntimeException('找不到财务管理菜单节点,请确认基础数据已安装');
+        }
+        $financePid = $financeMenu[0]['id'];
+
+        // 动态查找审批流程财务模块 ID(oa_flow_module.title='财务')
+        $flowModule = $db->query("SELECT id FROM `oa_flow_module` WHERE title='财务' LIMIT 1")
+                         ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($flowModule)) {
+            throw new \RuntimeException('找不到审批流程财务模块,请确认基础数据已安装');
+        }
+        $flowModuleId = $flowModule[0]['id'];
+
+        // 动态查找超级权限角色 ID
+        $superGroup = $db->query("SELECT id FROM `oa_admin_group` WHERE title='超级权限角色' LIMIT 1")
+                         ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($superGroup)) {
+            throw new \RuntimeException('找不到超级权限角色,请确认基础数据已安装');
+        }
+        $superGroupId = $superGroup[0]['id'];
+
+        // 1. 注册审批类型
+        $this->execute("INSERT INTO `oa_flow_cate`
+            (`title`, `name`, `module_id`, `check_table`, `icon`,
+             `department_ids`, `sort`, `is_copy`, `is_file`, `is_export`, `is_back`, `is_reversed`,
+             `form`, `add_url`, `view_url`, `form_id`, `is_list`, `status`,
+             `template_id`, `create_time`, `update_time`)
+            VALUES
+            ('费用返还申请', 'fee_refund', {$flowModuleId}, 'fee_refund', 'icon-zhangbu',
+             '', 54, 1, 1, 0, 1, 0,
+             1, '/finance/feerefund/add', '/finance/feerefund/view', 0, 1, 1,
+             0, {$now}, 0)");
+
+        $cateId = $db->lastInsertId();
+
+        // 2. 创建默认审批流程(自由审批流)
+        $this->execute("INSERT INTO `oa_flow`
+            (`title`, `cate_id`, `check_type`, `department_ids`, `copy_uids`, `flow_list`,
+             `status`, `remark`, `admin_id`, `create_time`)
+            VALUES
+            ('费用返还申请审批', {$cateId}, 1, '', '', '', 1, '', 1, {$now})");
+
+        // 3. 注册消息模板
+        $this->execute("INSERT INTO `oa_template`
+            (`title`, `name`, `types`, `check_types`, `remark`, `msg_link`,
+             `msg_title_0`, `msg_content_0`,
+             `msg_title_1`, `msg_content_1`,
+             `msg_title_2`, `msg_content_2`,
+             `msg_title_3`, `msg_content_3`,
+             `email_link`, `status`, `admin_id`, `create_time`, `update_time`)
+            VALUES
+            ('费用返还申请审批', 'fee_refund', 2, 0, '', '/finance/feerefund/view/id/{action_id}',
+             '{from_user}提交了一个『费用返还申请』,请及时审批',
+             '您有一个新的『费用返还申请』需要处理。',
+             '您提交的『费用返还申请』已被审批通过。',
+             '您在{create_time}提交的『费用返还申请』已于{date}被审批通过。',
+             '您提交的『费用返还申请』已被驳回拒绝。',
+             '您在{create_time}提交的『费用返还申请』已于{date}被驳回拒绝。',
+             '{from_user}提交的『费用返还申请』已被审批通过并抄送给你',
+             '{from_user}在{create_time}提交的『费用返还申请』已被审批通过并抄送给你,请及时查看详情。',
+             '', 1, 1, {$now}, 0)");
+
+        $tplId = $db->lastInsertId();
+        $this->execute("UPDATE `oa_flow_cate` SET `template_id` = {$tplId} WHERE `id` = {$cateId}");
+
+        // 4. 菜单 + 权限节点
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$financePid}, 'finance/feerefund/datalist', '费用返还申请', '费用返还申请', 'finance', 'icon-zhangbu', 1, 54, 1, {$now})");
+
+        $ruleId = $db->lastInsertId();
+
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$ruleId}, 'finance/feerefund/datalist', '列表',      '费用返还申请-列表',     'finance', '', 2, 1, 1, {$now}),
+            ({$ruleId}, 'finance/feerefund/add',      '添加/编辑', '费用返还申请-添加编辑', 'finance', '', 2, 2, 1, {$now}),
+            ({$ruleId}, 'finance/feerefund/view',     '详情',      '费用返还申请-详情',     'finance', '', 2, 3, 1, {$now}),
+            ({$ruleId}, 'finance/feerefund/del',      '删除',      '费用返还申请-删除',     'finance', '', 2, 4, 1, {$now})");
+
+        // 5. 授权给超级权限角色
+        $this->execute("UPDATE `oa_admin_group`
+            SET `rules` = CONCAT(`rules`, ',', (
+                SELECT GROUP_CONCAT(t.id) FROM (
+                    SELECT id FROM `oa_admin_rule` WHERE id = {$ruleId} OR pid = {$ruleId}
+                ) t
+            ))
+            WHERE `id` = {$superGroupId}");
+    }
+
+    public function down(): void
+    {
+        $db = $this->getAdapter()->getConnection();
+
+        // 1. 回收权限节点:先从超级权限角色 rules 中剔除,再删除节点
+        $ruleIds = $db->query("SELECT id FROM `oa_admin_rule` WHERE src LIKE 'finance/feerefund/%'")
+                      ->fetchAll(\PDO::FETCH_COLUMN);
+        if (!empty($ruleIds)) {
+            $this->removeRulesFromGroups($ruleIds);
+            $in = implode(',', array_map('intval', $ruleIds));
+            $this->execute("DELETE FROM `oa_admin_rule` WHERE id IN ({$in})");
+        }
+
+        // 2. 删除审批流程(依赖审批类型 cate_id,需先删)
+        $cateIds = $db->query("SELECT id FROM `oa_flow_cate` WHERE name='fee_refund'")
+                      ->fetchAll(\PDO::FETCH_COLUMN);
+        if (!empty($cateIds)) {
+            $in = implode(',', array_map('intval', $cateIds));
+            $this->execute("DELETE FROM `oa_flow` WHERE `cate_id` IN ({$in})");
+        }
+
+        // 3. 删除消息模板与审批类型
+        $this->execute("DELETE FROM `oa_template` WHERE `name` = 'fee_refund'");
+        $this->execute("DELETE FROM `oa_flow_cate` WHERE `name` = 'fee_refund'");
+    }
+
+    /**
+     * 从所有角色的 rules 字段中剔除指定权限节点 ID
+     *
+     * @param array<int, int|string> $ruleIds
+     */
+    protected function removeRulesFromGroups(array $ruleIds): void
+    {
+        $db      = $this->getAdapter()->getConnection();
+        $ruleIds = array_map('strval', $ruleIds);
+        $groups  = $db->query("SELECT id, rules FROM `oa_admin_group`")->fetchAll(\PDO::FETCH_ASSOC);
+        foreach ($groups as $group) {
+            $rules = array_filter(explode(',', (string) $group['rules']), static fn($v) => $v !== '');
+            $kept  = array_values(array_diff($rules, $ruleIds));
+            if (count($kept) !== count($rules)) {
+                $newRules = implode(',', $kept);
+                $this->execute("UPDATE `oa_admin_group` SET `rules` = '{$newRules}' WHERE `id` = {$group['id']}");
+            }
+        }
+    }
+}

+ 87 - 0
database/migrations/20260527110005_seed_fee_refund_cate_rule.php

@@ -0,0 +1,87 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedFeeRefundCateRule extends Migrator
+{
+    public function up(): void
+    {
+        $now = time();
+        $db  = $this->getAdapter()->getConnection();
+
+        // 动态查找「财务模块」(基础数据-财务管理)节点 ID
+        $financeMenu = $db->query("SELECT id FROM `oa_admin_rule` WHERE title='财务模块' AND module='finance' LIMIT 1")
+                          ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($financeMenu)) {
+            throw new \RuntimeException('找不到财务模块菜单节点,请确认基础数据已安装');
+        }
+        $financePid = $financeMenu[0]['id'];
+
+        // 动态查找超级权限角色 ID
+        $superGroup = $db->query("SELECT id FROM `oa_admin_group` WHERE title='超级权限角色' LIMIT 1")
+                         ->fetchAll(\PDO::FETCH_ASSOC);
+        if (empty($superGroup)) {
+            throw new \RuntimeException('找不到超级权限角色,请确认基础数据已安装');
+        }
+        $superGroupId = $superGroup[0]['id'];
+
+        // 菜单节点
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$financePid}, 'finance/feerefundcate/datalist', '费用返还类型', '费用返还类型管理', 'finance', '', 1, 55, 1, {$now})");
+
+        $ruleId = $db->lastInsertId();
+
+        // 子权限节点
+        $this->execute("INSERT INTO `oa_admin_rule`
+            (`pid`, `src`, `title`, `name`, `module`, `icon`, `menu`, `sort`, `status`, `create_time`)
+            VALUES
+            ({$ruleId}, 'finance/feerefundcate/datalist', '列表',      '费用返还类型-列表',     'finance', '', 2, 1, 1, {$now}),
+            ({$ruleId}, 'finance/feerefundcate/add',      '添加/编辑', '费用返还类型-添加编辑', 'finance', '', 2, 2, 1, {$now}),
+            ({$ruleId}, 'finance/feerefundcate/del',      '删除',      '费用返还类型-删除',     'finance', '', 2, 3, 1, {$now}),
+            ({$ruleId}, 'finance/feerefundcate/set',      '设置',      '费用返还类型-设置',     'finance', '', 2, 4, 1, {$now})");
+
+        // 授权给超级权限角色
+        $this->execute("UPDATE `oa_admin_group`
+            SET `rules` = CONCAT(`rules`, ',', (
+                SELECT GROUP_CONCAT(t.id) FROM (
+                    SELECT id FROM `oa_admin_rule` WHERE id = {$ruleId} OR pid = {$ruleId}
+                ) t
+            ))
+            WHERE `id` = {$superGroupId}");
+    }
+
+    public function down(): void
+    {
+        $db = $this->getAdapter()->getConnection();
+
+        $ruleIds = $db->query("SELECT id FROM `oa_admin_rule` WHERE src LIKE 'finance/feerefundcate/%'")
+                      ->fetchAll(\PDO::FETCH_COLUMN);
+        if (!empty($ruleIds)) {
+            $this->removeRulesFromGroups($ruleIds);
+            $in = implode(',', array_map('intval', $ruleIds));
+            $this->execute("DELETE FROM `oa_admin_rule` WHERE id IN ({$in})");
+        }
+    }
+
+    /**
+     * 从所有角色的 rules 字段中剔除指定权限节点 ID
+     *
+     * @param array<int, int|string> $ruleIds
+     */
+    protected function removeRulesFromGroups(array $ruleIds): void
+    {
+        $db      = $this->getAdapter()->getConnection();
+        $ruleIds = array_map('strval', $ruleIds);
+        $groups  = $db->query("SELECT id, rules FROM `oa_admin_group`")->fetchAll(\PDO::FETCH_ASSOC);
+        foreach ($groups as $group) {
+            $rules = array_filter(explode(',', (string) $group['rules']), static fn($v) => $v !== '');
+            $kept  = array_values(array_diff($rules, $ruleIds));
+            if (count($kept) !== count($rules)) {
+                $newRules = implode(',', $kept);
+                $this->execute("UPDATE `oa_admin_group` SET `rules` = '{$newRules}' WHERE `id` = {$group['id']}");
+            }
+        }
+    }
+}