kintone 同期処理（レコード差分追加）

kintoneアプリ間のレコード同期処理を cli-kintone を使い行います。
同期する先のアプリにレコードが無い場合は追加を行います。

# 同期シナリオ

* レコードのバックアップ処理
* 定期的なバックアップ

# 環境

* macOS 10.13.6
* Ruby 2.5.0
* cli-kintone 0.9.4

# 前提条件

* 更新用のキーフィールドを用意する。（今回はフィールドコード：顧客コードを用意）
* 更新用のキーは数値、必須、重複無し、1から順に割り振られている。(1,2,3..,n)
* 処理は更新元のマスタアプリからバックアップ先のアプリへの一方向。

# 処理概要

更新元のマスタアプリ（MA）に新しく追加されたレコードをバックアップ先アプリ（BA）へ追加します。

1. バックアップ先の最後に追加された顧客コードを取得（例：BA顧客コード→20）
2. マスタからバックアップ先の顧客コード20より後のレコードを取得（例：MA顧客コード→21,22）
3. 取得したマスタのレコードをバックアップ先に追加

# 全体コード

```add_difference_records.rb
require 'minitest/autorun'
require 'rubygems'
require 'bundler/setup'
require 'dotenv/load'
require 'open3'
require 'csv'
require 'stringio'
require 'pp'
require 'net/http'
require 'uri'
require 'json'
require 'base64'

SUBDOMAIN = ENV['SUBDOMAIN']
APP_FROM = ENV['APP_FROM']
API_FROM = ENV['API_FROM']
APP_TO = ENV['APP_TO']
API_TO = ENV['API_TO']
SELECT_COLUMN = ENV['SELECT_COLUMN']

# 1. バックアップ先の最後に追加された顧客コードを取得
def get_customer_code_by_last(domain, app_to, api_to, select_column)
  query = '"order by 顧客コード desc limit 1"'
  cap_str = %Q(cli-kintone -d #{domain} -a #{app_to} -c '\"#{select_column}\"' -t #{api_to}  -q #{query})
  out, err, status = Open3.capture3(cap_str)
  if (err != "")
    pp err; pp status; exit
  end
  if (out.size > 0)
    begin
      lines = []
      out.each_line { |line| lines << (line.chomp).parse_csv }
      lines[1][0]
    rescue => e
      pp e
      exit
    end
  else
    return 0
  end
end

# 2. バックアップ先とマスタの差分のレコードを取得
def fetch_records_by_master(domain, app_from, api_from, select_column, last_customer_code)
  query = "\"顧客コード > #{last_customer_code} order by 顧客コード asc\""
  cap_str = %Q(cli-kintone -d #{domain} -a #{app_from} -c '\"#{select_column}\"' -t #{api_from}  -q #{query})
  out, err, status = Open3.capture3(cap_str)
  if (err != "")
    pp err; pp status; exit
  end
  out
end

# 3. 取得した差分のレコードをバックアップ先に追加
def add_difference_records(domain, app_to, api_to, records_string)
  cap_str = %Q(cli-kintone --import -d #{domain} -a #{app_to} -t #{api_to})
  csv_out =  StringIO.new
  csv_out << records_string
  out, err, status = Open3.capture3(cap_str, :stdin_data => csv_out.string)
  if (err != "")
    pp err; pp status; exit
  end
  out
end

# UnitTest
class AddDifferenceRecordsTest < Minitest::Test
  def setup
    puts __method__
    unless (get_customer_code_by_last(SUBDOMAIN, APP_TO, API_TO, "顧客コード") == "20")
      test_setup
    end
  end
  def test_1
    puts __method__
    assert_equal "20", get_customer_code_by_last(SUBDOMAIN, APP_TO, API_TO, "顧客コード")
  end
  def test_2
    puts __method__
    records = fetch_records_by_master(SUBDOMAIN, APP_FROM, API_FROM, SELECT_COLUMN, "20")
    io_string = StringIO.new(records)
    ary = io_string.readlines
    assert_equal 3, ary.size
    assert_equal "\"21\"", ary[1].split(',')[4]
    assert_equal "22", CSV.parse(ary[2])[0][4]
  end
  def test_3
    puts __method__
    records = <<EOS
"住所","担当者名","部署名","FAX","顧客コード","顧客名","備考","郵便番号","TEL","メールアドレス"
"北海道","吉里","","","21","有限会社　吉里吉里","","123456","123-456-7890","hoge@test.com"
"北海道","ホワイト","","","22","ホワイトルーム","","123456","123-456-7890","hoge@test.com"
EOS
    ret = add_difference_records(SUBDOMAIN, APP_TO, API_TO, records)
    assert_equal false, ret !~ /SUCCESS/
  end
end

def test_setup
  puts __method__
  url = "https://#{ENV['SUBDOMAIN']}.cybozu.com/k/v1/records.json"
  uri = URI.parse(url)
  api_token = ENV['API_TO']
  app_id = ENV['APP_TO']
  req_get = Net::HTTP::Get.new(uri.path)
  req_get['X-Cybozu-API-Token'] = api_token
  req_get['Content-Type'] = 'application/json'
  fields = ["\$id", "顧客コード"]
  query = %q(顧客コード > "20")
  req_get.body = JSON.generate({"app" => app_id, "fields" => fields, "query" => query, "totalCount" => true})
  ary_ids = []
  Net::HTTP.start(uri.host, uri.port, :use_ssl => true) {|http|
    res_get = http.request(req_get)
    case res_get.code.to_i
    when 200
      pp JSON.parse(res_get.body)
      ary_ids = JSON.parse(res_get.body)['records'].map { |rec| rec['$id']['value'] } 
    else
      pp %Q(#{res_get.code} #{res_get.message})
      pp JSON.parse(res_get.body)
    end
  }
  req_delete = Net::HTTP::Delete.new(uri.path)
  req_delete['X-Cybozu-API-Token'] = api_token
  req_delete['Content-Type'] = 'application/json'
  req_delete.body = JSON.generate(
    {"app" => app_id,
     "ids" => ary_ids}
  )
  Net::HTTP.start(uri.host, uri.port, :use_ssl => true) {|http|
    res = http.request(req_delete)
    case res.code.to_i
    when 200
      pp JSON.parse(res.body)
    else
      pp %Q(#{res.code} #{res.message})
      pp JSON.parse(res.body)
    end
  }
end
```

