LoginSignup
5
8

More than 5 years have passed since last update.

STEP13:Laravel5.7 + Vue2.5 で給与明細:給与明細のPDFを自動生成する(GitHubで公開中)

Posted at

アップロードしたCSVから社員の給与明細PDFファイルを自動生成してみます

CSVで100人分のデータをアップすれぱ、100人分の給与明細PDFが1発で出来上がります
明細のレイアウトはHTML(LaravelのBlade)で作るので自由に変更可能

明細の「項目名」はCSVで指定できるようにするので、特定月のみしか使わないような項目を毎月表示させる必要もありません
(年末調整とか賞与とか。。)

社員が自分の明細を見るユーザ側の「給与明細の電子交付」機能は別の記事で書く予定
この記事は管理者側の機能のみです

PDFの生成は TCPDF を利用
 利用方法を調査した記事はこちら
 STEP09:Laravel5.7 でデータを埋め込んだPDFを生成する

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

今回は、記事がすごく長いです。。
ソースが長すぎなんですよね。。。

CSVレイアウト

CSVは1行目にヘッダー情報を書き込み
このヘッダー情報がそのまま明細の「項目名」として出力されます
2行目以降に明細のデータとして作成すること
最大行数はプログラム上で特に決めてないです。。。が、PHPのアップロードファイルサイズ指定の規制に引っかからない範囲で
(upload_max_filesizeとか)

CSVの各行の項目は以下のように作成すること
1カラム目に「誰の」明細かということで社員を特定するIDとしてログインIDを指定。 これ必須
2カラム目に「ファイル名」を指定。 通常は空にしておくとデフォルトのファイル名になるけど、例えば内容を修正した後にアップした場合に「訂正版」などの名前を付けたい場合に指定する。
3カラム目以降は明細の金額やら時間やらの情報を詰め込むこと
最大カラム数もプログラム上で特には決めてないです。。。が、明細情報は暗号化してDBに保存するのでDB制限がかかるかな
(MySQL の Text型 なので 65535バイトまでかな? 足りない場合は LongText型とかにしたら4GBまで増やせるはず)

サンプル(表)

loginID file 総支給額 基本給 交通費 支給額合計 雇用保険料 健康保険料 厚生年金保険料 控除額合計
1234567 1,000 2,000 3,000 4,000 5,000 6,000 7,000 8,000
ABCDEFG 補正 1,000 2,000 3,000 4,000 5,000 6,000 7,000 8,000

3カラム目の「総支給額」からが明細データ

サンプル(実ファイル)
"loginID","file","総支給額","基本給","交通費","支給額合計","雇用保険料","健康保険料","厚生年金保険料","控除額合計"
"1234567","","1,000","2,000","3,000","4,000","5,000","6,000","7,000","8,000"
"ABCDEFG","補正","1,000","2,000","3,000","4,000","5,000","6,000","7,000","8,000"

↑↑
こんな感じで作成

給与明細レイアウト

Bladeでレイアウトを組みます
とりあえずどこかのテンプレートサイトで見つけてきたレイアウトっぽいのを作ってみました

z31.png

