在上海疫情持续下,买菜成了居民生活的一大难题。为了阻止疫情传播扩散风险,政府暂停了商超的线下购物。我所在的奉贤区区政府组织了一些名为“满天星”的社区团购。其主要流程是:

  1. 居委会当天向所辖各居民区公布能供货的物品及价格,包括米面粮油、纸尿裤、泡菜、猪肉等等,每次不同。一般一周1、3、5统计需求,次日周2、4、6配送,周日休息。
  2. 各居民区楼栋志愿者统计本楼居民所需购买的物资数量
  3. 楼栋志愿者将本楼所需购买数量上报给居委会
  4. 居委会垫资统一组织购买物资,这其中有政府工作人员协助沟通,部分物资可能会后到
  5. 物资到达后居委会分到各居民区,各居民区统一消杀后,拿进小区
  6. 小区楼栋志愿者分发物资。

     这些社区团购因为是政府主导,所以价格还算合理,当天订购大部分物资第二天能送到。这极大的解决了买菜难的问题。但因为是人为主导,所以需要志愿者汇总统计各栋楼的居民买菜需求。我所在的楼栋由于志愿者人手不够,只能满足基本的年长的居民的购物需求,且限制了单数。于是我就报名了社区团购买菜志愿者,希望能够让本栋楼更多的人包括我自己也能够吃上菜。

需求


    统计本楼所有居民的购物需求是一件比较繁琐的事,楼长会建一个微信群,将本楼的所有居民拉进来,我作为本楼社区团购买菜的负责人,在群里发布居委会当天公布的菜单,然后发起接龙,大家在里面接上自己需要购买的物资。

▲ 4月18日居委提供的“满天星”供货清单

    拿到清单后,在所属楼群里发布接龙,大家按需购买:

▲ 微信群里买菜接龙,已隐去隐私信息

    居委要求4点上报统计清单,本小区要汇总,所以本楼栋会在3点多停止接龙,然后根据接龙内容统计各物资数量上报给本小区负责人,小区负责人再上报给居委。为了尽量收集更多人的需求,所以接龙时间我会尽可能的长,这样留给志愿者的统计时间就不多。

    在收集需求时,有一些比较年长的住户,不会使用手机,或者不会在微信里接龙,他们会直接在微信群里语音说需要购买什么,所以还需要帮助他们登记和确认。最终,需要在截止时间之前得到如下统计表格:

▲ 4月18日,本楼栋居民的买菜需求,根据微信接龙内容整理

    这个表格需要反复核对,如果漏记可能会导致某位居民几天吃不上某种蔬菜或肉,因为下次社区团购的物资清单可能会发生变化。以这次为例,14个物品,36位居民需求,需要逐个根据接龙里面的描述,或者群里聊天语音来确认登记,所以光单元格就需要14*36=504次的扫描,非常考验眼睛。

   完成核对后,需要把各物资的汇总数量(上图的最后一行)汇报给本小区负责人,他再汇总上报各居委。

解决方法


    作为一个码农,这种需要人工手动处理的工作显然会不可避免的遇到问题(人的能力总会受到情绪、状态的影响),所以需要考虑让计算机来解决。根据提供的菜单,将微信接龙数据进行处理,解析物品和数量,然后自动统计汇总(包括菜品总数,每位居民的购买申请件数、总金额)。

    能解决这种收集需求统计汇总的方法有很多,群里其它楼栋负责人推荐微信小程序里的“群报数”,这个挺好,类似一些剁手App里的购物车那样,管理员只需要输入商品、价格,群里的人员点进去勾选需要的商品和数量即可。这种方式得到的数据是格式化和规范化的,它唯一的缺点就是可能需要一点学习成本,一些年长的居民可能不会使用。

    我所在的楼栋之前的志愿者使用的是微信里的接龙功能,我作为后继的志愿者,不能颠覆用户已有的习惯,强迫他们使用新的系统,所以还是继续使用接龙功能,而且这个功能有个最大的优点就是使用简单,不需要额外点进其它小程序,且用户学习成本极小(即使这样仍有一些居民不会接龙)。

    但接龙的最大问题是,灵活性太高,用户在接龙填写需求时,格式不统一(且无法要求统一)。各种格式、各种表述方式都有,使得使用代码来规范化分析提取数据比较困难,但只要思想不滑坡,办法总比困难多,这些问题最后都得到了解决,后面会说明。

    确定了收集需求采用微信接龙之后,下一步就是呈现方式了。这有很多种,比如可以使Winform、WPF、或者Excel插件。因为这个涉及到汇总、统计,所以可以说是天然适合使用Excel来处理,于是决定采用Excel插件的解决方法。因为恰好我在多年前做过金融类的Excel插件开发,所以这个很简单。

    我的解决如下演示,非常简单易于上手,经调整后,准确率基本能到90%以上,能够节省大量的眼力(这种即使准确率能到100%,为了保险起见仍然需要人工审核一遍,因为它涉及到买菜的问题,在现阶段,买多或少都会带来很大问题。但和手工录入、统计相比,这个审核对照的工作显然要小了非常多。)

    这里唯一需要手动操作的是,把菜单输入进来,因为给的是图片格式,后续的改进是把OCR加进来,可以自动识别图片里的文字。

