LINEとGASで家計簿(コードの紹介)

GAS
スポンサーリンク

LINEとGASで家計簿の紹介記事


こちらの記事で言っていたやつです。

前回の記事から1カ月経っていたんですね・・・
時間が経つのが早くて恐ろしいです。

この記事ではコードを載せていきます。
Gitとかにあげればいいんでしょうが、1カ月経っちゃったので色々やる前にひとまずブログに載せます。

ちょっと長いし処理も頭もよくないかもしれませんが動きますのでよければ参考にしてみてください。

※2022/5 現在またグラフを取得するメソッドがエラーを吐くようになったので回避策を取らないといけなくなりました。具体的にはコメントに残しますのでお気を付けください。

コード.gs

// 「メッセージ送受信設定」のアクセストークン(ロングターム)を記入してください。
const ACCESS_TOKEN = '***************';

// 発言させたい日時と内容が書かれたシートのKeyを記入してください。/d/~/editの「~」の部分です。
// シート名には「alarm」としてください。
const SHEET_KEY = '*****************';

//このGASと紐づくスプレッドシートのURL。LINEから「設定」とトークした時に返ってくる返事の内容です。
const ss_URL = 'https://docs.google.com/spreadsheets/d/******/edit#gid=*****';

//LINE message api
const REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
const PUSH_URL = 'https://api.line.me/v2/bot/message/push';

// 特定のシートのメモ化を行う関数
function getSrcSheet() {
  if (getSrcSheet.src_sheet) { return getSrcSheet.src_sheet; }
  getSrcSheet.src_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('一覧');
  return getSrcSheet.src_sheet;
}
function getAggregationSheet() {
  if (getAggregationSheet.aggregation_sheet) { return getAggregationSheet.aggregation_sheet; }
  getAggregationSheet.aggregation_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('集計');
  return getAggregationSheet.aggregation_sheet;
}
function getMonthlySheet() {
  if (getMonthlySheet.monthly_sheet) { return getMonthlySheet.monthly_sheet; }
  getMonthlySheet.monthly_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('月集計');
  return getMonthlySheet.monthly_sheet;
}
function getUserIdSheet() {
  if (getUserIdSheet.useid_sheet) { return getUserIdSheet.useid_sheet; }
  getUserIdSheet.useid_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('userId');
  return getUserIdSheet.useid_sheet;
}
function getGraphSheet() {
  if (getGraphSheet.graph_sheet) { return getGraphSheet.graph_sheet; }
  getGraphSheet.graph_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('グラフ');
  return getGraphSheet.graph_sheet;
}
function getCategorySheet() {
  if (getCategorySheet.category_sheet) { return getCategorySheet.category_sheet; }
  getCategorySheet.category_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('分類');
  return getCategorySheet.category_sheet;
}
function getFixedCostSheet() {
  if (getFixedCostSheet.fixedcost_sheet) { return getFixedCostSheet.fixedcost_sheet; }
  getFixedCostSheet.fixedcost_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('固定費');
  return getFixedCostSheet.fixedcost_sheet;
}
function getResultSheet() {
  if (getResultSheet.result_sheet) { return getResultSheet.result_sheet; }
  getResultSheet.result_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('結果');
  return getResultSheet.result_sheet;
}
function getTestSheet() {
  if (getTestSheet.test_sheet) { return getTestSheet.test_sheet; }
  getTestSheet.test_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('test');
  return getTestSheet.test_sheet;
}

const src_sheet = getSrcSheet();
const aggregation_sheet = getAggregationSheet();
const monthly_sheet = getMonthlySheet();
const userid_sheet = getUserIdSheet();
const graph_seet = getGraphSheet();
const category_sheet = getCategorySheet();
const result_sheet = getResultSheet();
const fixedcost_sheet = getFixedCostSheet();
const test_sheet = getTestSheet();
const today = (Utilities.formatDate( new Date(), 'Asia/Tokyo', 'yyyy/MM/dd'));

function debugSendGraph(){
  const message = sendGraph();
  console.log(message);
}
function debugMessage(event){
  // testシートにメッセージを書き込む
  var test_sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('test');
  var last_row = test_sheet.getLastRow() + 1;
  test_sheet.getRange(last_row,1).setValue(event);
  var message = "メッセージを記録しました。";
  return message;
}