resources/views/m31_02.blade.php
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
  .w25  { width: 25%; }
  .w50  { width: 50%; }
  .w75  { width: 75%; }
  .w100 { width: 100%; }
  .left   { text-align: left; }
  .center { text-align: center; }
  .right  { text-align: right; }
  .bk_white   { background-color: #fff; }
  .bk_primary { background-color: #3490dc; }
  .bk_table   { background-color: #eee; }
  .font_S { font-size: 8; }
  .font_M { font-size: 10; }
  .font_L { font-size: 12; }
</style>
</head>
<body class="bk_white"
  ><div
   ><table border="0" width="100%" cellpadding="10" cellspacing="0">
      <!-- タイトル:社名+給与明細  対象年月-->
      <tr class="bk_primary font_L">
        <td class="w75 left">@isset($company) {{ $company }} @else 会社名不明 @endisset  給与明細</td>
        <td class="w25 right">@isset($ym) {{ $ym }} @else 対象年月不明 @endisset </td>
      </tr>
    </table>
  </div>

  <div
   ><table border="1" width="100%" cellpadding="2" cellspacing="0">
      <tr class="font_M">
        <!-- 氏名 -->
        <td class="w25 left   bk_primary">&nbsp;&nbsp;氏名</td>
        <td class="w25 center bk_white  ">@isset($name) {{ $name }} @else 氏名不明 @endisset </td>
        <!-- 総支給額 : CSVの3カラム目(C)のデータ-->
        <td class="w25 left   bk_primary">&nbsp;&nbsp;@isset($head[3]) {{ $head[3]  }} @endisset </td>
        <td class="w25 right  bk_white  ">@isset($data[3]) {{ $data[3]  }} @endisset </td>
      </tr>
    </table>
  </div>

  <div
   ><table border="0" width="100%" cellpadding="0" cellspacing="2">
      <tr>
      <td class="w50"
       ><table border="1" width="100%" cellpadding="2" cellspacint="0">
          <tr class="bk_primary font_M">
            <td class="w100 center font_M" colspan="2">支 給</td>
          </tr>
          <!-- 支給の明細部分 : CSVの4カラム目(D)~14カラム目(N)までのデータ -->
          @for ($i=4; $i<=14; $i++)
            @if ($i % 2 == 0)
              <tr class="bk_white font_S">
            @else
              <tr class="bk_table font_S">
            @endif
                <td class="w50 left ">&nbsp;@isset($head[$i]) {{ $head[$i] }} @endisset </td>
                <td class="w50 right">      @isset($data[$i]) {{ $data[$i] }} @endisset &nbsp;</td>
              </tr>
          @endfor

          <!-- 支給の合計部分 : CSVの15カラム目(O)のデータ-->
          <tr class="font_M">
            <td class="w50 left bk_primary">&nbsp;&nbsp;@isset($head[15]) {{ $head[15] }} @endisset </td>
            <td class="w50 right">                      @isset($data[15]) {{ $data[15] }} @endisset &nbsp;</td>
          </tr>
        </table>
      </td>

      <td class="w50"
       ><table border="1" width="100%" cellpadding="2" cellspacint="0">
          <tr class="bk_primary font_M">
            <td class="w100 center font_M" colspan="2">控 除</td>
          </tr>
          <!-- 控除の明細部分 : CSVの16カラム目(P)~26カラム目(Z)までのデータ-->
          @for ($i=16; $i<=26; $i++)
            @if ($i % 2 == 0)
              <tr class="bk_white font_S">
            @else
              <tr class="bk_table font_S">
            @endif
                <td class="w50 left ">&nbsp;@isset($head[$i]) {{ $head[$i] }} @endisset </td>
                <td class="w50 right">      @isset($data[$i]) {{ $data[$i] }} @endisset &nbsp;</td>
              </tr>
          @endfor

          <!-- 控除の合計部分 : CSVの27カラム目(AA)のデータ-->
          <tr class="font_M">
            <td class="w50 left bk_primary">&nbsp;&nbsp; @isset($head[27]) {{ $head[27] }} @endisset </td>
            <td class="w50 right">                       @isset($data[27]) {{ $data[27] }} @endisset &nbsp;</td>
          </tr>
        </table>
      </td>
      </tr>
    </table>
  </div>
</body>
</html>

HTML4時代の感じで書くとよい感じ、というかHTML4な感じで書かないとダメです
TCPDF の制限で使えるタグやCSSがかなり制限されてますので昔ながらの?頑張ってTABLEでレイアウトを組む感じで仕上げます

給与明細PDF自動生成

Laravel側のPDF生成部分です
要求のあった社員の指定月の明細データをDBから取得してPDFファイルを生成します

app/Http/Controllers/PayslipController.php
~~~

use TCPDF;
use TCPDF_FONTS;

class PayslipController extends Controller
{
~~~
    public function pdf(Request $request)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $request->all());

        // INIT
        mb_internal_encoding("UTF-8");

        // データ確認 & 取得 -- エラーがあれば処理終了
        $payslip = $this -> validate_pdf($request);
        if (is_object($payslip)) {
            Log::Debug(__CLASS__.':'.__FUNCTION__.' - ERROR - '. print_r($payslip, true));
            return $payslip;
        }

        // PDF 情報生成
        $data = $this -> make_pdf_data($payslip);

        // PDF 生成メイン - A4 縦に設定
        $pdf = new TCPDF("P", "mm", "A4", true, "UTF-8" );
        $pdf->setPrintHeader(false);
        $pdf->setPrintFooter(false);

        // PDF プロパティ設定
        $pdf->SetTitle('給与明細 '. $data['ym'] .' '. $data['name']);       // PDFドキュメントのタイトルを設定
        $pdf->SetAuthor($data['company']);                                    // PDFドキュメントの著者名を設定
        $pdf->SetSubject($data['filename']);                                  // PDFドキュメントのサブジェクト(表題)を設定
        $pdf->SetKeywords('給与明細 '. $data['ym'] .' '. $data['name']);    // PDFドキュメントのキーワードを設定
        $pdf->SetCreator($data['company'].' u9m31');                         // PDFドキュメントの製作者名を設定

        // 日本語フォント設定
        $pdf->setFont('kozminproregular','',10);

        // ページ追加
        $pdf->addPage();

        // HTMLを描画、viewの指定と変数代入
        $html = view("m31_02", $data)->render();
        $pdf->writeHTML($html);

        // 出力指定 ファイル名、D(ダウンロード)
        $pdf->output('m31.pdf', 'D');
        return;
    }

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

        // INIT
        $req = $request -> all();
        $error_msg = '';

        // データを取得 User(操作者)
        $user = $request -> user();
        if (!$user) {
            return response() -> json(['errors' => [ 'request' => '12345  user not found']], 422);
        }
        Log::Debug(__CLASS__.':'.__FUNCTION__.' - user :: ', $user -> toArray());

        // データを取得 CSV Payslip  --  管理者の場合は論理削除済みでも取得可能
        if (array_key_exists('csv_id', $req)) {
            $query = CsvPayslip::query();
            if ($user -> can('admin')) { $query -> withTrashed(); }
            $csv_payslip = $query -> find($req['csv_id']);
            if (!$csv_payslip) {
                $error_msg = '12346 [csv_id] data  not found ';
            }
            else Log::Debug(__CLASS__.':'.__FUNCTION__.' - csv_payslip :: ', $csv_payslip -> toArray());
        } else {
            $error_msg = '12347 [csv_id] not found ';
        }

        // データを取得 Payslip  --  管理者の場合は論理削除済みでも取得可能
        if (array_key_exists('id', $req)) {
            $query = Payslip::query();
            if ($user -> can('admin')) { $query -> withTrashed(); }
            $payslip = $query -> find($req['id']);
            if (!$payslip) {
                $error_msg = '12348 [payslip id] data  not found ';
            }
            else Log::Debug(__CLASS__.':'.__FUNCTION__.' - payslip :: ', $payslip -> toArray());
        } else {
            $error_msg = '12349 [payslip id] not found ';
        }

        // ここまででエラーが発生していたら処理終了
        if ($error_msg) {
            return response() -> json(['errors' => [ 'request' => $error_msg]], 422);
        }

        // データの関連チェック payslipのcsv_id と csv_payslip の ID が違ったら、要求エラー
        if ($payslip -> csv_id != $csv_payslip -> id) {
            $error_msg = '12350 [payslip.csv_id] - [csv.id] mismatch';
            return response() -> json(['errors' => [ 'request' => $error_msg]], 422);
        }

        // 権限確認(一般ユーザは自分の明細以外はエラー)
        if (($user -> can('user')) && ($user -> id != $payslip -> user_id)) {
            return response() -> json(['errors' => [ 'auth' => '12351 対象の明細を開けませんでした']], 422);
        }

        // エラーなしリターン
        return array(
            'head' => $csv_payslip -> header,
            'data' => $payslip -> data,
            'name' => $payslip -> name,
            'file' => $payslip -> filename,
            'ym'   => $payslip -> ym,
        );
    }

    private function make_pdf_data($payslip)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $payslip);

        // INIT
        mb_language('ja');
        mb_internal_encoding("UTF-8");

        // 明細情報生成
        $ym = substr($payslip['ym'], 0, 4). '年' .substr($payslip['ym'], 4, 2) .'月度';
        $data['ym'] = mb_convert_kana($ym, 'N'); // - 全角数字に変換
        $data['name'] = $payslip['name'];
        $data['filename'] = $payslip['file'];
        $data['company'] = env('MIX_COMPANY_NAME', '環境変数 MIX_COMPANY_NAME を設定してください');

        // PDF埋め込み用 明細情報領域初期化
        $data['head'] = array_fill(0, count($payslip['head']), '');
        $data['data'] = array_fill(0, count($payslip['data']), '');

        // 明細項目名設定
        $cnt = 0;
        foreach($payslip['head'] as $v) {
            $data['head'][++$cnt] = $v;
        }

        // 明細項目データ設定 - もし項目が半角ハイフンだったらヘッダーを隠す
        $cnt = 0;
        foreach($payslip['data'] as $v) {
            $data['data'][++$cnt] = $v;
            if ($v == '-') {
                $data['head'][$cnt] = '';
                $data['data'][$cnt] = '';
            }
        }
        Log::Debug(__CLASS__.':'.__FUNCTION__.' - pdf data :: ', $data);

        // 生成結果を戻す
        return $data;
    }
}

