案例:應用輕量級編程快速匯總電子發票金額

原創:顧遠山
著作權歸作者所有,轉載請標明出處。

TALK IS CHEAP! SHOW ME YOUR CODE!
OK... Here comes my code...

let main pathIn = 
    pathIn |> Directory.GetFiles |> Array.filter (fun f -> f.ToLower().EndsWith(".pdf")) 
           |> Array.map (fun filename -> filename |> readAllText |> Regex(@"(?<=¥)\d+?\.\d+?").Matches |> Seq.cast<Match> |> Seq.map (fun m -> m.Value |> decimal) |> Seq.max) 
           |> Array.sum

!@#%^&*&^%#@!
CODE IS CHEAP! SHOW ME YOUR POINT!
OK... Here comes my point...


前言: 在日常生活中我們經常會遇到一些實際問題,比如少量數據的非常規處理,人肉手工做又累又傻,現成的工具或平臺卻要么過于通用要么過于笨重以至于無法直接被應用在特定場景,空有百般本領卻無從下手。也許真相是好多人對它們的功能不夠熟悉,例如筆者并不從事數據分析工作,類似Power BI這種入門級的簡單工具學了又忘忘了又學也用不好,一來是工作中缺乏足夠案例實踐,二來是年紀大了確實記不住。對于這種情況,輕量級編程可以靈活快速地把問題解決。

導讀: 這是一篇用輕量級編程方式解決實際問題后復盤的文章,主要圍繞軟件工程實踐中的設計和開發階段展開,順便推廣一下F#編程語言(和正則表達式)在日常生活中的應用。

關鍵字: 輕量級編程軟件工程F#正則表達式

第零部分:問題描述

現有格式相同的電子發票PDF文件若干,我們使用Edge瀏覽器或Reader類工具打開它們后,能選取和復制里面的文字內容,比如下面兩個截圖中,發票樣本1選取到的是發票金額¥1029.40,發票樣本2選取到的發票金額¥799.70,即藍色高亮部分。如果不想逐個點開PDF文件找到發票金額進行復制粘貼或手抄匯總,如何快速求得這堆電子發票的總金額?

發票樣本1
發票樣本2

這個問題的實質無非是數據抽取+類型轉換+數值計算,解決思路五花八門。同事S早前做過各大公司年報抽數分析的項目,她建議用輕量級編程的方法直接從PDF文件中讀取發票金額然后匯總求解。思路很有創意,那具體怎么求出這個值呢?實現的方式也是豐富多彩的,筆者使用了其中一種,僅供參考。

第一部分:高階設計

目標: 實現一個程序。
輸入: 一個包含電子發票文件的Windows文件夾。
輸出: 所有電子發票金額(含稅)的匯總值。
假設: 該文件夾存在且可被訪問但沒有子文件夾,該文件夾里有符合指定格式的電子發票文件(PDF格式),且這些文件能被Edge或者Reader類工具打開并選取和復制發票金額。

高階設計

測試用例:

  1. 文件夾里只有文件20200831.pdf(發票含稅金額¥1029.40)時,輸出1029.40。
  2. 文件夾里有文件20200831.pdf(發票含稅金額¥1029.40)和20200921(發票含稅金額¥799.70)時,輸出1829.10。

第二部分:詳細設計

我們把期望實現的程序功能按模塊進行了簡單分解。
主程序由三個子模塊組成,其中:

  • 子模塊1:收集待處理發票文件列表;
  • 子模塊2:對每個發票文件進行操作,打開發票文件,獲取發票金額;
  • 子模塊3:匯總發票金額。
程序功能的模塊化分解

按函數式編程的范式進一步把模塊對應為函數,則整個程序將由四個函數構成,一個主函數(main)和三個子函數(getPDFsgetInvoiceAmountsumUp),四者與輸入輸出的關系如下圖所示:

main函數關系圖

其實上面的getInvoiceAmount函數有兩個坑,穩妥起見先把它們填了:第一,打開目標文件(PDF格式)并讀取所有內容為文本并非編程語言的內置功能,必須依賴第三方的包間接實現。第二,讀取出來的文本是一個字符串,實現時把字符串里所有(¥數值)全部抓出來取最大值即可。為此我們對getInvoiceAmount函數進一步分解為兩個子函數readAllTextgetTargetValue,三者與輸入輸出的關系如下圖所示:

getInvoiceAmount函數關系圖

通過把高階設計分解為主程序和三個模塊,對應的四個函數(加兩個子函數)組合起來可以實現程序期望的功能,小結如下:

  • main: string -> decimal
  • getPDFs: string -> string []
  • getInvoiceAmount: string -> decimal
    • readAllText: string -> string
    • getTargetValue: string -> decimal
  • sumUp: decimal [] -> decimal

第三部分:代碼實現

準備條件: 創建F# Console Application (.NET Framework 4.7+)解決方案,通過Nuget Package Manager安裝PDFSharp包(最新穩定版1.50.5147),并打開代碼實現所依賴的以下命名空間:

open System.Text
open PdfSharp.Pdf.IO
open PdfSharp.Pdf.Content
open PdfSharp.Pdf.Content.Objects
open System.Text.RegularExpressions
open System.IO

System.IO用于獲取文件夾里的文件名,System.Text.RegularExpressions用于通過正則表達式從文本中獲取目標值,其他是PDFSharp相關的命名空間。
實現詳細設計里面的四個函數(加兩個子函數)
由于main函數在最后調用,我們先實現它的三個子函數,然后再實現它。

  • getPDFs函數
let getPDFs pathIn = 
    pathIn 
    |> Directory.GetFiles //獲取該文件夾下所有文件
    |> Array.filter (fun f -> f.ToLower().EndsWith(".pdf"))//篩選PDF文件
  • getInvoiceAmount函數
    詳細設計中提到,實現這個函數需要先實現它的兩個子函數readAllTextgetTargetValue,我們逐個實現。

    • readAllText函數
      F# Snippets的網站上,直接有可用的代碼,直接引用。

    • getTargetValue函數

let getTargetValue filecontent =
    let regex = new Regex(@"(?<=¥)\d+?\.\d+?") //獲取金額文本的正則表達式
    filecontent  |> regex.Matches |> Seq.cast //詳細設計中的getMatchedStrings
    |> Seq.map (fun m -> m.Value |> decimal) //詳細設計中的decimal 
    |> Seq.max //詳細設計中的max

實現了readAllText子函數和getTargetValue子函數之后,根據詳細設計易得:

let getInvoiceAmount filename = filename |> readAllText |> getTargetValue
  • sumUp函數
    F#內置有匯總函數Array.sum,直接使用。

  • main函數
    基于上述三個子函數的實現,根據詳細設計即得:

let main pathIn = pathIn |> getPDFs |> Array.map getInvoiceAmount |> Array.sum

把實現完畢的main函數對比高階設計里的概念圖,數據流過程并無二致。

至此四個函數(加兩個子函數)都已用F#代碼實現完畢,設定輸入參數pathIn便可運行測試。

第四部分:用戶接受測試

測試用例1:
期待值1029.40,實際值1029.40,通過。

測試用例1驗證通過

測試用例2:
期待值1829.10,實際值1829.10,通過。

測試用例2驗證通過

測試用例驗證通過后,筆者認為用戶驗收測試完成,程序可用,問題解決。

結語

筆者最后用這個小程序快速匯總了60個PDF電子發票文件的含稅總金額,非常方便。之所以說這是輕量級編程解決方案,是因為除去引用的外部代碼之外,實現所有功能只需要不到10行代碼,如下:

let getPDFs pathIn = pathIn |> Directory.GetFiles 
                            |> Array.filter (fun f -> f.ToLower().EndsWith(".pdf"))

let getTargetValue filecontent = 
    let regex = new Regex(@"(?<=¥)\d+?\.\d+?")
    filecontent |> regex.Matches |> Seq.cast<Match> 
                |> Seq.map (fun m -> m.Value |> decimal) 
                |> Seq.max

let getInvoiceAmount filename = filename |> readAllText |> getTargetValue

let main pathIn = pathIn |> getPDFs |> Array.map getInvoiceAmount |> Array.sum 

