スプレッドシートで顧客管理を作る#4

スプレッドシートで顧客管理を作る

前回、データの更新・削除処理を作りました。
今回はソースコードの見通しを良くしたいと思います。

今回の目標

見た目はほとんど変わりません。
が、ボタンを連打できなくなっていたり、使用者が見えないソースコードが把握しやすくなっています。

処理の流れを整理する

今回、顧客管理を作るにあたって、
1.フォームやデータシートの作成・データの登録処理
2.データの検索処理・検索結果の描画処理
3.データの更新処理・削除処理
上記の順番で実装してきました。

Apps Script 上のソースコードも、記事に合わせて#1. #2. #3. となっています。
#1.には登録周りの処理
#2.には検索・結果描画の処理
#3.には更新・削除の処理
となっています。
ですが、#2で検索処理をする際や#3で更新・削除処理をする際に、#1で作成したフォーム上のデータを取得する関数を利用していたり、フォームの情報を初期化する処理を呼び出したりしています。
できることなら、フォーム上のデータを扱う処理はまとめて書きたいところです。
ということで今回はリファクタリング回です。
・登録シート
・データシート
・ボタンとの対応付け
に分けてみたいと思います。
顧客管理全体の流れとしては、
1.検索して登録がなければ新規登録
2.検索して情報が誤っていれば更新処理
3.検索して不要なデータなら削除処理
といった感じですね。

flowchart LR A[顧客管理開始] --> B[検索] --> C{既に登録済み} C --> |いいえ| D[新規登録] --> B C -.-> |はい| E{データチェック} E --> |更新処理が必要| F[更新] --> B E -.-> |不要なデータ| G[削除] --> B

最終的に下記のように3つのスクリプトに分けます。
早足で行くので、細かいところはソースコード内のコメントを参考にしてください。

作成するスクリプトは、
ユーザーの操作を受け付ける(ボタンから呼び出される関数群) onClick
フォームデータの取得や検索結果の描画などを扱う addFormSheet
登録・更新・削除・検索など、データを処理する dataSheet
となります。
各スクリプトは下記のように動作します。

flowchart LR onClick --> |1|A[addFomSheet] --> |2| dataSheet --> |3| A ユーザーがボタンを押下 --> |1|B[フォーム上のデータをごにょごにょ] --> |2|データをごにょごにょ --> |3|フォーム上で通知

登録シート

登録シート関係の処理をまとめていきます。
使用者が登録シート上で行う処理を整理します。

登録処理登録フォーム上のデータを取得ポップアップの表示フォームの初期化
検索処理登録フォーム上のデータを取得検索結果の描画検索結果の描画切り替え
更新処理登録フォーム上のデータを取得ポップアップの表示
削除処理登録フォーム上のデータを取得ポップアップの表示フォームの初期化

表面上の動作のみでも、共通処理がいくつかありました。
上記を一つのオブジェクトとして処理をまとめていきます。
また、作ったものを消してしまうのはあまりよくないので、登録シートをコピーして addSheet というシートを対象にしています。