function doPost(e) {
  try{
    const event = JSON.parse(e.postData.contents).events[0];
    const userMessage = event.message.text;
    const replyToken = event.replyToken;
    const talkType = event.message.type;
    let talkID = "";
    if (event.source.type === "group") {
      talkID = event.source.groupId;
    } else if (event.source.type === "room") {
      talkID = event.source.roomId;
    }else if(event.source.type === "user"){
    talkID = event.source.userId;
    }
    test_sheet.getRange("A1").setValue(event);
    const genre_list = ['収入','固定費','変動費'];
    const genre_reg = new RegExp('^' + genre_list.join('$|^') + '$');
    const genre_mth_reg = new RegExp('^(0?[1-9]|1[012])月(' + genre_list.join('$|') + '$)');    
    const category_list = getCategoryList();
    const category_reg = '^' + category_list.join('$|^') + '$';
    const cat_reg = new RegExp(category_reg);
    const category_month_reg =  '(' + category_list.join('$|') + '$)';
    const cat_mth_regexp = new RegExp('^(0?[1-9]|1[012])月' + category_month_reg);
    let message = "";
    if (talkType == "text"){
      const this_month = (Utilities.formatDate( new Date(), 'Asia/Tokyo', 'yyyy/MM'));
      if (userMessage === "debug") {
        message = tellID(event);
      }
      else if (userMessage.match(/^設定$/)){
        message = "下記URLよりスプレッドシートにアクセスしてください。\r"+ ss_URL;
      }      
      else if (userMessage.match(/^科目$/)){
        message = showCategory();
      }
      else if (userMessage.match(/^.*\n[-]?([1-9]\d*|0)$/)){
        message = registCost(event);
      }
      else if (userMessage.match(/^いくら?$|今いくら?$/)){
        message = displayMonthlyCost(this_month);
      }
      else if (userMessage.match(/^(0?[1-9]|1[012])月いくら?$/)){
        const split_txt = event.message.text.split('月いくら?');
        const month = split_txt[0];
        const date = new Date();
        date.setMonth(month -1)
        const target_month = (Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM'));
        message = displayMonthlyCost(target_month);
      }
      else if (userMessage.match(genre_reg)){
        message = displayGenre(userMessage);
      }
      else if (userMessage.match(genre_mth_reg)){
        const split_txt = userMessage.split('月');
        test_sheet.getRange("A19").setValue(userMessage.match(split_txt));
        if(split_txt.length == 2){
          const genre = split_txt[1];
          const month = split_txt[0];
          const date = new Date();
          date.setMonth(month -1)
          const year_month = (Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM'));
          test_sheet.getRange("A16").setValue(year_month);
          message = displayGenre(genre, year_month);
        }
      }
      else if (userMessage.match(/^とりけし$|^取り消し$/)){
        message = undoSrc();
        registResult(this_month);
      }
      else if (userMessage === "グラフ"){
        message = sendGraph(talkID);
      }
      else if (userMessage.match(cat_reg)){
        message = displayDetailCategory(userMessage);
      }
      else if (userMessage.match(cat_mth_regexp)){
        const split_txt = userMessage.split('月');
        if(split_txt.length == 2){
          const category = split_txt[1];
          const month = split_txt[0];
          const date = new Date();
          date.setMonth(month -1)
          const year_month = (Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM'));
          message = displayDetailCategory(category, year_month);
        }else{
          message = displayDetailCategory(userMessage);  
        }
      }
      else if (userMessage.match(/^おしえて$|^教えて$|^どうやるの.$|^help$|^usage$/)){
        message = '次のようにメッセージを送ってください。😁\n';
        message += '\n①収入支出の登録\n◇[科目]<改行>金額◇\n(,や円はつけない数字だけ)\n※[科目]とは...「科目」と入力すると一覧が確認できます!\n'
        message += '\n②科目など間違えて登録してしまった場合は、直前の登録を取り消すことができます!\n◇取り消し◇\nまたは\n◇とりけし◇\n'
        message += '\n③今月の現在までの出費確認\n◇いくら?◇\nまたは\n◇今いくら?◇\n';
        message += '\n④今月の科目ごとの出費確認\n◇<科目>◇\n※登録されていない科目は、「その他」から確認できます。\n';
        message += '\n⑤指定した月の出費確認。1カ月分ずつ聞いてください。\n◇<1-12>月いくら?◇\n';
        message += '\n⑥グラフを見たいとき\n◇グラフ◇'
      }
    }else if (talkType === "image"){
      message = "user posted a image.In develop."
    }
      
    replyMessage(replyToken, message);
    //登録時に実行すると返事が遅くなりすぎるためdoGetのあとに必ず実行するようにした
    refreshResult();    
    return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
  }catch(e){
    
    test_sheet.getRange("A1").setValue("エラーおきた\n" + e);
  }
}

function showCategory(){
  const kind = category_sheet.getRange('A:A').getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  const categories = category_sheet.getRange('B:B').getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  if(!categories){return "科目が見つかりません";}
  let message = "";
  for(let i = 0; i < categories.length; i++){
    message += kind[i] + ": " + categories[i] + "\n";
  }
  console.log(message);
  return message;
}

function replyMessage(token, message) {
  const response = UrlFetchApp.fetch(REPLY_URL, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + ACCESS_TOKEN,
    },
    'method': 'post',
    'muteHttpExceptions': true,
    'payload': JSON.stringify({
      'replyToken': token,
      'messages': [{
        'type': 'text',
        'text': message,
      }],
    }),
  });
  test_sheet.getRange("A2").setValue(response);
  return response.getResponseCode();
}

function findRow(sheet,val,col){
  var dat = sheet.getDataRange().getValues(); //受け取ったシートのデータを二次元配列に取得
  for(var i=0;i<dat.length;i++){
    if(dat[i][col-1] === val){
      return i+1;
    }
  }
  return 0;
}

function findCol(sheet,val,row){
  var dat = sheet.getDataRange().getValues(); //受け取ったシートのデータを二次元配列に取得
  for(var i=1;i<dat.length;i++){
    if(dat[row-1][i] === val){
      return i+1;
    }
  }
  return 0;
}

// 引数 timestamp は13桁
const timestampToTime = (timestamp) => {
  const date = new Date(timestamp);
  const yyyy = `${date.getFullYear()}`;
  // .slice(-2)で文字列中の末尾の2文字を取得する
  // `0${date.getHoge()}`.slice(-2) と書くことで0埋めをする
  const MM = `0${date.getMonth() + 1}`.slice(-2); // getMonth()の返り値は0が基点
  const dd = `0${date.getDate()}`.slice(-2);
  const HH = `0${date.getHours()}`.slice(-2);
  const mm = `0${date.getMinutes()}`.slice(-2);
  const ss = `0${date.getSeconds()}`.slice(-2);
  return `${yyyy}/${MM}/${dd} ${HH}:${mm}:${ss}`;
}

function tellID(event) {
  // ID
  var userID = event.source.userId;
  var talkID = "";
  if(event.source.type === "group") {
    talkID = event.source.groupId;
  }else if(event.source.type === "room") {
    talkID = event.source.roomId;
  }else if(event.source.type === "user"){
    talkID = event.source.userId;
  }

  var message = "あなたのID: " + userID;
  if (talkID != "") {
    message += "\nこのチャットのID: " + talkID;
  }

  return message;
}

コード.gsでは、LINEボットのアクセストークンやスプレッドシートのキーやURLなどの環境ごとに決まった値を入力することにしています。定数ですね。
また、シートを取得するAPIを何度も叩かないように関数化したり、使いまわす関数がまとまっています。

メインはdoPost関数で、LINEでトークした内容がこの関数に送られます。
この関数でメッセージ毎の分岐処理を書いているんですね。

何か分岐処理を増やしたくなったらこちらを増築していく感じです。

グラフ.gs

function sendGraph(talkId){
  const folderId = '**********';  // Googleドライブの一時フォルダのID
  const today = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'YYYY-MM-dd');
  const file_name = today + "_household_account.png"  
  // そのシートにある全てのグラフを取得
  const charts = getGraphSheet().getCharts();
  // 取得したグラフの数だけ処理を繰り返す
  for(let i in charts) {
    let chart = charts[i];   
    let imageBlob = chart.getAs("image/png");
    let folder = DriveApp.getFolderById(folderId);  
    let file = folder.createFile(imageBlob.setName(file_name));
    file.setSharing(DriveApp.Access.ANYONE, DriveApp.Permission.EDIT);
    console.log(file.getDownloadUrl());
    pushImage(talkId, file.getDownloadUrl(),file.getDownloadUrl());
    file.setTrashed(true);
  }
  return '以上!';
}
function pushImage(to, src, srcPreview) {
  const url = "https://api.line.me/v2/bot/message/push";
  const headers = {
    "Content-Type" : "application/json; charset=UTF-8",
    'Authorization': 'Bearer ' + ACCESS_TOKEN,
  };
  const postData = {
    "to" : to,
    "messages" : [
      {
        'type':'image',
        'originalContentUrl':src,
        'previewImageUrl':srcPreview,
      }     
    ]
  };
  const options = {
    "method" : "post",
    "headers" : headers,
    "payload" : JSON.stringify(postData),
  };
  return UrlFetchApp.fetch(url, options);
}

