3
2

More than 5 years have passed since last update.

STEP11:Laravel5.7 + Vue2.5 で給与明細:給与明細データをアップロードする

Posted at

いよいよ給与明細系処理の作りこみ
明細データをCSVでアップロードしてデータベースへ保存します

環境設定他関連記事はこちら
Laravel + Vue + Vuetify で業務サイト作ってみる

アップロード用画面の作成

給与明細のCSVをアップロードする画面を作成
明細が何年何月のものか、対象年月を指定してアップロードできるようにしときます

対象年月の指定はVuetifyのDatePickerを使ってみます
https://vuetifyjs.com/ja/components/date-pickers

アップロードの実処理は以前に作成したCSVアップロード用のボタンコンポーネントを再利用です

resources/js/components/Admin/PayslipComponent.vue
<template>
  <v-flex>
    <!-- 1段目 :検索?登録? -->
    <v-card xs12 class="m-3 px-3" v-show="!csvList">
      <v-card-title class="title">
        <v-icon class="pr-2">{{ $route.meta.icon }}</v-icon> {{ $route.meta.name }} {{ /* 給与明細 */ }}
        【{{ activeTab }}】
        <v-spacer></v-spacer>
        <v-btn block depressed
          :outline="activeTab == '検索'"
          :color="activeTab == '検索' ? 'primary' : 'blue-gray'"
          @click="activeTab = '検索'"
        >
          <v-icon class="pr-1">search</v-icon>検索
        </v-btn>
        <v-btn block depressed
          :outline="activeTab != '検索'"
          :color="activeTab != '検索' ? 'primary' : 'blue-gray'"
          @click="activeTab = '登録'"
        >
          <v-icon class="pr-1">cloud_upload</v-icon>登録
        </v-btn>
      </v-card-title>

      <v-layout row wrap class="mx-3 my-2">
        <!-- 共通: 対象年月(カレンダーで月を選択)-->
        <v-flex xs2 md4 lg3>
          <v-menu
            ref="menu"
            v-model="menu"
            :return-value.sync="target.ym"
            :close-on-content-click="false"
            :nudge-right="20"
            lazy transition="scale-transition" offset-y full-width max-width="290px" min-width="290px"
            show-current="true"
          >
            <v-text-field
              readonly
              clearable
              autofocus
              slot="activator"
              v-model="target.ym"
              label="対象年月"
              placeholder="明細の対象年月を選択"
              :hint="'明細の対象年月を選択' + (activeTab=='検索'?'(指定なしで全期間対象)':'')"
            ></v-text-field>
            <v-date-picker v-model="target.ym" type="month" no-title scrollable locale="ja">
              <v-spacer></v-spacer>
              <v-btn flat color="primary" @click="menu = false">Cancel</v-btn>
              <v-btn flat color="primary" @click="$refs.menu.save(target.ym)">OK</v-btn>
            </v-date-picker>
          </v-menu>
        </v-flex>
      </v-layout>

      <v-card-actions class="pb-2">
        <!-- 登録ボタン -->
        <csv-upload
          v-show="activeTab == '登録'"
          :updata="target"
          color="primary"
          icon="cloud_upload"
          @reload="reload"
          @axios-logout="$emit('axios-logout')"
          url="/api/admin/payslip/upload"
        ></csv-upload>

        <!-- 検索ボタン -->
        <v-btn block flat
          v-show="activeTab == '検索'"
          @click="getCsvPayslip"
          color="primary"
        >
          <v-icon class="pr-2">search</v-icon>検索
        </v-btn>
      </v-card-actions>
    </v-card>

    <!-- 2段目 :CSVリスト表示 -->
    <v-card xs12 class="m-3 px-3" v-if="csvList">
      <v-card-title class="title">
        <v-icon class="pr-2" @click="closeList">{{ $route.meta.icon }}</v-icon> {{ csvTitle }} {{ /* 給与明細CSV検索 */ }}
        <v-spacer></v-spacer>
        <v-spacer></v-spacer>
        <v-text-field
          v-model="search"
          prepend-icon="search"
          label="絞り込み表示"
          single-line
          hide-details
          clearable
        ></v-text-field>
        <v-icon @click="closeList" class="accent ml-5" dark>close</v-icon>
      </v-card-title>

      <v-data-table
        :headers="headers"
        :items="tabledata"
        :pagination.sync="pagination"
        :rows-per-page-items='[5,10,20,{"text":"All","value":-1}]'
        :loading="loading"
        :search="search"
        class="elevation-0 p-1"
      >
        <v-progress-linear slot="progress" color="blue" indeterminate></v-progress-linear>

        <template slot="items" slot-scope="props">
          <td class="text-xs-center" xs1>{{ (props.index + 1) + (pagination.page - 1) * pagination.rowsPerPage }}</td>
          <template v-for="n in (headers.length - 1)">
            <td :class="'text-xs-' + headers[n].align" style="white-space: nowrap;" v-text="props.item[headers[n].value]"></td>
          </template>
        </template>
      </v-data-table>
    </v-card>

  </v-flex>
