Prohlížeč zdrojového kódu

app/examples/invoice_calculator/example.md

Náhled Zdrojový kód

Fakturace energií koncovým zákazníkům s řadou regulovaných položek a povinností vypočítávat průměrné náklady na dodanou energii není vůbec jednoduchá záležitost. V již rozpracovaném projektu, ke kterému jsem se připojil, byly výpočty rozházené po různých místech ad-hoc (model spotřeby, model faktury, šablona faktury) a slátané horkou jehlou. Když přišla potřeba posílat faktury automaticky do účetního software (Abra Flexibee) a ten některé faktury odmítal přijmout, neboť vypočtená DPH z jednotlivých položek neseděla na DPH za celkovou částku, byl potřeba rázný zásah.

K tomu jsem si vysnil nástroj, který by mi umožnil naházet všechny fakturované položky na jednu hromadu, spočítat DPH za celou fakturu a DPH za jednotlivé položky pak zkorigovat tak, jak jsem vyčetl z účetní příručky, a potom mi zase umožnil s pomocí štítků a filtrovacího systému položky z té hromady vyjmout a vypsat je v patřičných oddílech faktury a jednoduše je sčítat.

A tak jsem takový nástroj vyrobil.

Zdrojový kód

Problém 1: Zaokrouhlování DPH

Při počítání DPH po položkách snadno vznikne rozdíl, kdy součet DPH vypočtené pro jednotlivé položky nesedí s DPH vypočtené z celého základu daně za fakturu.

items = [
{ price: 1.07, vat: (1.07 * 0.21).round(2) }, # 0.22
{ price: 1.07, vat: (1.07 * 0.21).round(2) }, # 0.22
{ price: 1.07, vat: (1.07 * 0.21).round(2) }, # 0.22
]
total = items.sum { |i| i[:price] } # 3.21
sum_vat = items.sum { |i| i[:vat] } # 0.66
# Správná DPH z celku: (3.21 * 0.21).round(2) # 0.67
# Odchylka: 0.01 Kč - faktura nesedí!

InvoiceCalculator tento problém řeší metodou finalize: spočítá přesnou DPH z celkového součtu (pro každou sazbu) a odchylku rozdělí proporcionálně mezi položky:

ic = InvoiceCalculator.new(currency: 'CZK')
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21, currency: 'CZK')
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21, currency: 'CZK')
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21, currency: 'CZK')
ic.finalize
ic.items.map(&:vat) # [0.22, 0.23, 0.22] - odchylka rozložena
ic.items.sum(&:vat) # 0.67 - sedí s DPH z celku
ic.sum.vat # 0.67

Metoda funguje i obráceně — pokud jsou zadány ceny s DPH (unit_price_with_vat), opraví místo DPH základ daně.

Zamčení položek

Při opravách (dobropis) potřebujeme zachovat původní hodnotu některých položek. Kalkulátor rozlišuje dva typy „zámku":

  • total_unlocked? — základ DPH lze upravit (byla zadána cena včetně DPH)
  • total_with_vat_unlocked? — cenu s DPH lze upravit (byla zadána cena bez DPH)
  • obě false — položka je zcela zamčena (zadána cena bez i včetně DPH)

finalize upravuje pouze odemčené položky a nikdy nepřesáhne konfigurovaný max_delta_per_item.

Problém 2: Složitost šablon a výpočtů

Faktura musí zobrazovat různé součty např.: součet za jedno odměrné místo, součet nákladů za 1 MWh, součet nákladů za měsíc, platba za samotnou energii, platba za distribuci. Z toho důvodu si model faktury držel různé druhy položek v různých seznamech a speciálních strukturách na jedno použití. To vše muselo být předpočítáno na různých místech a množství lokálních a instančních proměnných s nejasným významem rostlo. To vše bylo nahrazeno štítky např. "commodity vt el_om_26" znamená, že jde o platbu za dodanou el. energii ve vysokém tarifu pro odběrné místo s ID 26 (proměnná om_tag).

Podrobný výpis položek

Ukázka sekce silové el. energie na faktuře