グラフ.gsではLINEで「グラフ」とメッセージした時に実行する処理が書かれています。
環境ごとに違う値になるのはfolderIdという変数だけです。
グラフを画像にしてLINEに返信します。
グラフ画像が複数になると返信までちょっと時間がかかるので、「以上!」と返信するようにして画像が送信し終わったか分かるようにしています。

計算と記録.gs

//トリガー設定で毎月1日に当月の固定費を記録する
function registFixedCost(){
  const this_month = (Utilities.formatDate( new Date(), 'Asia/Tokyo', 'yyyy/MM'));
  const cost_categories = fixedcost_sheet.getRange('A2:A100').getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  const fixed_consts = fixedcost_sheet.getRange("B2:B100").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  const result_header = result_sheet.getRange("1:1").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  const result_rows = result_sheet.getRange("A:A").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  let target_row;
  
  for(let i = 0; i < cost_categories.length; i++){
    for(let k = 0; k < result_rows.length; k++){
      if(Utilities.formatDate(new Date(result_rows[k]), 'Asia/Tokyo', 'yyyy/MM') == this_month){
        target_row = k+1;//配列だと0から、シート上だと1からなので
      }
    }
    for(let j = 0; j < result_header.length; j++){
      if(result_header[j] == cost_categories[i]){
        result_sheet.getRange(target_row,j + 1,1,1).setValue(fixed_consts[i]);
      }
    }
  }
}

