477 report: improve browse-edit UI
[freeside.git] / FS / FS / part_pkg.pm
index 9e3b67e..06f304a 100644 (file)
@@ -1,5 +1,7 @@
 package FS::part_pkg;
-use base qw( FS::m2m_Common FS::o2m_Common FS::option_Common );
+use base qw( FS::part_pkg::API
+             FS::m2m_Common FS::o2m_Common FS::option_Common
+           );
 
 use strict;
 use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
@@ -10,12 +12,14 @@ use Time::Local qw( timelocal timelocal_nocheck ); # eventually replace with Dat
 use Tie::IxHash;
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs dbh dbdef );
+use FS::Cursor; # for upgrade
 use FS::pkg_svc;
 use FS::part_svc;
 use FS::cust_pkg;
 use FS::agent_type;
 use FS::type_pkgs;
 use FS::part_pkg_option;
+use FS::part_pkg_fcc_option;
 use FS::pkg_class;
 use FS::agent;
 use FS::part_pkg_msgcat;
@@ -24,7 +28,6 @@ use FS::part_pkg_taxoverride;
 use FS::part_pkg_taxproduct;
 use FS::part_pkg_link;
 use FS::part_pkg_discount;
-use FS::part_pkg_usage;
 use FS::part_pkg_vendor;
 use FS::part_pkg_currency;
 
@@ -221,23 +224,6 @@ sub insert {
     }
   }
 