最初に validate_pdf でエラーチェック
次にデータのPDF用整形を make_pdf_data で実行
最後にPDF作成処理という流れになってます

PDF作成部分は動作確認(STEP09)と同じ
Bladeの出力(データを当てはめたHTML)をTCPDF に渡してPDFにしてます

ルート設定

新ルートを追加したのでルート設定
ついでにCSVファイルの管理と、明細の管理を分けたので整理もかねて全行表示

routes/web.php
<?php

Route::get('/', function () {
    return view('home');
})->middleware('auth');

Route::get('/pdf', 'DocumentController@downloadPdf')->name('pdf');

// Authentication Routes...
Route::get('/login', 'Auth\LoginController@showLoginForm')->name('login');
Route::post('/login', 'Auth\LoginController@login');
Route::post('/logout', 'Auth\LoginController@logout')->name('logout');

// Admin
Route::group( ['middleware' => ['auth', 'can:admin']], function() {

  // USER
  Route::post('/api/admin/user', 'UserController@index')->name('admin/user');
  Route::post('/api/admin/user/store', 'UserController@store')->name('admin/user/store');
  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');

  // CsvPayslip
  Route::post('/api/admin/csvpayslip/index', 'CsvPayslipController@index')->name('admin/csvpayslip/index');
  Route::post('/api/admin/csvpayslip/upload', 'CsvPayslipController@upload')->name('admin/csvpayslip/upload');
  Route::post('/api/admin/csvpayslip/delete', 'CsvPayslipController@delete')->name('admin/csvpayslip/delete');
  Route::post('/api/admin/csvpayslip/publish', 'CsvPayslipController@publish')->name('admin/csvpayslip/publish');

  // Payslip
  Route::post('/api/admin/payslip/index', 'PayslipController@index')->name('admin/payslip/index');
  Route::post('/api/admin/payslip/delete', 'PayslipController@delete')->name('admin/payslip/delete');
  Route::post('/api/admin/payslip/pdf', 'PayslipController@pdf')->name('admin/payslip/pdf');

});