</template>

<script>
  import csv_upload from './CsvUpload.vue'

  export default {
    name: 'PayslipComponent',

    components: {
      'csv-upload': csv_upload,
    },

    props: {
    },

    data: () => ({
      loading: false,
      search: '',
      pagination: { sortBy: 'created_at', descending: true, },

      // csv list
      tabledata: [],
      headers: [
        { align: 'center', sortable: false, text: 'No', },
        { align: 'center', sortable: true,  text: '年月',       value: 'ym' },
        { align: 'center', sortable: true,  text: '状態',       value: 'status' },
        { align: 'center', sortable: true,  text: 'CSV-ID',     value: 'id' },
        { align: 'center', sortable: true,  text: '対象者数',   value: 'line' },
        { align: 'center', sortable: true,  text: 'エラー数',   value: 'error' },
        { align: 'left',   sortable: true,  text: 'ファイル名', value: 'filename' },
        { align: 'left',   sortable: true,  text: '登録日時',   value: 'created_at' },
        { align: 'left',   sortable: true,  text: '公開日時',   value: 'published_at' },
        { align: 'center', sortable: false, text: 'アクション', },
      ],

      menu: false,
      activeTab: '検索',
      csvList: false,
      csvTitle: '',
      target: {
        ym: '',
      },

    }),

    created() {
      if (process.env.MIX_DEBUG) console.log('Payslip Component created.')
      this.initialize()
    },

    methods: {
      initialize() {
        // 初期表示時は現在年月を設定しておく
        this.target.ym = moment().format('YYYY-MM').toString()
      },

      reload() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component reload')
        this.getCsvPayslip()
      },

      initList() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component initList')
        this.setCsvTitle()
        this.tabledata = []
        this.csvList = true
      },

      closeList() {
        this.tabledata = []
        this.csvList = false
      },

      setCsvTitle() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component set csv title')
        this.csvTitle = ''
        if (this.target.ym) this.csvTitle += this.target.ym + ' '
        else this.csvTitle += '全期間 '
      },

      // 登録済みCSVのリストをサーバから取得する
      getCsvPayslip() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component getCsvPayslip')

        // 初期化
        this.initList()

        // 検索パラメータ設定
        var params = new URLSearchParams()
        params.append('ym', (this.target.ym ? this.target.ym : ''))

        // 検索要求
        this.loading = true
        axios.post('/api/admin/payslip/csvlist', params)

        // 検索結果[正常]
        .then( function (response) {
          this.loading = false
          if (process.env.MIX_DEBUG) console.log(response)
          if (response.data.data) {
            this.tabledata = response.data.data
            this.setStatus()
          }
        }.bind(this))

        // 検索結果[異常]
        .catch(function (error) {
          this.loading = false
          console.log(error)
          if (error.response) {
            if ([401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },

      setStatus() {
        var wk = '不明'
        for (var i=0; i<this.tabledata.length; i++) {
          if (this.tabledata[i].status) {
            wk = '不明'
            if (this.tabledata[i].status == 0) { wk = '非公開' }
            else if (this.tabledata[i].status == 1) { wk = '公開' }
            this.tabledata[i].status = wk
          }
        }
      },
    },
  }
</script>

アップロード後に自動的に検索して一覧表示するようにしてあります
しかし。。長いな。。

画面イメージ

アップロードデータを確認できるように同じ画面でタブ切り替えで検索もできるようにしてます
タブといってもボタンイメージで表示

z12.png

給与明細管理のリンク作成

アップロード用の画面を作成したので呼び出せるようにリンクを作成しておきます

resources/js/router/index.js
    import Vue from 'vue'
    import Router from 'vue-router'
    Vue.use(Router)

    //
    import example_component from '../components/ExampleComponent.vue'
    import admin_component   from '../components/AdminComponent.vue'
    import r_link            from '../components/RouterLink.vue'

    //
    Vue.component('example-component', example_component)
    Vue.component('admin-component', admin_component)
    Vue.component('r-link', r_link)

    //
    import home              from '../components/HomeComponent.vue'
    import admin_user        from '../components/Admin/UserComponent.vue'
 import admin_payslip     from '../components/Admin/PayslipComponent.vue'

    export default new Router({
      mode: 'history',
      routes: [
        { path: '/admin/user',   name: 'admin_user',    component: admin_user,    meta: {name: '社員管理', icon: 'supervisor_account'}},
        { path: '/home',         name: 'home',          component: home,          meta: {name: 'ホーム',   icon: 'home'}},
     { path: '/admin/payslip',name: 'admin_payslip', component: admin_payslip, meta: {name: '給与明細', icon: 'fa-file-invoice-dollar'}},
        { path: '*',             redirect: '/home' },
      ],
    })

作成したコンポーネントを読み込むように設定して、

resources/js/components/AdminComponent.vue
~~~
    <v-navigation-drawer v-model="drawer" clipped fixed app >
      <v-list dense>
        <r-link linkname='home'></r-link>
        <r-link linkname='admin_user'></r-link><r-link linkname='admin_payslip'></r-link>
      </v-list>
    </v-navigation-drawer>
~~~

リンクを追加します

z11.png

アイコンフォント追加

給与明細用の良いアイコンがなかったので FontAwesome を導入
追加しておきます

npm install --save-dev @fortawesome/fontawesome-free

追加したフォントを読み込むように設定

resources/js/app.js
~~~

// Font Awesome
import '@fortawesome/fontawesome-free/css/all.css'

~~~

FontAwesome はアイコン名に [ fa- ] をつけて指定すると利用できます
スタイル指定も可能
fas fa-xxxx とすると Solid(塗りつぶし)
far fa-xxxx とすると Regular(線・標準の太さ)
などなど
ただし Freeの無料版では使えないものもあるのでサイトで確認がよろしいかと
FontAwesomeのサイトの左メニューで FREE を選んでおくと無料版のアイコンのみが表示されるようになります

CSV アップロードの修正

アップロードする際に明細の「年月」情報をくっつけてアップできるようにちょっと修正です

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>

<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)があった場合に備えてファイル1つずつを非同期で処理しとく(未完)
      async onFilePicked(e) {
        if (process.env.MIX_DEBUG) console.log('CSV Upload onFilePicked')
        const files = e.target.files
        if(files[0] == undefined) return  // ファイル未選択は何もしない

        // ファイルを1つずつ送信 - 非同期処理
        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) {
            for (let key in this.updata) {
              formData.append(key, this.updata[key])
            }
          }

          // アップロード実行
          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)
            if (error.response) {
              if ([401, 419].includes(error.response.status)) {
                this.$emit('axios-logout')
              }
              else if ([422].includes(error.response.status)) {
                if (process.env.MIX_DEBUG) console.log("CSV Upload error 422")
                if (process.env.MIX_DEBUG) console.log(error.response.data.errors)
                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])
                    }
                  }
                }
              }
              else {
                alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
              }
            }
            else {
              alert('ERROR ' + error)
            }
          }.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.$emit('reload')

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

      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>

