【UI5・CAP】OData V4でドラフト対応したアプリを作る （4）CAPのイベントハンドラを実装

##はじめに
この記事は、[OData V4でドラフト対応したアプリを作る](https://qiita.com/tami/items/30a75e454f4a5fb68fb8)シリーズの4回目です。
今回は、CAPのイベントハンドラを使ってチェックや金額の更新などを行います。

CAPのイベントハンドラを使うと、データの取得や更新の前後に独自のロジックを追加することができます。詳しくは[ドキュメント](https://cap.cloud.sap/docs/node.js/services#event-handlers)をご参照ください。

今回実装するイベントハンドラのソースコードは以下に格納しています。
https://github.com/miyasuta/odata-v4-freestyle/blob/master/srv/ordering-service.js

##イベントハンドラの作成
イベントハンドラはordering-service.jsに実装します。サービスを定義した.cdsファイルと同じ名前で.jsファイルを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/c327e2ca-b3fb-0454-64bc-b5972d5a5ffb.png)

以下のような枠を作ります。この中にロジックを実装していきます。

```javascript:ordering-service.js
const cds = require('@sap/cds')

module.exports = function () {
    const { Orders, OrderItems } = cds.entities

   
}
```

##やりたいこと
今回やりたいことは以下です。

1. オーダー登録時にIDを採番
2. 明細追加時にItem Noを採番
3. Delivery Date, Delivery Timeが過去の場合、エラーにする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/8ca60a33-7a54-9561-9a33-1f3e12790bef.png)
4. 明細のQuantityまたはPriceが更新されたら、Amountを再計算する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/b9ee6d47-ef91-89d8-0e94-677a0332c1cf.png)
5. 明細金額が更新、または明細削除されたら、ヘッダのTotal Amountを更新する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4b0ede11-fce3-74f1-c5c0-e71db30e4449.png)

###使用するイベントハンドラ
上記の要件を、以下のイベントハンドラに実装していきます。

|イベントハンドラ（タイミング/イベント/エンティティ）  |動作するタイミング  |要件 |
|---|---|---|
|before/CREATE/Orders|新規Orderのドラフトを有効化するとき|1: ID採番, 3: 過去日チェック|
|before/UPDATE/Orders|既存Orderのドラフトを有効化するとき|3: 過去日チェック|
|before/PATCH/OrderItem|OrderItemのドラフトを保存するとき|2: Item No採番, 4: Amount再計算, 5: Toal Amount更新|
|on/CANCEL/OrderItem|OrderItemを削除するとき|5: Toal Amount更新|


###1. オーダー登録時にIDを採番
以下のイベントハンドラを実装します。
before/CREATE/Orders
既存のオーダーからorderIdの最大値を取得し、1を加えたものをorderIdにセットします。

```javascript
    this.before('CREATE', 'Orders', async (req) => {
        //select max orderId from table
        const q = SELECT.one.from(Orders).columns `{max(orderId) as maxOrderId}`
        const {maxOrderId} = await cds.tx(req).run(q)
        req.data.orderId = maxOrderId + 1
    })
```