// Other
Route::get('/{any}', function () {
  return view('home');
})->middleware('auth')->where('any', '.*');

前回までは 「PayslipController」にすべて入れてましたが、ファイルを分けました
内容はほぼ変わらずにファイル名を変更したのみなので、詳細は github を参照ってことで

明細出力画面

Laravel側ができたのでVue側の出力画面を作成
前回までに作っていた画面構成は
 1段目: 明細CSV登録/明細検索 画面
 2段目: 明細CSV一覧画面
となっていたので、今回は
 3段目: 明細一覧画面(CSVの中の1行1行を一覧で表示)
を作ります
3段目にPDF出力用のボタンを配置して、ボタンを押したらブラウザでPDFをダウンロード、って動きにしときます

3段目まで追加するとファイルが大きくなってしまうので、段毎にコンポーネントを分けておきます

1段目

分けたコンポーネントを呼び出す部分と、親子コンポーネントの連携を追加

z33.png

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 }} {{ /* 給与明細 */ }}
        【{{ (searchTab ? '検索' : '登録') }}】
        <v-spacer></v-spacer>
        <v-btn block depressed
          :outline="searchTab"
          :color="searchTab ? 'primary' : 'blue-grey lighten-5'"
          @click="searchTab = true"
        >
          <v-icon class="pr-1">search</v-icon>検索
        </v-btn>
        <v-btn block depressed
          :outline="!searchTab"
          :color="!searchTab ? 'primary' : 'blue-grey lighten-5'"
          @click="searchTab = false"
        >
          <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="'明細の対象年月を選択' + (searchTab?'(指定なしで全期間対象)':'')"
            ></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-flex xs5 md4 lg3 v-if="searchTab">
          <v-switch
            :label="`削除済データ ${target.deleted ? '対象' : '対象外'}`"
            v-model="target.deleted"
            color="error"
            class="px-2"
            hint="削除済みデータも検索したい場合に指定"
          ></v-switch>
        </v-flex>

      </v-layout>

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

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

    <!-- 2段目 :CSVリスト表示 -->
    <csv-payslip
      v-show="csvList"
      ref="csvPayslip"
      :target="target"
      :detail_id="detail_id"
      @axios-logout="$emit('axios-logout')"
      @closeList="closeList"
      @showDetail="showDetail"
    ></csv-payslip>

    <!-- 3段目 :明細リスト表示 -->
    <payslip-detail
      v-show="detailList"
      ref="detailPayslip"
      :target="target"
      @axios-logout="$emit('axios-logout')"
      @closeDetail="closeDetail()"
    ></payslip-detail>
  </v-flex>
</template>

