頑張らない勤怠管理〜ラズパイとfreeeでWi-Fi打刻〜

皆さん勤怠管理してますか？

今回は人事労務freeeさんのAPIと連携して、Wi-Fiに接続したら出勤、Wi-Fiの接続が切れたら退勤というものを作ってみました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/dc00b79c-ef05-771f-c79a-5ec6028a714b.png)


## 用意するもの
- freeeの開発用アカウント
- Raspberry Pi（社内サーバがあればそれで良いです）
- Firebaseアカウント

## システム構成図

- Wi-Fiが繋がったデバイスをラズパイでarpで取得
- MacアドレスをFirebaseに送信
- Firebaseから人事労務freeeのAPIを呼ぶ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/c8dced0a-7561-43ad-5817-9e1449b0f331.png)



## freeeのセットアップ
APIを叩くにはclient_id, client_secret,code, access token, refresh token がそれぞれ必要になるのですが、下記の公式チュートリアル記事が大変参考になりました。

https://developer.freee.co.jp/tutorials

OAuthを私が雰囲気でしか理解していないためか、認可コード(code)、refresh token, access tokenと似たような認証コードが幾つかあるので混乱しました。

ここで補足しておくと

- **認可コード**
    - アプリケーションを第３者に利用してもらう場合の、認証のために必要
    - access token, refresh tokenを発行するために使う
    - **使用すると使えなくなる**
    - 有効なrefresh tokenを無くすと認可コードを再発行する必要がある
- **refresh token**
    - 使用期間無限
    - 更新すると古いrefresh tokenは使えなくなる
    - access tokenを発行するために使う
    - 更新すると新しいrefresh tokenが返ってくる
- **access token**
    - API呼び出しに使うトークン
    - revokeで発行
    - 発行後24時間は有効


`https://accounts.secure.freee.co.jp/public_api/token` このAPIに渡すgrant_typeによって、`refresh tokenの新規発行`と`access tokenの更新`を管理しています。

### `grant_type` が authorization_codeの場合
新規access_token, refresh tokenの発行です。

### `grant_type` が refresh_tokenの場合
access tokenの更新です。


なので、まとめると認証/認可は以下のような順番になります。


- 初めての場合はgrant_typeをauthorization_codeにしてaccess_token, refresh tokenを取得する
- refresh tokenを取得後は、access tokenが24時間で切れてしまうことに注意を払いながら、利用する
- access tokenの期限が切れそう or 切れた場合は grant_typeをrefresh_tokenにしてrefresh tokenとaccess tokenを更新する。


## Raspberry Piのセットアップ
最初に断っておくと、ラズパイである必要はありません。安価で入手しやすく、24時間稼働するサーバとしてお手軽なので選んでいるだけです。

拙作のOSS [noplan-inc/arp-device-notifier](https://github.com/noplan-inc/arp-device-notifier)を利用すると、楽にMacアドレスをサーバーに送信することができます。

<a href="https://github.com/noplan-inc/arp-device-notifier"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/noplan-inc/arp-device-notifier.png" width="460px"></a>


`arp-device-notifier` はarpプロトコルでネットワーク内のデバイスのIPアドレスとMacアドレスを一覧にし、それをjsonで指定のエンドポイントで投げてくれる君です。

Goで書いたCLIツールなので、どこでも動くと思います。

### インストール
Goが入っているのであれば、下記コマンドで

```
$ go get github.com/noplan-inc/arp-device-notifier
```

入っていないのであればバイナリから入れると楽だと思います。

```bash
$ wget https://github.com/noplan-inc/arp-device-notifier/releases/download/v0.1/arp-device-notifier_linux_arm
$ chmod 755 arp-device-notifier_linux_arm
$ mv arp-device-notifier_linux_arm /usr/local/bin/arp-device-notifier
```

### 使い方
使い方は至ってシンプルで、エンドポイントを指定するだけです。
送信間隔を `-i`で決めれます。デフォルトだと10秒です。

そこまで厳密にする必要がない場合60秒ぐらいにしても全く問題ないと思います。

```bash
arp-device-notifier post -e http://example.com -i 60
```

こんな感じのjsonをpostしてくれます。
<img width="305" alt="スクリーンショット 2020-07-09 16.21.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/101ca7d8-711e-40dc-39bd-8c97cf276506.png">

## Firebaseのセットアップ
外部から社内にいる人がみたいという要件が今回は別にあったので、FirebaseとFirestoreを使って外部からも参照できるようにしました。ただ打刻するだけの場合、ここまでする必要はなくFreeeのAPIを叩くデーモンをラズパイに同居させておけば良いと思います。

こんな感じのシンプルなアーキテクチャになっています。デバイス登録・削除のfunctionsだけを外部に露出させ、あとはスケジューラーによって休憩の管理をしています。


![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/513423ea-1d53-d39a-778b-61c1a733e730.png)

### プロジェクトの作成
[http://console.firebase.google.com/](http://console.firebase.google.com/)にアクセスして、プロジェクトを作リます。

課金プランにしておかないと、スケジューラーが使えないので、従量課金プランにしておきます。

リージョンの設定とFirestoreの作成もついでにしておきましょう。

### プロジェクトの準備（ローカル）

```bash
$ firebase init
.........<省略>.........
 ◉ Firestore: Deploy rules and create indexes for Firestore
 ◉ Functions: Configure and deploy Cloud Functions
.........<省略>.........
Use an existing project
.........<省略>.........
先ほど作ったプロジェクト
.........<省略>.........
あとは大体enterかyes
.........<省略>.........
typescript yes
```

これでプロジェクトが作成される。


`functions/src/index.ts` をおもむろに開いてサンプルコードを参考にしてください。


```typescript:functions/src/index.ts
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import axios from 'axios'

admin.initializeApp()
const db = admin.firestore()

const config = functions.config()

const company_id = 2526055
type ClockType = 'clock_in' | 'break_begin' | 'break_end' | 'clock_out'
const base_endpoint = 'https://api.freee.co.jp/hr'

const refresh_token = async (date: string): Promise<string> => {
    const old_refresh_token_refs = db.collection('token').orderBy('created_at', 'desc').limit(1)
    const old_refresh_token_docs = await old_refresh_token_refs.get()
    if (old_refresh_token_docs.empty) {
        throw new Error('token collectionがありません')
    }

    const old_refresh_token_data = old_refresh_token_docs.docs[0].data() || {}

    const res = await axios.post(`https://accounts.secure.freee.co.jp/public_api/token`, {
        grant_type: 'refresh_token',
        client_id: config.freee.client_id,
        client_secret: config.freee.client_secret,
        refresh_token: old_refresh_token_data.refresh_token,
    })
    console.log(res.data)

    const {access_token, refresh_token} = res.data
    await db.collection('token').doc(date).create({
        access_token,
        refresh_token,
        created_at: admin.firestore.FieldValue.serverTimestamp()
    })
    return access_token
}