# 処理説明

主な処理を説明します。

## 1. バックアップ先の最後に追加された顧客コードを取得

バックアップ先のアプリに対して、クエリで顧客コードの降順でソートした先頭１レコードを取得します。

顧客コードは重複無しの1,2,..nの順で付けている前提です。

```ruby
  query = '"order by 顧客コード desc limit 1"'
```

## 2. マスタからバックアップ先の顧客コード20より後のレコードを取得

バックアップ元のマスタアプリとバックアップ先の差分のレコードを取得します。

last_customer_codeには、1.で取得した数値が入ってきます。（例えば "20" など）

```ruby
  query = "\"顧客コード > #{last_customer_code} order by 顧客コード asc\""
```


## 3. 取得したマスタのレコードをバックアップ先に追加

--import オプションを付けて、標準出力からの入力を元にレコードを追加します。

```ruby
  cap_str = %Q(cli-kintone --import -d #{domain} -a #{app_to} -t #{api_to})
```

# 参考リンク

## cli-kintone関連

* [はじめようkintone コマンドライン](https://developer.cybozu.io/hc/ja/articles/115000297083)
* [クエリ指定](https://developer.cybozu.io/hc/ja/articles/202331474#step2)
* [kintone コマンドラインツールの使い方](https://developer.cybozu.io/hc/ja/articles/202957070-kintone-%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3%E3%83%84%E3%83%BC%E3%83%AB%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9#step7)
* [定期実行でデータの同期を実現するスマートな方法 その1〜cli-kintone編〜
](https://developer.cybozu.io/hc/ja/articles/217964263-%E5%AE%9A%E6%9C%9F%E5%AE%9F%E8%A1%8C%E3%81%A7%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E5%90%8C%E6%9C%9F%E3%82%92%E5%AE%9F%E7%8F%BE%E3%81%99%E3%82%8B%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%81%AA%E6%96%B9%E6%B3%95-%E3%81%9D%E3%81%AE1-cli-kintone%E7%B7%A8-)
* [https://github.com/kintone/cli-kintone](https://github.com/kintone/cli-kintone)
* [https://developer.kintone.io/hc/en-us/articles/115002614853](https://developer.kintone.io/hc/en-us/articles/115002614853)

## Ruby関連

* [Open3ライブラリ](https://docs.ruby-lang.org/ja/latest/class/Open3.html)
* [Fileクラス](https://docs.ruby-lang.org/ja/latest/class/File.html)
* [CSVライブラリ](https://docs.ruby-lang.org/ja/latest/class/CSV.html)
* [StringIOクラス](https://docs.ruby-lang.org/ja/latest/class/StringIO.html)
* [ヒアドキュメント](https://docs.ruby-lang.org/ja/latest/doc/spec=2fliteral.html#here)
* [minitest/unit](https://docs.ruby-lang.org/ja/2.1.0/library/minitest=2funit.html)
* [Rubular](https://rubular.com/)
* [正規表現](https://docs.ruby-lang.org/ja/latest/doc/spec=2fregexp.html)