<script>
  import csv_upload from './CsvUpload.vue'
  import csv_payslip from './CsvPayslipComponent.vue'
  import payslip_detail from './PayslipDetailComponent.vue'

  export default {
    name: 'PayslipComponent',

    components: {
      'csv-upload': csv_upload,
      'csv-payslip': csv_payslip,
      'payslip-detail': payslip_detail,
    },

    props: {
    },

    data: () => ({
      menu: false,
      searchTab: true,
      csvList: false,
      detailList: false,
      detail_id: 0,
      target: {
        ym: '',
        deleted: false,
      },
    }),

    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.showList()
      },

      showList() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component SHOW List')
        this.csvList = true
        this.$refs.csvPayslip.getCsvPayslip()
      },

      closeList() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component CLOSE List')
        this.csvList = false
        this.closeDetail()
      },

      showDetail(item) {
        if (process.env.MIX_DEBUG) console.log('Payslip Component SHOW Detail')
        if (this.detail_id == item.id) {
            this.closeDetail()
        }
        else {
          this.detailList = true
          this.detail_id = item.id
          this.$refs.detailPayslip.getPayslipDetail(item)
        }
      },

      closeDetail() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component CLOSE Detail')
        this.detailList = false
        this.detail_id = 0
      },
    },
  }
</script>

2段目

CSVの一覧から明細データを呼び出す部分を追加

z34.png

resources/js/components/Admin/CsvPayslipComponent.vue

<template>
  <v-flex>
    <!-- 2段目 :CSVリスト表示 -->
    <v-card xs12 class="m-3 px-3">
      <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">
          <tr :class="{'pink--text': (props.item.deleted_at != null)}" v-show="props.item.id == detail_id || detail_id == 0">
            <td class="text-xs-center" xs1>{{ (props.index + 1) + (pagination.page - 1) * pagination.rowsPerPage }}</td>
            <template v-for="n in (headers.length - 1)">

              <td v-if="headers[n].text != 'アクション'"
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
                v-text="props.item[headers[n].value]"
              ></td>

              <td v-else
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
              >
                <v-tooltip right :color="(props.item.deleted_at == null) ? 'primary': 'grey'">
                  <v-btn fab small flat @click="$emit('showDetail', props.item)" slot="activator">
                    <v-icon color="primary">list</v-icon>
                  </v-btn>
                  <span>明細一覧</span>
                </v-tooltip>

                <v-tooltip v-if="props.item.published_at == null" right :color="(props.item.deleted_at == null) ? 'success' : 'grey'">
                  <v-btn fab small  flat @click="dialog_open(props.item, 'pub')" slot="activator"
                    :disabled="props.item.deleted_at != null"
                  >
                    <v-icon color="success">lock</v-icon>
                  </v-btn>
                  <span>公開</span>
                </v-tooltip>
                <v-btn v-else fab small flat disabled>
                  <v-icon color="grey lighten-1">lock_open</v-icon>
                </v-btn>

                <v-tooltip right :color="(props.item.deleted_at == null) ? 'error': 'grey'">
                  <v-btn fab small flat @click="dialog_open(props.item, 'del')" slot="activator"
                    :disabled="props.item.deleted_at != null">
                    <v-icon color="error">delete</v-icon>
                  </v-btn>
                  <span>{{(props.item.deleted_at == null ? '削除' : '削除済')}}</span>
                </v-tooltip>
            </td>
            </template>
          </tr>
        </template>
      </v-data-table>
    </v-card>

    <!-- 確認ダイアログ -->
    <v-dialog v-model="dialog" width="500" persistent>
      <v-card>
        <v-toolbar :color="d.titlecolor" dark>
          <v-toolbar-title>{{ d.title }}</v-toolbar-title>
        </v-toolbar>
        <v-card-text class="subheading">
          <span v-html="d.body"></span>
          <br>よろしいですか?
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-btn flat block @click="dialog_no()" > いいえ </v-btn>
          <v-spacer></v-spacer>
          <v-btn flat block @click="dialog_yes()" :color="d.titlecolor"> はい </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

  </v-flex>
</template>

