STEP08：Laravel5.7 + Vue2.5 でユーザ一覧をCSVアップロードして一括更新

※ 2018/11/16 Laravel5.6 から 5.7 に記述を更新


Laravelのユーザテーブル情報をVueの画面からダウンロードできるようになったので
ダウンロードしたCSVファイルを更新してアップロード（インポート）もできるようにしときます
アップロードは別コンポーネントに切り出して、アップロード結果も別コンポーネントでダイアログ表示するようにしときます


環境設定他関連記事はこちら
[Laravel + Vue + Vuetify で業務サイト作ってみる](https://qiita.com/nobu-maple/items/d1e7170d62ab07890a7a)
https://qiita.com/nobu-maple/items/d1e7170d62ab07890a7a



CSVファイルの取り込みには 
[github: goodby/csv](https://github.com/goodby/csv) を使わせてもらいます

詳しい使い方は以下を参考に（皆様に多謝）
[【Laravel】Goodby CSVライブラリを使ってCSVファイルを読み込む](https://nori-life.com/laravel-csv-import-goodbycsv/)
[Qiita: 【PHP】Goodby CSVを使って高メモリ効率でCSVをパースする](https://qiita.com/tomitz/items/bc767d26f5fa56ada515)



#１．goodby/csvのインストール


```bash:
composer require goodby/csv
composer update
composer dump-autoload
```

インストール後

```json:composer.json
{
    "name": "laravel/laravel",
    "description": "The Laravel Framework.",
    "keywords": ["framework", "laravel"],
    "license": "MIT",
    "type": "project",
    "require": {
        "php": "^7.1.3",
        "fideloper/proxy": "^4.0",
★        "goodby/csv": "^1.3",
        "laravel/framework": "5.7.*",
        "laravel/tinker": "^1.0"
    },
    "require-dev": {
        "beyondcode/laravel-dump-server": "^1.0",
        "filp/whoops": "^2.0",
        "fzaninotto/faker": "^1.4",
        "mockery/mockery": "^1.0",
        "nunomaduro/collision": "^2.0",
        "phpunit/phpunit": "^7.0"
    },
～～～～
```

1.3 が入りました


#２．goodby/csvを使ってＣＳＶを読み込み

サーバのLaravel側にCSV読み込み処理を追加します


```php:app/Services/CSV.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Log;
use Response;

use Goodby\CSV\Import\Standard\Lexer;
use Goodby\CSV\Import\Standard\Interpreter;
use Goodby\CSV\Import\Standard\LexerConfig;

class Csv
{
    /**
     * CSVダウンロード
     * @param array $csv_data
     * @param array $csv_header
     * @param string $csv_filename
     * @return \Illuminate\Http\Response
     */
    public function download($csv_data, $csv_header, $csv_filename)
    {
        // ヘッダー指定あれば１行目にヘッダーをセット
        if (count($csv_header) > 0) {
            array_unshift($csv_data, $csv_header);
        }

        // ストリームでレスポンス ::
        //      vendor/laravel/framework/src/Illuminate/Routing/ResponseFactory.php
        //          streamDownload($callback, $name = null, array $headers = [], $disposition = 'attachment')
        return response() -> streamDownload(
            function () use($csv_data) {
                $file = new \SplFileObject('php://output', 'w');
                foreach ($csv_data as $row) {
                    $file->fputcsv($row);
                }
            },
            $csv_filename,
            array('Content-Type' => 'application/octet-stream')
        );
    }

    /**
     * CSVアップロード（CSV 取り込み）
     * @param file $file
     * @param array $header
     * @return rows
     **/
    public function parse($file)
    {
        // Goodby CSVのconfig設定
        $config = new LexerConfig();
        $interpreter = new Interpreter();
        $lexer = new Lexer($config);

        // CharsetをUTF-8に変換
        $config->setToCharset("UTF-8");
        $config->setFromCharset("sjis-win");

        // CSVデータをパース
        $rows = array();
        try {
            $interpreter->addObserver(function(array $row) use (&$rows) {
                $rows[] = $row;
            });
            $lexer->parse($file, $interpreter);
        } catch (\Exception $e) {
            throw $e;
        }

        // １行ずつ処理
        $data = array();
        foreach ($rows as $key => $value) {

            // 最初の行はヘッダー
            if($key == 0) {
                $header = $value;
                continue;
            }

            // 配列化 - ２行目以降はヘッダーに沿って配列に
            foreach ($value as $k => $v) {
                $data[$key][$header[$k]] = $v;
            }
        }

        // ＣＳＶを配列で戻す
        return $data;
    }
}
```

CSVの１行目はヘッダーであること
１行目のヘッダーによって配列を組み立ててます
変なファイルをアップされたらどうなることやら・・

文字コード変更も ＳＪＩＳ －＞ ＵＴＦ８ を入れちゃってるけど、macとかだとこの処理で文字壊れそうな気が。。
ちゃんと文字コード判定をしたほうがよさげだけど。。そのうち対応する。。かも？





#３．読み込んだCSVでユーザテーブルを更新

CSVファイルのアップロード確認を外だししてます（Laravelステキ）

キーにしている loginid がユーザテーブルになければユーザを追加
ユーザテーブルにすでに存在すればデータの更新を行います
データにエラーがあっても処理を継続して最後にまとめてエラー情報を戻すようにしときます


```php:app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use App\User;
use Validator;
use App\Facades\Csv;
use App\Http\Requests\UploadCsvFile;

class UserController extends Controller
{
    public function index()
    {
～～省略～～
    }


    public function download(Request $request)
    {
～～省略～～
    }


  //public function upload(Request $request)
★    public function upload(UploadCsvFile $request)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $request->all());

        // 拡張子チェックがうまく動かないことがあるので独自で実施
        // -- https://api.symfony.com/3.0/Symfony/Component/HttpFoundation/File/UploadedFile.html
        $file = $request -> file('csvfile');
        if ($file ->getClientOriginalExtension() != 'csv') {
            Log::Debug(__CLASS__.':'.__FUNCTION__.' File Name: '. $file ->getClientOriginalName());
            Log::Debug(__CLASS__.':'.__FUNCTION__.' File Extension: '. $file ->getClientOriginalExtension());
            Log::Debug(__CLASS__.':'.__FUNCTION__.' ClientMimeType: '. $file ->getClientMimeType());
            Log::Debug(__CLASS__.':'.__FUNCTION__.' MimeType: '. $file ->getMimeType());
            return new JsonResponse(['errors' => [ 'csvfile' => 'CSVファイルを指定してください']], 422);
        }

        // CSV をパース
        try {
            $rows = Csv::parse($file);
        } catch (\Exception $e) {
            Log::Debug(__CLASS__.':'.__FUNCTION__.' parse Exception : '. $e -> getMessage());
            return new JsonResponse(['errors' => [
                'csvfile' => 'CSVファイルの読み込みでエラーが発生しました',
                'exception' => $e -> getMessage()
                ]]
            , 422);
        }

        // １行ずつ処理
        $ret = array();
        foreach ($rows as $line => $value) {

            // 行データに対してのバリデート（必須・内容の確認）
            $validator = $this->validator($value);

            // データに問題があればエラーを記録 => 処理は継続
            if ($validator -> fails()) {
                foreach ($validator -> errors() -> all() as $message) {
                    Log::Debug(__CLASS__.':'.__FUNCTION__.' ERROR line('.$line.') '.$message);
                    $ret['errors'][] = ['line' => $line, 'message' => $message];
                }
                continue;
            }

            // ＣＳＶに問題がなければ 更新 or 挿入
            $user = User::where('loginid', $value['loginid'])->first();

            // 存在したら、更新
            if ($user) {
                Log::Debug(__CLASS__.':'.__FUNCTION__.' UPDATE line('.$line.') '.$value['name']);
                $user->fill($value)->save();
                $ret['update'][] = ['line' => $line, 'message' => $value['name']];
            }

            // ＤＢ未登録なら新規登録
            else {
                Log::Debug(__CLASS__.':'.__FUNCTION__.' INSERT line('.$line.') '.$value['name']);
                $value['password'] = Hash::make($value['loginid']); // とりあえず初期パスワードは loginID にしとく
                User::create($value);
                $ret['insert'][] = ['line' => $line, 'message' => $value['name']];
            }
        } // １行ずつ処理

        // 結果を戻す
        return ['import' => $ret];
    }

    public function store(Request $request)
    {
～～省略～～
    }


    public function destroy(Request $request)
    {
～～省略～～
    }


    private function storeUser(array $data)
    {
～～省略～～
    }


    private function validator(array $data)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $data);

        // ログインＩＤに許可する 「 記号 」
        //          ,-.@_  |
        $ID_KIGO = ',-.@_\\|';

        // パスワードに許可する「 記号 」
        //       !"#$%& '()*+,-. /:;<=>?@   \[  ]^_`{  |}~
        $KIGO = '!"#$%&\'()*+,-.\/:;<=>?@\\\\[\\]^_`{\\|}~';

        // 入力項目チェック（必須やら文字数やら）
        $validator = Validator::make($data, [

            // 氏名：
            'name' => [
                'required',  // 必須
                'min:2',  // 最低２文字 (中国人でも２文字はあるよね)
                'max:64',  // 最大６４文字 （アルファベットの名前だと足りないか？）
            ],

            // ログインＩＤ：　
            'loginid' => [
                'required',  // 必須
                'min:6',  // 最低６文字（a@a.aa の形でも最低６文字は必要だよね）
                'max:128',  // 最長128文字（なんとなく）
                'unique:users,loginid,'.$data['loginid'].',loginid',  // 同じログインIDは登録不可
                'regex:/[a-zA-Z\d'.$ID_KIGO.']+\z/',
                // 英小文字(a-z) or 英大文字(A-Z)、数字(\d)、記号($ID_KIGO)以外の文字はエラー
            ],

            // パスワード：
            'pass' => [
                'nullable',  // 空でもＯＫ
                'min:4',  // 最低４文字
                'max:128',  // 最長128文字（なんとなく）
                'regex:/\A(?=.*?[a-zA-Z])(?=.*?\d)(?=.*?['.$KIGO.'])[a-zA-Z\d'.$KIGO.']+\z/',
                // 必ず英小文字(a-z)or英大文字(A-Z)、数字(\d)、記号($KIGO)を１文字含む(\A)こと
            ],

            // 権限：
            'role' => [
                'nullable',  // 空でもＯＫ
                'numeric',   // 数値であること
                Rule::in([5, 10]),  // role値は 5 か 10 であること
            ],
        ]);

        // チェック結果を戻す
        return $validator;
    }
}
```

★の部分

```php:
　function upload(Request $request) 
　　　　　　　　　　　↓
　function upload(UploadCsvFile $request)
```

と、Request を UploadCsvFile に書き換えておくと、
チェック処理をUploadCsvFileが自動的に行って
チェック結果OKのものだけが UserController@upload にわたる仕組み
Laravel ステキ

～～省略～～ のところは github のソースを参照のこと



## ルートも追加

```php:routes/web.php
～～
   Route::post('/api/admin/user/destroy', 'UserController@destroy')->name('admin/user/destroy');
   Route::post('/api/admin/user/download', 'UserController@download')->name('admin/user/download');
★  Route::post('/api/admin/user/upload', 'UserController@upload')->name('admin/user/upload');
 });