const get_access_token = async (): Promise<string> => {
    // ex) 2020-07-09
    const date_string = new Date().toJSON().split('T')[0]

    const token_ref = db.collection('token').doc(date_string)
    const token_doc = await token_ref.get()
    if (!token_doc.exists) {
        return refresh_token(date_string)
    }

    const token_data = token_doc.data() || {}
    return token_data.access_token
}


const change_clocks = async (type: ClockType, emp_id: string) => {
    const access_token = await get_access_token()
    const date = new Date()
    // JST => UTC
    date.setHours(date.getHours() + 9)
    try {
        await axios.post(`${base_endpoint}/api/v1/employees/${emp_id}/time_clocks`, {
            company_id,
            type,
            datetime: date.toLocaleDateString('ja', {
                year: 'numeric', month: '2-digit', day: '2-digit',
                hour: '2-digit', minute: '2-digit', second: '2-digit',
            }) // ex) '2020/07/09 18:07:54'
        }, {
            headers: {
                Authorization: `Bearer ${access_token}`,
            }
        })
    } catch (e) {
        console.error(e)
    }
}


export const addDevices = functions.https.onRequest(async (request, response) => {
    const devices = request.body
    const mac_addrs = Object.keys(devices).map(k => devices[k])
    console.log(mac_addrs)

    const devices_refs = db.collection('devices')
    const devices_docs = await devices_refs.get()
    const added_mac_addrs: { [key: string]: boolean } = {}

    devices_docs.docs.forEach(d => added_mac_addrs[d.id] = true)

    const clock_out_devices_refs = db.collection('maybe_clock_out_devices')
    const clock_out_devices_docs = await clock_out_devices_refs.get()
    const clock_out_mac_addrs: { [key: string]: boolean } = {}

    clock_out_devices_docs.docs.forEach(d => clock_out_mac_addrs[d.id] = true)

    const batch = db.batch()

    mac_addrs.forEach(addr => {
        const device_ref = db.collection('devices').doc(addr)
        if (addr in added_mac_addrs) {
            // 既に登録されている
            console.log(`already added! : ${addr}`)
            delete added_mac_addrs[addr]
        } else {
            batch.set(device_ref, {
                created_at: admin.firestore.FieldValue.serverTimestamp()
            })
        }
    })



    // 退勤した人 = 既に登録されたデバイスリスト - 今回のデバイスリスト
    console.log(`退勤したかも: ${JSON.stringify(added_mac_addrs)}`)
    Object.keys(added_mac_addrs).forEach(_addr => {
        const device_ref = db.collection('devices').doc(_addr)
        batch.delete(device_ref)

        const maybe_clock_out_devices_ref = db.collection('maybe_clock_out_devices').doc(_addr)
        batch.set(maybe_clock_out_devices_ref, {
            created_at: admin.firestore.FieldValue.serverTimestamp()
        })
    })

    // 退勤したかもリストの人が復活した
    Object.keys(clock_out_mac_addrs).forEach(_addr => {
        if (_addr in mac_addrs) {
            const maybe_clock_out_devices_ref = db.collection('maybe_clock_out_devices').doc(_addr)
            batch.delete(maybe_clock_out_devices_ref)
        }
    })


    const wr = await batch.commit()

    console.log(`${wr.length} devices was added!`)

    response.send(`${wr.length} devices was added!`)
});