<script>

  export default {
    name: 'CsvPayslipComponent',

    components: {
    },

    props: {
      target: Object,
      detail_id: Number,
    },

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

      // csv list
      csvTitle: '',
      tabledata: [],
      headers: [
        { align: 'center', sortable: false, text: 'No', },
        { align: 'center', sortable: true,  text: '年月',       value: 'ym' },
        { align: 'center', sortable: false, text: 'アクション', },
        { 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: 'left',   sortable: true,  text: '削除日時',   value: 'deleted_at' },
      ],

      // dialog
      dialog: false,
      d: {
        title: '',
        titlecolor: '',
        icon: '',
        type: '',
        item: [],
      },

    }),

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

    methods: {
      initialize() {
      },

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

      initList() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component initList')
        this.setCsvTitle()
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
      },

      closeList() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component closeList')
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
        this.$emit('closeList')
      },

      setCsvTitle() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip 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('CsvPayslip Component getCsvPayslip')

        // 初期化
        this.initList()

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

        // 検索要求
        this.loading = true
        axios.post('/api/admin/csvpayslip/index', 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() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component set status')
        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 = '公開' }
            if (this.tabledata[i].deleted_at != null ) { wk = '削除' }
            this.tabledata[i].status = wk
          }
        }
      },

      dialog_open(item, type) {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component Dialog Open')
        this.d.type = type
        this.d.item = item
        this.d.body = 'CSV ID:' + item.id + '<br>' + '対象年月:' + item.ym + '<br>' + 'ファイル:' + item.filename + '<br><br>'
        if (type == 'pub') {
          this.d.title = '明細情報を公開します'
          this.d.titlecolor = 'success'
          this.d.body += '対象のデータを公開します。<br>公開後は非公開とすることはできません。'
        }
        else {
          this.d.title = '明細情報を削除します'
          this.d.titlecolor = 'error'
          this.d.body += '対象のデータを削除します。'
        }
        this.dialog = true
      },

      dialog_yes() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component Dialog YES')
        this.dialog = false
        var url = '/api/admin/csvpayslip/publish'
        if (this.d.type == 'del') { url = '/api/admin/csvpayslip/delete' }
        this.csvUpdate(url, this.d.item.id)
        this.d.type = ''
        this.d.item = []
      },

      dialog_no() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component Dialog NO')
        this.dialog = false
        this.d.type = ''
        this.d.item = []
      },

      // CSVの状態を更新
      csvUpdate(url, id) {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component CSV Update')

        // パラメータ設定
        var params = new URLSearchParams()
        params.append('id', id)

        // 更新要求
        axios.post(url, params)

        // 更新結果[正常]
        .then( function (response) {
          if (process.env.MIX_DEBUG) console.log(response)
          this.reload()
        }.bind(this))

        // 更新結果[異常]
        .catch(function (error) {
          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))
      },
    },
  }
</script>

うーん
VUE ファイルは処理単位でまとまってて分かりやすいんだけど。。長いな。。。

3段目

新規作成

z35.png

PDFをダウンロードする処理を埋め込んでます
PDFのダウンロードのため ResponseType が BLOB なので、エラー時のメッセージ取り出しがめんどくさいことになってます

PDF のファイル名はVueでつけてます

PDFファイル名はこんな感じ

 '給与明細'_'年月'_'氏名''(補助ファイル名)'_'出力日時'.pdf

例1:給与明細_201901_メイプル管理者_20190102_010203.pdf
例2:給与明細_201901_メイプル管理者(補正1)_20190102_010203.pdf

CSVの2カラム目にファイル名を指定すると、氏名の後ろに追加するようにしてます

resources/js/components/Admin/PayslipDetailComponent.vue

<template>
  <v-flex>
    <!-- 3段目 :明細詳細表示 -->
    <v-card xs12 class="m-3 px-3">
      <v-card-title class="title">
        <v-icon class="pr-2" @click="closeDetail">{{ $route.meta.icon }}</v-icon> {{ title }} {{ /* 給与明細詳細検索 */ }}
        <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="closeDetail" 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='[10,25,50,{"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">
          <tr :class="{'pink--text': (props.item.deleted_at != null)}">
            <td class="text-xs-center" xs1>{{ (props.index + 1) + (pagination.page - 1) * pagination.rowsPerPage }}</td>
            <template v-for="n in (headers.length - 1)">

              <td v-if="headers[n].text != 'アクション'"
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
                v-text="props.item[headers[n].value]"
              ></td>

              <td v-else
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
              >
                <v-tooltip right :color="(csv_item.published_at != null) ? 'success': 'grey'">
                  <v-btn fab small  flat @click="pdf_download(props.item)" slot="activator"
                    :disabled="props.item.error != null"
                  >
                    <v-icon :color="(csv_item.published_at != null) ? 'success' : 'grey'">description</v-icon>
                  </v-btn>
                  <span>明細PDF</span>
                </v-tooltip>

                <v-tooltip right :color="(props.item.deleted_at == null) ? 'error': 'grey'">
                  <v-btn fab small flat @click="dialog_open(props.item, 'del')" slot="activator"
                    :disabled="props.item.deleted_at != null || csv_item.deleted_at != null"
                  >
                    <v-icon color="error">delete</v-icon>
                  </v-btn>
                  <span>{{(props.item.deleted_at == null ? '削除' : '削除済')}}</span>
                </v-tooltip>
            </td>
            </template>
          </tr>
        </template>
      </v-data-table>
    </v-card>

    <!-- 確認ダイアログ -->
    <v-dialog v-model="dialog" width="500" persistent>
      <v-card>
        <v-toolbar :color="d.titlecolor" dark>
          <v-toolbar-title>{{ d.title }}</v-toolbar-title>
        </v-toolbar>
        <v-card-text class="subheading">
          <span v-html="d.body"></span>
          <br>よろしいですか?
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-btn flat block @click="dialog_no()" > いいえ </v-btn>
          <v-spacer></v-spacer>
          <v-btn flat block @click="dialog_yes()" :color="d.titlecolor"> はい </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

  </v-flex>
