一個通用的匯入匯出excel的思路

nicowang發表於2023-12-14

本文中的程式碼示例都使用的Yii2,其他框架可能不適用,不過可以參考下思路。

本人經常給企業做專案,excel匯入匯出的需求實在是不少,最近又收到了excel匯出的一個需求,看專案組的小夥又是焦頭爛額的,決定最佳化一下實現一個通用的匯入匯出的方法。由於精力有限,本文僅提供一個大概的思路,有些功能還未能完全實現。

當前匯入匯出的一些問題

下面列出了多年了,系統中的匯入匯出功能的痛點和解決的方案:

問題 解決思路
匯出的excel如果客戶要調整欄位,非常麻煩,因為要依賴於硬編碼,而且要同時改匯入模板 可以根據欄位動態匯出excel模板
具備匯出功能的介面遍佈系統各處,難以統一管理 所有匯出的地方應該一個地方統一管理,動態開關
匯入的模板不是動態生成的,每次欄位修改都需要重新上傳模板 空白模板應該由程式動態生成
使用者無法確定匯入的內容範圍,同時無法定位具體的錯誤行數,無法實現部分匯入功能(即只匯入資料驗證正確的資料,不匯入錯誤的) 提供匯入預覽功能
研發經驗無法積累,每做一個匯出功能都是重新開發 提供通用方法給前端,長期維護一套匯出程式碼
使用者匯入失敗時資料不入庫,只能使用者本人發excel過來發現問題 excel匯入時,資料校驗失敗的也應儲存,方便查證問題
資料量大時,匯出時間太久 匯出與下載應該分離開
模版更新後,使用者使用舊版模版匯入時報錯 應提供模版版本檢測

而其中還需要注意的就是匯入的本質其實就是表單,應該跟頁面上的表單驗證走同一個方法同等的校驗,而不是單獨校驗,導致兩邊的校驗規則不一(這個實際中遇到過,匯入時直接運算元據庫,不做同等導致比頁面上的表單許可權大,產生錯誤),匯出的本質就是從資料庫顯示資料而已。

表設計

為了解決上面的問題,我想到的思路就是利用資料庫記錄匯入匯出的欄位和匯入的日誌。
這裡面我用三個表實現這個需求:

1 匯入日誌表

這個表格比較簡單,phase記錄一個uuid,一次匯入動作記錄一次,field對應excel中的字母列,row對應行數,value就是值,category用於區分不同的匯入模版:

2 活動記錄表

這個表也非常簡單,excel_name這裡記錄的就是excel檔案的名稱,parent_id sheet_name欄位這裡是考慮到了匯出多個sheet到一個excel中的需求。重點關注model欄位,這個是記錄Yii active record表的名稱空間的路徑欄位。

3 欄位規則表

這個表比較關鍵,這裡記錄了匯入匯出的規則。考慮匯出的話,我們使用field欄位作為字母列,original_field是模型的動態屬性,同時因為會有關聯資料所以我們應該支援gmm->project_id這種寫法,意思就是取關聯物件中的某一屬性。

程式碼實現

下面是一些程式碼實現的示例,重點提供一個思路,有些考慮還不到位。,

匯出空白模板方法

這個是讀取資料庫中設定的規則,匯出一個空白模板供使用者填入資訊,同時設定了一些簡單的樣式,例如合併單元格顯示錶格標題,有資料的列都是具備邊框。

public function actionTemplate()
    {
        // 查到規則表 是否存在
        $params = Yii::$app->request->get();
        $model = GmmImportField::find()->where(['category'=>$params['category'], 'type' => 1])->all();
        $table = GmmImport::find()->where(['category'=>$params['category']])->one();
        if(!$table){
            throw new ServerErrorHttpException('未設定匯入模版');
        }

        $spreadsheet = new Spreadsheet();
        $sheet = $spreadsheet->getActiveSheet();
        $sheet->getRowDimension(1)->setRowHeight(40);
        // 設定大標題的樣式
        $ATitle = $sheet->getCell('A1');                 /
        $ATitle->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER)->setVertical(Alignment::VERTICAL_CENTER);        // 內容水平居中
        $ATitle->getStyle('A1')->getFont()->setSize(22)->setBold(true);                              // 字型大小,加粗
        $ATitle->setValue($table->excel_name);

        // 設定有資料的列都增加邊框,同時設定prompt欄位
        $col_start = 'A';
        foreach($model as $index=>$item){
            $sheet->setCellValue($item->field.'2', $item->label);
            $validation = ($sheet->getCell($item->field.'2'))->getDataValidation();
            $validation->setShowInputMessage(true);
            $validation->setPrompt($item->prompt);
            $column = $sheet->getColumnDimension($item->field);
            $column->setWidth(20);
            if($index==0){
                $col_start = 'A';
            }else{
                $col_start++;
            }
        }

        // 設定邊框
        $sheet->getStyle('A1:'.$col_start.'22')->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
        $sheet->mergeCells('A1:'.$col_start.'1');            // 合併單元格

        // 操作完成匯出excel下載到瀏覽器
        $writer = new Xlsx($spreadsheet);
        $filename = $table->excel_name.".xlsx";
        header('Content-Type:application/vnd.ms-excel');
        header('Content-Disposition:attachment;filename=' . $filename);
        header('Cache-Control:max-age=0');
        $writer->save('php://output');

        $spreadsheet->disconnectWorksheets();
        unset($spreadsheet);
        exit;
    }