代码实现


    我这里使用的是VSTO开发Excel插件,语言是C#(这个需要Excel 2003 以上版本的支持,如果需要兼容所有的,可以使用Shared Add-in,实际上要开发一个所有Excel版本均可以应用、能部署分发的插件是有些复杂的,可以参考我前面写的那一系列文章,这里因为是个人使用,且现在大部分用Excel的应该都升级到了比Windows XP时代的Excel 2003更高级的版本了,所以这里直接用VSTO技术开发)。要在Visual Stuido中开发Excel插件需要安装特定的模块:

    勾选Office/SharePoint开发,这部分模块内容不多,下载起来很快。完成之后Visual Studio重启一下就可以。

    关于Excel插件开发,我在年轻的时候写过十几篇文章,其中浅谈Excel开发:二 Excel 菜单系统, 浅谈Excel开发:七 Excel 自定义任务窗体 这两篇文章基本可以用来编写上面这个插件。我这里只说明一下几个重要的点。

  • Excel里面的范围选择

     在Excel里面范围是用Range对象来表示,选择单元格、获取单元格内容、给单元格赋值、设置单元格样式,都是通过Range对象来实现的。当点击“选择物资...”时弹出的框为内置的InputBox,可以用它来选择范围:

    代码的实现如下:

private void btnMenu_Click(object sender, EventArgs e)
{
    dynamic d = Globals.ThisAddIn.Application.InputBox("请选择物资菜单区域:", Type: 8);
    if (d is Range range)
    {
        if (range != null)
        {
            txtMenu.Text = range.Address;
        }
    }
}

      这里有个特别需要注意的地方是,VSTO提供的Excel API大多数都是对COM组件的包装,所以跟一般的类库不一样,InputBox返回的类型是不确定的,当点击“确定”时,它返回的是Range类型,但当点击取消时,他返回的是bool类型的false,所以这里用到了dynamic关键字。

    当选取好内容范围之后,就获得了一个Range对象,可以使用他的Address属性来获得表示该范围的Excel字符串,这个在插入函数的时候非常有用,比如Sum函数,我们可以选取一个需要汇总统计的返回Range对象,然后取他的Address属性,把他放到Sum函数里作为参数,这样就可以插入函数了。

  • Excel里面获取单元格的值

    在获取了单元格Address之后,可以通过Address直接获取单元格的值,因为上述的面板里面,当点击生成报表控件的时候,其实只是触发了一个GenerateReportEventHandle类型的事件,把选取的物资范围Address和接龙范围Address给传递出来.订阅的对象,然后根据这两个值来分析和拼接数据。

    在拿到了两个Address对象后,就需要获取对象里面的值了。一般地,当只选择了一个单元格时,调用该Range对象的Value2属性,它是1个object类型,当选择的单元格不止一个时,返回的类型就变成了object[,]数组了。

private List<Menu> GetMenuRange(Range range)
{
    List<Menu> result = new List<Menu>();
    object[,] res1 = (object[,])range.Value2;
    int count = range.Count;
    //如果选中多个单元格
    if (count > 1)
    {
        int row_count = range.Rows.Count;
        int col_count = range.Columns.Count;
        for (int i = 1; i <= col_count; i++)
        {
            if (res1[1, i] != null)
            {
                string menuName = (string)res1[1, i];
                double menuPrice = 0;
                if (row_count > 1 && res1[2, i] != null)
                {
                    menuPrice = double.Parse(res1[2, i].ToString());
                }
                Menu m = new Menu(menuName, menuPrice);
                result.Add(m);
            }
        }
    }
    else
    {
        //如果选中单个
        if (res1 != null)
        {
            string menuName = res1.ToString();
            result.Add(new Menu(menuName, 0));
        }
    }
    return result;
}

    上面这个是获取“选择物资”里面的物资内容,我们初始得到的是一个Address字符串,由Address字符串得到Range对象可以使用get_Range方法:

Worksheet activeSheet = (Worksheet)Globals.ThisAddIn.Application.ActiveWorkbook.ActiveSheet;
Range menuRange = activeSheet.get_Range(staticArgs.MenuRange);
  • 给单元格赋值

    跟从单元格获取值一样,从单元格取值,如果是多选,它返回的是object[,]类型的,这就表示里面可以装任何类型,所以我们在需要往单元格里写入数值时,也需要准备一个object[,]类型的对象,千万不要逐个cell的赋值,这是效率极低的方法。

private void FastInsertValue(Worksheet activeSheet, Range rng, object[,] myArray, int offsetX = 0, int offsetY = 0)
{
    int rowCount = myArray.GetLength(0);
    int colCount = myArray.GetLength(1);
    int startX = rng.Row + offsetX;
    int startY = rng.Column + offsetY;
    Range range = activeSheet.Range[activeSheet.Cells[startX, startY], activeSheet.Cells[startX + rowCount - 1, startY + colCount - 1]];
    range.Value2 = myArray;
}

    上面是给单元格赋值的方法,基本就是对Range对象的Value2进行赋值,这个方法提供了一个起始的Range对象,在生成报表的时候,我把他赋值为鼠标所在的单元格。方法还提供了行和列的起始偏移,比如我希望生成的报表的左上角位置是鼠标所在的单元格,或者鼠标往右,往下多少个单元格。

  • 往单元格写入函数

    Excel最强大的莫过于其内置函数功能,比如我们常用的Sum。在插入函数的时候,需要确定统计的区域,获取该区域的Range对象,进而得到Address属性,然后往要插入的函数单元格的Range对象的Formula属性写入“=Sum(Address)” 这样的值。

private void FastInsertFormula(Worksheet activeSheet, Range baseRange, object[,] myArray, int offsetRow = 0, int offsetColumn = 0)
{
    int rowCount = myArray.GetLength(0);
    int colCount = myArray.GetLength(1);
    int startX = baseRange.Row + offsetRow;
    int startY = baseRange.Column + offsetColumn;
    for (int x = startX; x < startX + rowCount; x++)
    {
        for (int y = startY; y < startY + colCount; y++)
        {
            activeSheet.Range[activeSheet.Cells[x, y], activeSheet.Cells[x, y]].Formula = myArray[x - startX, y - startY];
        }
    }
}

    函数写入我还没找到批量写入的方法,目前只有逐个单元格给它的Formula属性赋值了,还好需要填入统计函数的单元格并不多。

  • 单元格格式化

    单元格格式化也是对Range进行操作,需要先添加一个样式,并给样式取个名字,如果这个样式之前存在,就会报错,但我似乎没找到如何判断某个样式是否存在的方法,这些对象都是dynamic类型的,看不到它真实的方法签名,所以只有用try catch来盖住了。

   样式创建好了之后,就可以对表头和行进行格式化了,我这里对表头和奇数行,进行了格式化处理,把背景改成了灰色,这样在看的时候有视觉变化,不会看错行。

Style oddRowStyle = null;
try
{
    oddRowStyle = Globals.ThisAddIn.Application.ActiveWorkbook.Styles.Add("OddRowStyle");
    oddRowStyle.Interior.Color = System.Drawing.ColorTranslator.ToOle(Color.FromArgb(217, 217, 217));
    oddRowStyle.Interior.Pattern = XlPattern.xlPatternSolid;
}
catch (Exception)
{
    oddRowStyle = Globals.ThisAddIn.Application.ActiveWorkbook.Styles["OddRowStyle"];
}

Range headerRange = activeSheet.Range[activeSheet.Cells[row, col], activeSheet.Cells[row + 1, col + menuList.Count + 1]];
headerRange.Style = "OddRowStyle";

//设置行样式
for (int i = 0; i < purchaseList.Count; i++)
{
    Range rowRange = activeSheet.Range[activeSheet.Cells[row + 2 + i, col], activeSheet.Cells[row + 2 + i, col + menuList.Count + 1]];
    if (i % 2 != 0)
    {
        rowRange.Style = "OddRowStyle";
    }
}

    获得Range对象之后,将它的Style属性设置为新建的样式名即可。

难点


    前面的都是技术方面的实现,真正麻烦的是对不规范的文本的解析。上面这个例子中,实际拿到的接龙文本是这样的:

#满天星4月18日

