From: Jonathan Prykop Date: Sat, 16 Jul 2016 20:43:42 +0000 (-0500) Subject: RT#38973: Bill for time worked on ticket resolution [checkpoint, not ready for backport] X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=fe25108857542f5d7c460ab831bc782f608179fa RT#38973: Bill for time worked on ticket resolution [checkpoint, not ready for backport] --- diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 9074ae433..ac585108e 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -7418,6 +7418,26 @@ sub tables_hashref { ], }, + 'rt_field_charge' => { + 'columns' => [ + 'rtfieldchargenum', 'serial', '', '', '', '', + 'pkgnum', 'int', '', '', '', '', + 'ticketid', 'int', '', '', '', '', + 'rate', @money_type, '', '', + 'units', 'decimal', '', '10,4', '', '', + 'charge', @money_type, '', '', + '_date', @date_type, '', '', + ], + 'primary_key' => 'rtfieldchargenum', + 'unique' => [], + 'index' => [ ['pkgnum', 'ticketid'] ], + 'foreign_keys' => [ + { columns => [ 'pkgnum' ], + table => 'cust_pkg', + }, + ], + }, + # name type nullability length default local #'new_table' => { diff --git a/FS/FS/TicketSystem/RT_Internal.pm b/FS/FS/TicketSystem/RT_Internal.pm index ffee484e9..01806c1b9 100644 --- a/FS/FS/TicketSystem/RT_Internal.pm +++ b/FS/FS/TicketSystem/RT_Internal.pm @@ -255,7 +255,10 @@ sub _ticket_info { } $ticket_info{'owner'} = $t->OwnerObj->Name; $ticket_info{'queue'} = $t->QueueObj->Name; + $ticket_info{'_cf_sort_order'} = {}; + my $cf_sort = 0; foreach my $CF ( @{ $t->CustomFields->ItemsArrayRef } ) { + $ticket_info{'_cf_sort_order'}{$CF->Name} = $cf_sort++; my $name = 'CF.{'.$CF->Name.'}'; $ticket_info{$name} = $t->CustomFieldValuesAsString($CF->Id); } @@ -649,5 +652,49 @@ sub selfservice_priority { } } +=item custom_fields + +Returns a hash of custom field names and descriptions. + +Accepts the following options: + +lookuptype - limit results to this lookuptype + +valuetype - limit results to this valuetype + +Fields must be visible to CurrentUser. + +=cut + +sub custom_fields { + my $self = shift; + my %opt = @_; + my $lookuptype = $opt{lookuptype}; + my $valuetype = $opt{valuetype}; + + my $CurrentUser = RT::CurrentUser->new(); + $CurrentUser->LoadByName($FS::CurrentUser::CurrentUser->username); + die "RT not configured" unless $CurrentUser->id; + my $CFs = RT::CustomFields->new($CurrentUser); + + $CFs->UnLimit; + + $CFs->Limit(FIELD => 'LookupType', + OPERATOR => 'ENDSWITH', + VALUE => $lookuptype) + if $lookuptype; + + $CFs->Limit(FIELD => 'Type', + VALUE => $valuetype) + if $valuetype; + + my @fields; + while (my $CF = $CFs->Next) { + push @fields, $CF->Name, ($CF->Description || $CF->Name); + } + + return @fields; +} + 1; diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 709e137f3..92943f25c 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -773,8 +773,12 @@ sub check { =item check_options For a passed I<$options> hashref, validates any options that -have 'validate' subroutines defined (I<$options> values might -be altered.) Returns error message, or empty string if valid. +have 'validate' subroutines defined in the info hash, +then validates the entire hashref if the price plan has +its own 'validate' subroutine defined in the info hash +(I<$options> values might be altered.) + +Returns error message, or empty string if valid. Invoked by L and L via the equivalent methods in L. @@ -793,6 +797,10 @@ sub check_options { } } # else "option does not exist" error? } + if (exists($plans{$self->plan}->{'validate'})) { + my $error = &{$plans{$self->plan}->{'validate'}}($options); + return $error if $error; + } return ''; } diff --git a/FS/FS/part_pkg/rt_field.pm b/FS/FS/part_pkg/rt_field.pm new file mode 100644 index 000000000..1b18720ac --- /dev/null +++ b/FS/FS/part_pkg/rt_field.pm @@ -0,0 +1,177 @@ +package FS::part_pkg::rt_field; + +use strict; +use FS::Conf; +use FS::TicketSystem; +use FS::Record qw(qsearchs qsearch); +use FS::part_pkg::recur_Common; +use FS::part_pkg::global_Mixin; +use FS::rt_field_charge; + +our @ISA = qw(FS::part_pkg::recur_Common); + +our $DEBUG = 0; + +use vars qw( $conf $money_char ); + +FS::UID->install_callback( sub { + $conf = new FS::Conf; + $money_char = $conf->config('money_char') || '$'; +}); + +my %custom_field = ( + 'type' => 'select-rt-customfield', + 'lookuptype' => 'RT::Queue-RT::Ticket', +); + +our %info = ( + 'name' => 'Bill from custom fields in resolved RT tickets', + 'shortname' => 'RT custom rate', + 'weight' => 65, + 'inherit_fields' => [ 'global_Mixin' ], + 'fields' => { + 'unit_field' => { 'name' => 'Units field', + %custom_field, + 'validate' => sub { return ${$_[1]} ? '' : 'Units field must be specified' }, + }, + 'rate_field' => { 'name' => 'Charge per unit (from RT field)', + %custom_field, + 'empty_label' => '', + }, + 'rate_flat' => { 'name' => 'Charge per unit (flat)', + 'validate' => \&FS::part_pkg::global_Mixin::validate_moneyn }, + 'display_fields' => { 'name' => 'Display fields', + %custom_field, + 'multiple' => 1, + 'parse' => sub { @_ }, # because /edit/process/part_pkg.pm doesn't grok select multiple + }, + # from global_Mixin, but don't get used by this at all + 'unused_credit_cancel' => {'disabled' => 1}, + 'unused_credit_suspend' => {'disabled' => 1}, + 'unused_credit_change' => {'disabled' => 1}, + }, + 'validate' => sub { + my $options = shift; + return 'Rate must be specified' + unless $options->{'rate_field'} or $options->{'rate_flat'}; + return 'Cannot specify both flat rate and rate field' + if $options->{'rate_field'} and $options->{'rate_flat'}; + return ''; + }, + 'fieldorder' => [ 'unit_field', 'rate_field', 'rate_flat', 'display_fields' ] +); + +sub price_info { + my $self = shift; + my $str = $self->SUPER::price_info; + $str .= ' plus ' if $str; + FS::TicketSystem->init(); + my %custom_fields = FS::TicketSystem->custom_fields(); + my $rate = $self->option('rate_flat',1); + my $rate_field = $self->option('rate_field',1); + my $unit_field = $self->option('unit_field'); + $str .= $rate + ? $money_char . sprintf("%.2",$rate) + : $custom_fields{$rate_field}; + $str .= ' x ' . $custom_fields{$unit_field}; + return $str; +} + +sub calc_setup { + my($self, $cust_pkg ) = @_; + $self->option('setup_fee'); +} + +sub calc_recur { + my $self = shift; + my($cust_pkg, $sdate, $details, $param ) = @_; + + my $charges = 0; + + $charges += $self->calc_usage(@_); + $charges += ($cust_pkg->quantity || 1) * $self->calc_recur_Common(@_); + + $charges; + +} + +sub can_discount { 0; } + +sub calc_usage { + my $self = shift; + my($cust_pkg, $sdate, $details, $param ) = @_; + + FS::TicketSystem->init(); + + # not date delimited--load all resolved tickets + # will subtract previous charges below + # only way to be sure we've caught everything + # limit set to be arbitrarily large (10000) + my $tickets = FS::TicketSystem->customer_tickets( $cust_pkg->custnum, 10000, undef, 'resolved'); + + my $rate = $self->option('rate_flat',1); + my $rate_field = $self->option('rate_field',1); + my $unit_field = $self->option('unit_field'); + my @display_fields = split(', ',$self->option('display_fields',1) || ''); + + my %custom_fields = FS::TicketSystem->custom_fields(); + my $rate_label = $rate + ? '' + : ' ' . $custom_fields{$rate_field}; + my $unit_label = $custom_fields{$unit_field}; + + $rate_field = 'CF.{' . $rate_field . '}' if $rate_field; + $unit_field = 'CF.{' . $unit_field . '}'; + + my $charges = 0; + foreach my $ticket ( @$tickets ) { + next unless $ticket->{$unit_field}; + next unless $rate || $ticket->{$rate_field}; + my $trate = $rate || $ticket->{$rate_field}; + my $tunit = $ticket->{$unit_field}; + my $subcharge = sprintf('%.2f', $trate * $tunit); + my $precharge = _previous_charges( $cust_pkg->pkgnum, $ticket->{'id'} ); + $subcharge -= $precharge; + + # if field values for previous charges increased, + # we can make additional charges here and now, + # but if field values were decreased, we just ignore-- + # credits will have to be applied manually later, if that's what's intended + next if $subcharge <= 0; + + my $rt_field_charge = new FS::rt_field_charge { + 'pkgnum' => $cust_pkg->pkgnum, + 'ticketid' => $ticket->{'id'}, + 'rate' => $trate, + 'units' => $tunit, + 'charge' => $subcharge, + '_date' => $$sdate, + }; + my $error = $rt_field_charge->insert; + die "Error inserting rt_field_charge: $error" if $error; + push @$details, $money_char . sprintf('%.2f',$trate) . $rate_label . ' x ' . $tunit . ' ' . $unit_label; + push @$details, ' - ' . $money_char . sprintf('%.2f',$precharge) . ' previously charged' if $precharge; + foreach my $field ( + sort { $ticket->{'_cf_sort_order'}{$a} <=> $ticket->{'_cf_sort_order'}{$b} } @display_fields + ) { + my $label = $custom_fields{$field}; + my $value = $ticket->{'CF.{' . $field . '}'}; + push @$details, $label . ': ' . $value if $value; + } + $charges += $subcharge; + } + return $charges; +} + +sub _previous_charges { + my ($pkgnum, $ticketid) = @_; + my $prev = 0; + foreach my $rt_field_charge ( + qsearch('rt_field_charge', { pkgnum => $pkgnum, ticketid => $ticketid }) + ) { + $prev += $rt_field_charge->charge; + } + return $prev; +} + +1; diff --git a/FS/FS/rt_field_charge.pm b/FS/FS/rt_field_charge.pm new file mode 100644 index 000000000..fb01f810e --- /dev/null +++ b/FS/FS/rt_field_charge.pm @@ -0,0 +1,132 @@ +package FS::rt_field_charge; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); + +=head1 NAME + +FS::rt_field_charge - Object methods for rt_field_charge records + +=head1 SYNOPSIS + + use FS::rt_field_charge; + + $record = new FS::rt_field_charge \%hash; + $record = new FS::rt_field_charge { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::rt_field_charge object represents an individual charge +that has been added to an invoice by a package with the rt_field price plan. +FS::rt_field_charge inherits from FS::Record. +The following fields are currently supported: + +=over 4 + +=item rtfieldchargenum - primary key + +=item pkgnum - cust_pkg that generated the charge + +=item ticketid - RT ticket that generated the charge + +=item rate - the rate per unit for the charge + +=item units - quantity of units being charged + +=item charge - the total amount charged + +=item _date - billing date for the charge + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new object. To add the object to the database, see L<"insert">. + +Note that this stores the hash reference, not a distinct copy of the hash it +points to. You can ask the object for a copy with the I method. + +=cut + +# the new method can be inherited from FS::Record, if a table method is defined + +sub table { 'rt_field_charge'; } + +=item insert + +Adds this record to the database. If there is an error, returns the error, +otherwise returns false. + +=cut + +# the insert method can be inherited from FS::Record + +=item delete + +Delete this record from the database. + +=cut + +# the delete method can be inherited from FS::Record + +=item replace OLD_RECORD + +Replaces the OLD_RECORD with this one in the database. If there is an error, +returns the error, otherwise returns false. + +=cut + +# the replace method can be inherited from FS::Record + +=item check + +Checks all fields to make sure this is a valid object. If there is +an error, returns the error, otherwise returns false. Called by the insert +and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('rtfieldchargenum') + || $self->ut_foreign_key('pkgnum', 'cust_pkg', 'pkgnum' ) + || $self->ut_number('ticketid') + || $self->ut_money('rate') + || $self->ut_float('units') + || $self->ut_money('charge') + || $self->ut_number('_date') + ; + return $error if $error; + + $self->SUPER::check; +} + +=back + +=head1 BUGS + + + +=head1 SEE ALSO + +L + +=cut + +1; + diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi index f2c4aacef..2c8477d8e 100755 --- a/httemplate/edit/part_pkg.cgi +++ b/httemplate/edit/part_pkg.cgi @@ -988,6 +988,16 @@ my $html_bottom = sub { : '' ). '>'; + } elsif ( $href->{$field}{'type'} eq 'select-rt-customfield' ) { + + $html .= include('/elements/select-rt-customfield.html', + 'name' => $layer.'__'.$field, + 'curr_value' => $options{$field}, + map { $_ => $href->{$field}{$_} } + grep { $_ !~ /^(name|type|parse)$/ } + keys %{ $href->{$field} } + ); + } elsif ( $href->{$field}{'type'} eq 'select-rate' ) { $html .= include('/elements/select-rate.html', diff --git a/httemplate/elements/select-rt-customfield.html b/httemplate/elements/select-rt-customfield.html index 85758d585..488accac3 100644 --- a/httemplate/elements/select-rt-customfield.html +++ b/httemplate/elements/select-rt-customfield.html @@ -1,31 +1,27 @@ -> % while ( @fields ) { - +% my $value = shift @fields; +% my $label = shift @fields; + % } <%init> my %opt = @_; -my $lookuptype = $opt{lookuptype}; -my $valuetype = $opt{valuetype}; -# get a list of TimeValue-type custom fields -my $CurrentUser = RT::CurrentUser->new(); -$CurrentUser->LoadByName($FS::CurrentUser::CurrentUser->username); -die "RT not configured" unless $CurrentUser->id; -my $CFs = RT::CustomFields->new($CurrentUser); -$CFs->Limit(FIELD => 'LookupType', - OPERATOR => 'ENDSWITH', - VALUE => $lookuptype) - if $lookuptype; - -$CFs->Limit(FIELD => 'Type', - VALUE => $valuetype) - if $valuetype; +my %curr_value = map { $_ => 1 } split(', ',$opt{'curr_value'}); my @fields; push @fields, '', $opt{empty_label} if exists($opt{empty_label}); -while (my $CF = $CFs->Next) { - push @fields, $CF->Name, ($CF->Description || $CF->Name); +my $conf = new FS::Conf; + +if ($conf->config('ticket_system') eq 'RT_Internal') { + + push @fields, FS::TicketSystem->custom_fields( + lookuptype => $opt{lookuptype}, + valuetype => $opt{valuetype}, + ); + } +