長いけど、 ★ のあたりに注目

Laravel 側のコントローラを作成

Laravel側の処理も作ります
まずはコントローラの作成

php artisan make:controller  PayslipController

スケルトン作成したコントローラに処理を作りこんでいきます
CSVファイルのアップロードとアップしたCSVファイルの一覧表示処理をば作りこみ
エラー時に Jsonで返すために JsonResponse を使ったりもしてます

app/Http/Controllers/PayslipController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;

use App\User;
use App\CsvPayslip;
use App\Payslip;
use App\Facades\Csv;
use App\Http\Requests\UploadCsvFile;

class PayslipController extends Controller
{

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

        // INIT
        $where = array();

        // 検索条件確認 - 対象年月(未指定の場合は全期間を対象とする)
        $ym = $this -> getYM($request -> all(), false);
        if (is_object($ym)) { return $ym; } // 期間エラー
        if ($ym) {
            $where['ym'] = $ym;
        }

        if ($where) {
            $CsvPayslips = CsvPayslip::where($where)->get();
        }
        else {
            $CsvPayslips = CsvPayslip::all();
        }
        return ['data' => $CsvPayslips];
    }


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

        // CSV をパース
        $file = $request -> file('csvfile');
        $rows = $this -> parse_csv($file);
        if (is_object($rows)) { return $rows; }

        // 対象年月を取得 (数字以外はすべて削除してから取得)
        $ym = $this -> getYM($request -> all());
        if (is_object($ym)) { return $ym; }

        // INIT
        $ret = array();
        $header_columns = 0;

        // 1行ずつ処理 - ループ処理でデータエラーがあっても処理は継続
        foreach ($rows as $line => $value) {
            Log::Debug(__CLASS__.':'.__FUNCTION__.' '. ($line + 1) .'/'. count($rows) .' : '. print_r($value, true));

            // INIT
            $wk = '';
            $data = array();

            // 1行目ならヘッダー情報を保存
            if ($line == 0) {
                // CSV 情報を保存
                $csv_payslip = $this -> insertCsvPayslip( $ym, $file, $value );
                if (!$csv_payslip) {
                    return new JsonResponse(['errors' => [ 'database' => '内部エラー csv payslip insert error']], 422);
                }

                // ヘッダーの項目数を取得
                $header_columns = count($value);
                continue; // ヘッダーはそのまま次の行へ
            }

            // CSV行データのカラム数チェック(ヘッダーのカラム数と違ったらエラー)
            if (count($value) != $header_columns) {
                $wk = 'ヘッダーの項目数('.$header_columns.')と行の項目数('.count($value).')が一致しません';
                $ret['errors'][] = ['line' => $line, 'message' => $wk];
                $data['error'] = $wk;
                Log::Debug(__CLASS__.':'.__FUNCTION__.' '. ($line + 1) .'/'. count($rows) .' : '. $wk);
            }

            // CSVの最初のカラムはユーザのログインIDで固定:ユーザを検索
            $data['loginid'] = trim($value['No0']);   // 対象者ログインID
            $user = User::where('loginid', $data['loginid']) -> first();

            // CSVに指定のユーザの存在チェック(ユーザが存在しなければエラー)
            if (!$user) {
                $wk = '該当社員(id: '.$data['loginid'] .')が見つかりませんでした';
                $ret['errors'][] = ['line' => $line, 'message' => $wk];
                $data['error'] = $wk;
                Log::Debug(__CLASS__.':'.__FUNCTION__.' '. ($line + 1) .'/'. count($rows) .' : '. $wk);
            }

            // CSV行データ保存設定
            $data['csv_id'] = $csv_payslip['id'];     // CsvPayslip id
            $data['line'] = $line;                    // CSV行番号
            $data['ym'] = $ym;                        // 明細年月
            $data['data'] = $value;                   // CSV行データ
            $data['user_id'] = $user['id'];           // 対象者内部ID

            // CSVの2番目のカラムは明細のファイル名:指定があればファイル名を保存
            if (trim($value['No1']) != '') {
                $data['filename'] = trim($value['No1']);
            }

            // CSV行データ保存
            Payslip::create($data);

            // CSV行カウンタ設定 - エラーあり?正常?
            if (isset($data['error'])) {
                $csv_payslip -> error ++;
            } else {
                $csv_payslip -> line ++;
                $ret['insert'][] = ['line' => $line, 'message' => $user['name'] ." の明細を登録しました."];
            }

        } // 1行ずつ処理

        // CSV行データ読み込み結果保存(正常行数、エラー数)
        $csv_payslip -> save();

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


    private function parse_csv($file)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__);

        // 拡張子チェックがうまく動かないことがあるので独自で実施
        // -- https://api.symfony.com/3.0/Symfony/Component/HttpFoundation/File/UploadedFile.html
        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, true);
        } catch (\Exception $e) {
            Log::Debug(__CLASS__.':'.__FUNCTION__.' parse Exception : '. $e -> getMessage());
            return new JsonResponse(['errors' => [
                'csvfile' => 'CSVファイルの読み込みでエラーが発生しました',
                'exception' => $e -> getMessage()
                ]]
            , 422);
        }

        // CSVレコード情報を戻す
        return $rows;
    }


    private function insertCsvPayslip($ym, $file, $value)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__);

        // CsvPayslip テーブル保存準備
        $data = array();
        $data['ym'] = $ym;
        $data['filename'] = $file -> getClientOriginalName();
        $data['header'] = $value;

        // CsvPayslip テーブル保存
        $csv_payslip = CsvPayslip::create($data);

        // 結果を戻す
        return $csv_payslip;
    }


    private function getYM($request, bool $required = true)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__);

        // 対象年月を取得 (数字以外はすべて削除してから取得)
        if (array_key_exists('ym', $request)) {
            $ym = preg_replace("/\D/", "", $request['ym']);
        }

        // 対象年月が未指定ならエラー
        else {
            return new JsonResponse(['errors' => ['target_ym1' => '対象年月を指定してください']], 422);
        }

        // 対象年月のチェックは必須?
        if ($required) {
            // 対象年月は 2010~2099年01~12月であること
            if (! preg_match('/^20([1-9]{1}[0-9]{1})(0[1-9]{1}|1[0-2]{1})$/', $ym)) {
                return new JsonResponse(['errors' => ['target_ym2' => '対象年月は2010年01月~2099年12月で指定してください']], 422);
            }
        }

        // 対象年月を戻す
        return $ym;
    }

}