export const deviceOnCreate = functions.firestore.document('devices/{device_id}').onCreate(async doc => {
    const mac_addr = doc.id

    const employee_refs = db.collection('employee').where('mac_addr', '==', mac_addr).limit(1)
    const employee_docs = await employee_refs.get()

    if (employee_docs.empty) {
        return
    }

    const employee_data = employee_docs.docs[0].data() || {}

    const {emp_id} = employee_data

    await change_clocks('clock_in', emp_id)
    console.log(`${employee_data.name}さんが出勤をしました！`)
})

export const clockOut = functions.pubsub.schedule('every 1 hours').onRun(async () => {
    const maybe_clock_out_devices_refs = db.collection('maybe_clock_out_devices')
    const maybe_clock_out_devices_docs = await maybe_clock_out_devices_refs.get()


    if (maybe_clock_out_devices_docs.empty) return

    const batch = db.batch()

    const promises = maybe_clock_out_devices_docs.docs.map(d => {
        const data = d.data() || {}
        const now = new Date()

        // 2時間以上離れていたら、退勤したとみなす
        if (+now - data.created_at.toDate() > 7200000) {
            batch.delete(d.ref)
            return change_clocks('clock_out', d.id)
        } else {
            return Promise.resolve(null)
        }
    })

    await Promise.all(promises)
    await batch.commit()
    console.log(`${promises.length} devices is clockOut!`)
})

// UTC 03:00 => JST12:00
export const breakBegin = functions.pubsub.schedule('every day 03:00').onRun(async () => {
    const device_refs = db.collection('devices')
    const device_docs = await device_refs.get()
    if (device_docs.empty) return

    const promises = device_docs.docs.map(d => {
        return change_clocks('break_begin', d.id)
    })

    await Promise.all(promises)
    console.log(`${promises.length} devices is break_begin`)
})

// UTC 04:00 => JST13:00
export const breakEnd = functions.pubsub.schedule('every day 04:00').onRun(async () => {
    const device_refs = db.collection('devices')
    const device_docs = await device_refs.get()
    if (device_docs.empty) return

    const promises = device_docs.docs.map(d => {
        return change_clocks('break_begin', d.id)
    })

    await Promise.all(promises)
    console.log(`${promises.length} devices is break_end`)
})
```


### デプロイ
```bash
$ firebase functions:config:set freee.client_secret='<CLIENT_SECRET>'
$ firebase functions:config:set freee.client_id='<CLIENT_ID>'
$ firebase deploy
```

### Firestoreにマスターデータを入れる
#### `token` collectionに 日付をdocument IDにしたドキュメントを作る

例) `2020-07-10`の場合

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/13dd6b3c-755b-6b21-b75a-6cb25b0bb6dd.png)

#### `employee`コレクションに従業員のMacアドレスとemp_idを紐付ける
必須じゃないですけど、名前がないと誰かわかりづらいのでnameフィールドも追加しておくといいと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/9a4ce6fa-ebf3-878d-f18c-67642765c845.png)

これで出勤と退勤と休憩が打刻されていれば成功です！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82779/0bae97ee-ad68-169a-1f67-03b9c3c410d3.png)



## freee APIの謎
- `DELETE /api/v1/employees/{emp_id}/work_records/{date}` で削除しても、clock_outしか打刻できないかもしれない？
    - デバッグのために何度も1日に出勤、退勤を繰り返せず、不便でした
    - 使い方を間違えているのかもしれません

## 先行研究
先行研究として、[勤怠打刻操作を意識しないAPI打刻をする](https://developers.freee.co.jp/entry/auto-dakoku-via-hr-api-2020)を参考にさせていただきました。

各MacBookで設定するのは、非エンジニアにとっては難しい気がしたので**非エンジニアでも気軽に使える**ということを目指しました。後**Windows対応**したかったっていうのもあります。
Macアドレスの登録が面倒ですが、そこはドキュメントにしてしまえばなんとか乗り切れるだろうと思いました！

## 工夫したところ
Wi-Fiが切れただけで退勤したことにしてしまうと、簡単に退勤してしまうことになりかねないので、Wi-Fiが切れた後に仮打刻をし2時間経過した時に本打刻をするようにしました。

## 今後の展望
時間なさすぎて、一旦リリースしましたが、以下の機能は設計当初に考えていた機能でした！

- デバイスのmacアドレスを登録するUI
- 出勤者一覧機能

## まとめ
急ぎ足になってしまいましたが、freeeさんのAPIを使うと簡単にWi-Fi打刻システムが作れます！！

Swaggerでリクエストが送れるAPIドキュメントが整備されていたり、素晴らしいな〜と思いました！



## 参考資料
- https://developer.freee.co.jp/tutorials
- https://developer.freee.co.jp/docs/hr/reference#/従業員/index