</template>

<script>

  export default {
    name: 'PayslipDetailComponent',

    components: {
    },

    props: {
      target: Object,
    },

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

      // detail list
      title: '',
      csv_item: {},
      tabledata: [],
      headers: [
        { align: 'center', sortable: false, text: 'No', },
        { align: 'center', sortable: false, text: 'アクション', },
        { align: 'center', sortable: true,  text: 'CSV行',      value: 'line' },
        { align: 'left',   sortable: true,  text: '氏名',       value: 'name' },
        { align: 'left',   sortable: true,  text: 'ログインID', value: 'loginid' },
        { align: 'center', sortable: true,  text: 'DL回数',     value: 'download' },
        { align: 'left',   sortable: true,  text: 'ファイル名', value: 'filename' },
        { align: 'left',   sortable: true,  text: 'CSVエラー',  value: 'error' },
        { align: 'left',   sortable: true,  text: '削除日時',   value: 'deleted_at' },
        { align: 'left',   sortable: true,  text: '削除者ID',   value: 'delete_user_id' },

        { align: 'center', sortable: true,  text: 'CSV-ID',     value: 'csv_id' },
        { align: 'left',   sortable: true,  text: '状態',       value: 'status' },
        { align: 'center', sortable: true,  text: '年月',       value: 'ym' },
        { align: 'center', sortable: true,  text: 'ユーザID',   value: 'user_id' },
      ],

      // dialog
      dialog: false,
      d: {
        title: '',
        titlecolor: '',
        icon: '',
        item: [],
      },

    }),

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

    methods: {
      initialize() {
      },

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

      initList() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component initList')
        this.setTitle()
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
      },

      closeDetail() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component closeDetail')
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
        this.$emit('closeDetail')
      },

      setTitle() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component set title')
        this.title = ''
        this.title = 'ID: ' + this.csv_item.id + '  - ' + this.csv_item.ym
        if (this.csv_item.deleted_at != null) {
          this.title += ' - 削除済'
        }
        else if (this.csv_item.published_at != null) {
          this.title += ' - 公開済'
        }
        else {
          this.title += ' - 未公開'
        }
      },

      // 指定の明細リストサーバから取得する
      getPayslipDetail(item) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component getPaslipDetail')

        // 初期化
        this.csv_item = (item ? item : this.csv_item)
        this.initList()

        // 検索パラメータ設定
        var params = new URLSearchParams()
        params.append('id', this.csv_item.id)

        // 検索要求
        this.loading = true
        axios.post('/api/admin/payslip/index', 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() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component set status')
        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 == 9) { wk = '削除' }
            if (this.tabledata[i].deleted_at != null ) { wk = '削除' }
            this.tabledata[i].status = wk
          }
        }
      },

      dialog_open(item) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Dialog Open')
        this.d.item = item
        this.d.body = 'CSV行:' + item.line + '<br>' +
                      '対象年月:' + item.ym + '<br>' +
                      '対象者:' + item.name + '<br>' +
                      'ログインID:' + item.loginid + '<br>' +
                      'ダウンロード回数:' + item.download + ' 回<br>' +
                      '<br>'
        this.d.title = '明細情報を削除します'
        this.d.titlecolor = 'error'
        this.d.body += '対象のデータを削除します。'
        if (item.download > 0) {
          this.d.body += '<br> 利用者は明細をダウンロードしたことがあるようです。'
        }
        this.dialog = true
      },

      dialog_yes() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Dialog YES')
        this.dialog = false
        var url = '/api/admin/payslip/delete'
        this.detailUpdate(url, this.d.item.id)
        this.d.item = []
      },

      dialog_no() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Dialog NO')
        this.dialog = false
        this.d.item = []
      },

      // 明細の状態を更新
      detailUpdate(url, id) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Detail Update')

        // パラメータ設定
        var params = new URLSearchParams()
        params.append('id', id)

        // 更新要求
        axios.post(url, params)

        // 更新結果[正常]
        .then( function (response) {
          if (process.env.MIX_DEBUG) console.log(response)
          this.reload()
        }.bind(this))

        // 更新結果[異常]
        .catch(function (error) {
          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))
      },

      pdf_download(item) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component PDF Download')

        // パラメータ設定
        var params = new URLSearchParams()
        params.append('loginid', item.loginid)  // 操作ログ記録用
        params.append('name',    item.name)     // 操作ログ記録用
        params.append('yyyymm',  item.ym)       // 操作ログ記録用
        params.append('csv_id',  item.csv_id)
        params.append('id',  item.id)
        var config = {
          responseType: 'blob',
        }

        // ダウンロード要求
        this.loading = true
        axios.post('/api/admin/payslip/pdf', params, config)

        // 正常
        .then( function (response) {
          this.loading = false
          if (response.data) {
            // PDFデータ取得
            var blob = new Blob([response.data], { "type" : "application/pdf" })

            // 補助ファイル名設定
            var f = ''
            if (item.filename != null) {
              f = '('+ item.filename +')'
            }

            // ファイル名設定 - '給与明細'_'年月'_'氏名''(補助ファイル名)'_'出力日時'.pdf
            //         - 氏名からは「全角空白」、「半角空白」、「スラッシュ」を削除しとく
            var filename = '給与明細'
            filename += '_' + item.ym
            filename += '_'+ item.name.replace(/ /g,'').replace(/ /g,'').replace(/\//g,'')
            filename += f
            filename += '_' + moment(Date.now()).format("YYYYMMDD_HHmmss")
            filename += '.pdf'

            // PDFダウンロード(表示) IE11
            if (window.navigator.msSaveBlob) {
              window.navigator.msSaveBlob(blob, filename)
              window.navigator.msSaveOrOpenBlob(blob, filename)
            }

            // PDFダウンロード Chrome, Safari, Firefox, etc...
            else {
              const url = window.URL.createObjectURL(blob)
              const link = document.createElement('a')
              link.href = url
              link.setAttribute('download', filename)
              document.body.appendChild(link)
              link.click()
              link.remove()
            }
          }
          else {
            alert('PDF ダウンロードエラー')
          }
        }.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 if ([422].includes(error.response.status)) {
              if (process.env.MIX_DEBUG) console.log("PDF Download error 422")
              if (process.env.MIX_DEBUG) console.log(error.response.data)

              // error message blog to json - 通信時に BLOB 指定しているのでエラーメッセージもBLOB.. JSONに戻して取り扱い
              //                                     - responseType: 'blob'
              var reader = new FileReader()
              reader.onload = function(e) { // 非同期処理のため
                var data = JSON.parse(reader.result)
                if (process.env.MIX_DEBUG) console.log(data)
                if (data.errors) {
                  for (let key in data.errors) {
                    if (data.errors[key]) {
                      alert(key + ' : ' + data.errors[key])
                    }
                  }
                }
              }
              reader.readAsText(error.response.data, "UTF-8")
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },

    },
  }