<% start, stop, qty, total = nil, nil, 0, 0 %>
<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>Doúčtování silové el. energie</th>
<% if items.size > 0 %>
<th style="font-size: 80%">Období</th>
<% end %>
<th class="num" style="font-size: 80%">Množství</th>
<th style="font-size: 80%">Jednotky</th>
<th class="num" style="font-size: 80%">Jedn. cena</th>
<th class="num" style="font-size: 80%">Cena bez DPH</th>
</tr>
<%# VT %>
<% items.each do |item| %>
<tr>
<% start = item[:start] if !start || start > item[:start] %>
<% stop = item[:stop] if !stop || stop < item[:stop] %>
<% qty += item[:qty] %>
<% total += item[:total_vt].round(2) %>
<td>Časové pásmo VT</td>
<% if items.size > 0 %>
<td style="font-size: 80%"><%= "#{item[:start]} - #{item[:stop]}" %></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3(item[:qty_vt].to_f / 1000) %></td>
<td style="font-size: 80%">MWh</td>
<td class='num' style="font-size: 80%"><%= num2 item[:price_vt] %></td>
<td class="num"><%= num2 item[:total_vt] %></td>
</tr>
<% end %>
<%# NT %>
<% items.each do |item| %>
<% next if item[:qty_nt].to_f == 0 %>
<tr>
<% total += item[:total_nt].round(2) %>
<td>Časové pásmo NT</td>
<% if items.size > 0 %>
<td style="font-size: 80%"><%= "#{item[:start]} - #{item[:stop]}" %></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3(item[:qty_nt].to_f / 1000) %></td>
<td style="font-size: 80%">MWh</td>
<td class='num' style="font-size: 80%"><%= num2 item[:price_nt] %></td>
<td class="num"><%= num2 item[:total_nt] %></td>
</tr>
<% end %>
<%# monthly %>
<% items.each do |item| %>
<% next if item[:total_month].to_f == 0 %>
<tr>
<% total += item[:total_month].round(2) %>
<td>Pevná cena za měsíc</td>
<% if items.size > 0 %>
<td style="font-size: 80%"><%= "#{item[:start]} - #{item[:stop]}" %></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3 item[:months] %></td>
<td style="font-size: 80%">měsíc</td>
<td class='num' style="font-size: 80%"><%= num2 item[:price_month] %></td>
<td class="num"><%= num2 item[:total_month] %></td>
</tr>
<% end %>
<%# tax %>
<tr>
<% total += qty * Invoice::TAX_ELECTRICITY / 1000 %>
<td>Spotřební daň</td>
<% if items.size > 0 %>
<td></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3 qty.to_f / 1000 %></td>
<td style="font-size: 80%">MWh</td>
<td class="num" style="font-size: 80%"><%= num2 Invoice::TAX_ELECTRICITY %></td>
<td class="num"><%= num2 qty * Invoice::TAX_ELECTRICITY / 1000 %></td>
</tr>
<%# total %>
<tr style="border-top:1px solid black;margin:5px 0">
<th <%= 'colspan="2"'.html_safe if items.size > 0 %>>Celkem za období od <%= start %> do <%= stop %></th>
<th class="num"><%= num3 qty.to_f / 1000 %></th>
<th>MWh</th>
<th></th>
<th class="num"><%= num2 total %></th>
</tr>
<% om_total += total %>
</table>
<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>Doúčtování silové el. energie</th>
<th style="font-size: 80%">Období</th>
<th class="num" style="font-size: 80%">Množství</th>
<th style="font-size: 80%">Jednotky</th>
<th class="num" style="font-size: 80%">Jedn. cena</th>
<th class="num" style="font-size: 80%">Cena bez DPH</th>
</tr>
<%# VT, NT, monthly %>
<% ["vt", "nt", "month"].each do |tag| %>
<% calc.filter_items("commodity", om_tag, tag).each do |item| %>
<tr>
<td><%= item.description %></td>
<td style="font-size: 80%"><%= "#{item.extras[:start]} - #{item.extras[:stop]}" %></td>
<td class="num" style="font-size: 80%"><%= num3(item.count) %></td>
<td style="font-size: 80%"><%= item.unit %></td>
<td class='num' style="font-size: 80%"><%= num2 item.unit_price %></td>
<td class="num"><%= num2 item.total %></td>
</tr>
<% end %>
<% end %>
<%# tax %>
<% if (tax_items = calc.filter_items("commodity", om_tag, "tax")).any? %>
<tr>
<td><%= tax_items.first.description %></td>
<td style="font-size: 80%"></td>
<td class="num" style="font-size: 80%"><%= num3(tax_items.sum(&:count)) %></td>
<td style="font-size: 80%"><%= tax_items.first.unit %></td>
<td class='num' style="font-size: 80%"><%= num2 tax_items.first.unit_price %></td>
<td class="num"><%= num2 tax_items.sum(&:total) %></td>
</tr>
<% end %>
<%# total %>
<tr style="border-top:1px solid black;margin:5px 0">
<th colspan="2">Celkem za období od <%= start %> do <%= stop %></th>
<% calc.with_sum("commodity", om_tag, except: "tax month") do |s| %>
<th class="num"><%= num3 s.count %></th>
<th><%= s.unit %></th>
<% end %>
<th></th>
<th class="num"><%= num2 calc.sum("commodity", om_tag).total %></th>
</tr>
</table>

Rekapitulace jednotlivých kategorií plateb

Ukázka sekce rekapitulace na faktuře