function undoSrc(){
  const src_sheet = getSrcSheet();
  const last_row = src_sheet.getLastRow();
  const content = src_sheet.getRange(last_row,4,1,2).getValues();
  if(content[0][0].includes('取り消し')){return 'すでに取り消しています。'}
  src_sheet.getRange(last_row,4,1,1).setValue("取り消し(" + content[0][0] + ":" + content[0][1] + ")");
  src_sheet.getRange(last_row,5,1,1).setValue(0);
  const this_month = (Utilities.formatDate( new Date(), 'Asia/Tokyo', 'yyyy/MM'));
  clearRegistValue('収入', this_month);
  clearRegistValue('変動費',this_month);
  registResult(this_month);
  const message = '直前の登録(' + content[0][0] + ":" + content[0][1] + ") の登録を取り消しました。\n「科目」と入力すると科目一覧が確認できます。"
  return message;
}

function clearRegistValue(genre, year_month){
  const result_sheet = getResultSheet();
  const target_row = findRow(result_sheet,year_month,1);
  const category_sheet = getCategorySheet();
  const genre_list = category_sheet.getRange("A:A").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  const category_list = category_sheet.getRange("B:B").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  let target_category = [];
  //対象ジャンルの科目のみの配列を作成
  for(let i = 0; i < genre_list.length; i++){
    if(genre_list[i] == genre){
      target_category.push(category_list[i]);
    }
  }
  //結果シートで収入の科目が記載されている列を探す
  let categories = [], target_cols = [];
  const result_header = result_sheet.getRange("1:1").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  for(let j = 0; j < result_header.length; j++){
    for(let k = 0; k < target_category.length; k++){
      if(result_header[j] == target_category[k]){
        target_cols.push(j);
        categories.push(result_header[j]);
      }
    }
  }
  result_sheet.getRange(target_row,target_cols[0] + 1,1,target_cols[target_cols.length - 1] - target_cols[0] + 1).setValue(0);
}

function registCost(event){
  // 一覧シートにメッセージを書き込む
  const last_row = src_sheet.getLastRow() + 1;
  const user = event.source.userId;
  const split_txt = event.message.text.split('\n');  
  const category = split_txt[0];
  const value = split_txt[1];
  const timestamp = timestampToTime(event.timestamp);
  src_sheet.getRange(last_row,1).setValue(timestamp);
  src_sheet.getRange(last_row,2).setValue(user);
  src_sheet.getRange(last_row,3).setFormula('=vlookup(B' + last_row + ',userId!$A$1:$B$3,2,FALSE)');
  src_sheet.getRange(last_row,4).setValue(category);
  src_sheet.getRange(last_row,5).setValue(value);
  registResult(category, value);
  const message = selectMessage(category);
  return message;
}

function selectMessage(category){
  const category_sheet = getCategorySheet();
  let message = "";
  const category_list = category_sheet.getRange("B:B").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  if(category_list.includes(category) === true){
    message = category + " を記録しました。";
  }else{
    message = category + " は集計科目ではありません。\n「その他」の費用として集計しました。";    
  }
  return message;
}

