Quellcode durchsuchen

✨ feat(finance): 新增授信&核销申请管理模块

- 新增 CreditWriteoff 控制器/模型/验证器,支持授信&核销申请的增删查改及审批流程控制。
- 新增 add / datalist / view 三个前端页面。
- 新增授信&核销申请数据表迁移 create_credit_writeoff。
- 新增数据填充迁移 seed_credit_writeoff_data。
- 注册审批流程、消息模板、权限节点及操作权限。
- 新增相关视图并优化前端交互逻辑。
lxz vor 3 Wochen
Ursprung
Commit
bdb55aa096

+ 128 - 0
app/finance/controller/CreditWriteoff.php

@@ -0,0 +1,128 @@
+<?php
+declare(strict_types=1);
+
+namespace app\finance\controller;
+
+use app\base\BaseController;
+use app\finance\model\CreditWriteoff as CreditWriteoffModel;
+use app\finance\validate\CreditWriteoffValidate;
+use think\exception\ValidateException;
+use think\facade\Db;
+use think\facade\View;
+
+class CreditWriteoff extends BaseController
+{
+    protected $model;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->model = new CreditWriteoffModel();
+    }
+
+    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(CreditWriteoffValidate::class)->scene('edit')->check($param);
+                } catch (ValidateException $e) {
+                    return to_assign(1, $e->getError());
+                }
+                $this->model->edit($param);
+            } else {
+                try {
+                    validate(CreditWriteoffValidate::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'     => '',
+                'daily_trade_vol' => '',
+                'credit_amount'   => '',
+                'return_date'     => '',
+                'remark'          => '',
+                'file_ids'        => '',
+                'check_status'    => 0,
+                'check_flow_id'   => 0,
+            ];
+            if ($id > 0) {
+                $detail = $this->model->getById($id);
+            }
+            View::assign('detail', $detail);
+            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, '错误的请求');
+    }
+}

+ 86 - 0
app/finance/model/CreditWriteoff.php

@@ -0,0 +1,86 @@
+<?php
+namespace app\finance\model;
+
+use think\Model;
+use think\facade\Db;
+
+class CreditWriteoff 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');
+        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();
+    }
+}

+ 29 - 0
app/finance/validate/CreditWriteoffValidate.php

@@ -0,0 +1,29 @@
+<?php
+namespace app\finance\validate;
+
+use think\Validate;
+
+class CreditWriteoffValidate extends Validate
+{
+    protected $rule = [
+        'customer_id'     => 'require|max:100',
+        'trade_scene'     => 'require|max:255',
+        'daily_trade_vol' => 'require|max:100',
+        'credit_amount'   => 'require|max:50',
+        'return_date'     => 'require|date',
+    ];
+
+    protected $message = [
+        'customer_id.require'     => '客户ID不能为空',
+        'trade_scene.require'     => '客户交易场景不能为空',
+        'daily_trade_vol.require' => '客户日交易量不能为空',
+        'credit_amount.require'   => '申请授信金额不能为空',
+        'return_date.require'     => '请选择归还日期',
+        'return_date.date'        => '归还日期格式不正确',
+    ];
+
+    protected $scene = [
+        'add'  => ['customer_id', 'trade_scene', 'daily_trade_vol', 'credit_amount', 'return_date'],
+        'edit' => ['customer_id', 'trade_scene', 'daily_trade_vol', 'credit_amount', 'return_date'],
+    ];
+}

+ 122 - 0
app/finance/view/creditwriteoff/add.html

@@ -0,0 +1,122 @@
+{extend name="../../base/view/common/base" /}
+{block name="body"}
+<form class="layui-form p-page" lay-filter="form-creditwriteoff">
+    <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">
+                <input type="text" name="trade_scene" class="layui-input"
+                       value="{$detail.trade_scene|default=''}"
+                       lay-verify="required" lay-reqText="请输入客户交易场景"
+                       placeholder="请输入客户交易场景">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">客户日交易量<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="daily_trade_vol" class="layui-input"
+                       value="{$detail.daily_trade_vol|default=''}"
+                       lay-verify="required" lay-reqText="请输入客户日交易量"
+                       placeholder="请输入客户日交易量">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">申请授信金额<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="credit_amount" class="layui-input"
+                       value="{$detail.credit_amount|default=''}"
+                       lay-verify="required" lay-reqText="请输入申请授信金额"
+                       placeholder="单位:USD-美元">
+            </td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">归还日期<font>*</font></td>
+            <td colspan="3">
+                <input type="text" name="return_date" id="return_date" class="layui-input" readonly
+                       value="{$detail.return_date|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, laydate = layui.laydate,
+            uploadPlus = layui.uploadPlus, oaCheck = layui.oaCheck;
+
+        oaCheck.init({ check_name: 'credit_writeoff', check_btn: 0 });
+        var fileUp = new uploadPlus();
+
+        laydate.render({
+            elem: '#return_date',
+            type: 'date',
+            trigger: 'click'
+        });
+
+        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 = 'credit_writeoff';
+                    data.field.action_id  = e.data.return_id;
+                    oaCheck.submit(data.field, checkCallback);
+                }
+            };
+            let clickbtn = $(this);
+            tool.post('/finance/creditwriteoff/add', data.field, callback, clickbtn);
+            return false;
+        });
+    }
+</script>
+{/block}