// 登録シートを管理するオブジェクト
const AddFormSheet = {
  SHEET_NAME: 'addSheet',
  sheet: null,
  // 登録フォームのシートを返す
  getSheet() {
    if(this.sheet === null) this.sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(this.SHEET_NAME);
    return this.sheet;
  },
  // 登録フォームの Range を返す
  getFormDataRange() {
    return this.getSheet().getRange(3, 1, 1, 5);
  },
  // 登録フォームの内容を返す [id, name, address, tel, remarks]
  getFormData() {
    return this.getFormDataRange().getValues()[0];
  },
  // フォームの初期化
  clearForm() {
    // 登録フォームの初期化
    this.getFormDataRange().clearContent();
    // 検索結果の初期化
    this.getFoundIndexRange().clearContent();
    this.getDrawingIndexRange().clearContent();
    this.clearFindResult();
  },
  // ポップアップの表示
  popup(str) {
    SpreadsheetApp.getUi().alert(str);
  },
  // 検索結果の描画
  drawFindData(data) {
    this.getFormDataRange().setValues([data]);
  },
  // 検索結果のインデックスを保持しておくセルを返す
  getFoundIndexRange() {
    return this.getSheet().getRange(4, 1);
  },
  // 検索結果をセルに描画しておく
  drawFoundDataIndex(foundIndex) {
    this.getFoundIndexRange().setValue(foundIndex);
  },
  // 検索結果のインデックスを配列で返す
  getFoundIndex() {
    const foundINdex = this.getFoundIndexRange().getValue() + '';
    return foundINdex.indexOf(',') === -1 ? [foundINdex] : foundINdex.split(',').filter(e => e !== '');
  },
  // 描画中の検索結果の添え字番号を保持しておくセルを返す
  getDrawingIndexRange() {
    return this.getSheet().getRange(4, 2);
  },
  // 描画中の検索結果の添え字番号をセルに描画しておく
  drawDrawingIndex(index) {
    this.getDrawingIndexRange().setValue(index);
  },
  // 描画中の検索結果の添え字を返す
  getDrawingIndex() {
    return this.getDrawingIndexRange().getValue();
  },
  // 検索件数と描画中のインデックスを初期化する
  clearFindResult() {
    this.getSheet().getRange(5, 3, 1, 2).setValues([['検索結果:', '表示中:']]);
  },
  // 検索件数の描画
  drawFindResultNum(num) {
    this.getSheet().getRange(5, 3).setValue('検索結果:' + num + '件');
  },
  // 表示中のインデックスの描画
  drawFindIndex(max, num) {
    this.getSheet().getRange(5, 4).setValue('表示中' + num + '/' + max);
  },
  // 連打できないようにセル上にフラグを用意する
  getLockRange() {
    return this.getSheet().getRange(5, 1);
  },
  // ロックに失敗した場合はほかのスクリプトが実行中
  lock() {
    // シートにアクセスする処理は時間がかかるため、LockService を利用して同期的に処理する
    const lock = LockService.getUserLock();
    try {
      lock.waitLock(5);
    } catch(e) {
      return false;
    }
    if(this.getLockRange().getValue()) return false;
    this.getLockRange().setValue(true);
    SpreadsheetApp.flush();
    return true;
  },
  // スクリプト終了時に呼び出してロックを解除する
  releaseLock() {
    const lock = LockService.getUserLock();
    lock.tryLock(5);
    this.getLockRange().setValue(false);
    SpreadsheetApp.flush();
    lock.releaseLock();
  }
}

最初から全文です。
特に難しいことはしていないと思います。
新しく追加してたことと言えば、LockService を利用して連打した時の処理に対応しています。

データシート

データシート関係の処理をまとめていきます。
使用者がデータシート上で何かを操作することは特にありません。
データシートは「登録フォームの使用者が操作した結果が反映されている」に過ぎません。
なので、登録シート上で操作があった時、データを提供したり、提供されたデータを反映したりする処理をまとめていきます。

// データシートを管理するオブジェクト
const DataSheet = {
  SHEET_NAME: 'data',
  sheet: null,
  data: null,
  // データシートを返す
  getSheet() {
    if(this.sheet === null) this.sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(this.SHEET_NAME);
    return this.sheet;
  },
  // 全データを返す
  getData(flg = false) {
    if(flg || this.data === null) this.data = this.getSheet().getDataRange().getValues();
    return this.data;
  },
  // 最終行の id を返す
  getLastRowId() {
    return this.getSheet().getRange(this.getSheet().getLastRow(), 1).getValue();
  },
  // 登録処理
  addData(data) {
    // 最終行の id + 1を id にする
    const id = this.getLastRowId();
    // 検索後、登録ボタンを押下すると、id が入力された状態で登録データが渡されるため、データが5個以上の場合は0個目を削除する
    while(5 <= data.length) data.splice(0, 1);
    // 1行目にヘッダー行が存在するので、現在登録されているデータが1つのみなら、 0 を id にする
    if(this.getData().length === 1) data.unshift(0);
    // 一応文字列の可能性を消しておく 文字列の数字に * 1 すると数値として扱えるようになる
    else data.unshift(id * 1 + 1);
    // データシートに追加
    this.getSheet().appendRow(data);
    return '登録しました。'
  },
  // 更新処理
  updateData(data) {
    // 行番号を取得する
    const row = this.getFoundRow(data[0]);
    if(row === -1) return '更新に失敗しました。\n登録されていない id のようです。';
    // 保持しているデータを更新する
    this.getData()[row - 1] = data;
    // データシートを更新する
    this.getSheet().getRange(row, 1, 1, data.length).setValues([data]);
    return '更新しました!';
  },
  // 削除処理
  deleteData(id) {
    const row = this.getFoundRow(id);
    if(row === -1) return '削除に失敗しました。\n登録されていない id のようです。';
    // データシート上の行を削除
    this.getSheet().deleteRow(row);
    // 保持しているデータをデータシート上のデータと合わせる
    this.getData(true);
    return '削除しました!';
  },
  // 検索処理 search は配列。検索に引っかかった行を , 区切りの文字列で返す。見つからなければ '' を返す
  findData(search) {
    const data = this.getData().map(e => e.join(' '));
    search = search.filter(e => e !== '');
    const foundIndex = [];
    for(let row = 1; row < data.length; row ++) {
      for(let index = 0; index < search.length; index ++) {
        if(data[row].indexOf(search[index]) === -1) continue;
        foundIndex.push(row);
        break;
      }
    }
    return foundIndex.join(',');
  },
  // id の行番号を返す 見つからなかったら -1 を返す
  getFoundRow(id) {
    const data = this.getData();
    for(let row = 0; row < data.length; row ++) {
      if(data[row][0] !== id) continue;
      return row + 1;
    }
    return -1;
  },
  // 検索結果を取得する
  getFoundData(index) {
    return this.getData()[index];
  }
}