function getSrcWithYearMonth(year_month){
  const src_sheet = getSrcSheet();
  const data = src_sheet.getDataRange().getValues();
  const date_col = src_sheet.getRange("A:A").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  let tmp, ym, result_array = [];
  //該当期間のカテゴリと金額の二次元配列を作成
  for(let i = 1; i < date_col.length; i++){
    tmp = new Date(date_col[i]);
    ym = Utilities.formatDate(tmp, 'Asia/Tokyo', 'yyyy/MM');
    if(year_month == ym){
      result_array.push(data[i]);
    }else if(year_month < ym){
      break;
    }
  }
  return result_array;
}

function registResult(category, value){
  try{
    const result_sheet = getResultSheet();
    const this_month = (Utilities.formatDate( new Date(), 'Asia/Tokyo', 'yyyy/MM'));
    const target_row = findRow(result_sheet, this_month, 1);
    const target_col = findCol(result_sheet, category, 1);
    const before_val = result_sheet.getRange(target_row, target_col).getValue();
    const after_val = Number(before_val) + Number(value);
    if(!after_val){throw Error}
    result_sheet.getRange(target_row, target_col).setValue(after_val);
  }catch(e){
    console.log(e);
  }
}

//スプシに編集があるたびに実行。今月のsrcを集計して結果シートを更新
function refreshResult(year_month){
  try{
    if(!year_month){year_month = (Utilities.formatDate( new Date(), 'Asia/Tokyo', 'yyyy/MM'));}
    const result_sheet = getResultSheet();
    const target_range_array = getSrcWithYearMonth(year_month);
    //同じ科目の金額を集計
    const aggregated_array = aggregationCategory(target_range_array);

    //集計結果を結果シートに反映
    let category = "", value = 0, target_row = 0, target_col = 0;

    target_row = findRow(result_sheet, year_month, 1);
        console.time('func1-1');
    for(let j = 0; j < aggregated_array.length; j++){
      category = aggregated_array[j][0];
      value = aggregated_array[j][1];
      target_col = findCol(result_sheet, category, 1);
      result_sheet.getRange(target_row, target_col).setValue(value);
    }
          console.timeEnd('func1-1');
  }catch(e){
    console.log(e);
  }
}

//対象期間の配列から、すべての科目の金額を集計した配列を返す。
function aggregationCategory(array){
  let map = new Map;
  let tmp_results = [];
  for (let i = 0, l = array.length, element, key; i < l; ++i) {
    element = array[i];
    key = element[3];
    //elementの値が同じものを加算していく <代入先 ? trueの返り値 : falseの返り値>
    map.set(key, map.has(key) ? map.get(key) + element[4] : element[4]);
  }
  for(let iterator = map.entries(), i = 0, l = map.size; i < l; ++i){
    tmp_results.push(iterator.next().value);
  }
  //「その他」と科目名にないものを集約
  const category_list = getCategoryList();
  let other_value = 0, category = "", index = 0, value = 0, splice_num = [];
  for(let j = 0; j < tmp_results.length; j ++){
    category = tmp_results[j][0];
    value = tmp_results[j][1];
    if(category_list.includes(category) === false){
      tmp_results[j][0] = 'その他';
    }
  }
  let map_other = new Map;
  let results = [];
  for (let i = 0, l = tmp_results.length, element, key; i < l; ++i) {
    element = tmp_results[i];
    key = element[0];
    //elementの値が同じものを加算していく <代入先 ? trueの返り値 : falseの返り値>
    map_other.set(key, map_other.has(key) ? map_other.get(key) + element[1] : element[1]);
  }
  for(let iterator = map_other.entries(), i = 0, l = map_other.size; i < l; ++i){
    results.push(iterator.next().value);
  }
  return results;
}

function getCategoryList(){
  const category_sheet = getCategorySheet();
  const category_list = category_sheet.getRange("B:B").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  return category_list;
}

