使用敏捷开发解决实数转中文大写金额问题

实数转中文大写的问题,虽然不能算是太难,但却也不是那种能一气呵成,一蹴而就的简单问题,一步到位的想法很容易就会陷入泥潭;正确的做法应该 是对转换的规律抽丝剥茧,由浅入深一步一步完成转换步骤,如此便能水到渠成……敏捷Programming的思想很适用于解决此类问题,借此机会正好和大家分享一些敏捷Programming 的经验。

开始之前,先看一下大写位进换数情况先,这里以目前财务体系的中法换算为准:

个=10的0次方
十=10的1次方
百=10的2次方
千=10的3次方
万=10的4次方
亿=10的8次方
兆=10的12次方
京=10的16次方
垓=10的20次方
杼=10的24次方
穰=10的28次方
沟=10的32次方
涧=10的36次方
正=10的40次方
载=10的44次方
极=10的48次方
恒河砂=10的52次方
阿僧祇=10的56次方
不可思议=10的60次方
中法换算中,“个十百千”可认为是基本单位,而从开始会复用这些基本单位,用完这4个基本单位之后就会有新的单位出现。然后再利用这4个基本单位,如此循环往复。因此,首要解决的就是“个十百千”这个级别上的转换问题,一则是因为它最简单,二则是它的转换逻辑还可以为以后更高层次的转换所复用。

先考虑最简单的情形,即不带零和小数的情况:

1
2
3
4
5
6
[Test]
public void SimpleConvert()
{
CurrencyConvert convert = new CurrencyConvert(1234M);
Assert.AreEqual(“壹仟贰佰叁拾肆元整”, convert.ToString());
}

没有小数,也先不考虑零的情况。在我们编写类来通过测试之前,先分析一下大写转换规律:

上表中,第三行,白底部分我们称为数字,灰底部分称为位字,红色的箭头代表我们分析金额时的顺序,是从右至左,第一个数字的位字为,此位字不作输出;第二个数字的位字为;第三个为;第四个为。那么,只要从右向左分别得出数字及其位字,依次插入到列表的头,再串联起来就可以得到其大写形式了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CurrencyConvert
{
private static string[] symbols1 = new string[] { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" };
private static string[] symbols2 = new string[] { "", "拾", "佰", "仟" };
private decimal _source;
public CurrencyConvert(decimal source)
{
_source = source;
}
public override string ToString()
{
string sourceString = _source.ToString("F");
List<string> list = new List<string>();
for (int i = sourceString.Length - 1, j = 0; i >= 0; i--, j++)
{
list.Insert(0, symbols2[j]);
list.Insert(0, symbols1[int.Parse(sourceString[i].ToString())]);
}
return string.Join("", list.ToArray()) + "元整";
}
}

现在考虑带零的情况,零的转换有以下几种情况:

  1. 零后面不打印位字(如1024为“壹仟零贰拾肆元整”)
  2. 当有多个零连续出现的时候只打印一个零(如1004为“壹仟零肆元整”)
  3. 连续的零一直延伸到个位的时候不打印零(如1200为“壹仟贰佰元整”)
    针对这带零的情况编写单位测试:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [Test]
    public void SimpleWithZeroConvert()
    {
    CurrencyConvert convert1 = new CurrencyConvert(1024M);
    Assert.AreEqual("壹仟零贰拾肆元整", convert1.ToString());
    CurrencyConvert convert2 = new CurrencyConvert(1004M);
    Assert.AreEqual("壹仟零肆元整", convert2.ToString());
    CurrencyConvert convert3 = new CurrencyConvert(1200M);
    Assert.AreEqual("壹仟贰佰元整", convert3.ToString());
    }

情况一容易解决,只要稍加判断当前数字是否为零即可;第二个也不难,只要判断最后一个插入的是不是零即可,但是在进行这个判断之前要先判断列表 是否为空,否则取值时会引发异常;第三个情况,看起来相对复杂一点,但其实在第二点判断列表是否为空的时候这个问题也同时被解决了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class CurrencyConvert
{
private static string[] symbols1 = new string[] { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" };
private static string[] symbols2 = new string[] { "", "拾", "佰", "仟" };
private decimal _source;
public CurrencyConvert(decimal source)
{
_source = source;
}
public override string ToString()
{
string sourceString = _source.ToString("F");
List<string> list = new List<string>();
for (int i = sourceString.Length - 1, j = 0; i >= 0; i--, j++)
{
int number = int.Parse(sourceString[i].ToString());
if (number == 0)
{
if (list.Count > 0 && list[0] != symbols1[0])
list.Insert(0, symbols1[number]);
}
else
{
list.Insert(0, symbols2[j]);
list.Insert(0, symbols1[number]);
}
}
return string.Join("", list.ToArray()) + "元整";
}
}

好,接下来,在向更高位迈进之前,先来解决一下小数的问题。相对来讲,小数的问题比较容易处理,我可不想在最困难的部分解决之后还要来料理这些细节。小数部分,大概需要考虑以下几种情形:

  1. 如果被转换数小于1,那么不打印“整数部分”的信息,直接打印小数部分且前面不打印“零”
  2. 如果被转换数大于1,则先打印“整数部分”,再打印“小数部分”,中间有多个零时只打印一个零
  3. 如果不存在小数部分,则返回“整”(这个在前面整数部分的测试用例已被覆盖)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [Test]
    public void DecimalConvert()
    {
    CurrencyConvert convert1 = new CurrencyConvert(0.1234567M);
    Assert.AreEqual("壹角贰分叁厘肆毫伍丝陆忽", convert1.ToString());
    CurrencyConvert convert2 = new CurrencyConvert(0.001M);
    Assert.AreEqual("壹厘", convert2.ToString());
    CurrencyConvert convert3 = new CurrencyConvert(0.01M);
    Assert.AreEqual("壹分", convert3.ToString());
    CurrencyConvert convert4 = new CurrencyConvert(1.1M);
    Assert.AreEqual("壹元壹角", convert4.ToString());
    CurrencyConvert convert5 = new CurrencyConvert(1.001M);
    Assert.AreEqual("壹元零壹厘",convert5.ToString());
    }

如上所示,小数部分,我们假定精确到“忽”,余下部分就忽略掉。如下图所示,小数部分的拼装顺序和整数部分的拼装顺序略有不同。这时,应该对程序进行重构,把总的拼装逻辑分为整数部分小数部分

重构后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class CurrencyConvert
{
private static string[] symbols0 = new string[] { "角", "分", "厘", "毫", "丝", "忽" };
private static string[] symbols1 = new string[] { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" };
private static string[] symbols2 = new string[] { "", "拾", "佰", "仟" };
private decimal _source;
public CurrencyConvert(decimal source)
{
_source = source;
}
public override string ToString()
{
string[] ary = _source.ToString("F").Split('.');
return ConvertIntegerPart(ary[0]) + ConvertDecimalPart(ary.Length == 2 ? ary[1] : string.Empty);
}
private string ConvertIntegerPart(string integerPart)
{
if (string.IsNullOrEmpty(integerPart) || decimal.Parse(integerPart) == 0M)
return string.Empty;
List<string> list = new List<string>();
for (int i = integerPart.Length - 1, j = 0; i >= 0; i--, j++)
{
int number = int.Parse(integerPart[i].ToString());
if (number == 0)
{
if (list.Count > 0 && list[0] != symbols1[0])
list.Insert(0, symbols1[number]);
}
else
{
list.Insert(0, symbols2[j]);
list.Insert(0, symbols1[number]);
}
}
if (list.Count > 0) list.Add("元");
return string.Join(string.Empty, list.ToArray());
}
private string ConvertDecimalPart(string decimalPart)
{
if (string.IsNullOrEmpty(decimalPart) || decimal.Parse(decimalPart) == 0M)
return "整";
List<string> list = new List<string>();
for (int i = 0; i < Math.Min(decimalPart.Length, symbols0.Length); i++)
{
int number = int.Parse(decimalPart[i].ToString());
if (number == 0)
{
if ((list.Count == 0 && _source > 1M) || (list.Count > 0 && list[list.Count - 1] != symbols1[0]))
list.Add(symbols1[number]);
}
else
{
list.Add(symbols1[number]);
list.Add(symbols0[i]);
}
}
return string.Join(string.Empty, list.ToArray());
}
}

完成了这个部份之后,我突然想到,如果传进去的是 0 ,则程序会正常输出“零元”吗?,所以我写多了一个测试用例:

1
2
3
4
5
6
[Test]
public void ZeroConvert()
{
CurrencyConvert convert = new CurrencyConvert(0M);
Assert.AreEqual("零元", convert.ToString());
}

这是一个十分特殊的的case,这时候左右部分都为空,而且原来的拼装法则也用上不了,所以我把它放在了总拼装函数ToString之前作一个特殊化判断。

1
2
3
4
5
6
public override string ToString()
{
if (_source == decimal.Zero) return "零元";
string[] ary = _source.ToString("F").Split('.');
return ConvertIntegerPart(ary[0]) + ConvertDecimalPart(ary.Length == 2 ? ary[1] : string.Empty);
}

好了,相对简单的部份解决了。现在来研究“万元”以上的情况:

再往左的情况就是红色部份会被下一个单位替换,这种简单重复而已。最右边空的其实就是个位,由此我们可以总结出拼装的规律:

  1. 数字每4个一组,从右至左使用“个万亿兆京……”为单位
    需要再重构一次,把原来的整数转换逻辑提取为组的转换ConvertGroup
  2. 每组数字从右至左使用“个十百千”为单位串联
    原来的整数转换逻辑
  3. 当某一组和其右边所有组全为零时,这些组将不被打印
    ConvertGroup的结果稍加判断即可实现
    依旧先从简单开始,编写第一点的测试用列:
    1
    2
    3
    4
    5
    6
    [Test]
    public void TenThousandAboveConvert()
    {
    CurrencyConvert convert1 = new CurrencyConvert(123456789012M);
    Assert.AreEqual("壹仟贰佰叁拾肆亿伍仟陆佰柒拾捌万玖仟零壹拾贰元整", convert1.ToString());
    }

接下来是实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private string ConvertIntegerPart(string integerPart)
{
if (string.IsNullOrEmpty(integerPart) || decimal.Parse(integerPart) == 0M)
return string.Empty;
List<string> list = new List<string>();
// 开始新的转换逻辑
const int groupMaxLength = 4;
int residue = integerPart.Length % groupMaxLength;
int groupsCount = integerPart.Length / groupMaxLength + (residue > 0 ? 1 : 0);
int lastGroupCount = residue == 0 ? groupMaxLength : residue;
for (int i = groupsCount - 1, j = 0; i >= 0 && j < symbols3.Length ; i--, j++)
{
int groupStart = i == 0 ? 0 : lastGroupCount + (i - 1) * groupMaxLength;
int groupLength = i == 0 ? lastGroupCount : groupMaxLength;
list.Insert(0, symbols3[j]);
list.Insert(0, ConvertGroup(integerPart.Substring(groupStart, groupLength)));
}
// 完成新的转换逻辑
if (list.Count > 0) list.Add("元");
return string.Join(string.Empty, list.ToArray());
}
// 原先的简单转换逻辑被提取出来
private string ConvertGroup(string group)
{
List<string> list = new List<string>();
for (int i = group.Length - 1, j = 0; i >= 0; i--, j++)
{
int number = int.Parse(group[i].ToString());
if (number == 0)
{
if (list.Count > 0 && list[0] != symbols1[0])
list.Insert(0, symbols1[number]);
}
else
{
list.Insert(0, symbols2[j]);
list.Insert(0, symbols1[number]);
}
}
return string.Join(string.Empty, list.ToArray());
}

组的分割还挺不容易,调试了许久,可惜看起来还是不太优雅,暂时就先这样吧,起码它已经能正确运行。好,现在加上零的情况:

1
2
3
4
5
6
7
8
[Test]
public void TenThousandAboveConvert()
{
CurrencyConvert convert1 = new CurrencyConvert(123456789012M);
Assert.AreEqual("壹仟贰佰叁拾肆亿伍仟陆佰柒拾捌万玖仟零壹拾贰元整", convert1.ToString());
CurrencyConvert convert2 = new CurrencyConvert(12300M);
Assert.AreEqual("壹万贰仟叁佰元整", convert2.ToString());
}

支持这个真是相当简单,只要对组的拼装结果进行判断就行了

1
2
3
4
5
6
7
8
9
10
11
for (int i = groupsCount - 1, j = 0; i >= 0 && j < symbols3.Length ; i--, j++)
{
int groupStart = i == 0 ? 0 : lastGroupCount + (i - 1) * groupMaxLength;
int groupLength = i == 0 ? lastGroupCount : groupMaxLength;
string groupString = ConvertGroup(integerPart.Substring(groupStart, groupLength));
if (!string.IsNullOrEmpty(groupString))
{
list.Insert(0, symbols3[j]);
list.Insert(0, groupString);
}
}

最后,综合测试:

1
2
3
4
5
6
[Test]
public void FinnalTest()
{
CurrencyConvert convert = new CurrencyConvert(120004000001.001M);
Assert.AreEqual("壹仟贰佰亿零肆佰万零壹元零壹厘", convert.ToString());
}

测试用例是我们假想出来程序可能遇到的情况,通常这些用例都是有代表性的,但往往不能覆盖每种情形,因此,完成单元测试后,我做了一个简单的winform来做烟雾测试,确定程序能正常运行。

Compartir Comentarios