1. 904 蔬菜包1份
2. 1002 郑女士 牛肉酱1份 白糖1份 鸡蛋1份
3. 1004   白糖1  牛肉酱1  盐1
4. 503付先生 蔬菜包一份
5. 1-1403  平板纸5,榨菜5,牛肉酱1
6. 1-1801胡女士 蔬菜包1份,榨菜3包,鸡蛋1盒
7. 1303 牛肉酱 1  榨菜3
8. 汪先生 504蔬菜包1平板纸1鸡蛋1士力架1
9. 汪先生
10. 601 蔬菜包1份
11. 1103 乌江榨菜 6包
12. 803 牛肉酱1 挂面3  鸡蛋2 榨菜3  苹果1
13. 1号1704,鸡蛋 2份,榨菜6包,笋干菜扣肉一份
14. 1-1301  蔬菜包2份
15. 102 蔬菜包1份
16. 1-903  牛肉酱1份,榨菜2包梅菜扣肉1份
17. 1103 牛肉酱2份
18. 1104 盐2,榨菜2,鸡蛋2
19. 1-1802,蔬菜*1
20. 1一1502 榨菜2,平板纸3包,士力架l包
21. 1401 蔬菜包1,榨菜5,牛肉酱1,苹果1
22. 1804 白砂糖2包 纸2包
23. 1101 榨菜5包
24. 1503 蔬菜包 白砂糖
25. 302室2份鸡蛋
26. 204 苹果1袋
27. 1002 郑女士 苹果1袋
28. 1-101 苹果1
29. 701 蔬菜包1份,士力架1份
30. 1一1502 苹果I袋
31. 1号1501蒋 牛肉酱2组,午餐肉2份
32. 从先生 103白砂糖一份
33. 从先生 103笋干扣肉一袋,烏江榨菜10包,苹果一袋。
34. 404 苹果一袋
35. 先生 103肉A35
36. 截止

     上面有些写的是自己的姓名,为了隐私,这里我用“先生,女士”代替。这里面,第一行和最后一行需要删除,第一列序号和第二列的点,是微信自动生成,我们从点后面开始数列,可以看到:

  • 需要删除一些无效的接龙,比如第9条和第36条,还有一些重复的接龙(有些是一户居民家里不同的人在接龙),还有些是多次相同的接龙,这种需要跟住户确认,不同商品的相同房间号接龙,这里不做处理,它不影响统计。
  • 第35条接龙是错误的,这个接龙里面不提供肉,肉是在另外一个接龙里统计的。
  • 有1名年长住户不会接龙,她是在群里语音购买的。
  • 从第1列开始 ,就比较五花八门了,正确的应该是房间号比如“904”,这个房间号默认是微信群里的昵称,但有些居民没有填上自己的房间号直接写的是自己的名字,房间号写在了后面的列,后面那些列应该是自己要购买的品种和数量。有些有房间号,但是后续带了个空格然后加上了自己的姓名,比如第2、27条记录。我们实际只关心房间号,姓名不需要。
  • 房间号后面,应该是要购买的物资名称和数量,有些接龙把房间号写在了蔬菜的前面,本应填写房间号的第一列填写的是姓名,比如第8、32、33条记录。
  • 要购买的物资的名称不统一,比如白砂糖全称应该是“玉棠白砂糖”,接龙里面有的写的“白糖” 比如记录3,有的写的“白砂糖”比如22,还有一些字体是繁体的,比如第33条“烏江榨菜”。这些都需要处理,才能方便匹配。我的解决方法是,把菜单里的名称调整到最简洁但不影响理解,“玉棠白砂糖”就改为“糖”,这样就容易匹配,直接用IndexOf方法就可以判断出来,当然这种简化不能跟其它商品的名称有冲突。
  • 要购买物资的数量格式不统一,有的使用的是阿拉伯数字“1份”,“2份”,“3包”,有的是使用的汉字,比如“一包”、“一袋”,还有的时使用的希腊字母比如第30条记录“I袋”这是个希腊数字,或者是个大写的i。之所以出现上面这些问题,我猜想可能有些年长的居民用的是手写输入法,所以识别出来的文字会五花八门,在处理文本的时候,需要考虑到这些情况。
  • 物资的名称和数量格式不统一,有的是“物品+份数”,有的是“几份物品”,比如有的是“苹果1袋”,有的是“苹果 1袋”,有的是“1袋苹果”,有的是“苹果1”。我的处理方式是,查找某段文本中是否有数字,如果有,按数字所在的位置拆分,正常前面就是商品名称,后面就是件数,如果没有数字,那么应该是商品名称,后面有空格,空格后面才是数字。

     针对以上的解析策略,大部分能够解决,对于那些若干个别不能解析的,可以调整一下接龙里面的文字表述,比如删除房间和名字之间的空格,或者直接删掉名字,比如第2、27条记录。比如第8、32、33条记录,可以删掉名字,把名字后面的房间号放到第一列,然后在名字和物资名称之间添加空格。可以一边调整文本,一边点击“生成报表”来查看解析结果,需要注意的是,在点击“生成报表”前,要确保鼠标所在的单元格不要发生变化。这样便于对比文本的调整前后的效果。

     我自己实践下来,以4月18日的这次接龙,稍微调整后,识别出来的精度跟我手工统计的相比,可以达到100%正确。这能为我在后续的志愿者工作中极大的减少核对统计的工作量。

