X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fpart_pkg.pm;h=bf607849bc530aa6508e1bafc7b18192c1cf9bce;hp=540919ee5932996621135b7c10fb7a6b8e3e10d2;hb=02d73ef84103d6bdaf49e6a179a0ad46f9719d25;hpb=ddbe06e47316e996f909dc534a9aeabe7b47d0f3 diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 540919ee5..bf607849b 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -4,7 +4,9 @@ use base qw( FS::part_pkg::API ); use strict; -use vars qw( %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 DateTime; @@ -30,11 +32,17 @@ 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; $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 @@ -126,6 +134,18 @@ part_pkg, will be equal to pkgpart. 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 @@ -170,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. @@ -211,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 ) { @@ -280,6 +314,7 @@ sub insert { 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 = @@ -293,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 ) { @@ -301,6 +337,12 @@ sub insert { } } + my $error = $self->check_pkg_svc(%options); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } if ( $options{'cust_pkg'} ) { @@ -367,24 +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. I can be set to a hashref of -svcparts and flag values ('Y' or '') to set the 'bulk_skip' field in those -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. +option_CURRENCY), appropriate FS::part_pkg::currency records will be +replaced. =cut @@ -513,11 +556,14 @@ sub replace { my $pkg_svc = $options->{'pkg_svc'}; my $hidden_svc = $options->{'hidden_svc'} || {}; 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 + 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 @@ -534,18 +580,21 @@ sub replace { 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; + $old_bulk_skip = $old_pkg_svc->old_bulk_skip; # should this just be bulk_skip? + $old_provision_hold = $old_pkg_svc->provision_hold; } next unless $old_quantity != $quantity || $old_primary_svc ne $primary_svc || $old_hidden ne $hidden - || $old_bulk_skip ne $bulk_skip; + || $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 : '' ), @@ -555,6 +604,7 @@ sub replace { '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) @@ -564,6 +614,13 @@ sub replace { 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; @@ -636,6 +693,25 @@ sub replace { ''; } +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 @@ -692,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', @@ -707,6 +784,11 @@ 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_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 ; @@ -722,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 @@ -1000,19 +1185,19 @@ definition. 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; @@ -1024,8 +1209,23 @@ 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 ] @@ -1298,12 +1498,10 @@ sub option { my( $self, $opt, $ornull ) = @_; #cache: was pulled up in the original part_pkg query - if ( $opt =~ /^(setup|recur)_fee$/ && defined($self->hashref->{"_$opt"}) ) { - return $self->hashref->{"_$opt"}; - } + return $self->hashref->{"_opt_$opt"} + if exists $self->hashref->{"_opt_$opt"}; - cluck "$self -> option: searching for $opt" - if $DEBUG; + cluck "$self -> option: searching for $opt" if $DEBUG; my $part_pkg_option = qsearchs('part_pkg_option', { pkgpart => $self->pkgpart, @@ -1314,6 +1512,11 @@ sub 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; @@ -1432,14 +1635,25 @@ sub supp_part_pkg_link { 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 { @@ -1557,6 +1771,19 @@ sub taxproduct_description { $part_pkg_taxproduct ? $part_pkg_taxproduct->description : ''; } +=item units_taxproduct + +Returns the L record used to report the taxable +service units (usually phone lines) on this package. + +=cut + +sub units_taxproduct { + my $self = shift; + $self->units_taxproductnum + ? FS::part_pkg_taxproduct->by_key($self->units_taxproductnum) + : ''; +} =item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ] @@ -1613,6 +1840,19 @@ for this package. 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 change_to_pkg { + my $self = shift; + my $pkgpart = $self->change_to_pkgpart or return ''; + FS::part_pkg->by_key($pkgpart); +} + =item _rebless Reblesses the object into the FS::part_pkg::PLAN class (if available), where @@ -1640,7 +1880,7 @@ sub _rebless { =item calc_setup CUST_PKG START_DATE DETAILS_ARRAYREF OPTIONS_HASHREF -=item calc_recur 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 @@ -1660,11 +1900,11 @@ Frequency override (for calc_recur) 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. +L records. =item real_pkgpart -For package add-ons, is the base L, otherwise +For package add-ons, is the base L package definition, otherwise no different than pkgpart. =item precommit_hooks @@ -1688,7 +1928,7 @@ 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. +see the L and L methods. =cut @@ -1730,13 +1970,27 @@ sub calc_remain { 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 provisoined +count_available_phones option is set, the number available to be provisioned in the package. =cut -#fallback that returns 0 for old legacy packages with no plan -sub calc_units { 0; } +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'; } @@ -1828,6 +2082,18 @@ sub recur_margin_permonth { $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 Returns data formatted according to the function 'format' described @@ -2119,6 +2385,69 @@ sub queueable_upgrade { 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 @@ -2173,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 @@ -2286,4 +2635,3 @@ schema.html from the base documentation. =cut 1; -