**イベントハンドラの中でのクエリ発行について**
Node.jsのイベントハンドラの中でエンティティやテーブルに対するクエリを発行する際は、[cds.ql](https://cap.cloud.sap/docs/node.js/cds-ql)を使います。cds.qlではSQLに似たAPIを使ってクエリを構築することができます。
今回使用しているcds.qlの構文について紹介します。

|構文  |説明  |
|---|---|
|[.one](https://cap.cloud.sap/docs/node.js/cds-ql#select-one)  |結果のうち最初の1件だけを取得する。結果はオブジェクト{}で返される。.oneを指定しない場合、結果は配列[]で返される。|
|[.from](https://cap.cloud.sap/docs/node.js/cds-ql#select-from)|取得元のエンティティを指定する。`SELECT.from (Orders,101)`のように、キーをセットで指定することもできる。|
|[.columns](https://cap.cloud.sap/docs/node.js/cds-ql#select-columns)|取得対象の列を指定する。max, countなど、一般的なSQLで使用するファンクションも使える。|
|[.where](https://cap.cloud.sap/docs/node.js/cds-ql#where)|取得条件を指定する。|

###2. 明細追加時にItem Noを採番
以下のイベントハンドラを実装します。この処理は、ドラフトを編集中にEnterを押すつど呼ばれます。
before/PATCH/OrderItem
冒頭で、処理の中で必要になる項目をドラフトテーブルから取得しています。ドラフトテーブルの名称は、[パート(1)](https://qiita.com/tami/items/30a75e454f4a5fb68fb8#%E3%83%89%E3%83%A9%E3%83%95%E3%83%88%E5%AF%BE%E5%BF%9C%E3%81%97%E3%81%9F%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%82%92%E8%A6%8B%E3%81%A6%E3%81%BF%E3%82%8B)で確認しました。
Item Noがまだ設定されていない場合、同じto_parent_IDを持つ明細の中から最大のItem Noを取得し、それに1を加えたものを設定します。

```javascript
    //Event handlers for OrderItems
    this.before('PATCH', 'OrderItems', async (req) => {       
        //get draft data from table
        const q1 = SELECT.one.from `OrderingService_OrderItems_drafts`
        .columns `{itemNumber, to_parent_ID, quantity as originalQuantity, price as originalPrice}`.where `ID = ${req.data.ID}`
        const {itemNumber, to_parent_ID, originalQuantity, originalPrice} = await cds.tx(req).run(q1)  
        
        //set Item number (if initial)
        if(!itemNumber) {
            const q2 = SELECT.one.from `OrderingService_OrderItems_drafts`
            .columns `{max(itemNumber) as maxItemNum}`
            .where `to_parent_ID = ${to_parent_ID}`
            const {maxItemNum} = await cds.tx(req).run(q2)
            req.data.itemNumber = maxItemNum + 1
        }
    })   
```

###3. Delivery Date, Delivery Timeが過去の場合、エラーにする
Orderエンティティの入力チェックは、保存ボタンを押したタイミングで実施することにします。（ドラフト保存のタイミングではチェックしない）
このため、以下2か所のイベントハンドラを実装します。

- before/CREATE/Orders
- before/UPDATE/Orders

CREATEイベントについては、1. で実装したイベントハンドラに処理を追加します。入力された日付、時刻をチェックするファンクションを作っておき、イベントハンドラから呼びます。

```javascript
const cds = require('@sap/cds')

const checkDeliveryDate = function (req) {
    const deliveryDateTime = new Date(`${req.data.date} ${req.data.time}`)
    if (deliveryDateTime < new Date()) {
        return req.error (409, `Delivery date time must not be past`)
    }
}

module.exports = function () {
    const { Orders, OrderItems } = cds.entities

    this.before('CREATE', 'Orders', async (req) => {
        checkDeliveryDate(req)

        //select max orderId from table
        ...

    })
```

UPDATEイベントについては、以下のイベントハンドラを新規に登録します。

```javascript
    this.before('UPDATE', 'Orders', (req) => {
        checkDeliveryDate(req)
    })
```



###4. 明細のQuantityまたはPriceが更新されたら、Amountを再計算する
2.で登録した以下のイベントハンドラに処理を追加します。
before/PATCH/OrderItem
PATCHイベントには、更新された項目のみ渡されます。よってquantityまたはpriceが初期値でなければ、それらの項目が更新されたと判断できます。

```javascript
    //Event handlers for OrderItems
    this.before('PATCH', 'OrderItems', async (req) => {       
        //get draft data from table
        const q1 = ...
        
        //set Item number (if initial)
        if(!itemNumber) {
            ...
        }

        //when quantity or price have been changed
        if (req.data.quantity || req.data.price) {
            //get original quantity and price from draft table      
            const quantity = req.data.quantity ? req.data.quantity : originalQuantity
            const price = req.data.price ? req.data.price : originalPrice

            //calculate new amount
            req.data.amount = quantity * price 
        }
    })  
```

###5. 明細金額が更新、または明細削除されたら、ヘッダのTotal Amountを更新する
以下2か所のイベントハンドラを実装します。

- before/PATCH/OrderItem（金額更新時）
- on/CANCEL/OrderItem（明細削除時）

まず、明細の合計金額を取得してTotalAmountを更新するメソッドを作成しておきます。明細金額の更新時に呼ばれるため、更新された明細の金額とそれ以外の明細の金額を合計してTotalAmountとするようにしています。

```javascript
const cds = require('@sap/cds')

...

const setTotalAmount = async function (req, to_parent_ID, itemAmount) {
    //calculate total amount
    //1. get all items belonging to the same parent order
    const q1 =  SELECT.one.from `OrderingService_OrderItems_drafts`
    .columns `{sum(amount) as restAmount}`
    .where `to_parent_ID = ${to_parent_ID} and ID <> ${req.data.ID}`
    const {restAmount} = await cds.tx(req).run(q1)
    const totalAmount = itemAmount + restAmount

    //2. set total amount to header
    const q2 = UPDATE `OrderingService_Orders_drafts` .set `totalAmount = ${totalAmount}`
                .where `ID = ${to_parent_ID}`
    return cds.tx(req).run(q2)
}

module.exports = function () {
    const { Orders, OrderItems } = cds.entities
    ...
```

OrderItemsエンティティのPATCHイベントにTotal Amountの更新処理を追加します。

```javascript
    //Event handlers for OrderItems
    this.before('PATCH', 'OrderItems', async (req) => {
        ...

        //when quantity or price have been changed
        if (req.data.quantity || req.data.price) {
            //get original quantity and price from draft table      
            const quantity = req.data.quantity ? req.data.quantity : originalQuantity
            const price = req.data.price ? req.data.price : originalPrice

            //calculate new amount
            req.data.amount = quantity * price 
            await setTotalAmount(req, to_parent_ID, req.data.amount) //追加
        }
    }) 
```

OrderItemsエンティティのCANCELイベント用のハンドラを新規に登録します。ここでは削除された明細の金額を除いて合計金額を計算するようにします。なお、ここだけタイミングを[before](https://cap.cloud.sap/docs/node.js/services#srv-before)ではなく[on](https://cap.cloud.sap/docs/node.js/services#srv-on)としています。削除処理が失敗した場合は合計金額を再計算したくないので、まず`await next()`でもともとの削除処理を呼び出し、それが終わってから合計金額をセットするようにしています。
※当初[after](https://cap.cloud.sap/docs/node.js/services#srv-after)イベントでの処理を考えていましたが、afterでは非同期処理は実行できないことがわかり、onに変更したという経緯があります。

```javascript
    this.on('CANCEL', 'OrderItems', async (req, next) => {
        //get parent id from table
        const q1 = SELECT.one.from `OrderingService_OrderItems_drafts`
        .columns `{to_parent_ID}`.where `ID = ${req.data.ID}`
        const {to_parent_ID} = await cds.tx(req).run(q1)      
        await next() 
        await setTotalAmount(req, to_parent_ID, 0) //exclude deleted item amount   
    })  
```

##次回予告
明細の更新、削除時、バックエンドからは更新された明細の情報しか返されません。CAP側でTotal Amountの更新処理を入れたとしても、現状では画面のTotal Amountは変わりません。

画面側でヘッダの合計金額を更新するためにはヘッダ情報を取り直す必要があり、その際は[Side Effects](https://sapui5.hana.ondemand.com/#/topic/fccfb2eb41414f0792c165e69a878717)というテクニックを使うことができます。次回はSide Effectsについて説明したいと思います。