スマレジからデータ取り出し
娘のパン屋さんは無事開業を迎えて、ほぼ順調な滑り出しです。
来年の消費税引き上げで、小売業では軽減税率が適用される見通しです。パン屋さんも販売だけなら8%の軽減、イートインのある店舗での飲食をすれば10%となるようで、複雑な仕組みになっています。この軽減税率対応のレジには「軽減税率対策補助金」が設定されており、対策(購入)費用の2/3から1/2の補助が受けられます。専用ソフトを搭載したモバイル端末なら1/2、レシートプリンタなら1/3の価格で買えるわけですので、これを利用しない手はありません。
いくら補助金が付いてもスーパーにあるようなPOSレジは高価なので、iPadとレシートプリンタおよび軽減税率対応ソフトウェアを導入しました。軽減税率対応レジソフトウェアを自分で作ってもダメで、指定ベンダ製のアプリケーションが補助の対象となります。で、我が家ではスマレジを導入することにしました。レジとしては、最低レシート発行と売上記録ができればよいので、無償のスタンダードプランです。無料でも実際に店舗で使うのには全然問題なく、店舗が増えるようならグレードを上げてもPOSレジより安くつきます。
さて、「レシート発行と売上記録ができればよい」とは書きましたが、せっかくデータ化するのであれば仕入れや支払いなどもパソコンで処理したくなります。また、Webでの商品紹介にもデータが必要なので、現状のままでは2重管理になりユーザに最も嫌われる姿です。そこで、スマレジとのデータ連携ができないかと機能を探っていたら、バックアップという機能があり自分のPOSレジデータをダウンロードすることができます。バックアップデータはメールの添付となっているので、少々気味悪い気がしますが仕方無いでしょう。企業の方はマネしないでくださいってところでしょうか。
取得したデータはsqliteデータベースでした。他所様で設計したデータベーススキーマを解説する訳にはいきませんので、ここではデータの取り出し方だけに絞ります。取得したデータは次の場所に保存しています。
$ mkdir -p ~/smageri/pos $ cp pos.sqlite ~/smageri/pos/ $ cd ~/smaregi
欲しいのは売上データです。売上データはTransactionHeadテーブルとTransactionDetailに分割して格納されています。TransactionHeadテーブルには販売日時、総額、値引き額、税額などがあります。また、TransactionDetailテーブルには商品、価格、商品分類、税抜価格、税額などがあります。商品マスターはProductテーブルとして存在し、そのProductIdがTransactionDetailに関連づけられていますが、TransactionDetailにはProductNameとして商品名も記録されており、これは正規化されていないのではないかと思いました。しかし、よく考えるとTransactionDetailに記録した時点でレシートとして印刷されていますので、後から商品マスタを書き換えるとユーザが持っている証憑と合わせられなくなることは一大事です。こういったトランザクションデータは、販売時点の記録をしっかり残しておくのがよいのでしょう。
データの取り出しには、次のようなsqlファイルを作成しました。
TransactionHeadテーブル
$ vi head.sql .mode csv select transactionHeadId,transactionDateTime,subtotal,discountPrice,total,tax from TransactionHead;
TransactionDetailテーブル
$ vi detail.sql .mode csv select TransactionHeadId,TransactionDetailId,ProductId,ProductName,Price,quantity from TransactionDetail;
これをコマンドラインで実行します。
$ sqlite3 pos/pos.sqlite < head.sql > ./head.csv $ sqlite3 pos/pos.sqlite < detail.sql > ./detail.csv
実行結果は次のようになりました。
$ vi head.csv (省略) 210,"2018-12-08 16:51:19",680.0,68.0,612.0,45.0 211,"2018-12-08 16:52:04",300.0,,300.0,22.0 212,"2018-12-08 16:55:52",830.0,,830.0,61.0 213,"2018-12-08 17:12:53",460.0,,460.0,34.0 214,"2018-12-09 10:51:16",3350.0,,3350.0,248.0 215,"2018-12-09 11:03:06",2180.0,,2180.0,161.0 216,"2018-12-09 11:04:21",860.0,,860.0,63.0 217,"2018-12-09 11:05:41",760.0,,760.0,56.0 (省略) $ vi detail.csv (省略) 84,1,93,"バタール",280.0,1.0 84,2,94,"フィセル",150.0,1.0 84,3,98,"ベーコンエピ",210.0,1.0 84,4,110,"シュトレーン風ブレッド",200.0,2.0 84,6,113,"チリコンカン",220.0,1.0 84,7,114,"ウインナーパン",200.0,1.0 85,1,97,"ガーリックフランス",220.0,3.0 85,2,120,"クッキー 5個入り",430.0,1.0 85,3,117,"ラスク プレーン",150.0,1.0 85,4,110,"シュトレーン風ブレッド",200.0,2.0 85,5,113,"チリコンカン",220.0,2.0 (省略)
出来上がったcsvファイルを自分のデータベースに読み込めば、色々応用ができそうです。先日作成したOpenWeatherMapで天候情報を定時記録していますので、次は需要予測に挑戦です。
2018-12-07 09:00:01 | 1852899 | 曇り | 11 | 71 | 1023 |
2018-12-07 12:00:03 | 1852899 | 雨まじりの雪 | 10.67 | 66 | 1024 |
2018-12-07 15:00:01 | 1852899 | 曇り | 9.47 | 61 | 1024 |
2018-12-07 18:00:01 | 1852899 | 曇り | 7 | 65 | 1025 |
2018-12-07 21:00:02 | 1852899 | 曇り | 5.47 | 60 | 1026 |
2018-12-08 00:00:01 | 1852899 | 曇り | 5 | 65 | 1027 |
2018-12-08 03:00:01 | 1852899 | 曇り | 5 | 65 | 1028 |
2018-12-08 06:00:01 | 1852899 | 本曇り | 8.11 | 100 | 1036 |
2018-12-08 09:00:01 | 1852899 | 曇り | 4 | 60 | 1029 |
2018-12-08 12:00:01 | 1852899 | 曇り | 6 | 52 | 1029 |
生データではなんなので、もう少し粉飾してから書くことにします。
パン屋さん開店
本日よりパン屋さん開店。二回目のプレオープンも完璧とは行きませんでしたが、間を置いた分微調整の余裕が出来て、何とか本格開店に間に合うとのことです。
娘へのヒアリングを待っていたけどなかなか時間が取れず、公式ホームページの開発が遅れぎみでしたが、昨日やっと設置することができました。
パンに関する知識はないので、急遽パンシェルジュ検定の本を購入して小一時間猛勉強。検定は3級、2級、1級とあり、真ん中ぐらいの知識でよいのかなと思って2級テキストを選択したのですが、3級から順に検定を取らないと上級の検定資格がないらしいです。もっとも、持ってても仕方のない資格なのですが。。。
フロントエンドにはnginxを使っており、サイト自体はほぼGO言語で作成していますので、まあ、無駄に速いです。
GO言語のフレームワークとして、Beegoを使いました。BeegoはCakePHPと似た構造を自動的に生成することができますので、CakePHPからGO言語への乗り換えは楽かも知れません。
プレオープン
昨日はパン屋さんのプレオープン。テスト的にお店を開いて、準備の不備や課題を確かめるものです。いきなり開店すると、不備があった場合にパニックになり、修正する時間も無いまま営業を続けなければならなくなるのと、「プレ」と名付けることで、お客さんの厳しい目に対してある程度の緩衝を設ける役割もあります。
で、我が家のプレオープンはどうだったかと言うと、11:00から14:00までの時短営業予定が、お客さんが早く来始めたことで10:30から開店、お昼には売るものが無くなってしまい12:30閉店というさらに時短営業となってしまいました。プレオープンとして告知しているは、14:00までですので、その後もお客さんが続々と続き、そのたび謝っていました。私は運営に携わっていないのですが、お客さんが来るたび閉店の言い訳をしました。
パンの場合、醗酵から焼き上がりまで何時間もかかりますので、一日の準備がすべてを決めてしまいます。少ないとビジネスチャンスを無くしますし、多すぎると最悪商品の廃棄となります。私自身は長崎へ単身赴任で、お昼は自前のお弁当も含めて自炊していますが、ここのところ三食パンとなっています。練習台はいいのですが、今後は長崎へ持っていけるほど商品が余らないように期待したいですね。
明後日、不備を修正したプレオープンに挑み、週末には本オープンとなります。
ところで、Web開発のほうですが、ユーザである娘の手が空かずヒアリングもできませんので、自分で調べて鋭意作成中。CRUDは完了していますが、肝心のUIが進まず色々思案中です。
OpenMeetingsへ外部動画登録
先日、数百名規模の講習会を行い、全員が主会場に入りきれないため別会場へプロジェクタを設置して、OpenMeetingsの映像をパブリックビューイング形式で見てもらいました。出だしのところで音声のトラブルはありましたが、おおむね順調で今後も活用が期待できそうです。
万が一のために、主会場の映像をビデオカメラで撮ってもらいバックアップ用としていましたが、映像そのものは当然Web中継画像に比べて格段に質が良いため、こちらを録画として当日参加できなかった職員に見てもらうことにしました。
OpenMeetingsへアップロードで動画ファイルを置く方法が見つからなかったので、力技で登録します。動画ファイルはmp4としてビデオカメラ動画を入手しました。
今回は、実際に記録した動画があるのですが、外部動画ファイルだけの場合について書くこちとにします。
1 .ダミー録画の作成
OpenMeetingsはユニークコードの生成により、データベースとファイル名の関連付けを行っていますので、これを生成するために適当な録画をします。
これにより、OpenMeetingsデータベースのom_fileテーブルに基本データを登録すると同時に、ハッシュ値が生成されます。また、このハッシュ値をもとに動画ファイル名およびサムネイル画像名を作成しています。
2.データ確認
データを特定しやすいように、ダミー録画に適切な名称を付けます。
「テスト画像」等(他と同じ名前であってはいけません)
mysqlにてOpenMeetingsのデータベースにログインします。うちでは、genomとして作成しています。
mysql -ugenom -pgenom genom
MariaDB [genom]> select * from om_file where name like '%テスト画像%';
8dea1bb-5d7e-4d7a-ad72-e36d7443f3b3のような値のフィールド(カラム名はhash)を探し、これを記録します。
3.外部録画データのコピー
例えば、ターゲットとなる動画ファイルを/tmp/動画.mp4hとしておいてある場合、OpenMeetingsの動画は、次の場所に保存されていますので、これを上書きします。
/opt/red5404/webapps/openmeetings/streams/hibernate/88dea1bb-5d7e-4d7a-ad72-e36d7443f3b3.mp4
ファイル名88dea1bb-5d7e-4d7a-ad72-e36d7443f3b3.mp4のベース名が前項で示したハッシュ値になっています。
従って、上書きコピーするにはつぎのように入力します。
cp /tmp/動画.mp4 /opt/red5404/webapps/openmeetings/streams/hibernate/88dea1bb-5d7e-4d7a-ad72-e36d7443f3b3.mp4
4.サムネイル画像作成
動画を動かす前に見える最初の画面をサムネイル画像として作成します。この画像はpngファイルとして、やはりハッシュ値.pngで生成することにします。
/usr/local/bin/ffmpeg -y -i /opt/red5404/webapps/openmeetings/streams/hibernate/88dea1bb-5d7e-4d7a-ad72-e36d7443f3b3.mp4 -vf thumbnail,scale=640:-1 -frames:v 1 /opt/red5404/webapps/openmeetings/streams/hibernate/88dea1bb-5d7e-4d7a-ad72-e36d7443f3b3.png
以上の操作で、外部動画ファイルをOpenMeetings内に取り込むことができました。この動画は、他の録画と同様にメニューの録画->録画から見ることができます。
これにより、様々な教育ビデオがOpenMeetingsのライブラリとして保存できるようになりました。ただし、設置する動画はこれぐれも著作権を侵さないものとしてください。
OpenWeatherMap
パン作りでは、発酵の過程が重要です。パン生地をこねた後は、一次発酵。成形後はさらに二次発酵。いずれも時間と気温との微妙な調整です。同じ時間でも、気温の高い夏は過発酵になったり、逆に冬では発酵時間が足りなくてパンが膨らまないなどのトラブルが発生します。
そこで、温度や湿度、その日の天気などを記録しておけば、うまくいく発酵時間を見極めることができるのではないかと思い、天候データのデータベース化を考えてみました。
色々調査してみたら、無償のOpenWeatherMapの利用が最適と考えました。頻繁なアクセスや高度な利用にはそれなりの費用がかかりますが、パン屋さんの記録用なら、2時間に1回程度の取得で充分でしょう。
データベースはMariaDBを使うことにします。
drop table if exists weathers; create table weathers ( measure_time timestamp default current_timestamp, -- 計測時刻 city_id int default 1852899, -- 市コード cloudiness varchar(20) not null, -- 天候 degree float default 0, -- 温度 humidity int default 0, -- 湿度 pressure int default 1000, -- 気圧 primary key (measure_time) );
OpenWeatherMapを利用するにはアカウント作成が必要ですので、サイトメニューのpriceメニューを確認して納得したうえで行ってください。アカウント作成が正常に完了するとAPIIDの長い文字列が表示されますので、これをAPIのデータに付けて問い合わせを行います。
http://api.openweathermap.org/data/2.5/weather?id=1852899&units=metric&appid=(取得したAPI ID)
これで取得できるデータをGO言語で直接取得して、先程作成したweathersテーブルに挿入していきます。作成するプログラムはコマンドとして1回実行して終了します。これをcrontabで2時間に1回呼び出せば、計測時刻の天候、温度、湿度、気圧を記録します。市コードはお店のある佐世保市のものですが、英国のサービスでありながら、日本についてもかなり細かなエリアで指定できるようです。
PalpanのWebサイトのフレームワークはBeegoで作成してますが、今回は特にこれを利用したものではありません。管理しやすいようにプロジェクトの下にvendorフォルダを作成して、その中にweather.goを作成します。
package main import ( "encoding/json" "log" "net/http" "time" "fmt" mdl "shop/models" )
shopプロジェクトの下のmodelsを読み込んでいますが、これはのちほど作成します。
続けて、今回必要な構造体や変数を定義します。
type weatherData struct { Name string `json:"name"` Sky []SkyData `json:"weather"` Main struct { Degree float64 `json:"temp"` Humidity float64 `json:"humidity"` Pressure float64 `json:"pressure"` } `json:"main"` } type SkyData struct { ID int `json:"id"` Descrip string `json:"description"` Icon string `json:"icon"` } var myClient = &http.Client{Timeout: 10 * time.Second}
空模様(SkyData、jsonではweatherで取得)でハマってしましましたが、配列として記述するのが良いようです。
これで準備が整いましたので、メインに取りかかります。
func main() { url := "http://api.openweathermap.org/data/2.5/weather?id=1852899&units=metric&appid=(取得したAPIID)" w := new(weatherData) err := getJson(url, &w) if err != nil { log.Println(err.Error()) return } _, err = mdl.NewDB() if err != nil { log.Println(err.Error()) return } weather := new(mdl.Weather) weather.Cloudiness = sky2Jp(w.Sky[0].ID) weather.Degree = w.Main.Degree weather.Humidity = int(w.Main.Humidity) weather.Pressure = int(w.Main.Pressure) fmt.Printf("%v", weather) err = mdl.AddWeather(weather) if err != nil { log.Println(err.Error()) } mdl.CloseDB() }
mainの中に書くと混乱しそうなので、jsonデータの取得やら、データベースへの書き込みは外出しにしてあります。
func getJson(url string, target interface{}) error { r, err := myClient.Get(url) if err != nil { log.Println(err.Error()) return err } defer r.Body.Close() return json.NewDecoder(r.Body).Decode(target) }
さらに、空模様は英語で帰ってくるので日本語に変換。かなり力技で書いてるように見えますが、Excelを使ってパパっと作ったものです。
func sky2Jp(id int) string { var s string = "" switch id { case 200: s = "雷雨" case 201: s = "強い雷雨" case 202: s = "激しい雷雨" case 210: s = "雷雨" case 211: s = "雷雨" case 212: s = "強い雷雨" case 221: s = "荒れた天気" case 230: s = "強い雷雨" case 231: s = "強い雷雨" case 232: s = "強い雷雨" case 300: s = "霧雨" case 301: s = "霧雨" case 302: s = "暴風雨" case 310: s = "暴風雨" case 311: s = "暴風雨" case 312: s = "暴風雨" case 313: s = "暴風雨" case 314: s = "暴風雨" case 321: s = "暴風雨" case 500: s = "小雨" case 501: s = "雨" case 502: s = "本降りの雨" case 503: s = "かなり激しい雨" case 504: s = "土砂降りの雨" case 511: s = "氷雨" case 520: s = "雨まじりの雪" case 521: s = "強い雨" case 522: s = "激しい雨" case 531: s = "荒れた天気" case 600: s = "小雪" case 601: s = "雪" case 602: s = "強い雪" case 611: s = "みぞれ" case 612: s = "みぞれ" case 615: s = "小雨まじりの雪" case 616: s = "雨まじりの雪" case 620: s = "雪" case 621: s = "雪" case 622: s = "激しい雪" case 701: s = "霧" case 711: s = "霞" case 721: s = "もや" case 731: s = "砂埃" case 741: s = "濃い霧" case 751: s = "砂嵐" case 761: s = "スモッグ" case 762: s = "火山灰" case 771: s = "夕立" case 781: s = "暴風" case 800: s = "快晴" case 801: s = "晴れ" case 802: s = "曇り" case 803: s = "曇り" case 804: s = "本曇り" } return s }
天候に関する英語の微妙な表現が分からないところは適当に書いてます。
データベースへの入出力はmodelsフォルダに書いています。データベースへの接続、切断はdb.goにまとめています。
// db.go package models import ( "database/sql" _ "github.com/go-sql-driver/mysql" "log" ) type DB struct { *sql.DB } const ( DataSource = "shop:shop@/shop?parseTime=true" ) var Db *sql.DB // データベース接続 func NewDB() (*DB, error) { db, err := sql.Open("mysql", DataSource) if err != nil { return nil, err } if err = db.Ping(); err != nil { return nil, err } Db = db return &DB{db}, nil } // データベース切断 func CloseDB() { if err := Db.Ping(); err == nil { Db.Close() log.Println("データベース切断") } }
DataSource文字列は、自分のMariaDB環境に合わせてください。
Weatherモデルはmodelsの下のweather.goに記述します。
package models import ( _ "github.com/go-sql-driver/mysql" "log" "time" ) type Weather struct { MasureTime time.Time `db:"measure_time"` CityId int `db:"city_id"` Cloudiness string `db:"cloudiness"` Degree float64 `db:"degree"` Humidity int `db:"humidity"` Pressure int `db:"pressure"` } // 追加 func AddWeather(p *Weather) error { que := "insert into weathers ( cloudiness,degree,humidity,pressure) values (?,?,?,?)" _, err := Db.Exec(que, p.Cloudiness,p.Degree,p.Humidity,p.Pressure) if err != nil { log.Println(err.Error()) return err } return nil } // 最新データ取得 func GetWeather() (*Weather, error) { que := "select * from weathers where measure_time = (select max(measure_time) from weathers)" p := new(Weather) err := Db.QueryRow(que).Scan( &p.MasureTime, &p.CityId, &p.Cloudiness, &p.Degree, &p.Humidity, &p.Pressure, ) if err != nil { return nil, err } return p, nil }
これを次のコマンドで実行
go run weather.go
これをMariaDBで直接参照してみると、
MariaDB [shop]> select * from weathers; +---------------------+---------+------------+--------+----------+----------+ | measure_time | city_id | cloudiness | degree | humidity | pressure | +---------------------+---------+------------+--------+----------+----------+ | 2018-11-14 19:36:25 | 1852899 | 快晴 | 12.94 | 67 | 1020 | | 2018-11-14 19:38:24 | 1852899 | 快晴 | 12.94 | 67 | 1020 | | 2018-11-14 20:01:13 | 1852899 | 快晴 | 12.93 | 67 | 1020 | | 2018-11-14 20:03:31 | 1852899 | 快晴 | 12.93 | 67 | 1020 | | 2018-11-14 21:00:02 | 1852899 | 快晴 | 11.93 | 71 | 1021 | | 2018-11-14 21:23:39 | 1852899 | 快晴 | 11.9 | 71 | 1021 | | 2018-11-14 23:00:01 | 1852899 | 晴れ | 14.02 | 100 | 1031 | +---------------------+---------+------------+--------+----------+----------+ 7 rows in set (0.00 sec)
予定どおり取れてます。まあ、書いている間に何回かテストしているので、結構な量出来上がってます。
ついでに、最新の1件表示。
MariaDB [shop]> select * from weathers where measure_time = (select max(measure_time) from weathers); +---------------------+---------+------------+--------+----------+----------+ | measure_time | city_id | cloudiness | degree | humidity | pressure | +---------------------+---------+------------+--------+----------+----------+ | 2018-11-14 23:00:01 | 1852899 | 晴れ | 14.02 | 100 | 1031 | +---------------------+---------+------------+--------+----------+----------+ 1 row in set (0.00 sec)
これで気象データは自動的に記録されたので、レジデータと組み合わせれば、天候による売れ筋商品が見えてくるかも。
MongoDBセキュリティ設定
本業で忙しかったので、間が大分空いてしまいました。
前回、Warningが出たまま放っておいたので、まずは修正。参考にしたURLのとおりなので、改めて書く必要はないでしょう。
https://qiita.com/SOJO/items/dc5bf9b4375eab14991b
Warningを消すとともに、データベースセキュリティの強化を実施して、sampleユーザを追加。
管理者ユーザで入り直して、次を実行します。
use sample db.createUser({user:"sample",pwd:"sample",roles:[{role:"readWrite",db:"sample"}]}) Successfully added user: { "user" : "sample", "roles" : [ { "role" : "readWrite", "db" : "sample" } ] }
一旦、コンソールから抜けたあと、普通に入りなおします。
$ mongo MongoDB shell version v4.0.3 connecting to: mongodb://127.0.0.1:27017 Implicit session: session { "id" : UUID("05af0f9a-3414-460f-8559-1deb478a9221") } MongoDB server version: 4.0.3
まず、そのままで利用可能か確認してみます。
> use sample switched to db sample > show collections Warning: unable to run listCollections, attempting to approximate collection names by parsing connectionStatus
期待通り、Warningが出ます。次に認証してから同じことをしてみます。
> db.auth("sample","sample") 1 > show collections >
authの引数にユーザ、パスワードを設定して、結果が1なら正常に認証されました。まだ、コレクションを作成していないので、分かり難いですが、show collectionsを実行しても先ほどのようなWarningはでなくなりました。
MonogoDB
様々な分野で使用するカレンダを統一的に扱おうと、各プロジェクトのカレンダや予定表部分だけRESTfulサービスにしようとしています。このRESTサーバにはMonogoDBを使います。
MonogoDBはSQLを使わないデータベース、いわゆるNoSQLの代表格です。現在作成しているスケジューラでは、スピードを稼ぐためスケジュールのJSONファイルを毎回出力しており、それならMongoDBでもあまり変わらないだろうと思い、初めてNoSQLを使ってみることにしました。
まずはMongoDBのインストール。公式ドキュメントで見ると、repoファイルは自前で書かないと望んだバージョンがインストールできないとのこと。ドキュメントに従ってrepoファイルを作成します。
sudo vi /etc/yum.repos.d/mongodb-org-4.0.repo [mongodb-org-4.0] name=MongoDB Repository baseurl=https://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/4.0/x86_64/ gpgcheck=1 enabled=1 gpgkey=https://www.mongodb.org/static/pgp/server-4.0.asc
作成したら、いつものようにyumを使いインストールします。
sudo yum install -y mongodb-org
SELinuxの利用の状況により、MongoDBの動作に影響を与えます。インストールマシンはSELinuxを有効にしています。
$ getenforce Enforcing
この場合は、MongoDBの利用するポートを開放します。
sudo semanage port -a -t mongod_port_t -p tcp 27017
SELinuxは扱いが面倒なのでdisabledにしがちですが、できる限り有効にしていたほうが無難です。
既にMongoDBを利用できる環境にありますので、早速実行してみます。
sudo service mongod start
さらに、再起動時に自動起動できるようにしておきます。
sudo systemctl enable mongod
私自身が初心者ですので、MongoDBがどのようなものか、mongo shellを起動してみました。
mongo --host localhost:27017 MongoDB server version: 4.0.1 Server has startup warnings: 2018-08-05T21:01:54.685+0900 I CONTROL [initandlisten] 2018-08-05T21:01:54.685+0900 I CONTROL [initandlisten] ** WARNING: Access control is not enabled for the database. ...
何やら警告がでていますが、今は対応が分からないので無視しときます。色々メッセージが出てきますが、最終的にはプロンプトが表示されていますので、利用可能なようです。
試しに、現在のデータベースを確認します。
> db test
testはMongoDBのデフォルトのデータベースです。
shellを抜けるにはquit()コマンドを使います。
> quit()
実際の利用については、もう少し探ってから書くことにします。