RT#38973: Bill for time worked on ticket resolution [checkpoint, not ready for backport]
authorJonathan Prykop <jonathan@freeside.biz>
Sat, 16 Jul 2016 20:43:42 +0000 (15:43 -0500)
committerJonathan Prykop <jonathan@freeside.biz>
Sat, 16 Jul 2016 20:43:42 +0000 (15:43 -0500)
FS/FS/Schema.pm
FS/FS/TicketSystem/RT_Internal.pm
FS/FS/part_pkg.pm
FS/FS/part_pkg/rt_field.pm [new file with mode: 0644]
FS/FS/rt_field_charge.pm [new file with mode: 0644]
httemplate/edit/part_pkg.cgi
httemplate/elements/select-rt-customfield.html

index 9074ae4..ac58510 100644 (file)
@@ -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' => {
     # name type nullability length default local
 
     #'new_table' => {
index ffee484..01806c1 100644 (file)
@@ -255,7 +255,10 @@ sub _ticket_info {
   }
   $ticket_info{'owner'} = $t->OwnerObj->Name;
   $ticket_info{'queue'} = $t->QueueObj->Name;
   }
   $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 } ) {
   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);
   }
     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;
 
 1;
 
index 709e137..92943f2 100644 (file)
@@ -773,8 +773,12 @@ sub check {
 =item check_options
 
 For a passed I<$options> hashref, validates any options that
 =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</insert> and L</replace> via the equivalent
 methods in L<FS::option_Common>.
 
 Invoked by L</insert> and L</replace> via the equivalent
 methods in L<FS::option_Common>.
@@ -793,6 +797,10 @@ sub check_options {
       }
     } # else "option does not exist" error?
   }
       }
     } # else "option does not exist" error?
   }
+  if (exists($plans{$self->plan}->{'validate'})) {
+    my $error = &{$plans{$self->plan}->{'validate'}}($options);
+    return $error if $error;
+  }
   return '';
 }
 
   return '';
 }
 
diff --git a/FS/FS/part_pkg/rt_field.pm b/FS/FS/part_pkg/rt_field.pm
new file mode 100644 (file)
index 0000000..1b18720
--- /dev/null
@@ -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 (file)
index 0000000..fb01f81
--- /dev/null
@@ -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<hash> 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<FS::Record>
+
+=cut
+
+1;
+
index f2c4aac..2c8477d 100755 (executable)
@@ -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',
       } elsif ( $href->{$field}{'type'} eq 'select-rate' ) {
 
         $html .= include('/elements/select-rate.html',
index 85758d5..488acca 100644 (file)
@@ -1,31 +1,27 @@
-<SELECT NAME="<% $opt{name} %>">
+<SELECT NAME="<% $opt{'name'} %>"<% $opt{'multiple'} ? ' MULTIPLE' : '' %>>
 % while ( @fields ) {
 % while ( @fields ) {
-<OPTION VALUE="<% shift @fields %>"><% shift @fields %></OPTION>
+%   my $value = shift @fields;
+%   my $label = shift @fields;
+<OPTION VALUE="<% $value %>"<% $curr_value{$value} ? ' SELECTED' : '' %>><% $label %></OPTION>
 % }
 </SELECT>
 <%init>
 my %opt = @_;
 % }
 </SELECT>
 <%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});
 
 
 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},
+  );
+
 }
 }
+
 </%init>
 </%init>