function displayMonthlyCost(month){
  try{
    const result_sheet = getResultSheet();
    const result_last_col = result_sheet.getLastColumn();
    const month_row = findRow(result_sheet, month, 1);
    const headers = result_sheet.getRange(1,2,1,result_last_col).getValues().flat();
    const values = result_sheet.getRange(month_row, 2, 1, result_last_col).getValues().flat();
    let message = "";
    const bar = "-----------------\n"
    message = month + " 状況\n" + bar;
    message += headers[0] + ": " + values[0] + " 円\n"
    message += headers[1] + ": " + values[1] + " 円\n"
    message += headers[2] + ": " + values[2] + " 円\n"
    message += bar;
    message += "収入 " + ": " + getGenereValue('収入',month) + " 円\n"
    message += "固定費" + ": " + getGenereValue('固定費',month) + " 円\n"
    message += "変動費" + ": " + getGenereValue('変動費',month) + " 円"
    return message;
  } catch(e){
    return month + " の記録が見つかりませんでした。";
  }
}

function getGenereValue(genre,month){
  const result_sheet = getResultSheet();
  const target_row = findRow(result_sheet,month,1);
  const category_sheet = getCategorySheet();
  const genre_list = category_sheet.getRange("A:A").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  const category_list = category_sheet.getRange("B:B").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  let target_category = [];
  //対象ジャンルの科目のみの配列を作成
  for(let i = 0; i < genre_list.length; i++){
    if(genre_list[i] == genre){
      target_category.push(category_list[i]);
    }
  }
  //結果シートで収入の科目が記載されている列を探す
  const result_header = result_sheet.getRange("1:1").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
  let categories = [], target_cols = [];
  for(let j = 0; j < result_header.length; j++){
    for(let k = 0; k < target_category.length; k++){
      if(result_header[j] == target_category[k]){
        target_cols.push(j);
        categories.push(result_header[j]);
      }
    }
  }
  let sum = 0;
  if(!target_cols){
    message = genre + ' の科目が見つかりませんでした。'
  }else{
    //対象の行列の範囲を取得し、メッセージにして戻す
    const value_array = result_sheet.getRange(target_row,target_cols[0] + 1,1,target_cols[target_cols.length - 1] - target_cols[0] + 1).getValues().flat();
    for(let l = 0; l < categories.length; l++){
      if(!value_array[l]){value_array[l] = 0;}
      sum += value_array[l];
    }
  }
  return sum;
}