-  my $conf = new FS::Conf;
-  if ( $conf->exists('agent_defaultpkg') ) {
-    warn "  agent_defaultpkg set; allowing all agents to purchase package"
-      if $DEBUG;
-    foreach my $agent_type ( qsearch('agent_type', {} ) ) {
-      my $type_pkgs = new FS::type_pkgs({
-        'typenum' => $agent_type->typenum,
-        'pkgpart' => $self->pkgpart,
-      });
-      my $error = $type_pkgs->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-    }
-  }
-
   warn "  inserting part_pkg_taxoverride records" if $DEBUG;
   my %overrides = %{ $options{'tax_overrides'} || {} };
   foreach my $usage_class ( keys %overrides ) {
@@ -262,10 +248,20 @@ sub insert {
   my %part_pkg_currency = %{ $options{'part_pkg_currency'} || {} };
   foreach my $key ( keys %part_pkg_currency ) {
     $key =~ /^(.+)_([A-Z]{3})$/ or next;
+    my( $optionname, $currency ) = ( $1, $2 );
+    if ( $part_pkg_currency{$key} =~ /^\s*$/ ) {
+      if ( $self->option($optionname) == 0 ) {
+        $part_pkg_currency{$key} = '0';
+      } else {
+        $dbh->rollback if $oldAutoCommit;
+        ( my $thing = $optionname ) =~ s/_/ /g;
+        return ucfirst($thing). " $currency is required";
+      }
+    }
     my $part_pkg_currency = new FS::part_pkg_currency {
       'pkgpart'     => $self->pkgpart,
-      'optionname'  => $1,
-      'currency'    => $2,
+      'optionname'  => $optionname,
+      'currency'    => $currency,
       'optionvalue' => $part_pkg_currency{$key},
     };
     my $error = $part_pkg_currency->insert;
@@ -340,6 +336,11 @@ sub insert {
       }
   }
 
+  if ( $options{fcc_options} ) {
+    warn "  updating fcc options " if $DEBUG;
+    $self->set_fcc_options( $options{fcc_options} );
+  }
+
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
@@ -621,6 +622,11 @@ sub replace {
     }
   }
 
+  if ( $options->{fcc_options} ) {
+    warn "  updating fcc options " if $DEBUG;
+    $new->set_fcc_options( $options->{fcc_options} );
+  }
+
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
@@ -663,7 +669,7 @@ sub check {
 
   my $error = $self->ut_numbern('pkgpart')
     || $self->ut_text('pkg')
-    || $self->ut_text('comment')
+    || $self->ut_textn('comment')
     || $self->ut_textn('promo_code')
     || $self->ut_alphan('plan')
     || $self->ut_enum('setuptax', [ '', 'Y' ] )
@@ -696,6 +702,7 @@ sub check {
     || $self->ut_numbern('delay_start')
     || $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart')
     || $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart')
+    || $self->ut_alphan('agent_pkgpartid')
     || $self->SUPER::check
   ;
   return $error if $error;
@@ -780,6 +787,44 @@ sub propagate {
   join("\n", @error);
 }
 
+=item set_fcc_options HASHREF
+
+Sets the FCC options on this package definition to the values specified
+in HASHREF.
+
+=cut
+
+sub set_fcc_options {
+  my $self = shift;
+  my $pkgpart = $self->pkgpart;
+  my $options;
+  if (ref $_[0]) {
+    $options = shift;
+  } else {
+    $options = { @_ };
+  }
+
+  my %existing_num = map { $_->fccoptionname => $_->num }
+                     qsearch('part_pkg_fcc_option', { pkgpart => $pkgpart });
+
+  local $FS::Record::nowarn_identical = 1;
+  # set up params for process_o2m
+  my $i = 0;
+  my $params = {};
+  foreach my $name (keys %$options ) {
+    $params->{ "num$i" } = $existing_num{$name} || '';
+    $params->{ "num$i".'_fccoptionname' } = $name;
+    $params->{ "num$i".'_optionvalue'   } = $options->{$name};
+    $i++;
+  }
+
+  $self->process_o2m(
+    table   => 'part_pkg_fcc_option',
+    fields  => [qw( fccoptionname optionvalue )],
+    params  => $params,
+  );
+}
+
 =item pkg_locale LOCALE
 
 Returns a customer-viewable string representing this package for the given
@@ -827,7 +872,18 @@ sub pkg_comment {
   #$self->pkg. ' - '. $self->comment;
   #$self->pkg. ' ('. $self->comment. ')';
   my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
-  $pre. $self->pkg. ' - '. $self->custom_comment;
+  my $custom_comment = $self->custom_comment(%opt);
+  $pre. $self->pkg. ( $custom_comment ? " - $custom_comment" : '' );
+}
+
+#without price info (so without hitting the DB again)
+sub pkg_comment_only {
+  my $self = shift;
+  my %opt = @_;
+
+  my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
+  my $comment = $self->comment;
+  $pre. $self->pkg. ( $comment ? " - $comment" : '' );
 }
 
 sub price_info { # safety, in case a part_pkg hasn't defined price_info
@@ -836,7 +892,16 @@ sub price_info { # safety, in case a part_pkg hasn't defined price_info
 
 sub custom_comment {
   my $self = shift;
-  ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment . ' ' . $self->price_info;
+  my $price_info = $self->price_info(@_);
+  ( $self->custom ? '(CUSTOM) ' : '' ).
+    $self->comment.
+    ( ($self->custom || $self->comment) ? ' - ' : '' ).
+    ($price_info || 'No charge');
+}
+
+sub pkg_price_info {
+  my $self = shift;
+  $self->pkg. ' - '. ($self->price_info || 'No charge');
 }
 
 =item pkg_class
@@ -844,17 +909,6 @@ sub custom_comment {
 Returns the package class, as an FS::pkg_class object, or the empty string
 if there is no package class.
 
-=cut
-
-sub pkg_class {
-  my $self = shift;
-  if ( $self->classnum ) {
-    qsearchs('pkg_class', { 'classnum' => $self->classnum } );
-  } else {
-    return '';
-  }
-}
-
 =item addon_pkg_class
 
 Returns the add-on package class, as an FS::pkg_class object, or the empty
@@ -920,13 +974,6 @@ sub addon_classname {
 
 Returns the associated agent for this event, if any, as an FS::agent object.
 
-=cut
-
-sub agent {
-  my $self = shift;
-  qsearchs('agent', { 'agentnum' => $self->agentnum } );
-}
-
 =item pkg_svc [ HASHREF | OPTION => VALUE ]
 
 Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
@@ -945,11 +992,6 @@ definition.
 
 =cut
 
-sub type_pkgs {
-  my $self = shift;
-  qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
-}
-
 sub pkg_svc {
   my $self = shift;
 
@@ -1089,6 +1131,10 @@ sub can_discount { 0; }
  
 # whether the plan allows changing the start date
 sub can_start_date { 1; }
+
+# whether the plan supports part_pkg_usageprice add-ons (a specific kind of
+#  pre-selectable usage pricing, there's others this doesn't refer to)
+sub can_usageprice { 0; }
   
 # the delay start date if present
 sub delay_start_date {
@@ -1205,13 +1251,6 @@ sub plandata {
 Returns all vendor/external package ids as FS::part_pkg_vendor objects (see
 L<FS::part_pkg_vendor>).
 
-=cut
-
-sub part_pkg_vendor {
-  my $self = shift;
-  qsearch('part_pkg_vendor', { 'pkgpart' => $self->pkgpart } );
-}
-
 =item vendor_pkg_ids
 
 Returns a list of vendor/external package ids by exportnum
@@ -1228,13 +1267,6 @@ sub vendor_pkg_ids {
 Returns all options as FS::part_pkg_option objects (see
 L<FS::part_pkg_option>).
 
-=cut
-
-sub part_pkg_option {
-  my $self = shift;
-  qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
-}
-
 =item options 
 
 Returns a list of option names and values suitable for assigning to a hash.
@@ -1256,6 +1288,8 @@ will be suppressed.
 
 sub option {
   my( $self, $opt, $ornull ) = @_;
+  cluck "$self -> option: searching for $opt"
+    if $DEBUG;
   my $part_pkg_option =
     qsearchs('part_pkg_option', {
       pkgpart    => $self->pkgpart,
@@ -1320,6 +1354,35 @@ sub part_pkg_currency_option {
   $part_pkg_currency->optionvalue;
 }
 
+=item fcc_option OPTIONNAME
+
+Returns the FCC 477 report option value for the given name, or the empty 
+string.
+
+=cut
+
+sub fcc_option {
+  my ($self, $name) = @_;
+  my $part_pkg_fcc_option =
+    qsearchs('part_pkg_fcc_option', {
+        pkgpart => $self->pkgpart,
+        fccoptionname => $name,
+    });
+  $part_pkg_fcc_option ? $part_pkg_fcc_option->optionvalue : '';
+}
+
+=item fcc_options
+
+Returns all FCC 477 report options for this package, as a hash-like list.
+
+=cut
+
+sub fcc_options {
+  my $self = shift;
+  map { $_->fccoptionname => $_->optionvalue }
+    qsearch('part_pkg_fcc_option', { pkgpart => $self->pkgpart });
+}
+
 =item bill_part_pkg_link
 
 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
@@ -1478,74 +1541,47 @@ sub taxproduct_description {
   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
 }
 
-=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
 
-Returns the package to taxrate m2m records for this package in the location
-specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
-CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
-(see L<FS::usage_class>).
+=item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
+
+Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
+package in the location specified by GEOCODE, for usage class CLASS (one of
+'setup', 'recur', null, or a C<usage_class> number).
 
 =cut
 
-sub _expand_cch_taxproductnum {
-  my $self = shift;
-  my $class = shift;
-  my $part_pkg_taxproduct = $self->taxproduct($class);
-
-  my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
-                         ? ( split ':', $part_pkg_taxproduct->taxproduct )
-                         : ()
-                     );
-  $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
-  my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
-                      OR taxproduct = '$a:$b:$c:'
-                      OR taxproduct = '$a:$b:".":$d'
-                      OR taxproduct = '$a:$b:".":' )";
-  map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
-                                       'hashref'   => { 'data_vendor'=>'cch' },
-                                       'extra_sql' => $extra_sql,
-                                   } );
-                                     
-}
-
-sub part_pkg_taxrate {
+sub tax_rates {
   my $self = shift;
-  my ($data_vendor, $geocode, $class) = @_;
-
-  my $dbh = dbh;
-  my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
-                  dbh->quote($data_vendor);
-  
-  # CCH oddness in m2m
-  $extra_sql .= ' AND ('.
-    join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
-                 qw(10 5 2)
-        ).
-    ')';
-  # much more CCH oddness in m2m -- this is kludgy
-  my @tpnums = $self->_expand_cch_taxproductnum($class);
-  if (scalar(@tpnums)) {
-    $extra_sql .= ' AND ('.
-                            join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
-                       ')';
-  } else {
-    $extra_sql .= ' AND ( 0 = 1 )';
+  my ($vendor, $geocode, $class) = @_;
+  my @taxclassnums = map { $_->taxclassnum } 
+                     $self->part_pkg_taxoverride($class);
+  if (!@taxclassnums) {
+    my $part_pkg_taxproduct = $self->taxproduct($class);
+    # If this isn't defined, then the class has no taxproduct designation,
+    # so return no tax rates.
+    return () if !$part_pkg_taxproduct;
+
+    # convert the taxproduct to the tax classes that might apply to it in 
+    # $geocode
+    @taxclassnums = map { $_->taxclassnum }
+                    grep { $_->taxable eq 'Y' } # why do we need this?
+                    $part_pkg_taxproduct->part_pkg_taxrate($geocode);
   }
+  return unless @taxclassnums;
 
-  my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
-  my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
-  my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
+  # then look up the actual tax_rate entries
+  warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
+      if $DEBUG;
+  my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
+  my @taxes = qsearch({ 'table'     => 'tax_rate',
+                        'hashref'   => { 'geocode'     => $geocode,
+                                         'data_vendor' => $vendor },
+                        'extra_sql' => $extra_sql,
+                      });
+  warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
+      if $DEBUG;
 
-  # should qsearch preface columns with the table to facilitate joins?
-  qsearch( { 'table'     => 'part_pkg_taxrate',
-             'select'    => $select,
-             'hashref'   => { # 'data_vendor'   => $data_vendor,
-                              # 'taxproductnum' => $self->taxproductnum,
-                            },
-             'addl_from' => $addl_from,
-             'extra_sql' => $extra_sql,
-             'order_by'  => $order_by,
-         } );
+  return @taxes;
 }
 
 =item part_pkg_discount
@@ -1553,25 +1589,11 @@ sub part_pkg_taxrate {
 Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
 for this package.
 
-=cut
-
-sub part_pkg_discount {
-  my $self = shift;
-  qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
-}
-
 =item part_pkg_usage
 
 Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for 
 this package.
 
-=cut
-
-sub part_pkg_usage {
-  my $self = shift;
-  qsearch('part_pkg_usage', { 'pkgpart' => $self->pkgpart });
-}
-
 =item _rebless
 
 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
@@ -1650,6 +1672,39 @@ sub cust_bill_pkg_recur {
   $cust_bill_pkg->recur;
 }
 
+=item unit_setup CUST_PKG
+
+Returns the setup fee for one unit of the package.
+
+=cut
+
+sub unit_setup {
+  my ($self, $cust_pkg) = @_;
+  $self->option('setup_fee') || 0;
+}
+
+=item setup_margin
+
+unit_setup minus setup_cost
+
+=cut
+
+sub setup_margin {
+  my $self = shift;
+  $self->unit_setup(@_) - $self->setup_cost;
+}
+
+=item recur_margin_permonth
+
+base_recur_permonth minus recur_cost_permonth
+
+=cut
+
+sub recur_margin_permonth {
+  my $self = shift;
+  $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_);
+}
+
 =item format OPTION DATA
 
 Returns data formatted according to the function 'format' described
@@ -1717,16 +1772,20 @@ sub _upgrade_data { # class method
     $part_pkg->replace;
 
   }
+  # the rest can be done asynchronously
+}
 
+sub queueable_upgrade {
   # now upgrade to the explicit custom flag
 
-  @part_pkg = qsearch({
+  my $search = FS::Cursor->new({
     'table'     => 'part_pkg',
     'hashref'   => { disabled => 'Y', custom => '' },
     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
   });
+  my $dbh = dbh;
 
-  foreach my $part_pkg (@part_pkg) {
+  while (my $part_pkg = $search->fetch) {
     my $new = new FS::part_pkg { $part_pkg->hash };
     $new->custom('Y');
     my $comment = $part_pkg->comment;
@@ -1743,15 +1802,25 @@ sub _upgrade_data { # class method
                                'primary_svc' => $primary,
                                'options'     => $options,
                              );
-    die $error if $error;
+    if ($error) {
+      warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
+      $dbh->rollback;
+    } else {
+      $dbh->commit;
+    }
   }
 
   # set family_pkgpart on any packages that don't have it
-  @part_pkg = qsearch('part_pkg', { 'family_pkgpart' => '' });
-  foreach my $part_pkg (@part_pkg) {
+  $search = FS::Cursor->new('part_pkg', { 'family_pkgpart' => '' });
+  while (my $part_pkg = $search->fetch) {
     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
     my $error = $part_pkg->SUPER::replace;
-    die $error if $error;
+    if ($error) {
+      warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
+      $dbh->rollback;
+    } else {
+      $dbh->commit;
+    }
   }
 
   my @part_pkg_option = qsearch('part_pkg_option',
@@ -1862,7 +1931,7 @@ sub _upgrade_data { # class method
       }
     } # $bad_upgrade exists
     else { # do the original upgrade, but correctly this time
-      @part_pkg = qsearch('part_pkg', {
+      my @part_pkg = qsearch('part_pkg', {
           fcc_ds0s        => { op => '>', value => 0 },
           fcc_voip_class  => ''
       });
@@ -1951,8 +2020,8 @@ sub _pkgs_sql {
 #false laziness w/part_export & cdr
 my %info;
 foreach my $INC ( @INC ) {
-  warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
-  foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
+  warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
+  foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
     warn "attempting to load plan info from $file\n" if $DEBUG;
     $file =~ /\/(\w+)\.pm$/ or do {
       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";