匯入預覽功能的實現

這部分程式碼中,我們將實現兩個功能,一個是將前端傳來的excel先暫存到匯入記錄表中,先不論資料驗證透過與否全部入庫,同時將這些資料按照返回給前端,我們這邊裡商定的是使用and design的表格的資料格式。

    /**
     * 匯入預覽
     * 存入匯入記錄表中,讀取出資料
     */
    public function actionImportPreSheet()
    {
        $params = Yii::$app->request->get();
        $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
        $spreadsheet = $reader->load($_FILES['file']['tmp_name']);

        $field_group = GmmImport::find()->where(['category'=>$params['category']])->one();
        if($field_group){
            $field_group_child = GmmImport::find()->where(['parent_id'=>$field_group->id])->orWhere(['id'=>$field_group->id])->all();
        }else{
            $field_group_child = $field_group;
        }

        $batchArray = [];

        # 期數
        $phase = Uuid::uuid4()->toString();

        foreach ($field_group_child as $k=>$v) {
            $fields=GmmImportField::findAll(['category'=>$v->category, 'type' => 1]);
            $last_letter= 'A';
            foreach($fields as $field){
                $last_letter ++;
            }
            $spreadsheet->setActiveSheetIndex($k);
            $sheetDataCount = $spreadsheet->getActiveSheet()->toArray();
            $last_row =count($sheetDataCount);
            $sheetData = $spreadsheet->getActiveSheet()->rangeToArray(
                'A3:'.$last_letter.$last_row,
                null,        // Value that should be returned for empty cells
            true,        // Should formulas be calculated (the equivalent of getCalculatedValue() for each cell)
            true,        // Should values be formatted (the equivalent of getFormattedValue() for each cell)
            true
            );
            $fields=[];
            foreach (array_values($sheetData)[0]  as $key=>$item) {
                $fields[]=$key;
            }
            foreach ($sheetData as $key =>$item) {
                $item_arr = array_values($item);
                if (empty(array_filter($item_arr))) {
                    continue;
                }
                $num =0;
                foreach ($fields as $field) {
                    $batchArray[] =[
                    'field'=>$field,
                    'value'=>$item_arr[$num],
                    'row'=>$key,
                    'phase'=>$phase,
                    'operator_id'=>Yii::$app->user->id,
                    'category'=>$v->category,
                ];
                    $num +=1;
                }
            }
        }


        Yii::$app->db->createCommand()->batchInsert('gmm_import_model_log', ['field', 'value','row','phase','operator_id','category'], $batchArray)->execute();

        $rows =  GmmImportLog::findAll(['phase'=>$phase]);
        $rows_arr =  ArrayHelper::index(ArrayHelper::toArray($rows),null,'category');
        $dataSourceArr = [];
        foreach ($rows_arr as $category => $sheet_item) {
            $sheet_model = GmmImport::findOne(['category'=>$category]);

            $row =  ArrayHelper::index(ArrayHelper::toArray($sheet_item), null, 'row');
            $dataSourceArrOne = [];
            foreach ($row as $item) {
                $dataSource = [];
                foreach ($item as $v) {
                    $dataSource[$v['field']] =$v['value'];
                }
                $multi_rows_arr[] = $dataSource;
            }
            $dataSourceArrOne['dataSource']=$multi_rows_arr;
            $dataSourceArrOne['category'] = $sheet_item[0]['category'];
            $dataSourceArrOne['sheet_name'] = $sheet_model->sheet_name;
            $dataSourceArr[] = $dataSourceArrOne;
        }

        $columns=GmmImportFieldIndex::find()->where(['like','category',$params['category'],'type'=>1])->orderBy(['field'=>SORT_ASC])->all();
        $columns_arr =  ArrayHelper::index(ArrayHelper::toArray($columns),null,'category');
        foreach($dataSourceArr as $key =>&$item){
            $dataSourceArr[$key]['columns'] = $columns_arr[$item['category']];
            unset($item['category']);
        }
        return $dataSourceArr;
        return [
                'dataSource'=>$dataSourceArr,
                'columns'=>$columns,
                'phase'=>$phase
        ];

    }

實際匯入功能