使用F#進行輕量級編程解決實際問題

時下很多人都在學Python,對于日常應用類的輕量級編程非常容易上手。但其實F#也同樣適合這種場景,而且很多時候F#的語法比其他語言更簡潔。
比如F#中被廣泛應用的前向管道運算符|>,它的定義為:

let (|>) x f = f x

前向管道運算符|>可以非常直觀地把輸入輸出按照流的形式直接串起來,相比其他語言省了不少括號從而增加了代碼的可讀性。這個運算符在函數式編程語言里其實是標配。
我們不妨用縮進的方式細看一下本案例的main函數:

let main pathIn = 
    pathIn 
    |> getPDFs //輸入文件夾路徑,獲取文件夾里所有PDF文件名
    |> Array.map getInvoiceAmount //對每個PDF文件,獲取里面的含稅發票金額
    |> Array.sum //匯總所有含稅發票金額

這樣的代碼,數據流的順序基本遵循業務邏輯,即便不是程序員也能猜個七七八八。但同樣的邏輯如果換成C#來寫,就算用上Linq的擴展方法也最多精簡如下:

public static int Main(string pathIn)
{
    return getPDFs(pathIn).Select(file=>getInvoiceAmount(file)).Sum();
}

其中各種括號和莫名其妙的關鍵字,邏輯要再復雜一點的話別說業務人員了,就算程序員讀起來恐怕也是云里霧里。

另外,在F#中代碼的復用比其他語言更靈活,因為不同的函數之間可以相互之間組合產生新的函數,而這些函數又可以作為參數傳給高階函數進行運算。函數組合運算符>>的應用也是相當高頻,比如本案例中的main函數,就算我們沒有顯式實現getInvoiceAmount,也可以臨時用readAllTextgetTargetVaule組合起來用,于是有:

let main pathIn = 
    pathIn 
    |> getPDFs
    |> Array.map (readAllText >> getTargetValue) //臨時組合的匿名函數作為高階函數的入參
    |> Array.sum

>>操作符我們很方便就把readAllTextgetTargetValue兩個函數結合成一個匿名函數,然后這個匿名函數被作為參數傳到Array.map高階函數里參與計算。這個操作符在函數式編程語言里同樣是標配。

F#中也有語法糖。還用本案例中的main函數舉例,Array.map f array |> Array.sum等效為Array.sumBy f array,所以這句代碼可以寫得更簡潔一些:

let main pathIn = 
    pathIn 
    |> getPDFs 
    |> Array.sumBy (readAllText >> getTargetValue)

其實函數式編程語言還有很多有趣且實用的特性。比如函數調用傳入參數可以不加括號這一點,就讓有些寫得足夠好的F#代碼看起來跟自然語言(英文)相當接近,甚至一般人也能看懂,所以F#的用戶群里有固定一部分是做領域特定語言編程的。領域特定語言是另一個話題了,就算只用于解決日常小問題,筆者還是強烈推薦產品經理學一學F#這門開源的全平臺語言,挺有用的。

另外,既然PDF格式的文件有特定的文件結構,為什么不通過文件結構分析獲取發票金額?這樣做的確沒問題,但筆者不熟悉PDFSharp包深入研究必然花費一定時間,且筆者有信心用正則表達式能把目標值提取出來,就直接讀取PDF所有內容為文本了。實際上抽取發票金額的正則表達式很短,各部分用不同的顏色標注如下:

案例中的正則表達式拆解

  • (?<=¥)為肯定式后向查找字符¥,零寬度斷言,僅匹配不捕獲
  • \d+?,向前惰性匹配所有數字,直到遇到第一個非數字字符(得到整數部分1029)
  • \.,字符 . 是正則表達式里的保留字(通過\字符轉義得到普通字符 . )
  • \d+?,向前惰性匹配所有數字,直到遇到第一個非數字字符(得到小數部分40)

正則表達式簡單暴力可行,但并不是出色的解決方案,慎用。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,156評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,401評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,069評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,873評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,635評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,128評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,203評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,365評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,881評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,733評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,935評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,475評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,172評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,582評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,821評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,595評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,908評論 2 372