～～
```




#４．アップロードファイルのバリデーション

CSVファイルのアップロード時に自動的にチェックして、エラーなら自動的に処理をブラウザに戻します

```php:app/Http/Requests/UploadCsvFile.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UploadCsvFile extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
           'csvfile' => [
               'required',  // 必須
               'file',  // ファイルであること
               'mimetypes:text/plain',  // ファイルタイプは text であること
            ],
        ];
    }
}

```


## バリデーションメッセージの追加

チェックエラー時のメッセージを日本語で追加しときます

```php:resources/lang/ja/validation.php
～～～省略～～～
    /*
    |--------------------------------------------------------------------------
    | Custom Validation Language Lines
    |--------------------------------------------------------------------------
    |
    | Here you may specify custom validation messages for attributes using the
    | convention "attribute.rule" to name the lines. This makes it quick to
    | specify a specific custom language line for a given attribute rule.
    |
    */

    'custom' => [
        'attribute-name' => [
            'rule-name' => 'custom-message',
        ],
        'pass' => [
            'regex' => '必ず英文字と数字、記号を１文字含むこと',
        ],
        'role' => [
            'in' => ':attributeは 5 か 10 を指定してください',
        ],
★        'csvfile' => [
            'required' => 'ファイルを選択してください。',
            'file' => 'ファイルアップロードに失敗しました。',
            'mimetypes' => 'ファイル形式が不正です(CSVファイルを選択してください）',
        ],
    ],