<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>REKAPITULACE (celkem za fakturační období)</th>
<th>Sazba DPH</th>
<th>Základ DPH (Kč)</th>
<th>DPH (Kč)</th>
<th>Celkem s DPH (Kč)</th>
</tr>
<tr>
<td>Celkem za silovou elektřinu</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.energy %></td>
<td class="num"><%= num2 @invoice.energy_vat %></td>
<td class="num"><%= num2 @invoice.energy_with_vat %></td>
</tr>
<tr>
<td>Celkem za regulované služby</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.total_delivery %></td>
<td class="num"><%= num2 @invoice.distribution_vat %></td>
<td class="num"><%= num2 @invoice.distribution_with_vat %></td>
</tr>
<%# other / irregular payments %>
<% if @invoice.other %>
<% opv = @invoice.other_per_vat_rate %>
<% opv.each do |vat, items| %>
<tr>
<td>Celkem za ostatní služby</td>
<td><%= vat %>%</td>
<td class="num"><%= num2(sum = items.sum{|x| x[:value]}) %></td>
<td class="num"><%= num2(vatsum = sum * vat / 100) %></td>
<td class="num"><%= num2(sum + vatsum) %></td>
<%# TODO this isn't counted in K ÚHRADĚ %>
</tr>
<% end %>
<% end %>
<%# penalizations %>
<% if @invoice.penalties.any? %>
<tr>
<td>Penalizace pozdních plateb</td>
<td><%= 0 %>%</td>
<td class="num"><%= num2 @invoice.penalty_total %></td>
<td class="num"><%= num2 0 %></td>
<td class="num"><%= num2 @invoice.penalty_total %></td>
</tr>
<% end %>
<tr>
<td>Daň z elektřiny</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.tax %></td>
<td class="num"><%= num2 @invoice.tax_vat %></td>
<td class="num"><%= num2 @invoice.tax_with_vat %></td>
</tr>
<% if @invoice.advance != 0 %>
<tr>
<td>Zúčtované zálohy</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 -@invoice.advance %></td>
<td class="num"><%= num2 -@invoice.advance_vat %></td>
<td class="num"><%= num2 -@invoice.advance_with_vat %></td>
</tr>
<% end %>
<tr>
<td>Rozdíl k úhradě</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.outstanding %></td>
<td class="num"><%= num2 @invoice.outstanding_vat %></td>
<td class="num"><%= num2 @invoice.outstanding_with_vat %></td>
</tr>
<tr>
<td>Zaokrouhlení</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td class="num"><%= num2 @invoice.rounding %></td>
</tr>
<tr style="border-top:1px solid black;margin:5px 0">
<th>K ÚHRADĚ</th>
<th></th>
<th></th>
<th></th>
<th class="num"><b style="font-size: 120%"><%= num0 @invoice.to_pay %></b></th>
</tr>
</table>
<%
calc = @invoice.calculator
summary = [
["Celkem za silovou elektřinu", calc.sum("commodity", except: "tax")],
["Celkem za regulované služby", calc.sum("delivery")],
["Celkem za ostatní služby", calc.sum("other")],
["Penalizace pozdních plateb", calc.sum("penalty")],
["Daň z elektřiny", calc.sum("tax")],
["Zúčtované zálohy", calc.sum("advance")],
]
%>
<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>REKAPITULACE (celkem za fakturační období)</th>
<th>Sazba DPH</th>
<th>Základ DPH (Kč)</th>
<th>DPH (Kč)</th>
<th>Celkem s DPH (Kč)</th>
</tr>
<%# za všechny sekce vyjmenované výše %>
<% summary.each do |title, sum| %>
<% sum.each_vat_rate do |s| %>
<tr>
<td><%= title %></td>
<td><%= num s.vat_rate %>%</td>
<td class="num"><%= num2 s.total %></td>
<td class="num"><%= num2 s.vat %></td>
<td class="num"><%= num2 s.total_with_vat %></td>
</tr>
<% end %>
<% end %>
<% calc.with_sum(except: "rounding") do |s| %>
<tr>
<td>Rozdíl k úhradě</td>
<td><%= s.vat_rate &&num(s.vat_rate) + "%" %></td>
<td class="num"><%= num2 s.total %></td>
<td class="num"><%= num2 s.vat %></td>
<td class="num"><%= num2 s.total_with_vat %></td>
</tr>
<% end %>
<tr>
<td>Zaokrouhlení</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td class="num"><%= num2 calc.sum("rounding").total_with_vat %></td>
</tr>
<tr style="border-top:1px solid black;margin:5px 0">
<th>K ÚHRADĚ</th>
<th></th>
<th></th>
<th></th>
<th class="num"><b style="font-size: 120%"><%= num calc.total_with_vat %></b></th>
</tr>
</table>

Problém 3: Přepočet měn

Později přibyla možnost pro zákazníky zasmluvnit cenu komodity v eurech, nebo možnost nechat si vystavit fakturu v eurech. K tomu bylo potřeba přidat funkci přepočtu měn. Každá položka proto nese informaci o měně, v níž je cena vedena a položky, které nesouhlasí s měnou faktury jsou při finalizaci přepočteny nastaveným kurzem

Testy

Implementace má pochopitelně i kompletní unit testy