
前面幾章問的是這個值進不進得去、傳不傳得到、要不要被記下來。這一篇換一個問題:進去了的那個值代表什麼。
一張單據上的數量、單價、金額、折扣率、匯率,在 TableSchema 那份定義裡長得幾乎一樣,都是一個十進位數字加一組精度。真正的差別在三個地方:要顯示幾位、寫進去的時候要不要捨、位數由誰說了算。多數系統把這三件事分別交給資料表的 DDL、畫面上的格式字串、業務邏輯裡的 Math.Round,三處各改各的。要好維護,這三件事得由同一套機制來管。
本篇說明:
資料表宣告 decimal(19,4) 之後,你知道這一欄最多放得下四位小數。但你不知道它該顯示幾位、算出來的值要不要捨,也不知道客戶要求單價只顯示兩位時,該改這一欄還是別的地方。
框架的作法是在 FormSchema 的欄位(FormField)上宣告一個語意分類 NumberKind,要留幾位、要不要捨,都由它決定:
NumberKind |
語意 | 捨入策略 | 位數來源 |
|---|---|---|---|
Quantity |
數量 | Round |
計量單位 |
Weight |
重量 | Round |
計量單位 |
Amount |
金額 | Round |
幣別 |
Percent |
百分比 | Round |
公司 |
UnitPrice |
單價 | Preserve |
公司 |
Cost |
成本 | Preserve |
公司 |
ExchangeRate |
匯率 | Preserve |
系統固定 |
算出來的數字要捨,拿去算的不捨。金額要印在單據上、加進合計、記進帳,得捨到該有的位數;單價、成本、匯率是拿去乘的,捨了就會把誤差帶進後面每一次計算。所以框架的捨入方法 RoundByKind 遇到這三種會原樣送回。單價顯示四位只是給人看,框架不會因此把它捨成四位。
沒標 NumberKind 的欄位,框架不給格式也不捨入,漏標了也不會報錯。
位數能不能先算好,看它在使用者操作單據的過程中會不會變。百分比、單價、成本的位數由公司訂,匯率由框架固定五位,這四種都不會變,所以送定義的時候就先把格式算好,前端照著印。
金額、數量與重量的位數會變:金額跟著幣別走,數量與重量跟著單位走,分別是後面兩節。
幣別的小數位是幣別自己的性質,不是公司政策:日圓沒有分、美元有兩位、巴林第納爾有三位,這是 ISO 4217 訂的,不會因為換一家公司就不一樣。所以幣別位數放在一張全系統共用的主檔裡,個別公司動不了它。公司能決定的是本幣用哪一種,以及最終應付金額要不要再對齊一個現金捨入單位(後面會談)。
那張主檔就是一份定義檔,跟表單定義放在同一個地方、走同一套快取(Day 4 那十三種定義都是這樣),也跟著一起送給前端(節選自框架附的 CurrencySettings.xml):
<CurrencyItem Code="USD" Numeric="840" Rounding="0.01" Symbol="$" Name="US Dollar" />
<CurrencyItem Code="JPY" Numeric="392" Rounding="1" Symbol="¥" Name="Japanese Yen" />
<CurrencyItem Code="BHD" Numeric="048" Rounding="0.001" Symbol="BD" Name="Bahraini Dinar" />
它存的不是位數,而是「自然最小單位」,位數再從它換算(0.01 是兩位、1 是零位)。最小單位才是貨幣真正存在的東西,後面的現金捨入用到的也是它。
金額的位數不能先算好,因為使用者可能在畫面上把幣別從美元改成日圓。所以定義裡不寫金額的位數,只寫「我的幣別在哪一欄」;沒指定就用單據上的幣別欄,等那一欄有值再算。
於是同一欄的每一列可以有不同的位數。美元加日圓沒有意義,頁尾只在整欄都是同一種幣別時才顯示合計;換算成公司本幣的那一欄只有一種幣別,一定加得出來。
幣別欄沒填,就用公司的本幣。本幣是公司必填的設定,沒設的話,框架算金額時會直接擲出例外,而不是退回預設位數。
數量也是同樣的道理:填「件」不該有小數,填「公斤」就可以是 0.375。能不能有小數,看的是這一列填了哪個單位,不是看欄位。
單位主檔也是全系統共用的定義檔,每個單位的位數是那個單位自己的性質,不歸公司管(節選自框架附的 UnitSettings.xml):
<UnitItem Code="PCS" Decimals="0" Dimension="count" Name="Pieces" />
<UnitItem Code="KG" Decimals="3" Dimension="weight" Name="Kilogram" />
兩張主檔的差別在存法:計量單位直接存位數;幣別存的是最小單位,再換算成位數,因為現金捨入要用到最小單位。
作法和金額一致:數量欄只寫「我的單位在哪一欄」,位數依那一欄的值查單位主檔。差別在沒填的時候:
| 代碼從哪一欄來 | 那一欄沒填 | 公司層的預設 | |
|---|---|---|---|
| 金額 | 金額欄指定的幣別欄,沒指定就用單據上的幣別欄 | 用公司本幣 | 本幣(必填) |
| 數量/重量 | 數量欄指定的單位欄 | 沒有可以頂上的 | 沒有預設單位 |
公司有本幣,卻不會有預設單位,因為同一張單的明細本來就可以一列公斤、一列箱。
原則是:標成 Quantity 或 Weight 的欄位就必須指定單位欄;不需要單位的數字,用一般數值,不標 NumberKind。
三筆明細,每筆都是一件乘以單價 10.3333。直覺上全精度加完再捨一次比較精確。用案例 FormSchema 的算式實際跑一次:
| 作法 | 每列顯示 | 合計 |
|---|---|---|
| 先捨再加 | 10.33 | 30.99 |
| 加完再捨 | 10.33 | 31.00 |
差了一分錢,錯的是看起來比較精確的那一個。單上印著三個 10.33,使用者自己加是 30.99,表頭卻寫 31.00,這張單自己就對不上。ERP 要求表頭合計必須等於明細加總,一分都不能差,因為單據要被列印、對帳、稽核。
框架的作法是每一列算完就先捨,合計拿到的都是捨過的值,加起來自然相等,這條規則叫 round-then-sum。框架只在這裡自動捨入,使用者手填的數字不會被捨。
明細每一列捨到幣別的自然小數,這一層全系統一致。單據最後的應付金額可以再加一層:對齊公司設定的現金捨入單位。例如瑞士法郎沒有一分的硬幣,公司可以設成 0.05,12.34 就收 12.35、12.32 收 12.30。
兩層的性質不同。第一層是去掉不存在的精度;第二層會刻意產生差額,多收的一分或少收的兩分都要記進捨入科目。所以框架這一層只回傳應付金額,差額由呼叫端自己算、自己記。
資料表欄位也有精度,但那是容量上限,只表示最多放得下幾位,跟這個值該顯示或捨到幾位無關。同一個金額型別,在五家資料庫分別建成 decimal(19,4)、numeric(19,4)、DECIMAL(19,4)、NUMBER(19,4)、NUMERIC(19,4),寫法不同,數字相同。
上限刻意訂得比業務位數高很多,否則每加一家公司、每多一種貨幣就要 ALTER TABLE 一次,沒辦法維運。
超出容量的位數,框架目前不處理。值送進資料庫時只帶值、型別、長度與允不允許 NULL,不帶精度,寫入前也不會把值捨到欄位的容量。
所以超出的部分由各家資料庫自己決定。往四位小數的欄位寫入 1.234567,SQLite 原樣保存(實測),其他資料庫會依自己的規則收掉,同一筆資料存進不同資料庫,精度就不一樣。
資料庫可以用 CHECK 約束限制位數,但那只會擋下不合的資料,不會捨入。要以正確的位數存進去,得由框架在寫入前處理,這一步還沒有做。
案例的 FormSchema 只有兩個欄位標了語意,而且正好一邊一個(節選自 Order.FormSchema.xml):
<FormField FieldName="unit_price" DbType="Currency" NumberKind="UnitPrice" />
<FormField FieldName="amount" DbType="Currency" NumberKind="Amount" ReadOnly="true"
ValueExpression="quantity * unit_price * (1 - discount)" />
第一個是單價,位數由公司訂。送定義時 unit_price 會拿到四位的格式,但案例手寫的版面那一欄沒有帶標記,畫面上顯示的仍是原值;填 10.33335 進去,跑完一輪求值還是 10.33335,沒有被捨。
第二個是 Day 8 那個計算欄,屬於幣別那一類。案例沒有幣別欄,所以用公司本幣 USD,從幣別主檔查出兩位,框架求完值就捨到兩位。送定義時 amount 的格式留空,也沒有指向幣別欄;畫面上的兩位,是版面那一欄的標記依本幣算出來的。多幣別和計量單位都示範不出來,ExchangeRate 也沒用到。
表頭總額不在定義裡,是 OrderBO 存檔前把明細的 amount 加起來。那些 amount 在加總前已經由框架逐列捨過,所以符合 round-then-sum:框架負責捨,應用負責加。Day 8 說過跨列加總是算式目前寫不出來的,捨入這一半框架已經做了,剩下的只有加總。
這一篇從欄位上的語意標記講到資料表的容量,都在回答同一件事:一個數字的位數由誰決定。
答案分散在不同地方,而且是刻意的:單價、成本、百分比與匯率由公司或框架訂,因為那是政策;金額由幣別決定,數量與重量由單位決定,因為那是幣別和單位本身的性質;應付金額要不要再對齊,由公司決定,因為那是收款政策;欄位放得下幾位由資料表決定,因為那只是容量。
把這幾件事混在一起就會出錯:位數綁在資料表上,換一種貨幣就要改結構;位數綁在顯示上,單價與匯率會被捨掉精度;加總之後才捨入,表頭就對不上明細。
位數之外還有值本身。框架只會自動捨它自己算出來的值,使用者填的單價、折扣、手輸的金額一律不動。捨入是針對計算結果的規則,不是去修正使用者的輸入;框架一旦替使用者改數字,帳上就會多出一筆沒人知道是誰改的差額。
明天換一種資料。數字至少看起來都一樣,時間卻不是:同一個 CLR 型別裡裝著語意不同的幾種值,型別系統分不出來。
本系列同步發表於 HackMD,完整目錄