#!/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 $config = RTI::Config->new(); #print Dump $config; exit; my $invoiceid = 1; # $state->{lastinvoice} + 1; my $startdate; my @invoices; foreach my $cust ( @{ $config->get('customers') } ) { # 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 ) # ->set( hour => 0, minute => 0, second => 0 ); my %invoice = ( from => $config->get('from'), info => $config->get('info'), to => $cust->{address}, rates => $cust->{rates}, match => $cust->{match}, ); if ( $cust->{base_rate} ) { my $day = $cust->{day} || 1; my $freq = $cust->{frequency} || 1; my $day_method; my $per; given ( $cust->{per} ) { when ('week') { $per = 'weeks'; $day_method = 'dow' } when ('month') { $per = 'months'; $day_method = 'day' } default { die "Unknown per [$cust->{per}]\n" } } my $lastbill = DateTime->now->set( hour => 0, minute => 0, second => 0 ); while ( $lastbill->$day_method != $day ) { $lastbill->subtract( days => 1 ); } my $date = $lastinvdate ? $lastinvdate->clone->add( days => 1 ) : $lastbill->clone->subtract( $per => $freq ); my $title = $freq == 1 ? ucfirst( $cust->{per} . 'ly' ) : $freq . ' ' . ucfirst( $cust->{per} ); $title .= ' Retainer'; my %project = ( title => $title, fees => [], ); while ( $date < $lastbill ) { my $start = $date->clone; $date->add( $per => $freq ); $date = $lastbill->clone if $date > $lastbill; if ( my $diff = $date->$day_method - $day ) { $date->subtract( days => $diff ); } my $end = $date->clone; $end->subtract( seconds => 1 ); $startdate = $start->clone if !$startdate || $startdate > $start; $invoice{start} ||= $start->clone; $invoice{end} = $end->clone; my %hours = ( start => $start->clone, end => $end->clone, hours => { %{ $cust->{hours} } }, ); push @{ $invoice{hours} }, \%hours; push @{ $project{fees} }, { count => 1, rate => $cust->{base_rate}, contents => $start->ymd . ' to ' . $end->ymd, }; } if ( @{ $project{fees} } ) { push @{ $invoice{projects} }, \%project; } } else { $invoice{end} = DateTime->now; push @{ $invoice{hours} }, { end => DateTime->now, hours => $cust->{hours} }; } next unless $invoice{end}; 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 /; if ($startdate) { push @limits, { attribute => 'last_updated', operator => '>=', value => $startdate->ymd, }; } my $results = $tickets->search( limits => \@limits, orderby => 'id', ); my $count = $results->count; print "There are $count results that matched your query\n"; my $iterator = $results->get_iterator; while ( my $ticket = &$iterator ) { my $invoice = find_invoice_for_ticket( \@invoices, $ticket ); if ( !$invoice ) { say "No invoice found for ticket " . $ticket->id; # XXX should construct a "new" invoice to pop onto the list next; } my %project = ( id => $ticket->id, queue => $ticket->queue, owner => $ticket->owner, title => $ticket->subject, detail => 'Ticket: ' . $ticket->id . ' Queue: ' . $ticket->queue . ' Requestors: ' . join( ', ', $ticket->requestors ), fees => [], expenses => [], ); #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 ( $date, $time ) = split ' ', $txn->created; my ( $year, $month, $day ) = split '-', $date; my ( $hour, $minute, $second ) = split ':', $time; my $txn_date = DateTime->new( year => $year, month => $month, day => $day, hour => $hour, minute => $minute, second => $second, ); next if $invoice->{start} && $invoice->{start} > $txn_date; next if $invoice->{end} < $txn_date; my $fee = make_fee( $ticket, $txn, $invoice->{rates} ); push @{ $project{fees} }, $fee; my $hours = hours_for_date( $invoice, $txn_date ); my $h_type = exists $hours->{ $fee->{type} } ? $fee->{type} : 'default'; next unless exists $hours->{$h_type} && $hours->{$h_type} > 0; my $discount_time = 0; if ( $hours->{$h_type} > $fee->{count} ) { $hours->{$h_type} -= $fee->{count}; $discount_time = $fee->{count}; } else { $discount_time = $hours->{$h_type}; $hours->{$h_type} = 0; } if ($discount_time) { $invoice->{discount}{amount} += $discount_time * $fee->{rate}; $invoice->{discount}{hours}{$h_type} += $discount_time; $h_type = '' if $h_type eq 'default'; $fee->{detail} = "$discount_time $h_type Hours Discounted"; } #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}; } foreach my $key (qw/ start end /) { if ( exists $invoice->{$key} ) { $invoice->{$key} = $invoice->{$key}->strftime('%B %d, %Y'); } } $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; } sub find_invoice_for_ticket { my ( $invoices, $ticket ) = @_; 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; } } return $i; } } sub make_fee { my ( $ticket, $txn, $rates ) = @_; 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 %fee = ( id => $txn->id, contents => $txn->created . ' (' . $txn->id . ')' . "\n\n" . ( $txn->data || $ticket->subject ), count => $work_time, rate => $work_rate, type => $work_type, ); if ( $work_type && $work_type ne 'Normal' ) { $fee{detail} = $work_type . ' rate'; } return \%fee; } sub hours_for_date { my ( $invoice, $date ) = @_; my $hours = {}; if ( $invoice->{hours} ) { foreach my $h ( @{ $invoice->{hours} } ) { next if $h->{start} && $h->{start} > $date; next if $h->{end} < $date; $hours = $h->{hours}; last; } } return $hours; } 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; }