当ブログでは改正電子帳簿保存法について、一問一答などを参考にしながら中小規模の事業者でも対応できそうな範囲について考察してきました。
簡単に振り返ると、 電子メールや FAX による情報のやりとり(電子取引)が非常に厄介でした。クラウドストレージを導入して、取引先にアップロードしてもらうことで最初から「訂正・削除の履歴の要件」を満たしたシステムで管理できそうだということが分かりました。
しかし、ただアップロードしてもらうだけだと(全文検索機能が備わっていても)無秩序にファイルが蓄積されていってしまい、検索性を確保できないことが考えられます。また、 Google Workspace 等 では、アップロード専用フォルダとは出来ず、取引先も削除できてしまいます。
こういった問題に対処するには、アップロードされたファイルを整理することが必要ですが、ひとつひとつ手動で整理していくのは手間で、運用していくうちに忘れられたりしがちです。
そういうときに便利だとされているのが RPA です。ただ、ファイルの整理という観点では人間の操作をシミュレーションする RPA よりも、プログラムとしてしまった方が早くて確実です。ここでは、将来的な拡張を見越して、 RPA も記述できる Python を使ってクラウドストレージのファイルを整理してみます。
※当記事では第三者性のないストレージも事例にしています。通常のクラウドストレージでも同様に動作しますが、導入の際は要件を満たしているか個別の確認を忘れないようにしてください。基本的には検索性の確保と、ファイルの整理という点でのみ参考にしてください。
また、当記事の内容を参考にした場合に生じた如何なる不利益に関しても筆者は責任を負いかねます。
基本的なファイル整理の要件を考えて見る
ここでは検索しやすいようにファイルを整理することのみ扱います。
アップロードに使われるフォルダは取引先ごとに分かれていると考えられます。保存先も取引先ごとに分割しますが、検索したときに似たようなファイル名が沢山でてきたら困ります。そのため、フォルダと同じように取引先名をファイル名にも付与します。
日付はアップロードされたときの日付を自動で入力しましょう。
最後に、金額については手動で入力する必要があるでしょう。ここは最後に考えることとしましょう。
複雑な検索条件を設定できる検索システムがあれば、不要だと言えます。現実的にはいくつか問題があります。
- マニュアルを整備して再現できる必要がある
税務署や税理士などのステークホルダーの求めに応じて、事務処理員がデータを検索できる必要があり、マニュアルを備え付けるにしてもできるだけ分かりやすいことが求められます。実際にはできるのに、「できないじゃないか!」と言われるのは不本意ですよね。 - クラウドストレージでは複雑な検索ができない場合がある
Nextcloud などでも Web GUI の検索条件はシンプルなファイル名による検索しか使えません。Nextcloud についてはまだ、WebDAV でマウントすることで様々な検索ツールが使えますが、その他のシステムではそうもいきません。仮想ファイルによるオンデマンドなファイル共有では OS / シェルによる検索ができない場合もあるため、可能な限りファイル名による検索を整備することが望ましいでしょう。
※これらの制限があるため、7 年もの運用期間となるとファイル名による検索では不十分であることが考えられます。将来を見越して補助簿を作成するべきでしょう(今後当サイトでも対応を検討します)。
Python についてとインストール
テスト環境
仮想ファイル共有環境を想定します。今のクラウドストレージのクライアントであれば、特別設定をしなければ同様の環境になると思います。今回は、ログの確認をしたいので Box で試してみます。
アップロードフォルダ
- /storageroot/upload/a/ ……A社用のアップロードフォルダ
- /storageroot/upload/b/ ……B社用のアップロードフォルダ
アップロード専用フォルダのベースを作って、そこに A社用、 B社用……と取引先ごとにフォルダを作成していく形式です。
保存用フォルダ
- /storageroot/etrade-documents/a/2022/10
- /sotrageroot/etrade-documents/a/2022/11
- /storageroot/etrade-documents/b/2022/10
- /storageroot/etrade-documents/b/2022/11
・
・
・
電子取引用ということで「etrade-documents」フォルダを作り、その中に取引先フォルダ、西暦フォルダ、月フォルダ……と作って行く形式です。取引数が少なければ月フォルダはなくして西暦フォルダだけでもいいかもしれません。
動作のイメージとしては、以下のようになりますね。
ファイル名に取引先を含めるので、取引先名もなく、年号フォルダだけの方がシンプルでいいかもしれません。ただ、件数が多い場合は人間が手動で操作しないとしても、ファイルの検索などに時間がかかるようになってしまいます。そのため、ここではこのように分割しています。
一方で、取引先の数が膨大で、しかもそれぞれ年に数件しか書類のやりとりをしない……といった場合は取引先フォルダを作成しない方がスムーズに動作すると思います。その辺りの整理の粒度はそれぞれの企業のビジネスモデルにあわせるといいでしょう。
なお、フォルダ名が英語なのは、楽介のただの癖と、プログラムだとローマ字だけにした方が色々楽な面があるという程度です。通常は、日本語でも動作します。
ロジックを考える
特別複雑なアルゴリズムは不要なのでシンプルです。入門編にぴったりです。
- upload フォルダの中にあるサブフォルダを確認する
- サブフォルダのリストを作成する
- サブフォルダの中にファイルがあるかチェックする
- ファイルがあったら、アップロード日時を確認する
- 該当する企業ごとの保存用フォルダにファイルを移動する
大まかな流れは以上となります。
細かく考えると、いつ、どうやって新規フォルダを作成するか? という問題が発生します。新規フォルダを作成しなければいけない局面は 3 種類あります。
- 新規の取引先サブフォルダが upload フォルダに作られていたとき
- 月が変わったとき
- 年が変わったとき
月と年が変わったときはほぼ一緒ですね。年月に関するフォルダが見つからないときです。
それぞれ、事前にフォルダリストをチェックして、必要なフォルダがなければまとめて作成する方法と、ファイルを移動するときにフォルダがなければ作成する方法の2種類があります。
数十、数百のファイルを扱うときには速度に差が出ると予想されますが、日々プログラムを動作させる場合にはそれほど大きな差が出ることはないので、プログラムの作りやすさ(自分がどちらが分かりやすいか)で決めてしまっていいでしょう。
一つ、重要な違いがあるとすれば、事前にフォルダを作る方法だと不要なフォルダが作られる可能性があります。2020 年から一切の取引がない取引先でも、(upload フォルダにサブフォルダがある限り)etrade-documents フォルダに毎月サブフォルダが作り続けられることになります。これが無駄だと感じる(または取引先数が増加し続けるタイプであれば)のであれば、必要な場合に都度フォルダを作成する方式がいいでしょう。
今回は、ファイルを移動しようとしたときにフォルダがなかったら作成する方法で実装してみます。
フォルダ・ファイルを列挙する
Python には、フォルダやファイルを、色々な条件をつけて一覧として取得できるモジュールがありますので、それを使います。対話モードで試してみると……
import glob
print(glob.glob(r'\storageroot\*'))
使い方は
glob.glob(‘パス’, オプション)
です。今回はオプションは特別必要ないのでシンプルにパスを与えれば大丈夫です。
また、ファイルやフォルダのパス文字列として簡単なワイルドカードが使えます。
- *……1文字以上のいずれかにマッチ
- ?……任意の1文字にマッチ。例えば、何らかの5文字にマッチさせたいときは ????? とする
- []……角括弧の中の1文字にマッチ。 – で範囲指定。[0-9]で数字のいずれか1文字にマッチ
- \……エスケープシーケンス。特殊文字をそのまま使いたいときに使う
上記の例だと、\storageroot\ フォルダの中の全てのファイルやフォルダが列挙されます(ただし、recursive = 再帰的オプションがついていないので、サブフォルダの中は探索しません)。
print 関数にシングルクォートの前には、 rをつけて raw文字列としているため、エスケープ文字でもあり、Windows におけるパスの区切りでもある \ 記号をそのまま記入してもエラーになりません。ただ、print の出力結果では、エラー \\ として、\記号自体をエスケープしています。よく分からなければこういうものだ、と考えてあまり混乱しないようにしてください(r がついていないためにこうなる)。
ここでは、ファイルもフォルダもまとめて列挙していますが、フォルダだけ列挙する場合には、
glob.glob(r'\storageroot\*\\')
のように、*の後にパスの区切り文字(mac の場合には / なので、2重にしなくていいです)をつけ、
特定の拡張子のファイルだけの場合には、
glob.glob('r\storageroot\*.pdf')
のようにします。拡張子によらずファイルだけを判別する方法もありますが、本論と外れるのでここでは扱いません。
ファイルの日付を取得する
ファイルがアップロードされた日付を取得してみます。
Python で日付を取得すると Unix 標準時という、人間には理解が難しい数字になってしまうので、変換して利用します。
import os
import datetime
test_file = r'\storageroot\test.txt'
time = datetime.datetime.fromtimestamp(os.path.getctime(test_file)) #test_file のパスの作成時間を使いやすい形式(datetime)で取る
date = time.strftime("%Y%m%d") #yyyymmdd 形式(一桁の月・日のときは0で埋める西暦つきの日付)の文字列に整形する
print(date)
この文字列をファイル名に使えば、取引先にわざわざ日付を入力してもらわなくても、日付情報を自動で入力できそうですね。また、 time.strftime(‘%Y’)で西暦だけ、time.strftime(‘%m’)で月だけを取得できるので、整理用フォルダを作れそうです。
フォルダを作成する
#一度対話モードを抜けていたら、一つ前から実行する
dest_root = r'\storageroot\dest' #コピー先フォルダのルート
dest_folder = dest_root + time.strftime('\\%Y\\%m') #コピー先フォルダのルート\西暦\月 フォルダのパスを示す文字列を作る
#if not os.path.isdir(dest_folder): #目的のフォルダがなかったら
# os.makedirs(dest_folder) #作成する
os.makedirs(dest_folder, exist_ok=True) #フォルダがあってもなくても作成する
フォルダの作成には、 os.mkdirという関数もありますが、親フォルダがなかったら親から作っていかないといけないなど不便なので(再起関数の勉強にはいいのですが)、os.makedirs関数を覚えておけばいいでしょう。また、サンプルコードのように、以前の Python ではフォルダの有無を最初にチェックしないとエラーになっていましたが、今は作成したいフォルダがあってもエラーにしない、 exist_ok オプション(引数)があるので、 True を代入して、1行で済ませてしまうと楽です。
makedirs 関数は、作成するフォルダのパスを渡せば、そのフォルダを作ってくれるというシンプルなものです。ただ、これだと取引先の判断ができないので、最終的にはもう少し工夫します。
ファイルを移動する
#一度対話モードを抜けていたら、1つ前から実行する
#storageroot に相当するフォルダ(Box フォルダや Nextcloud フォルダ)に、test.txt というファイルを用意します。
import shutil
shutil.move(r'\storageroot\test.txt', dest_folder + '\\' + date + '.txt') # date = time.strftime('%Y%m%d') をファイル名として移動する
ここまでは、適当なフォルダでもよかったですが、できるだけ利用予定の第三者性のあるクラウドストレージで行うようにします。
移動先を見て、バージョン履歴やアクティビティを確認します。Boxでは、スクリプトの実行時間ではなく、ファイルをアップロードした時刻で記録されていることが確認できます。認定タイムスタンプではありませんが、余計な編集は記録されていません。ファイル名も変更していますが、内容には変更が加わっていないので「同一のファイル」として保持されていることが分かります。
これで、ファイルの同一性の確認が取りやすいことが分かったので、安心して先に進められます。
動くはず、と思っていても意外に思わぬところでおかしな挙動になる場合があります。特に、仮想ファイルの操作は通常、エクスプローラーなどのシェル経由で動作させるため、プログラムからの操作では違った挙動になる可能性があります。
そういう場合には(一気に難しくなるので避けたいですが)、各クラウドストレージの API を利用するなどの方法が考えられます。
取引先フォルダ名を取得する
glob 関数の実行結果をもう一度見て見ます。
一覧は列挙できていますが、全てフルパスになっているので、このままでは目的のフォルダ名(取引先名)を取得できません。文字列として計算する方法もありますが、ここでは OS が変わっても動作するようにパス操作でフォルダ名を取得します。具体的には os.pathモジュールを活用します。
for l in map(lambda p: os.path.basename(p.rstrip('\\')), glob.glob(r'\storageroot\*\\')):
print(l)
os.path.basename はファイル名やフォルダ名を返します。ただ、末尾に \\ がついていると上手く動作しないので rstrip メソッドで削除します。この2つの処理を lambda 式で一つの無名関数として、 map 関数で glob モジュールの実行結果全てに適用しています。実行すると、
このようにフォルダの一覧が表示されます。
ただ、 for で回す場合、 map と lambda を使わなくても
for l in glob.glob(r'\storageroot\*\\'):
print(os.path.basename(l.rstrip('\\'))
で充分に動作しますので、 Python っぽい書き方をしたい! というのでなければ無理に使う必要はないと思います。
また、 rstrip を使わなければファイル名も取得できますので、ファイルを整理する情報が大体取得できましたので、python の対話モードを終了してテキストエディタで Python を描いていきます。
ファイル整理スクリプト version1
import os
import glob
import shutil as sh
import datetime as dt
uploads_root = r'/path/to/uploads/root' #各社のアップロードフォルダが格納されているフォルダ(一つ上)
moveto_root = r'/path/to/moveto/root' #各社のデータを整理して保存しておくフォルダの一番上
ext = '*.txt' #コピーしたいファイルの拡張子
def moveFiles(c, f): # for 文が長くなりすぎないように map 関数で呼び出す関数
time = dt.datetime.fromtimestamp(os.path.getctime(f)) #ファイルの作成時間を取得して扱いやすい形に
date = time.strftime('%Y%m%d')
year = time.strftime('%Y')
month = time.strftime('%m')
new_name = '-'.join([date, c, os.path.basename(f)]) #年月日-社名-元のファイル名 の形式のファイル名を作る
os.makedirs('\\'.join([moveto_root, c, year, month]), exist_ok=True) #移動先のルート\社名\年\月 フォルダを作る
sh.move(f, '\\'.join([moveto_root, c, year, month, new_name])) #移動先にファイルを移動する
for company in glob.glob(uploads_root + '\\*\\'): #アップロードフォルダの中にある取引先フォルダを列挙
company_name = os.path.basename(company.rstrip('\\')) #社名を取得
fileList = glob.glob(company + ext) #移動したいファイルの一覧を取得
cl = [company_name] * len(fileList) #コピーしたいファイルと同じ数だけコピーされた社名リストを作成
list(map(moveFiles, cl, fileList)) #上で定義したmoveFiles 関数に、社名と各ファイルのフルパスを渡す
import 後の先頭3行の設定を変更すれば、コピー & ペーストでも動作しますが、できれば自分の手で打って動作を確認しましょう。
※最終行が間違っていたため修正しました(2022/10/15)
話が違う?
はい、このスクリプトを実行しても「金額」や「書類の種別」といった命名はできません。何故なら、人間が確認しないとこれらの情報が分からないからですね。
もちろん、ファイル名にこれらの情報を入力してアップロードしてもらうという方法もありますが、取引先にそこまで求められなかったり、各社内部規定があったり、そもそもファイル名「だけ」が間違っていたときの責任の所在は? といった問題につながるので実際の業務に落とし込むのは難しいでしょう。仮に実行しても早晩崩壊するのが目に見えています。
OCR, AI-OCR を使って文字の読み取りが行うというのも魅力的です。ただ、その場合でも人間による目視確認は必要でしょう。
ということで、次回は金額や書類種別などを入力する補助をしてみます。
終わりに
結構無理矢理、 Python っぽくするために map 関数とか使って見ました。今回のような内容であれば、map 関数2個でもいいですし、for の入れ子、あるいは for の中で通常の関数呼び出し(社名リストを無理矢理作ってる辺りがクールじゃないので、普通に書くとこうなる気もします)といった書き方ができます。
楽介は元々、 Python 畑ではないのでどうしても Python っぽい書き方が苦手なので、無理矢理感が出てしまいます。特に、 map は遅延評価的な側面があるので、今回のように返り値を使わない場合は使うメリットが薄いです。
ただ、無理矢理でもなんでも、ある程度「それっぽく」書けるようにしておかないと、本格的な Python プログラムの改修を任された時などにソースコードを読み解くのが難しくなってしまいます。矯正作業みたいなものですね。
なお、なぜ map 関数を使うのが Python っぽいかというと、機械学習などのビッグデータを扱う場合には、リストやタプル、 pandas などのイテラブル(for に入れられるような、反復可能な)データの演算をよく行うため、簡潔に記述できることが求められるからですね。簡潔といっても、すごく数学っぽいので逆に難しかったりするのですが……。
ともあれ、次回は今回の結果を拡張して、より実務で役に立ちそうな形に仕上げたいと思います。
楽介でした。