bin/rdb läuft mit --disable-gems (ca. 85 statt 170 ms je Befehl), dafür rechnen Summen und /act mit Rational. Hilfsfunktionen und Konstanten liegen nicht mehr auf Object, Kopf-/Strichzeile und awk-Aufruf an einer Stelle. Tests laufen parallel: 21 statt 70 Sekunden.
428 lines
17 KiB
Ruby
428 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# The /act accounting package: general ledger, payroll, inventory, sales.
|
|
# Like the original, it is an example of data-base programming with /rdb, not
|
|
# an accounting package: each command reads and writes plain /rdb tables in
|
|
# the current directory.
|
|
|
|
Rdb::ACT_COMMANDS = %w[act adjust balance bom calcpay calculate cashflow chartdup close consolidate cstate explode
|
|
fillform foot getjournal invoice sale tax update.inv w2].freeze
|
|
|
|
module Rdb
|
|
module Helpers
|
|
# Amounts are exact decimals (Rational), not floats: sums of cents stay exact.
|
|
def num(str)
|
|
Rdb.decimal(str)
|
|
end
|
|
|
|
# Two decimals, always (money drops them from whole amounts).
|
|
def fixed2(val)
|
|
Rdb.fixed(val, 2)
|
|
end
|
|
|
|
def money(val)
|
|
val == val.to_i ? val.to_i.to_s : Rdb.fixed(val, 2)
|
|
end
|
|
|
|
def need(file)
|
|
File.exist?(file) || Rdb.die("#{file}: no such table (the /act commands work on tables in the current directory)")
|
|
Rdb::Table.open(file)
|
|
end
|
|
|
|
def next_date
|
|
ENV['NEXTDATE'] || Date.today.strftime('%y%m%d')
|
|
end
|
|
|
|
def account_key(acct)
|
|
[acct.to_f, acct]
|
|
end
|
|
end
|
|
end
|
|
|
|
Rdb.register('act', 'List all /act commands', 'act') do |_args|
|
|
names = Rdb::ACT_COMMANDS.sort
|
|
w = names.map(&:size).max + 2
|
|
names.each_slice(5) { |row| puts row.map { |n| n.ljust(w) }.join.rstrip }
|
|
end
|
|
|
|
Rdb.register('chartdup', 'Check for duplicate names and accounts in chart', 'chartdup') do |_args|
|
|
t = need('chart')
|
|
{ 'Account' => 'Account numbers', 'Short' => 'Short names' }.each do |col, label|
|
|
next unless (i = t.head.index(col))
|
|
|
|
dups = t.rows.group_by { |r| r[i] }.select { |_, v| v.size > 1 }
|
|
next if dups.empty?
|
|
|
|
puts "Duplicate #{label} in chart. See chart.#{col}"
|
|
dups.each_value { |rows| rows.each { |r| puts r.join("\t") } }
|
|
end
|
|
end
|
|
|
|
Rdb.register('foot', 'Foot or subtotal Debits and Credit of ledger', 'foot') do |_args|
|
|
t = need('ledger')
|
|
d = t.index_of('Debit')
|
|
c = t.index_of('Credit')
|
|
out = []
|
|
t.rows.sort_by { |r| account_key(r[0]) }.group_by { |r| r[0] }.each_value do |rows|
|
|
out.concat(rows)
|
|
out << Rdb::Table.dash_cells(t.head)
|
|
sub = [''] * t.head.size
|
|
sub[0] = rows.first[0]
|
|
sub[d] = money(rows.sum { |r| num(r[d]) })
|
|
sub[c] = money(rows.sum { |r| num(r[c]) })
|
|
out << sub
|
|
end
|
|
t.with(rows: out).write
|
|
end
|
|
|
|
Rdb.register('adjust', 'Create adjusted trial balance table', 'adjust') do |_args|
|
|
tb = need('trialbalance')
|
|
ja = need('journaladjust')
|
|
meta = {}
|
|
(tb.rows.map { |r| tb.head.zip(r).to_h } + ja.rows.map { |r| ja.head.zip(r).to_h }).each do |h|
|
|
m = (meta[h['Account']] ||= {})
|
|
h.each { |k, v| m[k] = v if !v.to_s.strip.empty? && !%w[Debit Credit].include?(k) && !m.key?(k) }
|
|
end
|
|
net = Hash.new(0r)
|
|
[tb, ja].each do |t|
|
|
d = t.index_of('Debit')
|
|
c = t.index_of('Credit')
|
|
t.rows.each { |r| net[r[0]] += num(r[d]) - num(r[c]) }
|
|
end
|
|
head = tb.head
|
|
rows = net.keys.sort_by { |a| account_key(a) }.map do |acct|
|
|
n = net[acct]
|
|
head.map do |h|
|
|
case h
|
|
when 'Debit' then n >= 0 ? money(n) : ''
|
|
when 'Credit' then n.negative? ? money(-n) : ''
|
|
else meta[acct][h].to_s
|
|
end
|
|
end
|
|
end
|
|
File.write('adjustedtrial', Rdb::Table.new(head, rows).to_s)
|
|
end
|
|
|
|
module Rdb
|
|
module Helpers
|
|
def trial_parts(t)
|
|
h = t.head
|
|
fmt = h.index('Format')
|
|
rows = t.rows.map { |r| { acct: r[0], debit: num(r[h.index('Debit')]), credit: num(r[h.index('Credit')]), fmt: r[fmt].to_s, name: r[h.index('Name')].to_s, row: r } }
|
|
rows
|
|
end
|
|
end
|
|
end
|
|
|
|
Rdb.register('balance', 'Create balance sheet from adjusted trial balance', 'balance') do |_args|
|
|
rows = trial_parts(need('adjustedtrial'))
|
|
bs = rows.select { |r| r[:fmt].start_with?('B-') }
|
|
inc = rows.select { |r| r[:fmt].start_with?('I-') }
|
|
assets = bs.select { |r| r[:fmt] == 'B-A' }
|
|
others = bs - assets
|
|
profit = inc.sum { |r| r[:credit] - r[:debit] }
|
|
company = File.exist?('company') ? File.read('company').strip : (ENV['COMPANY'] || 'Company')
|
|
out = []
|
|
out << company.center(30).rstrip << Date.today.strftime('%-d %B %Y').center(30).rstrip << 'Balancesheet'.center(30).rstrip
|
|
assets.each { |r| out << format('%5s %s', money(r[:debit] - r[:credit]), r[:name]) }
|
|
total = assets.sum { |r| r[:debit] - r[:credit] }
|
|
out << "#{'-' * 5} #{'-' * 24}" << format('%5s Total Assets', money(total))
|
|
others.each { |r| out << format('%6s %s', money(r[:credit] - r[:debit]), r[:name]) }
|
|
out << format('%6s Retained Earnings (Profit)', money(profit))
|
|
out << "#{'-' * 6} #{'-' * 26}"
|
|
out << format('%6s Total Liabilities and Equity', money(others.sum { |r| r[:credit] - r[:debit] } + profit))
|
|
File.write('balancesheet', out.join("\n") + "\n")
|
|
end
|
|
|
|
Rdb.register('close', 'Close accounting period creating journal for next', 'close') do |_args|
|
|
rows = trial_parts(need('adjustedtrial')).select { |r| r[:fmt].start_with?('B-') }
|
|
date = next_date
|
|
out = rows.map { |r| [r[:acct], date, r[:debit].zero? ? '' : money(r[:debit]), r[:credit].zero? ? '' : money(r[:credit]), 'jl', 'brought forward'] }
|
|
net = rows.sum { |r| r[:debit] - r[:credit] }
|
|
out << ['4997', date, net.negative? ? money(-net) : '', net.negative? ? '' : money(net), 'jl', 'brought forward']
|
|
File.write('journalnext', Rdb::Table.new(%w[Account Date Debit Credit Ref Description], out.map { |r| r }).to_s)
|
|
end
|
|
|
|
Rdb.register('getjournal', 'Copy journals for sales, purchasing, pay systems', 'getjournal journalname') do |args|
|
|
die 'usage: getjournal journalname' unless args.size == 1
|
|
t = need(args[0])
|
|
a = t.index_of('Amount')
|
|
total = t.rows.sum { |r| num(r[a]) }
|
|
Rdb::Table.new(%w[Date Amount Account Ref Description],
|
|
[[next_date, fixed2(total), '', t.rows.first&.fetch(t.head.index('Ref') || 0, ''), "#{args[0].delete_prefix('journal')} from #{args[0]}"]]).write
|
|
end
|
|
|
|
Rdb.register('consolidate', 'Combine all subsidiary journals to general journal', 'consolidate nextdate') do |args|
|
|
die 'usage: consolidate nextdate' unless args.size == 1
|
|
skip = %w[journal journaladjust journalnext journallast journalpay]
|
|
files = Dir['journal*'].reject { |f| skip.include?(f) || f.include?('.') }.sort
|
|
head = %w[Date Account Debit Credit Ref Description]
|
|
rows = []
|
|
files.each do |f|
|
|
t = Rdb::Table.open(f)
|
|
debit_journal = f.match?(/expense|pur|pay/)
|
|
t.rows.each do |r|
|
|
h = t.head.zip(r).to_h
|
|
if t.head.include?('Amount')
|
|
if h['Account'].to_s.strip.empty?
|
|
warn "consolidate: #{f}: row without an Account skipped"
|
|
next
|
|
end
|
|
amount = money(num(h['Amount']).abs)
|
|
h['Debit'], h['Credit'] = debit_journal ? [amount, ''] : ['', amount]
|
|
end
|
|
rows << head.map { |c| h[c].to_s }
|
|
end
|
|
end
|
|
d = rows.sum { |r| num(r[2]) }
|
|
c = rows.sum { |r| num(r[3]) }
|
|
rows << [args[0], '1010', '', money(d), '', 'double-entry'] unless d.zero?
|
|
rows << [args[0], '1010', money(c), '', '', 'double-entry'] unless c.zero?
|
|
File.write('journal', Rdb::Table.new(head, rows).to_s)
|
|
end
|
|
|
|
Rdb.register('fillform', 'Fill a tax from with adjusted trial balance data', 'fillform form') do |args|
|
|
die 'usage: fillform form' unless args.size == 1
|
|
lines = need("taxform.#{args[0]}")
|
|
trial = need('adjustedtrial')
|
|
ti = trial.index_of('Taxline')
|
|
sums = Hash.new(0r)
|
|
trial.rows.each { |r| sums[r[ti]] += (num(r[trial.index_of('Debit')]) - num(r[trial.index_of('Credit')])).abs }
|
|
rows = lines.rows.map { |r| [r[0], money(sums[r[0]]), r[1].to_s] }
|
|
File.write(args[0], Rdb::Table.new(%w[Taxline Amount Description], rows).to_s)
|
|
end
|
|
|
|
Rdb.register('calculate', 'Compute each tax form listed', 'calculate taxform ...') do |args|
|
|
die 'usage: calculate taxform ...' if args.empty?
|
|
args.each do |form|
|
|
t = need(form)
|
|
script = "#{form}.calc"
|
|
File.exist?(script) or die("#{script}: missing (one 'Column = expression' per line)")
|
|
prog = "BEGIN{OFS=\"\\t\"} { #{to_awk(File.readlines(script, chomp: true).join('; '), t.head)}; print }"
|
|
File.write(form, t.with(rows: awk(prog, t.rows)).to_s)
|
|
end
|
|
end
|
|
|
|
Rdb.register('tax', 'Compute tax from income and tax table', 'tax income < taxtable') do |args|
|
|
die 'usage: tax income < taxtable' unless args.size == 1
|
|
income = num(args[0])
|
|
rows = table_in.rows.map { |r| r.map { |c| num(c) } }.sort_by(&:first)
|
|
row = rows.reverse.find { |r| r[0] <= income }
|
|
puts format('%7d', row ? (row[1] + (income - row[0]) * row[2] / 100).round(half: :up) : 0)
|
|
end
|
|
|
|
Rdb.register('cashflow', 'Compute balance column of cash table', 'cashflow < cashtable') do |_args|
|
|
t = table_in
|
|
a = t.index_of('Amount')
|
|
b = t.index_of('Balance')
|
|
bal = 0r
|
|
rows = t.rows.map do |r|
|
|
r = r.dup
|
|
if r[a].strip.empty? && !r[b].strip.empty?
|
|
bal = num(r[b])
|
|
r[a] = money(bal)
|
|
else
|
|
bal += num(r[a])
|
|
end
|
|
r[b] = money(bal)
|
|
r
|
|
end
|
|
t.with(rows: rows).write
|
|
end
|
|
|
|
# ---- payroll -------------------------------------------------------------------------
|
|
|
|
Rdb.register('calcpay', 'Post payroll to ledgerpay', 'calcpay') do |_args|
|
|
t = need('journalpay')
|
|
n = t.index_of('Number')
|
|
File.write('ledgerpay', t.with(rows: t.rows.each_with_index.sort_by { |r, i| [r[n].to_f, i] }.map(&:first)).to_s)
|
|
end
|
|
|
|
Rdb.register('w2', 'Produce W-2 totals per employee from ledgerpay', 'w2') do |_args|
|
|
t = need('ledgerpay')
|
|
n = t.index_of('Number')
|
|
cols = %w[Gross Federal State Net].select { |c| t.head.include?(c) }
|
|
rows = t.rows.group_by { |r| r[n] }.map do |emp, rs|
|
|
[emp] + cols.map { |c| money(rs.sum { |r| num(r[t.index_of(c)]) }) }
|
|
end
|
|
Rdb::Table.new(['Number'] + cols, rows).write
|
|
end
|
|
|
|
# ---- operations -----------------------------------------------------------------------
|
|
|
|
Rdb.register('bom', 'Produce bill-of-materials from parts list', 'bom') do |_args|
|
|
part = need('part')
|
|
items = need('saleitem')
|
|
qty = Hash.new(0r)
|
|
items.rows.each { |r| qty[r[items.index_of('Code')]] += num(r[items.index_of('Qty')]) }
|
|
need_total = Hash.new(0r)
|
|
part.rows.each do |r|
|
|
code = r[part.index_of('Code')]
|
|
need_total[r[part.index_of('Subpart')]] += qty[code] * num(r[part.index_of('Count')]) if qty.key?(code)
|
|
end
|
|
File.write('bom', Rdb::Table.new(%w[Code Qty], need_total.sort.map { |k, v| [k, money(v)] }).to_s)
|
|
end
|
|
|
|
Rdb.register('explode', 'Produce table of subparts and their count for a part', 'explode Part Amount [ table ]') do |args|
|
|
die 'usage: explode Part Amount [table]' if args.size < 2
|
|
t = need(args[2] || 'parttable')
|
|
totals = Hash.new(0r)
|
|
queue = [[args[0], num(args[1])]]
|
|
until queue.empty?
|
|
part, amount = queue.shift
|
|
totals[part] += amount
|
|
t.rows.each { |r| queue << [r[1], amount * num(r[2])] if r[0] == part }
|
|
end
|
|
Rdb::Table.new(%w[Part Amount], totals.sort_by { |k, _| [k.to_f, k] }.map { |k, v| [k, money(v)] }).write
|
|
end
|
|
|
|
# ---- sales ------------------------------------------------------------------------------
|
|
|
|
module Rdb
|
|
module Helpers
|
|
def ask(prompt)
|
|
print prompt
|
|
$stdout.flush
|
|
($stdin.gets || '').strip
|
|
end
|
|
|
|
def address_block(customers, number)
|
|
c = customers.rows.find { |r| r[0] == number }
|
|
return ["Customer Number #{number}"] unless c
|
|
|
|
h = customers.head.zip(c).to_h
|
|
["Mailing Address for Customer Number #{number}",
|
|
[h['Title'], h['First'], h['Last'], h['Position']].compact.join(' ').strip,
|
|
h['Company'], h['Street'], [h['City'], h['State'], h['Country']].compact.join(' ')].compact.reject(&:empty?)
|
|
end
|
|
end
|
|
end
|
|
|
|
Rdb.register('invoice', 'Print invoice for a sale order', 'invoice') do |_args|
|
|
orders = need('salesorder')
|
|
items = need('saleitem')
|
|
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number], [])
|
|
loop do
|
|
n = ask('Please enter order number (or Return to exit): ')
|
|
break if n.empty?
|
|
|
|
o = orders.rows.find { |r| r[0] == n }
|
|
next puts('no such order') unless o
|
|
|
|
oh = orders.head.zip(o).to_h
|
|
puts ' Invoice', ' -------'
|
|
puts ENV['COMPANY'] || 'Company'
|
|
puts "Order Number #{n}", "Date Ordered #{oh['Date']}", "Date Shipped #{oh['Shipped']}"
|
|
puts address_block(customers, oh['Cust'])
|
|
mine = items.rows.select { |r| r[0] == n }
|
|
items.with(rows: mine).write
|
|
puts format('%-40s Gross %s', '', fixed2(num(oh['Gross'])))
|
|
puts format('%-40s Tax %s', '', fixed2(num(oh['Tax'])))
|
|
puts format('%-40s Total %s', '', fixed2(num(oh['Total'])))
|
|
end
|
|
end
|
|
|
|
Rdb.register('cstate', 'Produce customer statement', 'cstate') do |_args|
|
|
orders = need('salesorder')
|
|
deposits = File.exist?('deposit') ? Rdb::Table.open('deposit') : Rdb::Table.new(%w[Cust Date Amount Ref], [])
|
|
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number], [])
|
|
loop do
|
|
c = ask('Please enter customer number (or all or Return to exit): ')
|
|
break if c.empty?
|
|
|
|
chosen = c == 'all' ? orders.rows.map { |r| r[1] }.uniq : [c]
|
|
chosen.each do |cust|
|
|
lines = orders.rows.select { |r| r[1] == cust }.map { |r| [cust, r[2], fixed2(-num(r[orders.index_of('Total')])), r[0]] } +
|
|
deposits.rows.select { |r| r[0] == cust }.map { |r| [cust, r[1], fixed2(num(r[2])), r[3]] }
|
|
balance = lines.sum { |l| num(l[2]) }
|
|
puts ' Customer Statement', ' ------------------'
|
|
puts address_block(customers, cust)
|
|
puts "Now Due and Payable is your balance of: #{fixed2(-balance)}"
|
|
puts 'Details of Your Orders and Payments'
|
|
Rdb::Table.new(%w[Cust Date Amount Ref], lines.sort_by { |l| l[1] }).write
|
|
puts format('%-20s %s', '', fixed2(balance))
|
|
end
|
|
end
|
|
end
|
|
|
|
Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |_args|
|
|
products = need('product')
|
|
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number Company], [])
|
|
File.write('salesorder', Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], []).to_s) unless File.exist?('salesorder')
|
|
File.write('saleitem', Rdb::Table.new(%w[Order Number Code Backord Qty Price Total Name], []).to_s) unless File.exist?('saleitem')
|
|
loop do
|
|
cust = ask('Enter Customer Number (? for new, /string search, Return to exit) ')
|
|
break if cust.empty?
|
|
|
|
if cust == '?'
|
|
row = customers.head.map { |h| ask("#{h}: ") }
|
|
cust = row[0]
|
|
customers.rows << row
|
|
File.write('customer', customers.to_s)
|
|
elsif cust.start_with?('/')
|
|
re = Regexp.new(cust[1..], Regexp::IGNORECASE)
|
|
customers.rows.select { |r| r.join(' ').match?(re) }.each { |r| puts r.join("\t") }
|
|
next
|
|
end
|
|
puts address_block(customers, cust)
|
|
n = Rdb::Table.open('salesorder').rows.map { |r| r[0].to_i }.max.to_i + 1
|
|
gross = 0r
|
|
items = []
|
|
loop do
|
|
code = ask('Code (Return when done): ')
|
|
break if code.empty?
|
|
|
|
p = products.rows.find { |r| r[0] == code }
|
|
next puts('no such product') unless p
|
|
|
|
q = ask('Qty: ').to_i
|
|
price = num(p[products.index_of('Price')])
|
|
items << [n.to_s, (items.size + 1).to_s, code, '10', q.to_s, money(price), fixed2(price * q), p[products.index_of('Name')]]
|
|
gross += price * q
|
|
end
|
|
next if items.empty?
|
|
|
|
tax = gross * Rational('0.05')
|
|
order = [n.to_s, cust, next_date, '0', fixed2(gross), fixed2(tax), fixed2(gross + tax)]
|
|
puts Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], [order]).to_s
|
|
if ask('Is this correct? (y, n) ').downcase.start_with?('y')
|
|
File.write('salesorder', Rdb::Table.open('salesorder').tap { |t| t.rows << order }.to_s)
|
|
File.write('saleitem', Rdb::Table.open('saleitem').tap { |t| t.rows.concat(items) }.to_s)
|
|
end
|
|
end
|
|
end
|
|
|
|
Rdb.register('update.inv', 'Multi-user update with screen form and record locking', 'update.inv') do |_args|
|
|
file = ENV['INV'] || 'inv'
|
|
File.exist?(file) or die("#{file}: no such table (set INV to your inventory table)")
|
|
Rdb::Fast.build(file, 'i', 'Item') unless File.exist?("#{file}.i")
|
|
puts "Inventory Update #{Date.today.strftime('%a %b %-d, %Y')}"
|
|
loop do
|
|
item = ask('Item Number: (or hit Return key to exit.) ')
|
|
break if item.empty?
|
|
|
|
data = Rdb::Fast::Data.new(file)
|
|
hit = Rdb::Fast.search(data, 'i', ['Item'], item, first: true).first
|
|
next puts('no such item') unless hit
|
|
|
|
pid = Process.pid.to_s
|
|
unless system(File.join(Rdb::ROOT, 'bin', 'lock'), file, pid, hit.row.from.to_s, hit.row.to.to_s, hit.xfrom.to_s, hit.xto.to_s)
|
|
puts 'record is being updated by someone else'
|
|
next
|
|
end
|
|
begin
|
|
cells = hit.row.cells
|
|
h = data.head
|
|
puts h.zip(cells).map { |a, b| "#{a}=#{b.strip}" }.join(' ')
|
|
amount = ask("Amount #{h.include?('Onhand') ? 'Onhand' : 'Amount'}: ")
|
|
unless amount.empty?
|
|
cells[h.index('Onhand') || h.index('Amount')] = amount
|
|
cells[h.index('Value')] = money(num(amount) * num(cells[h.index('Cost')])) if h.include?('Value') && h.include?('Cost')
|
|
Rdb::Fast.replace_row(file, hit.row.from, hit.row.to, cells, 'i', hit.xfrom, hit.xto, 'Item')
|
|
end
|
|
ensure
|
|
system(File.join(Rdb::ROOT, 'bin', 'unlock'), file, pid, hit.row.from.to_s, hit.row.to.to_s)
|
|
end
|
|
end
|
|
end
|