X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fpart_pkg.pm;h=9e3b67ef1b603b286379706f4f1510b39c6d771c;hb=b5d22578f60ba105934c92c031799671bf6c0f29;hp=c08188bcd4807a85c018649b031888c2a2eeea06;hpb=5a52da30588e8811338845ce2edaf0631acad479;p=freeside.git diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index c08188bcd..9e3b67ef1 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -1,10 +1,12 @@ package FS::part_pkg; +use base qw( 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 ); 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 ); @@ -16,12 +18,16 @@ use FS::type_pkgs; use FS::part_pkg_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_usage; +use FS::part_pkg_vendor; +use FS::part_pkg_currency; -@ISA = qw( FS::m2m_Common FS::option_Common ); $DEBUG = 0; $setup_hack = 0; $skip_pkg_svc_hack = 0; @@ -101,6 +107,18 @@ 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. + +=item family_pkgpart - Foreign key for the part_pkg that was the earliest +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 + =back =head1 METHODS @@ -143,7 +161,9 @@ Currently available options are: I, I, I, 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. +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. If I is set to the svcpart of the primary service, the appropriate FS::pkg_svc record will be updated. @@ -161,6 +181,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 { @@ -188,6 +211,16 @@ sub insert { return $error; } + # set family_pkgpart + if ( $self->get('family_pkgpart') eq '' ) { + $self->set('family_pkgpart' => $self->pkgpart); + $error = $self->SUPER::replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my $conf = new FS::Conf; if ( $conf->exists('agent_defaultpkg') ) { warn " agent_defaultpkg set; allowing all agents to purchase package" @@ -225,10 +258,28 @@ 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 $part_pkg_currency = new FS::part_pkg_currency { + 'pkgpart' => $self->pkgpart, + 'optionname' => $1, + 'currency' => $2, + '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'} || {}; foreach my $part_svc ( qsearch('part_svc', {} ) ) { my $quantity = $pkg_svc->{$part_svc->svcpart} || 0; my $primary_svc = @@ -241,6 +292,7 @@ sub insert { 'svcpart' => $part_svc->svcpart, 'quantity' => $quantity, 'primary_svc' => $primary_svc, + 'hidden' => $hidden_svc->{$part_svc->svcpart}, } ); my $error = $pkg_svc->insert; if ( $error ) { @@ -270,7 +322,25 @@ sub insert { } } - warn " commiting transaction" if $DEBUG; + if ( $options{'part_pkg_vendor'} ) { + while ( my ($exportnum, $vendor_pkg_id) = + each %{ $options{part_pkg_vendor} } + ) + { + my $ppv = new FS::part_pkg_vendor( { + 'pkgpart' => $self->pkgpart, + 'exportnum' => $exportnum, + 'vendor_pkg_id' => $vendor_pkg_id, + } ); + my $error = $ppv->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error inserting part_pkg_vendor record: $error"; + } + } + } + + warn " committing transaction" if $DEBUG and $oldAutoCommit; $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; @@ -292,10 +362,15 @@ 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 and I +Currently available options are: 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. +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. If I is set to the svcpart of the primary service, the appropriate FS::pkg_svc record will be updated. @@ -303,6 +378,9 @@ 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 of options (with the keys as +option_CURRENCY), appropriate FS::part_pkg::currency records will be replaced. + =cut sub replace { @@ -317,7 +395,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) @@ -333,6 +411,28 @@ sub replace { my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; my $dbh = dbh; + + my $conf = new FS::Conf; + if ( $conf->exists('part_pkg-lineage') ) { + if ( grep { $options->{options}->{$_} ne $old->option($_, 1) } + qw(setup_fee recur_fee) #others? config? + ) { + + warn " superseding package" if $DEBUG; + + my $error = $new->supersede($old, %$options); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + else { + warn " committing transaction" if $DEBUG and $oldAutoCommit; + $dbh->commit if $oldAutoCommit; + return $error; + } + } + #else nothing + } #plandata shit stays in replace for upgrades until after 2.0 (or edit #_upgrade_data) @@ -376,46 +476,152 @@ sub replace { } } - warn " replacing pkg_svc records" if $DEBUG; - my $pkg_svc = $options->{'pkg_svc'} || {}; - foreach my $part_svc ( qsearch('part_svc', {} ) ) { - my $quantity = $pkg_svc->{$part_svc->svcpart} || 0; - my $primary_svc = - ( defined($options->{'primary_svc'}) && $options->{'primary_svc'} - && $options->{'primary_svc'} == $part_svc->svcpart - ) - ? 'Y' - : ''; + #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"; + } + } - my $old_pkg_svc = qsearchs('pkg_svc', { - 'pkgpart' => $old->pkgpart, - 'svcpart' => $part_svc->svcpart, - } ); - my $old_quantity = $old_pkg_svc ? $old_pkg_svc->quantity : 0; - my $old_primary_svc = - ( $old_pkg_svc && $old_pkg_svc->dbdef_table->column('primary_svc') ) - ? $old_pkg_svc->primary_svc - : ''; - next unless $old_quantity != $quantity || $old_primary_svc ne $primary_svc; + + warn " replacing pkg_svc records" if $DEBUG; + my $pkg_svc = $options->{'pkg_svc'}; + my $hidden_svc = $options->{'hidden_svc'} || {}; + my $bulk_skip = $options->{'bulk_skip'} || {}; + 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 $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 = ''; + 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; + } + + next unless $old_quantity != $quantity + || $old_primary_svc ne $primary_svc + || $old_hidden ne $hidden + || $old_bulk_skip ne $bulk_skip; + + 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, + } ); + 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 + } #if $options->{pkg_svc} - 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, - } ); - my $error = $old_pkg_svc - ? $new_pkg_svc->replace($old_pkg_svc) - : $new_pkg_svc->insert; + my @part_pkg_vendor = $old->part_pkg_vendor; + my @current_exportnum = (); + if ( $options->{'part_pkg_vendor'} ) { + my($exportnum,$vendor_pkg_id); + while ( ($exportnum,$vendor_pkg_id) + = each %{$options->{'part_pkg_vendor'}} ) { + my $noinsert = 0; + foreach my $part_pkg_vendor ( @part_pkg_vendor ) { + if($exportnum == $part_pkg_vendor->exportnum + && $vendor_pkg_id ne $part_pkg_vendor->vendor_pkg_id) { + $part_pkg_vendor->vendor_pkg_id($vendor_pkg_id); + my $error = $part_pkg_vendor->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error replacing part_pkg_vendor record: $error"; + } + $noinsert = 1; + last; + } + elsif($exportnum == $part_pkg_vendor->exportnum + && $vendor_pkg_id eq $part_pkg_vendor->vendor_pkg_id) { + $noinsert = 1; + last; + } + } + unless ( $noinsert ) { + my $ppv = new FS::part_pkg_vendor( { + 'pkgpart' => $new->pkgpart, + 'exportnum' => $exportnum, + 'vendor_pkg_id' => $vendor_pkg_id, + } ); + my $error = $ppv->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error inserting part_pkg_vendor record: $error"; + } + } + push @current_exportnum, $exportnum; + } + } + foreach my $part_pkg_vendor ( @part_pkg_vendor ) { + unless ( grep($_ eq $part_pkg_vendor->exportnum, @current_exportnum) ) { + my $error = $part_pkg_vendor->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error deleting part_pkg_vendor record: $error"; + } + } + } + + # propagate changes to certain core fields + if ( $conf->exists('part_pkg-lineage') ) { + warn " propagating changes to family" if $DEBUG; + my $error = $new->propagate($old); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } } - warn " commiting transaction" if $DEBUG; + warn " committing transaction" if $DEBUG and $oldAutoCommit; $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; } @@ -466,6 +672,8 @@ sub check { || $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_moneyn('setup_cost') #|| $self->ut_moneyn('recur_cost') || $self->ut_floatn('setup_cost') @@ -484,6 +692,10 @@ 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->SUPER::check ; return $error if $error; @@ -498,6 +710,105 @@ sub check { ''; } +=item supersede OLD [, OPTION => VALUE ... ] + +Inserts this package as a successor to the package OLD. All options are as +for C. After inserting, disables OLD and sets the new package as its +successor. + +=cut + +sub supersede { + my ($new, $old, %options) = @_; + my $error; + + $new->set('pkgpart' => ''); + $new->set('family_pkgpart' => $old->family_pkgpart); + warn " inserting successor package\n" if $DEBUG; + $error = $new->insert(%options); + return $error if $error; + + warn " disabling superseded package\n" if $DEBUG; + $old->set('successor' => $new->pkgpart); + $old->set('disabled' => 'Y'); + $error = $old->SUPER::replace; # don't change its options/pkg_svc records + return $error if $error; + + warn " propagating changes to family" if $DEBUG; + $new->propagate($old); +} + +=item propagate OLD + +If any of certain fields have changed from OLD to this package, then, +for all packages in the same lineage as this one, sets those fields +to their values in this package. + +=cut + +my @propagate_fields = ( + qw( pkg classnum setup_cost recur_cost taxclass + setuptax recurtax pay_weight credit_weight + ) +); + +sub propagate { + my $new = shift; + my $old = shift; + my %fields = ( + map { $_ => $new->get($_) } + grep { $new->get($_) ne $old->get($_) } + @propagate_fields + ); + + my @part_pkg = qsearch('part_pkg', { + 'family_pkgpart' => $new->family_pkgpart + }); + my @error; + foreach my $part_pkg ( @part_pkg ) { + my $pkgpart = $part_pkg->pkgpart; + next if $pkgpart == $new->pkgpart; # don't modify $new + warn " propagating to pkgpart $pkgpart\n" if $DEBUG; + foreach ( keys %fields ) { + $part_pkg->set($_, $fields{$_}); + } + # SUPER::replace to avoid changing non-core fields + my $error = $part_pkg->SUPER::replace; + push @error, "pkgpart $pkgpart: $error" + if $error; + } + join("\n", @error); +} + +=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, @@ -519,9 +830,13 @@ sub pkg_comment { $pre. $self->pkg. ' - '. $self->custom_comment; } +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->custom ? '(CUSTOM) ' : '' ). $self->comment . ' ' . $self->price_info; } =item pkg_class @@ -758,10 +1073,7 @@ Returns true if this package is free. sub is_free { my $self = shift; - unless ( $self->plan ) { - $self->setup =~ /^\s*0+(\.0*)?\s*$/ - && $self->recur =~ /^\s*0+(\.0*)?\s*$/; - } elsif ( $self->can('is_free_options') ) { + if ( $self->can('is_free_options') ) { not grep { $_ !~ /^\s*0*(\.0*)?\s*$/ } map { $self->option($_) } $self->is_free_options; @@ -772,7 +1084,25 @@ 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 { 1; } + +# the delay start date if present +sub delay_start_date { + my $self = shift; + + my $delay = $self->delay_start or return ''; + + # 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 @@ -822,7 +1152,7 @@ argument. sub add_freq { my( $self, $date, $freq ) = @_; - $freq = $self->freq if !defined($freq); + $freq = $self->freq unless $freq; #change this bit to use Date::Manip? CAREFUL with timezones (see # mailing list archive) @@ -831,6 +1161,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; @@ -867,6 +1200,29 @@ sub plandata { } } +=item part_pkg_vendor + +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 + +=cut + +sub vendor_pkg_ids { + my $self = shift; + map { $_->exportnum => $_->vendor_pkg_id } $self->part_pkg_vendor; +} + =item part_pkg_option Returns all options as FS::part_pkg_option objects (see @@ -915,6 +1271,55 @@ sub option { ''; } +=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 bill_part_pkg_link Returns the associated part_pkg_link records (see L). @@ -935,11 +1340,24 @@ 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", }); @@ -949,6 +1367,10 @@ sub self_and_bill_linked { shift->_self_and_linked('bill', @_); } +sub self_and_svc_linked { + shift->_self_and_linked('svc', @_); +} + sub _self_and_linked { my( $self, $type, $hidden ) = @_; $hidden ||= ''; @@ -1126,6 +1548,30 @@ sub part_pkg_taxrate { } ); } +=item part_pkg_discount + +Returns the package to discount m2m records (see L) +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) 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 @@ -1144,52 +1590,31 @@ sub _rebless { } return $self if ref($self) =~ /::$plan$/; #already blessed into plan subclass my $class = ref($self). "::$plan"; - warn "reblessing $self into $class" if $DEBUG; + warn "reblessing $self into $class" if $DEBUG > 1; eval "use $class;"; die $@ if $@; bless($self, $class) unless $@; $self; } -#fallbacks that eval the setup and recur fields, for backwards compat - -sub calc_setup { - my $self = shift; - warn 'no price plan class for '. $self->plan. ", eval-ing setup\n"; - $self->_calc_eval('setup', @_); -} - -sub calc_recur { - my $self = shift; - warn 'no price plan class for '. $self->plan. ", eval-ing recur\n"; - $self->_calc_eval('recur', @_); -} - -use vars qw( $sdate @details ); -sub _calc_eval { - #my( $self, $field, $cust_pkg ) = @_; - my( $self, $field, $cust_pkg, $sdateref, $detailsref ) = @_; - *sdate = $sdateref; - *details = $detailsref; - $self->$field() =~ /^(.*)$/ - or die "Illegal $field (pkgpart ". $self->pkgpart. '): '. - $self->$field(). "\n"; - my $prog = $1; - return 0 if $prog =~ /^\s*$/; - my $value = eval $prog; - die $@ if $@; - $value; -} +#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 - sub calc_remain { 0; } -sub calc_cancel { 0; } sub calc_units { 0; } +#fallback for everything not based on flat.pm +sub recur_temporality { 'upcoming'; } +sub calc_cancel { 0; } + #fallback for everything except bulk.pm sub hide_svc_detail { 0; } +#fallback for packages that can't/won't summarize usage +sub sum_usage { 0; } + =item recur_cost_permonth CUST_PKG recur_cost divided by freq (only supported for monthly and longer frequencies) @@ -1202,6 +1627,29 @@ sub recur_cost_permonth { sprintf('%.2f', $self->recur_cost / $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 format OPTION DATA Returns data formatted according to the function 'format' described @@ -1256,8 +1704,6 @@ sub _upgrade_data { # class method my @part_pkg = qsearch({ 'table' => 'part_pkg', 'extra_sql' => "WHERE ". join(' OR ', - ( map "($_ IS NOT NULL AND $_ != '' )", - qw( plandata setup recur ) ), 'plan IS NULL', "plan = '' ", ), }); @@ -1268,43 +1714,7 @@ sub _upgrade_data { # class method $part_pkg->plan('flat'); } - if ( length($part_pkg->option('setup_fee')) == 0 - && $part_pkg->setup =~ /^\s*([\d\.]+)\s*$/ ) { - - my $opt = new FS::part_pkg_option { - 'pkgpart' => $part_pkg->pkgpart, - 'optionname' => 'setup_fee', - 'optionvalue' => $1, - }; - my $error = $opt->insert; - die $error if $error; - - - #} else { - # die "Can't parse part_pkg.setup for fee; convert pkgnum ". - # $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n"; - } - $part_pkg->setup(''); - - if ( length($part_pkg->option('recur_fee')) == 0 - && $part_pkg->recur =~ /^\s*([\d\.]+)\s*$/ ) { - - my $opt = new FS::part_pkg_option { - 'pkgpart' => $part_pkg->pkgpart, - 'optionname' => 'recur_fee', - 'optionvalue' => $1, - }; - my $error = $opt->insert; - die $error if $error; - - - #} else { - # die "Can't parse part_pkg.setup for fee; convert pkgnum ". - # $part_pkg->pkgnum. " manually: ". $part_pkg->setup. "\n"; - } - $part_pkg->recur(''); - - $part_pkg->replace; #this should take care of plandata, right? + $part_pkg->replace; } @@ -1336,6 +1746,144 @@ sub _upgrade_data { # class method die $error if $error; } + # set family_pkgpart on any packages that don't have it + @part_pkg = qsearch('part_pkg', { 'family_pkgpart' => '' }); + foreach my $part_pkg (@part_pkg) { + $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart); + my $error = $part_pkg->SUPER::replace; + die $error if $error; + } + + my @part_pkg_option = qsearch('part_pkg_option', + { 'optionname' => 'unused_credit', + 'optionvalue' => 1, + }); + foreach my $old_opt (@part_pkg_option) { + my $pkgpart = $old_opt->pkgpart; + my $error = $old_opt->delete; + die $error if $error; + + foreach (qw(unused_credit_cancel unused_credit_change)) { + my $new_opt = new FS::part_pkg_option { + 'pkgpart' => $pkgpart, + 'optionname' => $_, + 'optionvalue' => 1, + }; + $error = $new_opt->insert; + die $error if $error; + } + } + + # migrate use_disposition_taqua and use_disposition to disposition_in + @part_pkg_option = qsearch('part_pkg_option', + { 'optionname' => { op => 'LIKE', + value => 'use_disposition%', + }, + 'optionvalue' => 1, + }); + my %newopts = map { $_->pkgpart => $_ } + qsearch('part_pkg_option', { 'optionname' => 'disposition_in', } ); + foreach my $old_opt (@part_pkg_option) { + my $pkgpart = $old_opt->pkgpart; + my $newval = $old_opt->optionname eq 'use_disposition_taqua' ? '100' + : 'ANSWERED'; + my $error = $old_opt->delete; + die $error if $error; + + if ( exists($newopts{$pkgpart}) ) { + my $opt = $newopts{$pkgpart}; + $opt->optionvalue($opt->optionvalue.",$newval"); + $error = $opt->replace; + die $error if $error; + } else { + my $new_opt = new FS::part_pkg_option { + 'pkgpart' => $pkgpart, + 'optionname' => 'disposition_in', + 'optionvalue' => $newval, + }; + $error = $new_opt->insert; + die $error if $error; + $newopts{$pkgpart} = $new_opt; + } + } + + # 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 + @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); + } + } =item curuser_pkgs_sql @@ -1422,19 +1970,53 @@ foreach my $INC ( @INC ) { next; } warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG; - if ( exists($info->{'disabled'}) && $info->{'disabled'} ) { - warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG; - next; - } + #if ( exists($info->{'disabled'}) && $info->{'disabled'} ) { + # warn "skipping disabled plan FS::part_pkg::$mod" if $DEBUG; + # next; + #} $info{$mod} = $info; + $info->{'weight'} ||= 0; # quiet warnings } } +# copy one level deep to allow replacement of fields and fieldorder tie %plans, 'Tie::IxHash', - map { $_ => $info{$_} } + map { my %infohash = %{ $info{$_} }; + $_ => \%infohash } sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} } keys %info; +# inheritance of plan options +foreach my $name (keys(%info)) { + if (exists($info{$name}->{'disabled'}) and $info{$name}->{'disabled'}) { + warn "skipping disabled plan FS::part_pkg::$name" if $DEBUG; + delete $plans{$name}; + next; + } + my $parents = $info{$name}->{'inherit_fields'} || []; + my (%fields, %field_exists, @fieldorder); + foreach my $parent ($name, @$parents) { + if ( !exists($info{$parent}) ) { + warn "$name tried to inherit from nonexistent '$parent'\n"; + next; + } + %fields = ( # avoid replacing existing fields + %{ $info{$parent}->{'fields'} || {} }, + %fields + ); + foreach (@{ $info{$parent}->{'fieldorder'} || [] }) { + # avoid duplicates + next if $field_exists{$_}; + $field_exists{$_} = 1; + # allow inheritors to remove inherited fields from the fieldorder + push @fieldorder, $_ if !exists($fields{$_}) or + !exists($fields{$_}->{'disabled'}); + } + } + $plans{$name}->{'fields'} = \%fields; + $plans{$name}->{'fieldorder'} = \@fieldorder; +} + sub plan_info { \%plans; }