アップロードしたCSVファイルの情報は App\CsvPayslip に保存
CSVファイルの中身は1行毎に App\Payslip に保存してます

CSVファイルの1カラム目は社員のログインIDを指定
CSVファイルの2カラム目は明細のファイル名を指定できるようにしておきました(訂正版などファイル名に変えたい場合に利用予定)
明細の実データは3カラム目以降にプログラム上は特に制限なしにしてます(上限はDBに入る長さまでかな?)

CSVファイルに記載のログインIDが見つからなかったりのエラーが発生しても、処理は継続して全行処理するようにしてあります

ちょっと冗長な書き方の部分も多いけど、とりあえず動くので現状はこれで良しとしときます

ルートの登録

コントローラを作ったのでルートの登録もしておきます

routes/web.php
~~~
// Admin
Route::group( ['middleware' => ['auth', 'can:admin']], function() {
 ~~~
  // Payslip
  Route::post('/api/admin/payslip/csvlist', 'PayslipController@csvlist')->name('admin/payslip/csvlist');
  Route::post('/api/admin/payslip/upload', 'PayslipController@upload')->name('admin/payslip/upload');
});
~~~

CSVファイルのアップロード用と、CSVファイルの一覧表示用です

CSVの文字コード自動判定

以前に作成したCSVサービスを改修して文字コードも自動判定できるようにしておきます(簡易判定だけど。。)
実験したところ BOM付きの場合にうまく動かなかったので手動でBOM削除も追加しときました
CSV読み込むときに削除してくれると嬉しいんだけどな(Goodby CSVでやってくれないのかしら。。)