全文です。
気を付けたほうが良いのは登録処理(addData())のところくらいだと思います。
登録する際、id を自動で割り当てているため、データシートの最終行の id + 1 を登録するデータの最初に追加しています。
しかし検索後、登録ボタンを押下すると、

フォームから渡されたID名前住所電話番号備考

という配列が引数に渡されます。
何も考えずに、データシートの最終行の id + 1 を登録するデータの最初に追加すると、

IDフォームから渡されたID名前住所電話番号備考

となってしまい、エラーになってしまいます。
なので25行目で、登録データの個数以上の場合は、登録データの数になるまで、先頭のデータを削除しています。

ボタンとの対応付け

ボタンに処理を登録するいつものやつですね。
また、処理完了時にメッセージを表示するようにします。

// 登録ボタンの処理
const addDataFunc = () => {
  // 連打できないようにシート上にロック状態を保持する
  if(!AddFormSheet.lock()) {
    AddFormSheet.popup('現在他の処理を実行中です!');
    return;
  }
  // 登録フォームシートオブジェクトから登録用のデータを取得する
  const formData = AddFormSheet.getFormData();
  // データシートオブジェクトにデータを渡して登録する。成否のメッセージを取得する
  const mes = DataSheet.addData(formData);
  // フォームの初期化
  AddFormSheet.clearForm();
  // ポップアップの描画
  AddFormSheet.popup(mes);
  // ロックを解除する
  AddFormSheet.releaseLock();
}
// 更新ボタンの処理
const updateDataFunc = () => {
  // 連打できないようにシート上にロック状態を保持する
  if(!AddFormSheet.lock()) {
    AddFormSheet.popup('現在他の処理を実行中です!');
    return;
  }
  // 登録フォームシートオブジェクトから更新用のデータを取得する
  const formData = AddFormSheet.getFormData();
  // データシートオブジェクトにデータを渡して更新する。成否のメッセージを取得する
  const mes = DataSheet.updateData(formData);
  // ポップアップの描画
  AddFormSheet.popup(mes);
  // ロックを解除する
  AddFormSheet.releaseLock();
}
// 削除ボタンの処理
const deleteDataFunc = () => {
  // 連打できないようにシート上にロック状態を保持する
  if(!AddFormSheet.lock()) {
    AddFormSheet.popup('現在他の処理を実行中です!');
    return;
  }
  // 登録フォームシートオブジェクトからの 削除対象の id を取得する
  const formData = AddFormSheet.getFormData();
  // データシートオブジェクトにデータを渡して削除する。成否のメッセージを取得する
  const mes = DataSheet.deleteData(formData[0]);
  // フォームの初期化
  AddFormSheet.clearForm();
  // ポップアップの描画
  AddFormSheet.popup(mes);
  // ロックを解除する
  AddFormSheet.releaseLock();
}
// 検索ボタンの処理
const findDataFunc = () => {
  // 連打できないようにシート上にロック状態を保持する
  if(!AddFormSheet.lock()) {
    AddFormSheet.popup('現在他の処理を実行中です!');
    return;
  }
  // 登録フォームシートオブジェクトから検索用のデータを取得する
  const formData = AddFormSheet.getFormData();
  // データシートオブジェクトにデータを渡して検索する
  const foundIndex = DataSheet.findData(formData);
  if(foundIndex === '') {
    AddFormSheet.popup('登録されているデータに含まれていないようです。');
    // ロックを解除する
    AddFormSheet.releaseLock();
    return;
  }
  // 検索結果のインデックスを登録シート上に描画しておく
  AddFormSheet.drawFoundDataIndex(foundIndex);
  // 描画中の検索結果の添え字番号を登録シート上に描画しておく
  AddFormSheet.drawDrawingIndex(0);
  // 検索結果の件数を描画する
  const foundIndexArray = foundIndex.split(',');
  AddFormSheet.drawFindResultNum(foundIndexArray.length);
  // 現在フォームに表示中のインデックスを描画する
  AddFormSheet.drawFindIndex(foundIndexArray.length, 1);
  // 検索結果
  const findResult = DataSheet.getFoundData(foundIndexArray.filter(e => e !== '')[0]);
  // 検索結果の最初のデータを登録シート上に描画しておく
  AddFormSheet.drawFindData(findResult);
  // ロックを解除する
    AddFormSheet.releaseLock();
}
// 検索結果切り替えボタンの処理
const nextFindDataFunc = () => {
  // 連打できないようにシート上にロック状態を保持する
  if(!AddFormSheet.lock()) {
    AddFormSheet.popup('現在他の処理を実行中です!');
    return;
  }
  // 描画中の検索結果の添え字番号
  const drawingIndex = AddFormSheet.getDrawingIndex();
  // 検索結果が見つからない場合
  if(!MyValidation.isNumber(drawingIndex)) {
    AddFormSheet.popup('検索結果が見つかりません。');
    AddFormSheet.releaseLock();
    return;
  }
  // 検索結果のインデックス
  const foundIndex = AddFormSheet.getFoundIndex();
  // 検索結果の数より描画予定の添え字番号が大きい場合は 0 番目の検索結果を描画する
  const nextIndex = foundIndex.length <= drawingIndex * 1 + 1 ? 0 : drawingIndex * 1 + 1;
  // 表示する添え字番号を登録シート上に記録する
  AddFormSheet.drawDrawingIndex(nextIndex);
  // 検索結果の件数を描画する
  AddFormSheet.drawFindResultNum(foundIndex.length);
  // 現在フォームに表示中のインデックスを描画する
  AddFormSheet.drawFindIndex(foundIndex.length, nextIndex + 1);
  // 次の検索結果を描画する
  AddFormSheet.drawFindData(DataSheet.getFoundData(foundIndex[nextIndex]));
  // ロックを解除する
  AddFormSheet.releaseLock();
}
const previousFindDataFunc = () => {
  // 連打できないようにシート上にロック状態を保持する
  if(!AddFormSheet.lock()) {
    AddFormSheet.popup('現在他の処理を実行中です!');
    return;
  }
  // 描画中の検索結果の添え字番号
  const drawingIndex = AddFormSheet.getDrawingIndex();
  // 検索結果が見つからない場合
  if(!MyValidation.isNumber(drawingIndex)) {
    AddFormSheet.popup('検索結果が見つかりません。');
    // ロックを解除する
    AddFormSheet.releaseLock();
    return;
  }
  // 検索結果のインデックス
  const foundIndex = AddFormSheet.getFoundIndex();
  // 描画予定の添え字番号が 0 より小さい場合は検索結果の最後の要素を描画する
  const nextIndex = drawingIndex * 1 - 1 < 0 ? foundIndex.length - 1 : drawingIndex * 1 - 1;
  // 表示する添え字番号を登録シート上に記録する
  AddFormSheet.drawDrawingIndex(nextIndex);
  // 検索結果の件数を描画する
  AddFormSheet.drawFindResultNum(foundIndex.length);
  // 現在フォームに表示中のインデックスを描画する
  AddFormSheet.drawFindIndex(foundIndex.length, nextIndex + 1);
  // 次の検索結果を描画する
  AddFormSheet.drawFindData(DataSheet.getFoundData(foundIndex[nextIndex]));
  // ロックを解除する
  AddFormSheet.releaseLock();
}

最後に

さて、一応今回で「スプレッドシートで顧客管理を作る」 が終了になります。
最初よりも見通しの良いソースコードになった気がします。
このような作りにしておくと何が良いかというと、対象のシートを簡単に変更できます。
さらりと登録フォームの対象シートを変更していますが、もし今回のような作りにしていなかった場合、
#1. のスクリプト上で登録シートを対象に動作している変数をすべて書き換え
#2. のスクリプト上で登録シートを対象に動作している変数をすべて書き換え
#3. のスクリプト上で登録シートを対象に動作している変数をすべて書き換え
といったことをしなくてはなりません。
リファクタリングをした後なら、addFormSheet オブジェクトの SHEET_NAME を書き換えるだけで対象シートを変更できます。
データシートについても同様に、 dataSheet オブジェクトの SHEET_NAME を書き換えるだけでデータを保持するシートを変更できます。
ちなみに GAS にはプロパティサービスといったものも存在するので、そちらを利用するのも一つです。

また、今回作成したものは、Web版のスプレッドシート上でしか動作しません。
もし、iPad や スマホを利用したい場合は GAS × HTML をするとどうにかなります。
そのうち、別の内容で取り扱いと思います。

*このサイトを参考に何かを作成した際に起こった全ての責任を負いません*