#!/usr/bin/perl use strict; use warnings; use 5.010; use YAML::Any; use Template; use RT::Client::REST; use RT::Client::REST::Ticket; use RT::Client::REST::User; use DateTime; my %rates; my %included_hours; my $config = RTI::Config->new(); #print Dump $config; exit; # my $state = RTI::State->new($cust); # $invoice{state} = $state; my $lastinvdate; # = $state->{lastinvoicedte}; XXX Needs to be a DateTime #$lastinvdate = DateTime->now->subtract( months => 2 ); my $invoiceid = 1; # $state->{lastinvoice} + 1; my $startdate; my @invoices; foreach my $cust ( @{ $config->get('customers') } ) { my %invoice = ( from => $config->get('from'), to => $cust->{address}, rates => $cust->{rates}, match => $cust->{match}, ); if ( $cust->{base_rate} ) { my $date = DateTime->now; my $day = $cust->{day} || 1; my $freq = $cust->{frequency} || 1; my $diff; my $per; given ( $cust->{per} ) { when ('week') { $per = 'weeks'; $diff = $date->dow - $day; } when ('month') { $per = 'months'; $diff = $date->day - $day; } default { die "Unknown per [$cust->{per}]\n" } } # $day is start day, end should be one day further back $diff = abs($diff) + 1; $date->subtract( days => $diff ); my $title = $freq == 1 ? ucfirst( $cust->{per} . 'ly' ) : $freq . ' ' . ucfirst( $cust->{per} ); $title .= ' Retainer'; my %project = ( title => $title, fees => [], ); # XXX need to add them until we get to where we billed already # if we don't know the last invoice date, assume the day before $lastinvdate ||= $date->clone->subtract( days => 1 ); while ( $date > $lastinvdate ) { $invoice{enddate} = $date->clone; my %hours = ( end => $date->clone, hours => { %{ $cust->{hours} } }, ); my $contents = ' to ' . $date->ymd; $date->subtract( $per => $freq )->add( days => 1 ); $contents = $date->ymd . $contents; $invoice{startdate} ||= $date->clone; $hours{start} = $date->clone; $startdate = $date->clone; unshift @{ $invoice{hours} }, \%hours; unshift @{ $project{fees} }, { count => 1, rate => $cust->{base_rate}, contents => $contents, }; # Next time, one day less $date->subtract( days => 1 ); } push @{ $invoice{projects} }, \%project; } else { $invoice{enddate} = DateTime->now->ymd; push @{ $invoice{hours} }, { end => DateTime->now, hours => $cust->{hours} }; } push @invoices, \%invoice; } #print Dump \@invoices; exit; my $rt_conf = $config->get('rt'); my $rt = RT::Client::REST->new( server => $rt_conf->{server}, timeout => $rt_conf->{timeout}, ); $rt->login( username => $rt_conf->{user}, password => $rt_conf->{pass}, ); my $tickets = RT::Client::REST::Ticket->new( rt => $rt ); my $users = RT::Client::REST::User->new( rt => $rt ); my @limits = map +{ attribute => 'status', operator => '=', value => $_, aggregator => 'or', }, # XXX This should be a config option qw/ open stalled resolved /; #push @limits, # { # attribute => 'resolved', # operator => '>=', # value => '2011-03-01' # }; if ($startdate) { push @limits, { attribute => 'last_updated', operator => '>=', value => $startdate->ymd, }; } #push @limits, { attribute => 'id', operator => '=', value => '51' }; #push @limits, { attribute => '', operator => '', value => '' }; my $results = $tickets->search( limits => \@limits, orderby => 'id', ); #print Dump $ticket, $results; my $count = $results->count; print "There are $count results that matched your query\n"; my $iterator = $results->get_iterator; while ( my $ticket = &$iterator ) { my %project = ( id => $ticket->id, queue => $ticket->queue, owner => $ticket->owner, title => $ticket->id . ': ' . $ticket->subject, detail => 'Requestors: ' . join( ', ', $ticket->requestors ) . ' Queue: ' . $ticket->queue, fees => [], expenses => [], ); my $invoice; INVOICE: foreach my $i (@invoices) { next INVOICE unless $i->{match}; foreach my $m ( @{ $i->{match} } ) { my $type = $m->{type}; my $thing = join ' ', $ticket->$type; if ( $m->{$type} ) { my $match = lc $m->{$type}; next INVOICE unless lc($thing) ~~ $match; } elsif ( $m->{regex} ) { next INVOICE unless $thing ~~ /\Q$m->{regex}\E/; } else { warn "Invalid match!"; next INVOICE; } } $invoice = $i; } if ( !$invoice ) { say "No invoice found for ticket " . $ticket->id; # XXX should construct a "new" invoice to pop onto the list next; } #foreach my $r ($ticket->requestors) { # $users->id( $r ); # $users->retrieve; # print Dump $users; #} my $txns = $ticket->transactions( type => [qw(Comment Correspond)] ); my $txn_i = $txns->get_iterator; while ( my $txn = $txn_i->() ) { next unless $txn->time_taken; my $work_time = sprintf "%.03f", $txn->time_taken / 60; my $work_type = $txn->cf('WorkType'); my $work_rate = $rates{$work_type} || $rates{default} || 0; my $ih_type = exists $included_hours{$work_type} ? $work_type : 'default'; my %fee = ( id => $txn->id, contents => $txn->created . ' (' . $txn->id . ')' . "\n\n" . ( $txn->data || $ticket->subject ), count => $work_time, rate => $work_rate, ); if ( $work_type && $work_type ne 'Normal' ) { $fee{detail} = $work_type . ' rate'; } push @{ $project{fees} }, \%fee; next unless $included_hours{$ih_type} && $included_hours{$ih_type} > 0; my $discount_time = 0; if ( $included_hours{$ih_type} > $work_time ) { $included_hours{$ih_type} -= $work_time; $discount_time = $work_time; } else { $discount_time = $included_hours{$ih_type}; $included_hours{$ih_type} = 0; } if ($discount_time) { $fee{detail} = "$discount_time $work_type Hours Discounted"; $invoice->{discount}{amount} += $discount_time * $fee{rate}; $invoice->{discount}{hours}{$work_type} += $discount_time; } #print Dump $txn; exit; } next unless @{ $project{fees} } || @{ $project{expenses} }; print " Added ticket $project{id}\n"; push @{ $invoice->{projects} }, \%project; } foreach my $invoice (@invoices) { next unless $invoice->{projects} && @{ $invoice->{projects} }; foreach my $project ( @{ $invoice->{projects} } ) { print "$project->{title}\n"; my $subtotal = 0; foreach my $fee ( @{ $project->{fees} } ) { my $amount = round( $fee->{count} * $fee->{rate} ); print " $amount (" . ( $fee->{count} * $fee->{rate} ) . ")\n"; $subtotal += $amount; } foreach my $expense ( @{ $project->{expenses} } ) { $subtotal += round( $expense->{amount} ); } $project->{total} = $subtotal; $invoice->{total} += $subtotal; print " Subtotal $subtotal\n"; } if ( $invoice->{discount} ) { my $c = "Included Hours\n"; if ( $invoice->{discount}{hours} ) { foreach my $t ( keys %{ $invoice->{discount}{hours} } ) { $c .= "\n$invoice->{discount}{hours}{$t} $t hour"; $c .= 's' if $invoice->{discount}{hours}{$t} != 1; $c .= "\n"; } } $invoice->{discount}{contents} = $c; $invoice->{total} -= round( $invoice->{discount}{amount} ); } if ( $invoice->{past_due} ) { $invoice->{total_due} = $invoice->{total} + $invoice->{past_due}; } $invoice->{id} = $invoiceid; $invoice->{file} = 'invoice_' . $invoiceid . '.pdf'; print "Created Invoice\n"; print Dump $invoice; my $tt = Template->new; $tt->process( 'invoice.tex.tt', $invoice, $invoice->{file} ) or die $tt->error . "\n"; print "Generated $invoice->{file}\n"; $invoiceid++; } # XXX Save State sub round { my ($amount) = @_; #$amount =~ s/\.\d\d\K.*$//; #return $amount; return sprintf "%.02f", $amount; } package RTI::Config; use strict; use warnings; use 5.010; use YAML::Any qw/ LoadFile /; sub new { my ( $class, $args ) = @_; my $self = { file => '', }; bless $self, $class; my $file = $args->{file} || $self->_find_config; $self->read_config($file); return $self; } sub _find_config { my ($self) = @_; # XXX This needs to be better foreach my $file (qw/ rt_invoice.conf rt_invoice.cfg .rt_invoicerc /) { foreach my $dir ( '.', $ENV{HOME} . '/.rt_invoice', $ENV{HOME} ) { my $path = join '/', $dir, $file; return $path if -e $path; } } return; } sub read_config { my ( $self, $file ) = @_; $file ||= $self->{file}; die "$file: no such file\n" unless -e $file; my $c = LoadFile($file) or die "Unable to load $file\n"; $c->{customers} ||= []; if ( $c->{default} ) { foreach my $cust ( @{ $c->{customers} } ) { foreach my $k ( keys %{ $c->{default} } ) { $cust->{$k} //= $c->{default}->{$k}; } } } $self->{_config} = $c; $self->{file} = $file; } sub get { my ( $self, $key ) = @_; # XXX This should deep copy? not a reference would be good return $self->{_config}->{$key}; } package RTI::State; use strict; use warnings; use 5.010; use YAML::Any qw/ LoadFile DumpFile /; sub new { my ( $class, $args ) = @_; my $self = { file => '', }; bless $self, $class; my $file = $args->{file} || $self->_find_config; $self->read_config($file); return $self; }