X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fpart_pkg.pm;h=bf607849bc530aa6508e1bafc7b18192c1cf9bce;hp=061001bdcd2e7ec65e34d2d3b51d714bd475fd76;hb=02d73ef84103d6bdaf49e6a179a0ad46f9719d25;hpb=f84937b2a6cc6ba63cdab177866b1417c32b1028 diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 061001bdc..bf607849b 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -1,33 +1,48 @@ package FS::part_pkg; +use base qw( FS::part_pkg::API + FS::m2m_Common FS::o2m_Common FS::option_Common + ); use strict; -use vars qw( @ISA %plans $DEBUG $setup_hack $skip_pkg_svc_hack ); +use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack + $cache_enabled %cache_link %cache_pkg_svc + ); use Carp qw(carp cluck confess); use Scalar::Util qw( blessed ); -use Time::Local qw( timelocal_nocheck ); +use DateTime; +use Time::Local qw( timelocal timelocal_nocheck ); # eventually replace with DateTime 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; use FS::part_pkg_taxrate; use FS::part_pkg_taxoverride; use FS::part_pkg_taxproduct; use FS::part_pkg_link; use FS::part_pkg_discount; use FS::part_pkg_vendor; +use FS::part_pkg_currency; +use FS::part_svc_link; -@ISA = qw( FS::m2m_Common FS::option_Common ); $DEBUG = 0; + $setup_hack = 0; $skip_pkg_svc_hack = 0; +$cache_enabled = 0; +%cache_link = (); +%cache_pkg_svc = (); + =head1 NAME FS::part_pkg - Object methods for part_pkg objects @@ -103,6 +118,9 @@ inherits from FS::Record. The following fields are currently supported: =item fcc_ds0s - Optional DS0 equivalency number for FCC form 477 +=item fcc_voip_class - Which column of FCC form 477 part II.B this package +belongs in. + =item successor - Foreign key for the part_pkg that replaced this record. If this record is not obsolete, will be null. @@ -110,6 +128,24 @@ If this record is not obsolete, will be null. ancestor of this record. If this record is not a successor to another part_pkg, will be equal to pkgpart. +=item delay_start - Number of days to delay package start, by default + +=item start_on_hold - 'Y' to suspend this package immediately when it is +ordered. The package will not start billing or have a setup fee charged +until it is manually unsuspended. + +=item change_to_pkgpart - When this package is ordered, schedule a future +package change. The 'expire_months' field will determine when the package +change occurs. + +=item expire_months - Number of months until this package expires (or changes +to another package). + +=item adjourn_months - Number of months until this package becomes suspended. + +=item contract_end_months - Number of months until the package's contract +ends. + =back =head1 METHODS @@ -154,7 +190,8 @@ I and I. If I is set to a hashref with svcparts as keys and quantities as values, appropriate FS::pkg_svc records will be inserted. I can be set to a hashref of svcparts and flag values ('Y' or '') to set the -'hidden' field in these records. +'hidden' field in these records, and I can be set similarly +for the 'provision_hold' field in these records. If I is set to the svcpart of the primary service, the appropriate FS::pkg_svc record will be updated. @@ -172,6 +209,9 @@ records will be inserted. If I is set to a hashref of options, appropriate FS::part_pkg_option records will be inserted. +If I is set to a hashref of options (with the keys as +option_CURRENCY), appropriate FS::part_pkg::currency records will be inserted. + =cut sub insert { @@ -192,6 +232,19 @@ sub insert { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + if ( length($self->classnum) && $self->classnum !~ /^(\d+)$/ ) { + my $pkg_class = qsearchs('pkg_class', { 'classname' => $self->classnum } ) + || new FS::pkg_class { classname => $self->classnum }; + unless ( $pkg_class->classnum ) { + my $error = $pkg_class->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + $self->classnum( $pkg_class->classnum ); + } + warn " inserting part_pkg record" if $DEBUG; my $error = $self->SUPER::insert( $options{options} ); if ( $error ) { @@ -209,23 +262,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 ) { @@ -246,11 +282,39 @@ sub insert { } } + warn " inserting part_pkg_currency records" if $DEBUG; + 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' => $optionname, + 'currency' => $currency, + 'optionvalue' => $part_pkg_currency{$key}, + }; + my $error = $part_pkg_currency->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + unless ( $skip_pkg_svc_hack ) { warn " inserting pkg_svc records" if $DEBUG; my $pkg_svc = $options{'pkg_svc'} || {}; my $hidden_svc = $options{'hidden_svc'} || {}; + my $provision_hold = $options{'provision_hold'} || {}; foreach my $part_svc ( qsearch('part_svc', {} ) ) { my $quantity = $pkg_svc->{$part_svc->svcpart} || 0; my $primary_svc = @@ -264,6 +328,7 @@ sub insert { 'quantity' => $quantity, 'primary_svc' => $primary_svc, 'hidden' => $hidden_svc->{$part_svc->svcpart}, + 'provision_hold' => $provision_hold->{$part_svc->svcpart}, } ); my $error = $pkg_svc->insert; if ( $error ) { @@ -272,6 +337,12 @@ sub insert { } } + my $error = $self->check_pkg_svc(%options); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } if ( $options{'cust_pkg'} ) { @@ -311,6 +382,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; @@ -333,19 +409,25 @@ sub delete { Replaces OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. -Currently available options are: I, I, I -and I +Currently available options are: I, I, I, +I, I and I If I is set to a hashref with svcparts as keys and quantities as values, the appropriate FS::pkg_svc records will be replaced. I -can be set to a hashref of svcparts and flag values ('Y' or '') to set the -'hidden' field in these records. +can be set to a hashref of svcparts and flag values ('Y' or '') to set the +'hidden' field in these records. I and I can be +set to a hashref of svcparts and flag values ('Y' or '') to set the +respective field in those records. -If I is set to the svcpart of the primary service, the appropriate -FS::pkg_svc record will be updated. +If I is set to the svcpart of the primary service, the +appropriate FS::pkg_svc record will be updated. + +If I is set to a hashref, the appropriate FS::part_pkg_option +records will be replaced. -If I is set to a hashref, the appropriate FS::part_pkg_option records -will be replaced. +If I is set to a hashref of options (with the keys as +option_CURRENCY), appropriate FS::part_pkg::currency records will be +replaced. =cut @@ -361,7 +443,7 @@ sub replace { ? shift : { @_ }; - $options->{options} = {} unless defined($options->{options}); + $options->{options} = { $old->options } unless defined($options->{options}); warn "FS::part_pkg::replace called on $new to replace $old with options". join(', ', map "$_ => ". $options->{$_}, keys %$options) @@ -442,54 +524,104 @@ sub replace { } } + #trivial nit: not the most efficient to delete and reinsert + warn " deleting old part_pkg_currency records" if $DEBUG; + foreach my $part_pkg_currency ( $old->part_pkg_currency ) { + my $error = $part_pkg_currency->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error deleting part_pkg_currency record: $error"; + } + } + + warn " inserting new part_pkg_currency records" if $DEBUG; + my %part_pkg_currency = %{ $options->{'part_pkg_currency'} || {} }; + foreach my $key ( keys %part_pkg_currency ) { + $key =~ /^(.+)_([A-Z]{3})$/ or next; + my $part_pkg_currency = new FS::part_pkg_currency { + 'pkgpart' => $new->pkgpart, + 'optionname' => $1, + 'currency' => $2, + 'optionvalue' => $part_pkg_currency{$key}, + }; + my $error = $part_pkg_currency->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error inserting part_pkg_currency record: $error"; + } + } + + warn " replacing pkg_svc records" if $DEBUG; - my $pkg_svc = $options->{'pkg_svc'} || {}; + my $pkg_svc = $options->{'pkg_svc'}; my $hidden_svc = $options->{'hidden_svc'} || {}; - foreach my $part_svc ( qsearch('part_svc', {} ) ) { - my $quantity = $pkg_svc->{$part_svc->svcpart} || 0; - my $hidden = $hidden_svc->{$part_svc->svcpart} || ''; - my $primary_svc = - ( defined($options->{'primary_svc'}) && $options->{'primary_svc'} - && $options->{'primary_svc'} == $part_svc->svcpart - ) - ? 'Y' - : ''; + my $bulk_skip = $options->{'bulk_skip'} || {}; + my $provision_hold = $options->{'provision_hold'} || {}; + if ( $pkg_svc ) { # if it wasn't passed, don't change existing pkg_svcs - my $old_pkg_svc = qsearchs('pkg_svc', { - 'pkgpart' => $old->pkgpart, - 'svcpart' => $part_svc->svcpart, + foreach my $part_svc ( qsearch('part_svc', {} ) ) { + my $quantity = $pkg_svc->{$part_svc->svcpart} || 0; + my $hidden = $hidden_svc->{$part_svc->svcpart} || ''; + my $bulk_skip = $bulk_skip->{$part_svc->svcpart} || ''; + my $provision_hold = $provision_hold->{$part_svc->svcpart} || ''; + my $primary_svc = + ( defined($options->{'primary_svc'}) && $options->{'primary_svc'} + && $options->{'primary_svc'} == $part_svc->svcpart + ) + ? 'Y' + : ''; + + my $old_pkg_svc = qsearchs('pkg_svc', { + 'pkgpart' => $old->pkgpart, + 'svcpart' => $part_svc->svcpart, + } + ); + my $old_quantity = 0; + my $old_primary_svc = ''; + my $old_hidden = ''; + my $old_bulk_skip = ''; + my $old_provision_hold = ''; + if ( $old_pkg_svc ) { + $old_quantity = $old_pkg_svc->quantity; + $old_primary_svc = $old_pkg_svc->primary_svc + if $old_pkg_svc->dbdef_table->column('primary_svc'); # is this needed? + $old_hidden = $old_pkg_svc->hidden; + $old_bulk_skip = $old_pkg_svc->old_bulk_skip; # should this just be bulk_skip? + $old_provision_hold = $old_pkg_svc->provision_hold; } - ); - my $old_quantity = 0; - my $old_primary_svc = ''; - my $old_hidden = ''; - if ( $old_pkg_svc ) { - $old_quantity = $old_pkg_svc->quantity; - $old_primary_svc = $old_pkg_svc->primary_svc - if $old_pkg_svc->dbdef_table->column('primary_svc'); # is this needed? - $old_hidden = $old_pkg_svc->hidden; - } - - next unless $old_quantity != $quantity || - $old_primary_svc ne $primary_svc || - $old_hidden ne $hidden; - - my $new_pkg_svc = new FS::pkg_svc( { - 'pkgsvcnum' => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ), - 'pkgpart' => $new->pkgpart, - 'svcpart' => $part_svc->svcpart, - 'quantity' => $quantity, - 'primary_svc' => $primary_svc, - 'hidden' => $hidden, - } ); - my $error = $old_pkg_svc - ? $new_pkg_svc->replace($old_pkg_svc) - : $new_pkg_svc->insert; + + next unless $old_quantity != $quantity + || $old_primary_svc ne $primary_svc + || $old_hidden ne $hidden + || $old_bulk_skip ne $bulk_skip + || $old_provision_hold ne $provision_hold; + + my $new_pkg_svc = new FS::pkg_svc( { + 'pkgsvcnum' => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ), + 'pkgpart' => $new->pkgpart, + 'svcpart' => $part_svc->svcpart, + 'quantity' => $quantity, + 'primary_svc' => $primary_svc, + 'hidden' => $hidden, + 'bulk_skip' => $bulk_skip, + 'provision_hold' => $provision_hold, + } ); + my $error = $old_pkg_svc + ? $new_pkg_svc->replace($old_pkg_svc) + : $new_pkg_svc->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } #foreach $part_svc + + my $error = $new->check_pkg_svc(%$options); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } - } + + } #if $options->{pkg_svc} my @part_pkg_vendor = $old->part_pkg_vendor; my @current_exportnum = (); @@ -551,11 +683,35 @@ 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; ''; } +sub validate_number { + my ($option, $valref) = @_; + + $$valref = 0 unless $$valref; + return "Invalid $option" + unless ($$valref) = ($$valref =~ /^\s*(\d+)\s*$/); + return ''; +} + +sub validate_number_blank { + my ($option, $valref) = @_; + + if ($$valref) { + return "Invalid $option" + unless ($$valref) = ($$valref =~ /^\s*(\d+)\s*$/); + } + return ''; +} + =item check Checks all fields to make sure this is a valid package definition. If @@ -593,17 +749,18 @@ 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' ] ) - || $self->ut_enum('recurtax', [ '', 'Y' ] ) + || $self->ut_flag('setuptax') + || $self->ut_flag('recurtax') || $self->ut_textn('taxclass') - || $self->ut_enum('disabled', [ '', 'Y' ] ) - || $self->ut_enum('custom', [ '', 'Y' ] ) - || $self->ut_enum('no_auto', [ '', 'Y' ]) - || $self->ut_enum('recur_show_zero', [ '', 'Y' ]) - || $self->ut_enum('setup_show_zero', [ '', 'Y' ]) + || $self->ut_flag('disabled') + || $self->ut_flag('custom') + || $self->ut_flag('no_auto') + || $self->ut_flag('recur_show_zero') + || $self->ut_flag('setup_show_zero') + || $self->ut_flag('start_on_hold') #|| $self->ut_moneyn('setup_cost') #|| $self->ut_moneyn('recur_cost') || $self->ut_floatn('setup_cost') @@ -611,6 +768,7 @@ sub check { || $self->ut_floatn('pay_weight') || $self->ut_floatn('credit_weight') || $self->ut_numbern('taxproductnum') + || $self->ut_numbern('units_taxproductnum') || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum') || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum') || $self->ut_foreign_keyn('taxproductnum', @@ -622,8 +780,16 @@ sub check { : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right) ) || $self->ut_numbern('fcc_ds0s') + || $self->ut_numbern('fcc_voip_class') + || $self->ut_numbern('delay_start') || $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart') || $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart') + || $self->ut_numbern('expire_months') + || $self->ut_numbern('adjourn_months') + || $self->ut_numbern('contract_end_months') + || $self->ut_numbern('change_to_pkgpart') + || $self->ut_foreign_keyn('change_to_pkgpart', 'part_pkg', 'pkgpart') + || $self->ut_alphan('agent_pkgpartid') || $self->SUPER::check ; return $error if $error; @@ -638,6 +804,109 @@ sub check { ''; } +=item check_options + +Pass an I<$options> hashref that contains the values to be +inserted or updated for any FS::part_pkg::MODULE.pm. + +For each key in I<$options>, validates the value by calling +the 'validate' subroutine defined for that option e.g. +FS::part_pkg::MODULE::plan_info()->{$KEY}->{validate}. The +option validation function is only called when the hashkey for +that option exists in I<$options>. + +Then the module validation function is called, from +FS::part_pkg::MODULE::plan_info()->{validate} + +Returns error message, or empty string if valid. + +Invoked by L and L via the equivalent +methods in L. + +=cut + +sub check_options { + my ($self,$options) = @_; + foreach my $option (keys %$options) { + if (exists $plans{ $self->plan }->{fields}->{$option}) { + if (exists($plans{$self->plan}->{fields}->{$option}->{'validate'})) { + # pass option name for use in error message + # pass a reference to the $options value, so it can be cleaned up + my $error = &{$plans{$self->plan}->{fields}->{$option}->{'validate'}}($option,\($options->{$option})); + return $error if $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 ''; +} + +=item check_pkg_svc + +Checks pkg_svc records as a whole (for part_svc_link dependencies). + +If there is an error, returns the error, otherwise returns false. + +=cut + +sub check_pkg_svc { + my( $self, %opt ) = @_; + + my $agentnum = $self->agentnum; + + my %pkg_svc = map { $_->svcpart => $_ } $self->pkg_svc; + + foreach my $svcpart ( keys %pkg_svc ) { + + foreach my $part_svc_link ( $self->part_svc_link( + 'src_svcpart' => $svcpart, + 'link_type' => 'part_pkg_restrict', + ) + ) { + + return $part_svc_link->dst_svc. ' must be included with '. + $part_svc_link->src_svc + unless $pkg_svc{ $part_svc_link->dst_svcpart }; + } + + } + + return '' if $opt{part_pkg_restrict_soft_override}; + + foreach my $svcpart ( keys %pkg_svc ) { + + foreach my $part_svc_link ( $self->part_svc_link( + 'src_svcpart' => $svcpart, + 'link_type' => 'part_pkg_restrict_soft', + ) + ) { + return $part_svc_link->dst_svc. ' is suggested with '. + $part_svc_link->src_svc + unless $pkg_svc{ $part_svc_link->dst_svcpart }; + } + + } + + ''; +} + +=item part_svc_link OPTION => VALUE ... + +Returns the service dependencies (see L) for the given +search options, taking into account this package definition's agent. + +Available options are any field in part_svc_link. Typically used options are +src_svcpart and link_type. + +=cut + +sub part_svc_link { + FS::part_svc_link->by_agentnum( shift->agentnum, @_ ); +} + =item supersede OLD [, OPTION => VALUE ... ] Inserts this package as a successor to the package OLD. All options are as @@ -708,6 +977,73 @@ 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 +locale, from the part_pkg_msgcat table. If the given locale is empty or no +localized string is found, returns the base pkg field. + +=cut + +sub pkg_locale { + my( $self, $locale ) = @_; + return $self->pkg unless $locale; + my $part_pkg_msgcat = $self->part_pkg_msgcat($locale) or return $self->pkg; + $part_pkg_msgcat->pkg; +} + +=item part_pkg_msgcat LOCALE + +Like pkg_locale, but returns the FS::part_pkg_msgcat object itself. + +=cut + +sub part_pkg_msgcat { + my( $self, $locale ) = @_; + qsearchs( 'part_pkg_msgcat', { + pkgpart => $self->pkgpart, + locale => $locale, + }); +} + =item pkg_comment [ OPTION => VALUE... ] Returns an (internal) string representing this package. Currently, @@ -726,7 +1062,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 @@ -735,7 +1082,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 @@ -743,17 +1099,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 @@ -819,13 +1164,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) for this package @@ -844,27 +1182,22 @@ definition. =cut -sub type_pkgs { - my $self = shift; - qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } ); -} - sub pkg_svc { my $self = shift; + return @{ $cache_pkg_svc{$self->pkgpart} } + if $cache_enabled && $cache_pkg_svc{$self->pkgpart}; + # #sort { $b->primary cmp $a->primary } # grep { $_->quantity } # qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } ); my $opt = ref($_[0]) ? $_[0] : { @_ }; - my %pkg_svc = map { $_->svcpart => $_ } - grep { $_->quantity } - qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } ); + my %pkg_svc = map { $_->svcpart => $_ } $self->_pkg_svc; unless ( $opt->{disable_linked} ) { foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) { - my @pkg_svc = grep { $_->quantity } - qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } ); + my @pkg_svc = $dst_pkg->_pkg_svc; foreach my $pkg_svc ( @pkg_svc ) { if ( $pkg_svc{$pkg_svc->svcpart} ) { my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity; @@ -876,10 +1209,25 @@ sub pkg_svc { } } - values(%pkg_svc); + my @pkg_svc = values(%pkg_svc); + + $cache_pkg_svc{$self->pkgpart} = \@pkg_svc if $cache_enabled; + + @pkg_svc; } +sub _pkg_svc { + my $self = shift; + grep { $_->quantity } + qsearch({ + 'select' => 'pkg_svc.*, part_svc.*', + 'table' => 'pkg_svc', + 'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )', + 'hashref' => { 'pkgpart' => $self->pkgpart }, + }); +} + =item svcpart [ SVCDB ] Returns the svcpart of the primary service definition (see L) @@ -983,9 +1331,32 @@ sub is_free { } } +# whether the plan allows discounts to be applied to this package sub can_discount { 0; } + +# whether the plan allows changing the start date +sub can_start_date { + my $self = shift; + $self->start_on_hold ? 0 : 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 { + my $self = shift; + + my $delay = $self->delay_start or return ''; -sub can_start_date { 1; } + # avoid timelocal silliness + my $dt = DateTime->today(time_zone => 'local'); + $dt->add(days => $delay); + $dt->epoch; +} + +sub can_currency_exchange { 0; } sub freqs_href { # moved to FS::Misc to make this accessible to other packages @@ -1044,6 +1415,9 @@ sub add_freq { if ( $freq =~ /^\d+$/ ) { $mon += $freq; until ( $mon < 12 ) { $mon -= 12; $year++; } + + $mday = 28 if $mday > 28 && FS::Conf->new->exists('anniversary-rollback'); + } elsif ( $freq =~ /^(\d+)w$/ ) { my $weeks = $1; $mday += $weeks * 7; @@ -1085,13 +1459,6 @@ sub plandata { Returns all vendor/external package ids as FS::part_pkg_vendor objects (see L). -=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 @@ -1108,13 +1475,6 @@ sub vendor_pkg_ids { Returns all options as FS::part_pkg_option objects (see L). -=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. @@ -1136,21 +1496,112 @@ will be suppressed. sub option { my( $self, $opt, $ornull ) = @_; + + #cache: was pulled up in the original part_pkg query + return $self->hashref->{"_opt_$opt"} + if exists $self->hashref->{"_opt_$opt"}; + + cluck "$self -> option: searching for $opt" if $DEBUG; my $part_pkg_option = qsearchs('part_pkg_option', { pkgpart => $self->pkgpart, optionname => $opt, } ); return $part_pkg_option->optionvalue if $part_pkg_option; + my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); } split("\n", $self->get('plandata') ); return $plandata{$opt} if exists $plandata{$opt}; + + # check whether the option is defined in plan info (if so, don't warn) + if (exists $plans{ $self->plan }->{fields}->{$opt}) { + return ''; + } cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ". "not found in options or plandata!\n" unless $ornull; + ''; } +=item part_pkg_currency [ CURRENCY ] + +Returns all currency options as FS::part_pkg_currency objects (see +L), or, if a currency is specified, only return the +objects for that currency. + +=cut + +sub part_pkg_currency { + my $self = shift; + my %hash = ( 'pkgpart' => $self->pkgpart ); + $hash{'currency'} = shift if @_; + qsearch('part_pkg_currency', \%hash ); +} + +=item part_pkg_currency_options CURRENCY + +Returns a list of option names and values from FS::part_pkg_currency for the +specified currency. + +=cut + +sub part_pkg_currency_options { + my $self = shift; + map { $_->optionname => $_->optionvalue } $self->part_pkg_currency(shift); +} + +=item part_pkg_currency_option CURRENCY OPTIONNAME + +Returns the option value for the given name and currency. + +=cut + +sub part_pkg_currency_option { + my( $self, $currency, $optionname ) = @_; + my $part_pkg_currency = + qsearchs('part_pkg_currency', { 'pkgpart' => $self->pkgpart, + 'currency' => $currency, + 'optionname' => $optionname, + } + )#; + #fatal if not found? that works for our use cases from + #part_pkg/currency_fixed, but isn't how we would typically/expect the method + #to behave. have to catch it there if we change it here... + or die "Unknown price for ". $self->pkg_comment. " in $currency\n"; + + $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). @@ -1171,16 +1622,38 @@ sub svc_part_pkg_link { shift->_part_pkg_link('svc', @_); } +=item supp_part_pkg_link + +Returns the associated part_pkg_link records of type 'supp' (supplemental +packages). + +=cut + +sub supp_part_pkg_link { + shift->_part_pkg_link('supp', @_); +} + sub _part_pkg_link { my( $self, $type ) = @_; - qsearch({ table => 'part_pkg_link', - hashref => { 'src_pkgpart' => $self->pkgpart, - 'link_type' => $type, - #protection against infinite recursive links - 'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart }, - }, - order_by => "ORDER BY hidden", - }); + + return @{ $cache_link{$type}->{$self->pkgpart} } + if $cache_enabled && $cache_link{$type}->{$self->pkgpart}; + + cluck $type.'_part_pkg_link called' if $DEBUG; + + my @ppl = + qsearch({ table => 'part_pkg_link', + hashref => { src_pkgpart => $self->pkgpart, + link_type => $type, + #protection against infinite recursive links + dst_pkgpart => { op=>'!=', value=> $self->pkgpart }, + }, + order_by => "ORDER BY hidden", + }); + + $cache_link{$type}->{$self->pkgpart} = \@ppl if $cache_enabled; + + return @ppl; } sub self_and_bill_linked { @@ -1298,74 +1771,63 @@ sub taxproduct_description { $part_pkg_taxproduct ? $part_pkg_taxproduct->description : ''; } -=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ] +=item units_taxproduct -Returns the package to taxrate m2m records for this package in the location -specified by GEOCODE (see L) and usage class CLASS. -CLASS may be one of 'setup', 'recur', or one of the usage classes numbers -(see L). +Returns the L record used to report the taxable +service units (usually phone lines) on this package. =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 units_taxproduct { my $self = shift; - my ($data_vendor, $geocode, $class) = @_; + $self->units_taxproductnum + ? FS::part_pkg_taxproduct->by_key($self->units_taxproductnum) + : ''; +} - 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 )'; +=item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ] + +Returns the tax table entries (L objects) that apply to this +package in the location specified by GEOCODE, for usage class CLASS (one of +'setup', 'recur', null, or a C number). + +=cut + +sub tax_rates { + my $self = shift; + my ($vendor, $geocode, $class) = @_; + # if this part_pkg is overridden into a specific taxclass, get that class + my @taxclassnums = map { $_->taxclassnum } + $self->part_pkg_taxoverride($class); + # otherwise, get its tax product category + 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, + 'disabled' => '' }, + '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 @@ -1373,11 +1835,22 @@ sub part_pkg_taxrate { Returns the package to discount m2m records (see L) for this package. +=item part_pkg_usage + +Returns the voice usage pools (see L) defined for +this package. + +=item change_to_pkg + +Returns the automatic transfer target for this package, or an empty string +if there isn't one. + =cut -sub part_pkg_discount { +sub change_to_pkg { my $self = shift; - qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart }); + my $pkgpart = $self->change_to_pkgpart or return ''; + FS::part_pkg->by_key($pkgpart); } =item _rebless @@ -1405,16 +1878,134 @@ sub _rebless { $self; } +=item calc_setup CUST_PKG START_DATE DETAILS_ARRAYREF OPTIONS_HASHREF + +=item calc_recur CUST_PKG START_DATE_SCALARREF DETAILS_ARRAYREF OPTIONS_HASHREF + +Calculates and returns the setup or recurring fees, respectively, for this +package. Implementation is in the FS::part_pkg:* module specific to this price +plan. + +Adds invoicing details to the passed-in DETAILS_ARRAYREF + +Options are passed as a hashref. Available options: + +=over 4 + +=item freq_override + +Frequency override (for calc_recur) + +=item discounts + +This option is filled in by the method rather than controlling its operation. +It is an arrayref. Applicable discounts will be added to the arrayref, as +L records. + +=item real_pkgpart + +For package add-ons, is the base L package definition, otherwise +no different than pkgpart. + +=item precommit_hooks + +This option is filled in by the method rather than controlling its operation. +It is an arrayref. Anonymous coderefs will be added to the arrayref. They +need to be called before completing the billing operation. For calc_recur +only. + +=item increment_next_bill + +Increment the next bill date (boolean, for calc_recur). Typically true except +for particular situations. + +=item setup_fee + +This option is filled in by the method rather than controlling its operation. +It indicates a deferred setup fee that is billed at calc_recur time (see price +plan option prorate_defer_bill). + +=back + +Note: Don't calculate prices when not actually billing the package. For that, +see the L and L methods. + +=cut + #fatal fallbacks sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; } sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; } -#fallback that return 0 for old legacy packages with no plan +=item calc_remain CUST_PKG [ OPTION => VALUE ... ] + +Calculates and returns the remaining value to be credited upon package +suspension, change, or cancellation, if enabled. + +Options are passed as a list of keys and values. Available options: + +=over 4 + +=item time + +Override for the current time + +=item cust_credit_source_bill_pkg + +This option is filled in by the method rather than controlling its operation. +It is an arrayref. +L records will +be added to the arrayref indicating the specific line items and amounts which +are the source of this remaining credit. + +=back + +Note: Don't calculate prices when not actually suspending or cancelling the +package. + +=cut + +#fallback that returns 0 for old legacy packages with no plan sub calc_remain { 0; } -sub calc_units { 0; } + +=item calc_units CUST_PKG + +This returns the number of provisioned svc_phone records, or, of the package +count_available_phones option is set, the number available to be provisioned +in the package. + +=cut + +sub calc_units { + my($self, $cust_pkg ) = @_; + my $count = 0; + if ( $self->option('count_available_phones', 1)) { + foreach my $pkg_svc ($cust_pkg->part_pkg->pkg_svc) { + if ($pkg_svc->part_svc->svcdb eq 'svc_phone') { # svc_pbx? + $count += $pkg_svc->quantity || 0; + } + } + $count *= $cust_pkg->quantity; + } else { + $count = + scalar(grep { $_->part_svc->svcdb eq 'svc_phone' } $cust_pkg->cust_svc); + } + $count; +} #fallback for everything not based on flat.pm sub recur_temporality { 'upcoming'; } + +=item calc_cancel START_DATE DETAILS_ARRAYREF OPTIONS_HASHREF + +Runs any necessary billing on cancellation: another recurring cycle for +recur_temporailty 'preceding' pacakges with the bill_recur_on_cancel option +set (calc_recur), or, any outstanding usage for pacakges with the +bill_usage_on_cancel option set (calc_usage). + +=cut + +#fallback for everything not based on flat.pm, doesn't do this yet (which is +#okay, nothing of ours not based on flat.pm does usage-on-cancel billing sub calc_cancel { 0; } #fallback for everything except bulk.pm @@ -1432,7 +2023,75 @@ recur_cost divided by freq (only supported for monthly and longer frequencies) sub recur_cost_permonth { my($self, $cust_pkg) = @_; return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0; - sprintf('%.2f', $self->recur_cost / $self->freq ); + sprintf('%.2f', ($self->recur_cost || 0) / $self->freq ); +} + +=item cust_bill_pkg_recur CUST_PKG + +Actual recurring charge for the specified customer package from customer's most +recent invoice + +=cut + +sub cust_bill_pkg_recur { + my($self, $cust_pkg) = @_; + my $cust_bill_pkg = qsearchs({ + 'table' => 'cust_bill_pkg', + 'addl_from' => 'LEFT JOIN cust_bill USING ( invnum )', + 'hashref' => { 'pkgnum' => $cust_pkg->pkgnum, + 'recur' => { op=>'>', value=>'0' }, + }, + 'order_by' => 'ORDER BY cust_bill._date DESC, + cust_bill_pkg.sdate DESC + LIMIT 1 + ', + }) or return 0; #die "use cust_bill_pkg_recur credits with once_perinv condition"; + $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 || 0); +} + +=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 intro_end PACKAGE + +Takes an L object. If this plan has an introductory rate, +returns the expected date the intro period will end. If there is no intro +rate, returns zero. + +=cut + +sub intro_end { + 0; } =item format OPTION DATA @@ -1482,7 +2141,7 @@ sub parse { # Used by FS::Upgrade to migrate to a new database. sub _upgrade_data { # class method - my($class, %opts) = @_; + my($class, %opts) = @_; warn "[FS::part_pkg] upgrading $class\n" if $DEBUG; @@ -1503,15 +2162,62 @@ sub _upgrade_data { # class method } + # Convert RADIUS accounting usage metrics from megabytes to gigabytes + # (FS RT#28105) + my $upgrade = 'part_pkg_gigabyte_usage'; + if (!FS::upgrade_journal->is_done($upgrade)) { + foreach my $part_pkg (qsearch('part_pkg', + { plan => 'sqlradacct_hour' }) + ){ + + my $pkgpart = $part_pkg->pkgpart; + + foreach my $opt (qsearch('part_pkg_option', + { 'optionname' => { op => 'LIKE', + value => 'recur_included_%', + }, + pkgpart => $pkgpart, + })){ + + next if $opt->optionname eq 'recur_included_hours'; # unfortunately named field + next if $opt->optionvalue == 0; + + $opt->optionvalue($opt->optionvalue / 1024); + + my $error = $opt->replace; + die $error if $error; + } + + foreach my $opt (qsearch('part_pkg_option', + { 'optionname' => { op => 'LIKE', + value => 'recur_%_charge', + }, + pkgpart => $pkgpart, + })){ + $opt->optionvalue($opt->optionvalue * 1024); + + my $error = $opt->replace; + die $error if $error; + } + + } + FS::upgrade_journal->set_done($upgrade); + } + + # 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; @@ -1528,15 +2234,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', @@ -1592,6 +2308,146 @@ sub _upgrade_data { # class method } } + # set any package with FCC voice lines to the "VoIP with broadband" category + # for backward compatibility + # + # recover from a bad upgrade bug + my $upgrade = 'part_pkg_fcc_voip_class_FIX'; + if (!FS::upgrade_journal->is_done($upgrade)) { + my $bad_upgrade = qsearchs('upgrade_journal', + { upgrade => 'part_pkg_fcc_voip_class' } + ); + if ( $bad_upgrade ) { + my $where = 'WHERE history_date <= '.$bad_upgrade->_date. + ' AND history_date > '.($bad_upgrade->_date - 3600); + my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) } + qsearch({ + 'select' => '*', + 'table' => 'h_part_pkg_option', + 'hashref' => {}, + 'extra_sql' => "$where AND history_action = 'delete'", + 'order_by' => 'ORDER BY history_date ASC', + }); + my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) } + qsearch({ + 'select' => '*', + 'table' => 'h_pkg_svc', + 'hashref' => {}, + 'extra_sql' => "$where AND history_action = 'replace_old'", + 'order_by' => 'ORDER BY history_date ASC', + }); + my %opt; + foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) { + my $pkgpart ||= $deleted->pkgpart; + $opt{$pkgpart} ||= { + options => {}, + pkg_svc => {}, + primary_svc => '', + hidden_svc => {}, + }; + if ( $deleted->isa('FS::part_pkg_option') ) { + $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue; + } else { # pkg_svc + my $svcpart = $deleted->svcpart; + $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity; + $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden; + $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc; + } + } + foreach my $pkgpart (keys %opt) { + my $part_pkg = FS::part_pkg->by_key($pkgpart); + my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} ); + if ( $error ) { + die "error recovering damaged pkgpart $pkgpart:\n$error\n"; + } + } + } # $bad_upgrade exists + else { # do the original upgrade, but correctly this time + my @part_pkg = qsearch('part_pkg', { + fcc_ds0s => { op => '>', value => 0 }, + fcc_voip_class => '' + }); + foreach my $part_pkg (@part_pkg) { + $part_pkg->set(fcc_voip_class => 2); + my @pkg_svc = $part_pkg->pkg_svc; + my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc; + my %hidden = map {$_->svcpart, $_->hidden } @pkg_svc; + my $error = $part_pkg->replace( + $part_pkg->replace_old, + options => { $part_pkg->options }, + pkg_svc => \%quantity, + hidden_svc => \%hidden, + primary_svc => ($part_pkg->svcpart || ''), + ); + die $error if $error; + } + } + FS::upgrade_journal->set_done($upgrade); + } + + # migrate adjourn_months, expire_months, and contract_end_months to + # real fields + foreach my $field (qw(adjourn_months expire_months contract_end_months)) { + foreach my $option (qsearch('part_pkg_option', { optionname => $field })) { + my $part_pkg = $option->part_pkg; + my $error = $option->delete; + if ( $option->optionvalue and $part_pkg->get($field) eq '' ) { + $part_pkg->set($field, $option->optionvalue); + $error ||= $part_pkg->replace; + } + die $error if $error; + } + } + + # remove custom flag from one-time charge packages that were accidentally + # flagged as custom + $search = FS::Cursor->new({ + 'table' => 'part_pkg', + 'hashref' => { 'freq' => '0', + 'custom' => 'Y', + 'family_pkgpart' => { op => '!=', value => '' }, + }, + 'addl_from' => ' JOIN + (select pkgpart from cust_pkg group by pkgpart having count(*) = 1) + AS singular_pkg USING (pkgpart)', + }); + my @fields = grep { $_ ne 'pkgpart' + and $_ ne 'custom' + and $_ ne 'disabled' } FS::part_pkg->fields; + PKGPART: while (my $part_pkg = $search->fetch) { + # can't merge the package back into its parent (too late for that) + # but we can remove the custom flag if it's not actually customized, + # i.e. nothing has been changed. + + my $family_pkgpart = $part_pkg->family_pkgpart; + next PKGPART if $family_pkgpart == $part_pkg->pkgpart; + my $parent_pkg = FS::part_pkg->by_key($family_pkgpart); + foreach my $field (@fields) { + if ($part_pkg->get($field) ne $parent_pkg->get($field)) { + next PKGPART; + } + } + # options have to be identical too + # but links, FCC options, discount plans, and usage packages can't be + # changed through the "modify charge" UI, so skip them + my %newopt = $part_pkg->options; + my %oldopt = $parent_pkg->options; + OPTION: foreach my $option (keys %newopt) { + if (delete $newopt{$option} ne delete $oldopt{$option}) { + next PKGPART; + } + } + if (keys(%newopt) or keys(%oldopt)) { + next PKGPART; + } + # okay, now replace it + warn "Removing custom flag from part_pkg#".$part_pkg->pkgpart."\n"; + $part_pkg->set('custom', ''); + my $error = $part_pkg->replace; + die $error if $error; + } # $search->fetch + + return; } =item curuser_pkgs_sql @@ -1646,6 +2502,26 @@ sub _pkgs_sql { } +=item join_options_sql + +Returns an SQL fragment for JOINing the part_pkg_option records for this +package's setup_fee and recur_fee (as setup_option and recur_option, +respectively). Useful for optimization. + +=cut + +sub join_options_sql { + #my $class = shift; + " + LEFT JOIN part_pkg_option AS setup_option + ON ( part_pkg.pkgpart = setup_option.pkgpart + AND setup_option.optionname = 'setup_fee' ) + LEFT JOIN part_pkg_option AS recur_option + ON ( part_pkg.pkgpart = recur_option.pkgpart + AND recur_option.optionname = 'recur_fee' ) + "; +} + =back =head1 SUBROUTINES @@ -1659,8 +2535,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"; @@ -1759,4 +2635,3 @@ schema.html from the base documentation. =cut 1; -