由於上面我們將匯入預覽和真正的匯入分開了,這裡還需要實現一個真正往業務資料庫中寫入資料的方法

    public function actionImportConfirm()
    {
        $params = Yii::$app->request->post();
        $rows = GmmImportLog::findAll(['phase'=>$params['phase']]);
        if($rows == null){
            throw new BadRequestHttpException('找不到匯入實體');
        }
        $rows_arr =  ArrayHelper::index(ArrayHelper::toArray($rows),null,'row');
        $dataSourceArr = [];
        foreach($rows_arr as $item){
                $dataSource = [];
                foreach($item as $v){
                    $dataSource[$v['target_field']] =$v['value'];

                }
                $dataSourceArr[] = $dataSource;
        }
        $target_model = GmmImport::findOne(['category'=>$rows[0]->category]);
        $target_model_class = $target_model->model;
        foreach($dataSourceArr as $item){
            if ($item['UUID']) {
                $model = $target_model_class::find()->where(['UUID' => $item['UUID']])->one();
                if(!$model){
                    throw new BadRequestHttpException('未找到對應UUID');
                }
            } else {
                $model = new $target_model_class;
                $item['UUID'] = Uuid::uuid4()->toString();
            }
            $model->scen = 'import';
            $model->load($item,'');
            if(!$model->save()){
                throw new BadRequestHttpException(implode(' ', $model->getFirstErrors()));
            }
        }
        return true;
    }

這裡是excel的匯出功能,目前實現的是單個sheet的匯出,多個sheet匯出功能也類似,但是還不是非常完善就不貼了。注意這裡匯出時,仍需要增加上匯出空白模板時設定的必填項規則和下拉選項等,因為有時使用者需要匯出一組資料,然後在這組資料上面改一下,再匯入回去,這也要支援。

    public function actionExportExcel()
    {
        $params = Yii::$app->request->get();
        $model = GmmImportField::find()->where(['category'=>$params['category'],'type'=>1])->all();
        $table = GmmImport::find()->where(['category'=>$params['category']])->one();
        if(!$table){
            throw new ServerErrorHttpException('未設定匯出模版');
        }
        $spreadsheet = new Spreadsheet();
        $sheet = $spreadsheet->getActiveSheet();
        $sheet->getRowDimension(1)->setRowHeight(40);
        // 設定標題
        $ATitle = $sheet->getCell('A1');                 // 獲取單元格
        $ATitle->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER)->setVertical(Alignment::VERTICAL_CENTER);        // 內容水平居中
        $ATitle->getStyle('A1')->getFont()->setSize(22)->setBold(true);                              // 字型大小,加粗
        $ATitle->setValue($table->excel_name);

        $col_start = 'A';
        foreach($model as $index=>$item){
            $sheet->setCellValue($item->field.'2', $item->label??'');
            $validation = ($sheet->getCell($item->field.'2'))->getDataValidation();
            $validation->setShowInputMessage(true);
            if(!empty($item->prompt)){
                $validation->setPrompt($item->prompt);
            }
            $column = $sheet->getColumnDimension($item->field);
            $column->setWidth(20);
            if($index==0){
                $col_start = 'A';
            }else{
                $col_start++;
            }
        }
        $field_list = ArrayHelper::toArray($model);
        // $field_arr = array_column($field_list,'original_field');

        $field_arr =[];
        foreach($field_list as $k=>$item){
            $field_arr = array_merge($field_arr,[trim($item['original_field'])=>trim($item['field'])]);
        }

        $rows = $table->model::findItems();

        $count = count($rows);
        $count = $count+2;
        $start_row = 3;
        foreach($rows as $index => $item_obj){
            $items= $item_obj->toArray(array_keys($field_arr));
            foreach($items as $k=>$v){
                $sheet->setCellValue($field_arr[$k].$start_row, $v);
                $sheet->setCellValue($field_arr['row_number'].$start_row, $index + 1);
            }
            $start_row ++;
        }

        // 設定邊框
        $sheet->getStyle('A1:'.$col_start.$count)->getBorders()->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
        $sheet->mergeCells('A1:'.$col_start.'1');            // 合併單元格

        $this->outputFile($spreadsheet,$table->excel_name.".xlsx");
          $writer = new Xlsx($spreadsheet);
        header('Content-Type:application/vnd.ms-excel');
        header('Content-Disposition:attachment;filename=' . $filename);
        header('Cache-Control:max-age=0');
        $writer->save('php://output');

        $spreadsheet->disconnectWorksheets();
        unset($spreadsheet);
        exit;
    }

模型的實現

這裡面我為了簡潔,刪減了大部分欄位,其中需要給外注意的是這裡面實現了一個介面,這個介面裡面要求每個模型都需要具備兩個方法getId()findItems()getId()是獲取原始表的主鍵ID,findItems可以設定查詢條件,例如要查詢那些特定的資料等等。

<?php
namespace app\models;
class  GmmCrawlProject extends AreaProject implements GmmPushTemplate
{
    public function fields()
    {
        return [
            'STAGE_ID',
        ];
    }

    public function getSTAGE_ID()
    {
        return $this->statusCode->code;
    }

    public function getId()
    {
        return $this->id;
    }

    public static function findItems($ids)
    {
       return  self::find()->where(['lev'=>4,'status'=>1,'project_type'=>[1,2],'id'=>$ids]);
    }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章