Files
rdb/lib/rdb/cmd/act.rb
T

410 lines
16 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.
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
def num(str)
str.to_s.strip.empty? ? 0.0 : str.to_s.strip.to_f
end
def money(val)
val == val.to_i ? val.to_i.to_s : format('%.2f', val)
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
Rdb.register('act', 'List all /act commands', 'act') do |_args|
names = 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 << t.head.map { |h| '-' * [h.size, 1].max }
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(0.0)
[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
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
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, format('%.2f', 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(0.0)
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 = args[0].to_f
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.0).round : 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 = 0.0
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(0.0)
items.rows.each { |r| qty[r[items.index_of('Code')]] += num(r[items.index_of('Qty')]) }
need_total = Hash.new(0.0)
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(0.0)
queue = [[args[0], args[1].to_f]]
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 ------------------------------------------------------------------------------
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
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 %.2f', '', num(oh['Gross']))
puts format('%-40s Tax %.2f', '', num(oh['Tax']))
puts format('%-40s Total %.2f', '', 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], format('%.2f', -num(r[orders.index_of('Total')])), r[0]] } +
deposits.rows.select { |r| r[0] == cust }.map { |r| [cust, r[1], format('%.2f', 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: #{format('%.2f', -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', '', format('%.2f', 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 = 0.0
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), format('%.2f', price * q), p[products.index_of('Name')]]
gross += price * q
end
next if items.empty?
tax = gross * 0.05
order = [n.to_s, cust, next_date, '0', format('%.2f', gross), format('%.2f', tax), format('%.2f', 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