</script>

サンプルCSV

明細用のサンプルCSVです
このデータをアップロードして表示してます

loginID,file,総支給額,基本給,交通費,No6-F,No7-G,No8-H,No9-I,No10-J,No11-K,No12-L,No13-M,No14-N,支給額合計,雇用保険料,健康保険料,厚生年金保険料,No19-S,No20-T,No21-U,No22-V,No23-W,No24-X,No25-Y,No26-Z,控除額合計
maple_admin,補正2,"1,000","2,000","3,000",0,1,2,3,4,-,0,1,-,"4,000","5,000","6,000","7,000",-,0,1,1.1,2.2,-,3,-,"8,000"
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27

No~ のアルファベットは Excelの A1,B1 などの列アルファベットに対応
作る時の目安にでもしてくださいです

データ行で "-"(半角ハイフン)だけの項目は、PDFから項目名の表示を消すことができるようにしてあります
特定の人以外には表示したくない場合に指定してください

表示を消しても行を「詰める」ことはしないです
ただの空行として表示します

動作確認

いろいろいじっているのでコンパイルしてから、laravelを起動


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

 ※ IPアドレスやポートは任意ですよ

ブラウザでアクセスして
http://172.16.0.100:8000/

給与明細ページを開いて、CSVを登録して、PDF表示ボタンを押すと

z38.png

ちゃんとPDFがダウンロードされること

z36.png

ダウンロードされたPDFは表示できること

PDFのプロパティも設定されていること

z37.png


以上
給与明細のPDFファイルを生成できました

今回の記事ではブラウザ上でのデータ内容の更新には対応してませんので、CSVが全てです
この記事を参考にして給与明細の電子交付を実際に行う場合には、CSVファイルの内容について税理士さんに確認してもらったりデータチェックは確実に行ってからアップロードしてください

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

5
8
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
5
8