//指定月の分類を小計して表示する
function displayGenre(genre, month){
  try{
    if(!month){month = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM');}
    const result_sheet = getResultSheet();
    const target_row = findRow(result_sheet,month,1);
    if(target_row == 0){return month + "の記録がみつかりませんでした。";}
    const category_sheet = getCategorySheet();
    const genre_list = category_sheet.getRange("A:A").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
    const category_list = category_sheet.getRange("B:B").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
    let target_category = [];
    //対象ジャンルの科目のみの配列を作成
    for(let i = 0; i < genre_list.length; i++){
      if(genre_list[i] == genre){
        target_category.push(category_list[i]);
      }
    }
    //結果シートで収入の科目が記載されている列を探す
    const result_header = result_sheet.getRange("1:1").getValues().flat().filter(function(x){return !(x === null || x === undefined || x === "");});
    let categories = [], target_cols = [];
    for(let j = 0; j < result_header.length; j++){
      for(let k = 0; k < target_category.length; k++){
        if(result_header[j] == target_category[k]){
          target_cols.push(j);
          categories.push(result_header[j]);
        }
      }
    } 
    let sum = 0;
    let message = "【" + genre + "】"+ month +" 状況 \n";
    if(target_cols == 0){
      message = genre + ' の科目が見つかりませんでした。'
    }else{
      //対象の行列の範囲を取得し、メッセージにして戻す
      const value_array = result_sheet.getRange(target_row,target_cols[0] + 1,1,target_cols[target_cols.length - 1] - target_cols[0] + 1).getValues().flat();
      for(let l = 0; l < categories.length; l++){
        if(!value_array[l]){value_array[l] = 0;}
        message += categories[l] + ": " + value_array[l] + "円\n";
        sum += value_array[l];
      }
      message += "---------------------\n合計:" + sum + "円!"
    }
    return message;
  }catch(e){
    return error_msg(e);
  }
}

//今月の科目の詳細を取得して表示する
function displayDetailCategory(category, month){
  if(!month){month = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM');}
  const target_term_array = getSrcWithYearMonth(month);
  const category_list = getCategoryList();
  let dt, person, cat = "", val = 0, target_range_array = [];
  //該当期間のカテゴリと金額の二次元配列を作成
  if(category == 'その他'){
    for(let i = 0; i < target_term_array.length; i++){
      cat = target_term_array[i][3];
      if(!category_list.includes(cat)){
        dt = Utilities.formatDate(target_term_array[i][0], 'Asia/Tokyo', 'MM/dd');
        person = target_term_array[i][2];
        val = target_term_array[i][4];
        target_range_array.push([dt,person,val,cat]);
      }
    }
  }else{
    for(let i = 0; i < target_term_array.length; i++){
      cat = target_term_array[i][3];
      if(cat == category){
        dt = Utilities.formatDate(target_term_array[i][0], 'Asia/Tokyo', 'MM/dd');
        person = target_term_array[i][2];
        val = target_term_array[i][4];
        target_range_array.push([dt,person,val,cat]);
      }
    }
  }
  //人毎の合計を出す
  let map = new Map;
  let person_sum = [];
  for (let i = 0, l = target_range_array.length, element, key; i < l; ++i) {
    element = [target_range_array[i][1], target_range_array[i][2]];
    key = element[0];
    //elementの値が同じものを加算していく <代入先 ? trueの返り値 : falseの返り値>
    map.set(key, map.has(key) ? map.get(key) + element[1] : element[1]);
  }
  for(let iterator = map.entries(), i = 0, l = map.size; i < l; ++i){
    person_sum.push(iterator.next().value);
  }
  let message = category + " の詳細は...\n";
  if(category == 'その他'){
    for(let i = 0; i < target_range_array.length ; i++){message += target_range_array[i][0] + ": " + target_range_array[i][3] + ": " + target_range_array[i][2] + " 円(" + target_range_array[i][1] + ")\n";}
  }else{
    for(let i = 0; i < target_range_array.length ; i++){message += target_range_array[i][0] + ": " + target_range_array[i][2] + " 円(" + target_range_array[i][1] + ")\n";}
  }
  message += '---------------------\n';
  let sum = 0;
  for(let i = 0; i < person_sum.length ; i++){
    message += person_sum[i][0] + ": " + person_sum[i][1] + " 円\n"
    sum += person_sum[i][1];
  }
  message += '---------------------\n';
  message += "計: " + sum + "円!"
  return message;
}

計算と記録.gsが家計簿の機能の中核です。
科目ごとに金額を集計して返信したり、トークされた内容を家計簿に登録したりする処理がまとまっています。

改善の余地は大いにあると思いますので今後も勝手にいじっていきます。

通知.gs

function notice() {
  var sheet = SpreadsheetApp.openById(SHEET_KEY).getSheetByName('alarm');
  var data  = sheet.getDataRange().getValues();

  var dayStr = ["日", "月", "火", "水", "木", "金", "土"];
  var now = new Date();
  for (var i=1; i<data.length; i++) {
    var [year, month, dayOfMonth, weekNum, dayOfWeek, hour, minute, message, to] = data[i];

    // 本文と発言する場所が空の場合はスキップ
    if (message === "" || to === "") { continue; }

    if ( (year       ==  now.getFullYear()               || year       === "")
      && (month      ==  now.getMonth() + 1              || month      === "")
      && (dayOfMonth ==  now.getDate()                   || dayOfMonth === "")
      && (weekNum    ==  parseInt(now.getDate() / 7) + 1 || weekNum    === "")
      && (dayOfWeek  === dayStr[now.getDay()]            || dayOfWeek  === "")
      && (hour       ==  now.getHours()                  || hour       === "")
      && (minute     ==  now.getMinutes()                || minute     === "")
      ) {
        pushMessage(to, message);
      }
  }
}

function pushMessage(to, message) {
  UrlFetchApp.fetch(PUSH_URL, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + ACCESS_TOKEN,
    },
    'method': 'post',
    'muteHttpExceptions': true,
    'payload': JSON.stringify({
      'to': to,
      'messages': [{
        'type': 'text',
        'text': message,
      }],
    }),
  });
}

こちらは家計簿とは関係ないですが、「alarm」シートにあらかじめ設定を書いておくとその通りにLINEにメッセージを送ってくれる処理の中身です。
ゴミ出し前日とかに何ゴミの日か送ってもらったりしてます。
ちなみに参考元があるコードですのでこのコードは綺麗になってます・・・


以上!

エディタの画面はこんな感じです。
トリガーはこんな感じ。

このコードを使うには、

  1. 先日公開したスプシをマイドライブにコピー
  2. 当記事のコードをコピペ
  3. ****で記述している固有値を自分の環境の値にする
  4. notice()とregistFixedCost()のトリガー設定をする

と使えるはずです。

もっと簡単に配布できるようにしたいですが、そうなるとGoogleWorkspaceから外れちゃいますよね。
スマホアプリ作れって感じになっちゃうかなと。