ついでに「カラム名」でのアクセスではなくて、カラム番号でのアクセスも可能にしておきます
(明細のカラム名やカラム数は不定にしちゃったから。。)

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)
    {
        // ヘッダー指定あれば1行目にヘッダーをセット
        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 bool $headless
     * @return rows
     **/
    public function parse($file, $headless = false)
    {
        // Goodby CSVのconfig設定
        $config = new LexerConfig();
        $interpreter = new Interpreter();
        $lexer = new Lexer($config);

        // 文字コード判定 - ファイルの最初から1MB分の文字列から判定
        $str = file_get_contents($file, NULL, NULL, 0, 1024*1024);
        $enc = mb_detect_encoding($str,['UTF-8', 'SJIS-win', 'SJIS', 'JIS', 'Unicode', 'ASCII'], true);
        if ($enc === false) { $enc = 'SJIS-win'; } // 文字コード自動判定できなかったら。。SJISにしとく
        Log::Debug(__CLASS__.':'.__FUNCTION__.' CSV FILE CHARSET : '. $enc);

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

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

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

            // 最初の行はヘッダー - BOMが含まれている場合にうまく削除されていないようなので手動で削除する
            if($key == 0) {
                //$header = $value;
                $header = preg_replace("/^". pack('H*', 'EFBBBF') ."/", '', $value);
                if (!$headless) continue;    // ヘッダー要らなければ continue
            }

            // 配列化 - 2行目以降はヘッダーに沿って配列に
            //        - 元のCSV列番号[No999]でも取得できるようにしておく
            foreach ($value as $k => $v) {
                if ($headless) { $data[$key]['No'.$k] = $v; }
                else           { $data[$key][$header[$k]] = $v; }
            }
        }

        // CSVを配列で戻す
         Log::Debug(__CLASS__.':'.__FUNCTION__.' data ', $data);
        return $data;
    }
}

