案例:应用轻量级编程快速汇总电子发票金额

原创:顾远山
著作权归作者所有,转载请标明出处。

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阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350