コメント

  1. たいぼん より:

    2022/5 現在、グラフ.gsの
    let imageBlob = chart.getAs(“image/png”);
    がエラーになります。Google側のアップデートの影響かと思います・・・
    下記のように変えると動きます。

    function sendGraph(talkId){
    const folderId = ‘1Z_8YQCvN0jPSdc8V2gQlR3qJ69sW33ll’; // Googleドライブの一時フォルダのID
    const today = Utilities.formatDate(new Date(), ‘Asia/Tokyo’, ‘YYYY-MM-dd’);
    const file_name = today + “_household_account.png”
    // そのシートにある全てのグラフを取得
    const charts = getGraphSheet().getCharts();
    // 取得したグラフの数だけ処理を繰り返す
    for(let i in charts) {
    let chart = charts[i];

    let slides = SlidesApp.create(“temp”);
    let tmp_id = slides.getId();
    let imageBlob = slides // スライドに挿入した画像を変数に保存
    .getSlides()[0]
    .insertSheetsChartAsImage(chart) // スライドにグラフを画像として挿入
    .getAs(“image/png”);

    //let imageBlob = chart.getAs(“image/png”);
    let folder = DriveApp.getFolderById(folderId);
    let file = folder.createFile(imageBlob.setName(file_name));
    file.setSharing(DriveApp.Access.ANYONE, DriveApp.Permission.EDIT);
    console.log(file.getDownloadUrl());
    pushImage(talkId, file.getDownloadUrl(),file.getDownloadUrl());
    file.setTrashed(true);

    let tmpfile = DriveApp.getFileById(tmp_id);
    tmpfile.setTrashed(true);

    }
    return ‘以上!’;
    }

  2. mrtk より:

    はじめまして。
    家計簿作成を試みて調べていたところ、とても分かりやすくご紹介されていたので参考にさせていただきました!
    連携まではできたのですが、肝心な記録の際に同じ書き込みを何度も繰り返してしまいます。
    コードは紹介されている通りで行っているのですが、何度試してもうまくいかず…
    なにか設定に問題があるのでしょうか…?
    不躾な質問で恐縮ですが、可能でしたらご教示いただけますと幸いです><

    • たいぼん より:

      はじめまして!
      どんなエラーが出ていますか?
      エラーが出そうなのは
      ・ACCESS_TOKEN
      ・SHEET_KEY
      ・ss_URL
      ・スプレッドシートのシート名やセル指定の箇所が違う
      などなど、コード以外の部分が原因になることもありますので、もしデバッグや実行時にエラーメッセージが出ていましたら丸っとコピペして教えていただけると何かわかるかもしれません!

      • mrtk より:

        デバッグや実行時にエラーは出ておらず、
        LINEからの呼び出しにも基本はちゃんと動いていています。

        具体的には、
        ・「食費 1000円」とLINEを送る ⇒ 一覧シートに記録される
        ・そのあとも大体1分間隔で、1つ、また1つ…と16回まで同じ時刻・同じ内容の書き込みがされる(何回か試しましたが、どれも16回で書き込みは終わりました)
        ・食費が16000円まで膨れ上がる
        という状況です。

        コード以外の部分が原因になっていそうなので、
        ACCESS_TOKENやSHEET_KEYなども含め、そのほかについても探ってみますmm

        • たいぼん より:

          エラーが出ていないのであれば、細かい設定箇所で何かありそうですね!
          ・そのあとも大体1分間隔で、1つ、また1つ…と16回まで同じ時刻・同じ内容の書き込みがされる(何回か試しましたが、どれも16回で書き込みは終わりました)
          →doPost()で呼び出しているregistCost()という関数が一覧シートに金額を書き込んでいます。とくにこの辺りで繰り返し処理を実装していない(はず)なので不思議ですね。。。別のところで繰り返し処理になりそうなトリガー設定などはしていませんか?
          もしかしたら、16個AppsScriptを作っていてそれぞれ同じWebhook、doGet、SHEET_KEYを設定しており1つのメッセージ投稿で並行して16個動いていたり・・・
          試行錯誤している中だとありえそうかなと思いました。(的外れでしたら申し訳ありません。)

          もしまた別の情報が分かったらお教えください!

          • mrtk より:

            こちら解決しました…!
            LINEのMessaging APIでWebhook再送機能が設定されていることが原因でした;;

            曖昧な質問にもかかわらず、色々な可能性を探っていただいたりと本当に助かりました。
            これで問題なく活用できそうです^^本当にありがとうございました!

タイトルとURLをコピーしました