動作確認

いろいろ作ったのでコンパイルして実行
管理者でログインして

npm run dev
php artisan serve --host=172.16.0.100  --port=8000

登録画面を表示

z12.png

CSVファイルを用意して

loginid,filename,あああ
maple_user,,1111
maple_admin,,2222
test,test,test

アップロード

正常にアップロードされました

z15.png

CSVファイルをUTF-8 BOMあり で用意してアップロード

(ファイル内容はさっきと同じ)
※ アップロード完了後は指定年月で自動で一覧を開きます

z14.png

一応データベースの内容も確認しとく

明細CSV情報


mysql> select * from csv_payslips;
+----+--------+--------+----------------------------------------+-------------------------------------------------+------+-------+--------------+---------------------+---------------------+------------+
| id | ym     | status | filename                               | header                                          | line | error | published_at | created_at          | updated_at          | deleted_at |
+----+--------+--------+----------------------------------------+-------------------------------------------------+------+-------+--------------+---------------------+---------------------+------------+
|  1 | 201901 | 0      | 201901-明細CSVSJIS.csv             | eyJpdiI6ImJyaDRoK1VCbU9zYWhMRUNYzYzNjFlYiJ9     |    2 |     1 | NULL         | 2019-01-04 07:34:31 | 2019-01-04 07:34:32 | NULL       |
|  2 | 201901 | 0      | 201901-明細CSVUTF-8 BOM有).csv      | eyJpdiI6IkpIb0RlTjEzSFdMaTQ2cUNY2E3ZTNjZGM4YiJ9 |    2 |     1 | NULL         | 2019-01-04 07:34:43 | 2019-01-04 07:34:44 | NULL       |
+----+--------+--------+----------------------------------------+-------------------------------------------------+------+-------+--------------+---------------------+---------------------+------------+
2 rows in set (0.00 sec)