～～～省略～～～
```







#５．Vueのファイルアップロードコンポーネント

ファイルのダウンロードと同じく、アップロードもコンポーネントにして処理を閉じ込めときます
ちょっと長いのでまずは HTML 部分をば

```html:resources/js/components/Admin/CsvUpload.vue
<template>
  <v-btn block flat
    :color="color ? color : 'primary'"
    :loading="csvuploading"
    :disabled="csvuploading"
    @click="$refs.input_csvup.click()"
  >
    <v-icon dark class="mr-1">{{icon ? icon : 'cloud_upload' }}</v-icon> {{title ? title : 'CSV アップロード'}}
    <v-progress-circular slot="csvuploading" indeterminate color="primary" dark></v-progress-circular>

    <input
      name="file"
      :value="csvupfile"
      type="file"
      style="display: none"
      ref="input_csvup"
      accept=".csv,.txt"
      :multiple="multiple"
      @change="onFilePicked"
    >
    <upload-dialog ref="UploadDialog"></upload-dialog>
  </v-btn>
</template>
```


ファイルの選択時に自動的にアップロード処理を実行させるためちょっとトリッキー？なこともしてます
検証結果ではうまく動いているけど、環境によってはアップロードできないこともあるかも。。

1. アップロードボタンを押すと "<input type=file"　を実行してファイル選択ダイアログを開く
2. ファイル選択ダイアログで何らかの動き(change)があると関数(onFilePicked)を呼ぶ



次に Javascript部分

```html:resources/js/components/Admin/CsvUpload.vue
<script>
  import upload_dialog from './CsvUploadDialog.vue'

  export default {
    name: 'CSVUpload',

    components: {
      'upload-dialog': upload_dialog,
    },

    props: {
      color: String,
      icon: String,
      title: String,
      url: String,
      multiple: String,
      updata: Object,
    },

    data: () => ({
      csvuploading: false,
      csvupfile: null,
    }),

    created() {
      if (process.env.MIX_DEBUG) console.log('CSV Upload Btn created.')
    },

    methods: {
      // アップロードボタンを押すと <input type=file>　を実行してファイル選択ダイアログを開く
      // ファイル選択ダイアログで何らかの動き(change)がるとこの関数(onFilePicked)が呼ばれる
      // 複数ファイル選択の指示(multple)があった場合に備えてファイル１つずつを非同期で処理しとく
      async onFilePicked(e) {
        if (process.env.MIX_DEBUG) console.log('CSV Upload onFilePicked')
        const files = e.target.files
        if(files[0] == undefined) return  // ファイル未選択は何もしない

        // ファイルを１つずつ送信 - 非同期処理
        for (var i=0 ; i<files.length ; i++) {
          if (process.env.MIX_DEBUG) console.log("FILE:" + files[i].name + " (" + files[i].size + " byte)")
          await this.csvupload(files[i])
        }
      },

      csvupload(file) {
        return new Promise((resolve, reject) => {
          if (process.env.MIX_DEBUG) console.log('CSV Upload csv upload')
          var config = {
            headers: {'Content-Type': 'multipart/form-data'}
          }

          // 送信ファイル設定
          var formData = new FormData()
          formData.append('csvfile', file)

          // 追加送信パラメータ設定（指定があれば）
          if (this.updata) {
            formData.append(this.updata.key, this.updata.value)
          }

          // アップロード実行
          this.csvuploading = true
          axios.post(this.url, formData, config)

          // アップロード 正常
          .then( function (response) {
            this.csvuploading = false
            if (process.env.MIX_DEBUG) console.log("CSV Upload success")
            if (process.env.MIX_DEBUG) console.log(response.data)
            this.resultDialog(file, response.data.import)
          }.bind(this))

          // アップロード 異常
          .catch(function (error) {
            this.csvuploading = false
            if (process.env.MIX_DEBUG) console.log("CSV Upload error")
            if (process.env.MIX_DEBUG) console.log(error.response)
            if (error.response && [401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else if (error.response && [422].includes(error.response.status)) {
              if (error.response.data.errors) {
                for (let key in error.response.data.errors) {
                  if (error.response.data.errors[key]) {
                    alert(file.name + ' : ' + error.response.data.errors[key])
                  }
                }
              }
            }
          }.bind(this))

        return resolve(file)
        })
      },

      resultDialog(file, data) {
        var res = []
        // ERROR
        res.error = ''
        if (data.errors) {
          res.error = this.getResult(data.errors, 'データエラー: ', ' 箇所')
        }

        // UPDATE
        res.result = ''
        if (data.update) {
          res.result = this.getResult(data.update, '更新 : ', ' レコード')
          res.result += '\n'
        }

        // INSERT
        if (data.insert) {
          res.result += this.getResult(data.insert, '新規 : ', ' レコード')
        }

        // ダイアログを開く
        this.$refs.UploadDialog.open(file, res)

        //  一覧を再読み込み
        this.$emit('reload')
      },

      getResult(data, comment1, comment2) {
        var res = comment1 + data.length + comment2 + '\n'
        for (var i=0; i<data.length; i++) {
          res += '　　' + data[i].line + ' 行目 : ' + data[i].message + '\n'
        }
        return res
      },
    },
  }
</script>

```

ファイルをサーバに送信して、送信結果を受け取ると内容をまとめてダイアログに表示してます




#６．Vueのファイルアップロード結果表示ダイアログコンポーネント

ファイルアップ結果を表示するダイアログです

```html:resources/js/components/Admin/CsvUploadDialog.vue
<template>
  <transition name="fade">
  <v-dialog v-model="dialog" max-width="650px" persistent>
      <v-card>
      <v-toolbar :color="color ? color : 'primary'" dark>
        <v-toolbar-title>
          <v-icon class="pb-1">{{ icon ? icon : 'cloud_upload' }}</v-icon>
          {{ title ? title : 'CSV アップロード'}}
        </v-toolbar-title>
      </v-toolbar>

        <v-card-text>
          <h2 class="headline">{{filename}}</h2>
          <v-divider></v-divider>
          <v-alert v-model="error.length != 0"  type="error"   outline><pre class="error--text">{{error}}</pre></v-alert>
          <v-alert v-model="result.length != 0" type="success" outline><pre>{{result}}</pre></v-alert>
        </v-card-text>

      <v-card-actions>
        <v-btn color="gray darken-1" flat block @click.native="close">閉じる</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
  </transition>
</template>

<script>
  export default {
    name: 'CsvUploadDialog',

    props: {
      color: String,
      icon: String,
      title: String,
    },

    data: () => ({
      dialog: false,
      result: '',
      error: '',
      filename: '',
    }),

    created() {
      if (process.env.MIX_DEBUG) console.log('Csv Upload Dialog created.')
    },

    methods: {
      close() {
        if (process.env.MIX_DEBUG) console.log("Csv Upload Dialog func close")
        this.dialog = false
      },

      open(file, data) {
        if (process.env.MIX_DEBUG) console.log("Csv Upload Dialog func open")
        this.dialog = true
        this.filename = file.name
        this.result = ''
        this.error = ''
        if (data.result) this.result = data.result
        if (data.error) this.error = data.error
      },
    },
  }
</script>
```


受け取った情報をそのまま表示しているだけですね



#７．CSVアップロードボタンを組み込み

ユーザ一覧画面にアップロードボタンを組み込みます

ソースは抜粋の該当箇所のみ記載。全文は githubを参照

```html:resources/js/components/Admin/UserComponent.vue
<template>
～～～省略～～～
      <v-card-actions>
        <v-btn flat block color="primary" @click="dialogOpen(null)"><v-icon>person_add</v-icon>新規追加</v-btn>
        <v-spacer></v-spacer>
        <csv-download url="/api/admin/user/download" color="primary" @axios-logout="$emit('axios-logout')"></csv-download>
★        <csv-upload   url="/api/admin/user/upload" multiple="false" @reload="reload"  @axios-logout="$emit('axios-logout')"></csv-upload>
      </v-card-actions>
    </v-card>
  </v-flex>
</template>

<script>
  import user_dialog from './UserDialog.vue'
  import csv_download from './CsvDownload.vue'
  import csv_upload from './CsvUpload.vue'

  export default {
    name: 'UserComponent',

    components: {
      'user-dialog': user_dialog,
      'csv-download': csv_download,
      'csv-upload': csv_upload,
    },

～～～省略～～～
</script>
```







#８．動作確認

用意したCSVファイル

```text:
loginid,name,role
takahashi.naoki@example.org,鈴木 ヨハネ 康弘,5
yakobu@example.com,ヤコブ 治,10
p,p,1
```




##アップロードボタン
![z01.png](https://qiita-image-store.s3.amazonaws.com/0/153259/f62c07e3-ba99-6fdc-07f1-138bdd60399b.png)



##アップロード実行
![z02.png](https://qiita-image-store.s3.amazonaws.com/0/153259/d4ea10b9-8fb8-a80b-cbde-8519da5ae916.png)



新規登録も、更新も、エラー判定もうまく動いているようです


以上
CSVアップロードして一括更新ができました


ソースはこちら
https://github.com/u9m31/u9m31/tree/step08