+ 106 - 0
app/finance/view/creditwriteoff/datalist.html

@@ -0,0 +1,106 @@
+{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/creditwriteoff/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_creditwriteoff" lay-filter="table_creditwriteoff"></table>
+        </div>
+    </div>
+</div>
+{/block}
+
+{block name="script"}
+<script>
+    const moduleInit = ['tool', 'oaTable'];
+    function gouguInit() {
+        var tool = layui.tool, table = layui.table, form = layui.form;
+
+        var tab = 0;
+        var tableIns = table.render({
+            elem: '#table_creditwriteoff',
+            url: '/finance/creditwriteoff/datalist',
+            where: { tab: tab },
+            page: true,
+            cols: [[
+                { field: 'id', title: 'ID', width: 80 },
+                { field: 'customer_id', title: '客户ID', minWidth: 120 },
+                { field: 'credit_amount', title: '申请授信金额(USD)', width: 150 },
+                { field: 'return_date', title: '归还日期', width: 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) {
+                        return '<a class="layui-btn layui-btn-xs" data-href="/finance/creditwriteoff/view/' + d.id + '" data-title="授信&核销申请详情" data-width="800" data-height="90%">查看</a>'
+                            + '<a class="layui-btn layui-btn-xs layui-btn-danger j-del" data-id="' + d.id + '">删除</a>';
+                    }
+                }
+            ]],
+            done: function () {
+                tool.sideOpen();
+                // 删除
+                $('body').off('click', '.j-del').on('click', '.j-del', function () {
+                    var id = $(this).data('id');
+                    layer.confirm('确认删除该授信&核销申请?', { icon: 3 }, function () {
+                        tool.delete('/finance/creditwriteoff/del', { id: id }, function () {
+                            tableIns.reload();
+                        });
+                    });
+                });
+            }
+        });
+
+        // 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}

+ 67 - 0
app/finance/view/creditwriteoff/view.html

@@ -0,0 +1,67 @@
+{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|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">客户日交易量</td>
+            <td colspan="3">{$detail.daily_trade_vol|default=''}</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">申请授信金额</td>
+            <td colspan="3">{$detail.credit_amount|default=''} USD</td>
+        </tr>
+        <tr>
+            <td class="layui-td-gray-2">归还日期</td>
+            <td colspan="3">{$detail.return_date|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: 'credit_writeoff' });
+    }
+</script>
+{/block}

+ 43 - 0
database/migrations/20260525070001_create_credit_writeoff.php

@@ -0,0 +1,43 @@
+<?php
+
+use Phinx\Db\Adapter\MysqlAdapter;
+use think\migration\Migrator;
+
+class CreateCreditWriteoff extends Migrator
+{
+    public function change(): void {
+        $table = $this->table('credit_writeoff', [
+            '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', 'string', ['limit' => 255, 'null' => false, 'default' => '', 'comment' => '客户交易场景'])
+            ->addColumn('daily_trade_vol', 'string', ['limit' => 100, 'null' => false, 'default' => '', 'comment' => '客户日交易量'])
+            ->addColumn('credit_amount', 'string', ['limit' => 50, 'null' => false, 'default' => '', 'comment' => '申请授信金额(单位:USD美元)'])
+            ->addColumn('return_date', 'string', ['limit' => 20, '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();
+    }
+}

+ 151 - 0
database/migrations/20260525070002_seed_credit_writeoff_data.php

@@ -0,0 +1,151 @@
+<?php
+
+use think\migration\Migrator;
+
+class SeedCreditWriteoffData 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
+            ('授信&核销申请', 'credit_writeoff', {$flowModuleId}, 'credit_writeoff', 'icon-shoukuanzuofei',
+             '', 51, 1, 1, 0, 1, 0,
+             1, '/finance/creditwriteoff/add', '/finance/creditwriteoff/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
+            ('授信&核销申请审批', 'credit_writeoff', 2, 0, '', '/finance/creditwriteoff/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/creditwriteoff/datalist', '授信&核销申请', '授信&核销申请', 'finance', 'icon-shoukuanzuofei', 1, 51, 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/creditwriteoff/datalist', '列表',      '授信核销申请-列表',     'finance', '', 2, 1, 1, {$now}),
+            ({$ruleId}, 'finance/creditwriteoff/add',      '添加/编辑', '授信核销申请-添加编辑', 'finance', '', 2, 2, 1, {$now}),
+            ({$ruleId}, 'finance/creditwriteoff/view',     '详情',      '授信核销申请-详情',     'finance', '', 2, 3, 1, {$now}),
+            ({$ruleId}, 'finance/creditwriteoff/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/creditwriteoff/%'")
+                      ->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='credit_writeoff'")
+                      ->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` = 'credit_writeoff'");
+        $this->execute("DELETE FROM `oa_flow_cate` WHERE `name` = 'credit_writeoff'");
+    }
+
+    /**
+     * 从所有角色的 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']}");
+            }
+        }
+    }
+}