ちゃんと登録したCSVの情報が登録されているようです
ヘッダー情報も暗号化して保存されてますね(内容は見やすいように短く削ってます)

明細CSV詳細情報


mysql> select * from payslips;
+----+--------+------+--------+--------+---------+-------------+-------------------------------------------------+----------+----------+---------------------------------------------------------+----------------+---------------------+---------------------+------------+
| id | csv_id | line | ym     | status | user_id | loginid     | data                                            | filename | download | error                                                   | delete_user_id | created_at          | updated_at          | deleted_at |
+----+--------+------+--------+--------+---------+-------------+-------------------------------------------------+----------+----------+---------------------------------------------------------+----------------+---------------------+---------------------+------------+
|  1 |      1 |    1 | 201901 | 0      |       2 | maple_user  | eyJpdiI6IlA5THA2dWwNDk3ZjdjNDE1ZTM0NzgxMTY3In0= | NULL     |        0 | NULL                                                    |           NULL | 2019-01-04 07:34:32 | 2019-01-04 07:34:32 | NULL       |
|  2 |      1 |    2 | 201901 | 0      |       1 | maple_admin | eyJpdiI6IlMxZmxmbj5N2JmNzkzMmM3YzY2Y2U4NCJ9     | NULL     |        0 | NULL                                                    |           NULL | 2019-01-04 07:34:32 | 2019-01-04 07:34:32 | NULL       |
|  3 |      1 |    3 | 201901 | 0      |    NULL | test        | eyJpdiI6IjhHSUMzbV9                             | test     |        0 | 該当社員(id: test)が見つかりませんでした                |           NULL | 2019-01-04 07:34:32 | 2019-01-04 07:34:32 | NULL       |
|  4 |      2 |    1 | 201901 | 0      |       2 | maple_user  | eyJpdiI6IkpLYWd6eDhZDBjOTEzZDkwZDMyNmE1MTkifQ== | NULL     |        0 | NULL                                                    |           NULL | 2019-01-04 07:34:43 | 2019-01-04 07:34:43 | NULL       |
|  5 |      2 |    2 | 201901 | 0      |       1 | maple_admin | eyJpdiI6Iks0YWZqS02YTQ4OTIwN2FlODIyNzA0MDAifQ== | NULL     |        0 | NULL                                                    |           NULL | 2019-01-04 07:34:44 | 2019-01-04 07:34:44 | NULL       |
|  6 |      2 |    3 | 201901 | 0      |    NULL | test        | eyJpdiI6IkpmUjh6Tl9                             | test     |        0 | 該当社員(id: test)が見つかりませんでした                |           NULL | 2019-01-04 07:34:44 | 2019-01-04 07:34:44 | NULL       |
+----+--------+------+--------+--------+---------+-------------+-------------------------------------------------+----------+----------+---------------------------------------------------------+----------------+---------------------+---------------------+------------+
6 rows in set (0.00 sec)


こちらもCSVの行毎の情報が登録されているようです
データ内容もちゃんと暗号化して保存されてますね(内容は見やすいように短く削ってます)


以上
明細のCSVファイルをアップロードしてDBへ保存するところまで完成です

今回もソースはこちら
https://github.com/u9m31/u9m31/tree/step11

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2