其它


    上面只是买菜志愿者工作的第一步,统计居民需求并上报。除了统计上报之外,实际的情况还有很多工作要做。比如4月18日统计了“满天星”的买菜需求,当天还有猪肉的统计需求,它只有A、B两个套餐,大家只需要A或B即可,比较简单。

    4月19日的中午,物资当时还没到,但群里通知,订购的榨菜和糖数量比较少,我们楼订购了57分榨菜,只能分到6份,糖订购了7份,只能分到2分。蔬菜包和苹果明天才能到。

      这种缺少的物资,就涉及到分配的问题,基本的原则是,对于必须的生活物品,优先照顾年长的居民,但本楼年长居民不止两户,2份糖我就选了两户,其中一户是腿脚不方便的,一户是不会使用微信接龙语音购买的。然后在群里说了分配的原则,另外询问了另外1位没有分配到的居民,询问是否急需糖,得到的答复是急需,他的糖前几天就缺,有邻居说可以先给,但是因为邻居不收钱,他没要。所以经过和另外一户进行沟通,将糖分给了这位急需的居民,总之大家都很谦让有礼,缺少的家里有多的都愿意分一些出来。榨菜由于不是急需,所以就在群里按照先到先得分了。

    4月19日的下午3点多,我们楼14分猪肉都到了,在居家办公的工作完成之后,我去小区门口跟另外一位志愿者把肉拿到了楼道门口,然后通知大家下来领,领的时候,我手动拿纸笔登记(要是有带有Pencil的iPad平板或者支持分屏的手机就好了),同时把我的微信支付宝支付宝给大家扫码付款。有1户居民不方便下楼,帮忙送上去了。

     猪肉分完了之后,紧接着“满天星”团购的物资就到了,因为缺少蔬菜和苹果,榨菜和糖数量不多,这些之前都在群里通知了。把物资分几次用小推车从小区门口推到楼下后,然后通知大家下来领取,领取的时候询问房间号,并拿纸笔记录领取的物资种类和数量,在对应位置上打√,并计算应付款项。比如有位居民订了10包榨菜,他第一个到,所以来的6包榨菜就都给他了,这就要算他要付多少钱。还有1位买鸡蛋的居民会微信接龙,但是不会微信和支付宝支付,问能否现金,我说可以但是没有零钱找,他又回家拿现金。在大概1个小时之后,还有几份没有人下来领,于是在群里通知下来领,等了10分钟,对剩下的物资上门送了。我们小区没病例是防范区,但因为要尽快清理,所以物业不让出小区,但可以在小区内活动。

      ▲ 物资发放差不多完了之后,剩下的往上送,疫情期间大家都在家,有些住户没看微信群

     今天发了两批物资,猪肉和“满天星”的物资,大概5点全部弄完,晚上还需要统计大家给我支付宝和微信付款的金额,然后要把这些钱在群里转给本小区的负责人,因为这些物资都是居委会垫资购买的。

     总结这半天的志愿者工作,其实挺繁琐的,物资短缺需要在群里跟大家沟通如何分配,需要去门口按数量领物资,需要按照大家的购买核对领取的数量和计算应付的款项,需要上楼送物资。因为小区是防范区,大家可以下楼,这些工作相比很多其他种类的志愿者,已经算是比较简单容易的工作了。另外本楼的居民都很配合,一些年长的邻居,可能知道我说的普通话,怕我听不懂上海话(我确实听不太懂😂),还特意尽量说使我能听懂的普通话,志愿者期间听到最多的是“谢谢”、“谢谢你啊杨师傅”😂 。

    疫情期间,能和不同的人说说话(做好防范,安全距离,口罩),哪怕是领菜时问问“几号房间啊”,“这一件x元,您买了x件,共x元”,“我家做包子馒头都要放点糖,糖不够了”,“我家里还有,你来拿吧”,“微信还是支付宝”,“我都行”,能做一点微小的工作,帮点小忙,感觉挺好,希望疫情能早点结束,大家能早日过上吃菜自由的生活😃。