X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fpart_pkg.pm;h=98bb74cab60e35cccf78c87a1ca1077615e1586c;hp=e4c13aade932a22f45c8b731c998994870596e22;hb=ff92fba056c2b61753d464c84ecede6ec2f57258;hpb=83a6a677f62ca501011d9bac6371344dbb5a13b3 diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index e4c13aade..98bb74cab 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -1,8 +1,10 @@ package FS::part_pkg; use strict; -use vars qw( @ISA %plans $DEBUG ); +use vars qw( @ISA %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 Tie::IxHash; use FS::Conf; use FS::Record qw( qsearch qsearchs dbh dbdef ); @@ -13,12 +15,17 @@ use FS::agent_type; use FS::type_pkgs; use FS::part_pkg_option; use FS::pkg_class; - -@ISA = qw( FS::m2m_Common FS::Record ); # FS::option_Common ); # this can use option_Common - # when all the plandata bs is - # gone - +use FS::agent; +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; + +@ISA = qw( FS::m2m_Common FS::option_Common ); $DEBUG = 0; +$setup_hack = 0; +$skip_pkg_svc_hack = 0; =head1 NAME @@ -81,10 +88,20 @@ inherits from FS::Record. The following fields are currently supported: =item disabled - Disabled flag, empty or `Y' +=item custom - Custom flag, empty or `Y' + +=item setup_cost - for cost tracking + +=item recur_cost - for cost tracking + =item pay_weight - Weight (relative to credit_weight and other package definitions) that controls payment application to specific line items. =item credit_weight - Weight (relative to other package definitions) that controls credit application to specific line items. +=item agentnum - Optional agentnum (see L) + +=item fcc_ds0s - Optional DS0 equivalency number for FCC form 477 + =back =head1 METHODS @@ -103,9 +120,8 @@ sub table { 'part_pkg'; } =item clone An alternate constructor. Creates a new package definition by duplicating -an existing definition. A new pkgpart is assigned and `(CUSTOM) ' is prepended -to the comment field. To add the package definition to the database, see -L<"insert">. +an existing definition. A new pkgpart is assigned and the custom flag is +set to Y. To add the package definition to the database, see L<"insert">. =cut @@ -114,8 +130,7 @@ sub clone { my $class = ref($self); my %hash = $self->hash; $hash{'pkgpart'} = ''; - $hash{'comment'} = "(CUSTOM) ". $hash{'comment'} - unless $hash{'comment'} =~ /^\(CUSTOM\) /; + $hash{'custom'} = 'Y'; #new FS::part_pkg ( \%hash ); # ? new $class ( \%hash ); # ? } @@ -129,7 +144,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. @@ -140,6 +157,10 @@ record itself), the object will be updated to point to this package definition. In conjunction with I, if I is set to a scalar reference, the scalar will be updated with the custnum value from the cust_pkg record. +If I is set to a hashref with usage classes as keys and comma +separated tax class numbers as values, appropriate FS::part_pkg_taxoverride +records will be inserted. + If I is set to a hashref of options, appropriate FS::part_pkg_option records will be inserted. @@ -163,61 +184,13 @@ sub insert { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - warn " saving legacy plandata" if $DEBUG; - my $plandata = $self->get('plandata'); - $self->set('plandata', ''); - warn " inserting part_pkg record" if $DEBUG; - my $error = $self->SUPER::insert; + my $error = $self->SUPER::insert( $options{options} ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } - if ( $plandata ) { - - warn " inserting part_pkg_option records for plandata" if $DEBUG; - foreach my $part_pkg_option ( - map { /^(\w+)=(.*)$/ or do { $dbh->rollback if $oldAutoCommit; - return "illegal plandata: $plandata"; - }; - new FS::part_pkg_option { - 'pkgpart' => $self->pkgpart, - 'optionname' => $1, - 'optionvalue' => $2, - }; - } - split("\n", $plandata) - ) { - my $error = $part_pkg_option->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - } - - } elsif ( $options{'options'} ) { - - warn " inserting part_pkg_option records for options hashref" if $DEBUG; - foreach my $optionname ( keys %{$options{'options'}} ) { - - my $part_pkg_option = - new FS::part_pkg_option { - 'pkgpart' => $self->pkgpart, - 'optionname' => $optionname, - 'optionvalue' => $options{'options'}->{$optionname}, - }; - - my $error = $part_pkg_option->insert; - 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" @@ -235,28 +208,54 @@ sub insert { } } - warn " inserting 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 = - ( $options{'primary_svc'} && $options{'primary_svc'}==$part_svc->svcpart ) - ? 'Y' + warn " inserting part_pkg_taxoverride records" if $DEBUG; + my %overrides = %{ $options{'tax_overrides'} || {} }; + foreach my $usage_class ( keys %overrides ) { + my $override = + ( exists($overrides{$usage_class}) && defined($overrides{$usage_class}) ) + ? $overrides{$usage_class} : ''; - - my $pkg_svc = new FS::pkg_svc( { - 'pkgpart' => $self->pkgpart, - 'svcpart' => $part_svc->svcpart, - 'quantity' => $quantity, - 'primary_svc' => $primary_svc, - } ); - my $error = $pkg_svc->insert; + my @overrides = (grep "$_", split(',', $override) ); + my $error = $self->process_m2m ( + 'link_table' => 'part_pkg_taxoverride', + 'target_table' => 'tax_class', + 'hashref' => { 'usage_class' => $usage_class }, + 'params' => \@overrides, + ); 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 = + ( $options{'primary_svc'} && $options{'primary_svc'}==$part_svc->svcpart ) + ? 'Y' + : ''; + + my $pkg_svc = new FS::pkg_svc( { + 'pkgpart' => $self->pkgpart, + 'svcpart' => $part_svc->svcpart, + 'quantity' => $quantity, + 'primary_svc' => $primary_svc, + 'hidden' => $hidden_svc->{$part_svc->svcpart}, + } ); + my $error = $pkg_svc->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + } + if ( $options{'cust_pkg'} ) { warn " updating cust_pkg record " if $DEBUG; my $old_cust_pkg = @@ -298,27 +297,38 @@ 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 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 replace. +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. 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. + =cut sub replace { - my( $new, $old ) = ( shift, shift ); - my %options = @_; + my $new = shift; - # We absolutely have to have an old vs. new record to make this work. - if (!defined($old)) { - $old = qsearchs( 'part_pkg', { 'pkgpart' => $new->pkgpart } ); - } + my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') ) + ? shift + : $new->replace_old; - warn "FS::part_pkg::replace called on $new to replace $old ". - "with options %options" + my $options = + ( ref($_[0]) eq 'HASH' ) + ? shift + : { @_ }; + + $options->{options} = {} unless defined($options->{options}); + + warn "FS::part_pkg::replace called on $new to replace $old with options". + join(', ', map "$_ => ". $options->{$_}, keys %$options) if $DEBUG; local $SIG{HUP} = 'IGNORE'; @@ -332,6 +342,8 @@ sub replace { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + #plandata shit stays in replace for upgrades until after 2.0 (or edit + #_upgrade_data) warn " saving legacy plandata" if $DEBUG; my $plandata = $new->get('plandata'); $new->set('plandata', ''); @@ -346,13 +358,13 @@ sub replace { } warn " replacing part_pkg record" if $DEBUG; - my $error = $new->SUPER::replace($old); + my $error = $new->SUPER::replace($old, $options->{options} ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; } - warn " inserting part_pkg_option records for plandata" if $DEBUG; + warn " inserting part_pkg_option records for plandata: $plandata|" if $DEBUG; foreach my $part_pkg_option ( map { /^(\w+)=(.*)$/ or do { $dbh->rollback if $oldAutoCommit; return "illegal plandata: $plandata"; @@ -373,21 +385,36 @@ sub replace { } 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 $primary_svc = $options{'primary_svc'} == $part_svc->svcpart ? 'Y' : ''; + 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 $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; + 'pkgpart' => $old->pkgpart, + 'svcpart' => $part_svc->svcpart, + } + ); + 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 : '' ), @@ -395,6 +422,7 @@ sub replace { 'svcpart' => $part_svc->svcpart, 'quantity' => $quantity, 'primary_svc' => $primary_svc, + 'hidden' => $hidden, } ); my $error = $old_pkg_svc ? $new_pkg_svc->replace($old_pkg_svc) @@ -424,7 +452,8 @@ sub check { for (qw(setup recur plandata)) { #$self->set($_=>0) if $self->get($_) =~ /^\s*$/; } - return "Use of $_ field is deprecated; set a plan and options" + return "Use of $_ field is deprecated; set a plan and options: ". + $self->get($_) if length($self->get($_)); $self->set($_, ''); } @@ -438,6 +467,12 @@ sub check { $self->freq($1); } + my @null_agentnum_right = ( 'Edit global package definitions' ); + push @null_agentnum_right, 'One-time charge' + if $self->freq =~ /^0/; + push @null_agentnum_right, 'Customize customer package' + if $self->disabled eq 'Y'; #good enough + my $error = $self->ut_numbern('pkgpart') || $self->ut_text('pkg') || $self->ut_text('comment') @@ -447,19 +482,30 @@ sub check { || $self->ut_enum('recurtax', [ '', 'Y' ] ) || $self->ut_textn('taxclass') || $self->ut_enum('disabled', [ '', 'Y' ] ) + || $self->ut_enum('custom', [ '', 'Y' ] ) + || $self->ut_enum('no_auto', [ '', 'Y' ]) + #|| $self->ut_moneyn('setup_cost') + #|| $self->ut_moneyn('recur_cost') + || $self->ut_floatn('setup_cost') + || $self->ut_floatn('recur_cost') || $self->ut_floatn('pay_weight') || $self->ut_floatn('credit_weight') + || $self->ut_numbern('taxproductnum') + || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum') + || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum') + || $self->ut_foreign_keyn('taxproductnum', + 'part_pkg_taxproduct', + 'taxproductnum' + ) + || ( $setup_hack + ? $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum' ) + : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right) + ) + || $self->ut_numbern('fcc_ds0s') || $self->SUPER::check ; return $error if $error; - if ( $self->classnum !~ /^$/ ) { - my $error = $self->ut_foreign_key('classnum', 'pkg_class', 'classnum'); - return $error if $error; - } else { - $self->classnum(''); - } - return 'Unknown plan '. $self->plan unless exists($plans{$self->plan}); @@ -470,6 +516,32 @@ sub check { ''; } +=item pkg_comment [ OPTION => VALUE... ] + +Returns an (internal) string representing this package. Currently, +"pkgpart: pkg - comment", is returned. "pkg - comment" may be returned in the +future, omitting pkgpart. The comment will have '(CUSTOM) ' prepended if +custom is Y. + +If the option nopkgpart is true then the "pkgpart: ' is omitted. + +=cut + +sub pkg_comment { + my $self = shift; + my %opt = @_; + + #$self->pkg. ' - '. $self->comment; + #$self->pkg. ' ('. $self->comment. ')'; + my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': '; + $pre. $self->pkg. ' - '. $self->custom_comment; +} + +sub custom_comment { + my $self = shift; + ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment; +} + =item pkg_class Returns the package class, as an FS::pkg_class object, or the empty string @@ -486,6 +558,37 @@ sub pkg_class { } } +=item addon_pkg_class + +Returns the add-on package class, as an FS::pkg_class object, or the empty +string if there is no add-on package class. + +=cut + +sub addon_pkg_class { + my $self = shift; + if ( $self->addon_classnum ) { + qsearchs('pkg_class', { 'classnum' => $self->addon_classnum } ); + } else { + return ''; + } +} + +=item categoryname + +Returns the package category name, or the empty string if there is no package +category. + +=cut + +sub categoryname { + my $self = shift; + my $pkg_class = $self->pkg_class; + $pkg_class + ? $pkg_class->categoryname + : ''; +} + =item classname Returns the package class name, or the empty string if there is no package @@ -501,18 +604,84 @@ sub classname { : ''; } -=item pkg_svc +=item addon_classname + +Returns the add-on package class name, or the empty string if there is no +add-on package class. + +=cut + +sub addon_classname { + my $self = shift; + my $pkg_class = $self->addon_pkg_class; + $pkg_class + ? $pkg_class->classname + : ''; +} + +=item agent + +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 definition (with non-zero quantity). +One option is available, I. If set true it will return the +services for this package definition alone, omitting services from any add-on +packages. + +=cut + +=item type_pkgs + +Returns all FS::type_pkgs objects (see L) for this package +definition. + =cut +sub type_pkgs { + my $self = shift; + qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } ); +} + sub pkg_svc { my $self = shift; - #sort { $b->primary cmp $a->primary } - grep { $_->quantity } - qsearch( 'pkg_svc', { 'pkgpart' => $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 } ); + + 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 } ); + foreach my $pkg_svc ( @pkg_svc ) { + if ( $pkg_svc{$pkg_svc->svcpart} ) { + my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity; + $pkg_svc{$pkg_svc->svcpart}->quantity($quantity + $pkg_svc->quantity); + } else { + $pkg_svc{$pkg_svc->svcpart} = $pkg_svc; + } + } + } + } + + values(%pkg_svc); + } =item svcpart [ SVCDB ] @@ -521,22 +690,60 @@ Returns the svcpart of the primary service definition (see L) associated with this package definition (see L). Returns false if there not a primary service definition or exactly one service definition with quantity 1, or if SVCDB is specified and does not match the -svcdb of the service definition, +svcdb of the service definition. SVCDB can be specified as a scalar table +name, such as 'svc_acct', or as an arrayref of possible table names. =cut sub svcpart { + my $pkg_svc = shift->_primary_pkg_svc(@_); + $pkg_svc ? $pkg_svc->svcpart : ''; +} + +=item part_svc [ SVCDB ] + +Like the B method, but returns the FS::part_svc object (see +L). + +=cut + +sub part_svc { + my $pkg_svc = shift->_primary_pkg_svc(@_); + $pkg_svc ? $pkg_svc->part_svc : ''; +} + +sub _primary_pkg_svc { my $self = shift; - my $svcdb = scalar(@_) ? shift : ''; + + my $svcdb = scalar(@_) ? shift : []; + $svcdb = ref($svcdb) ? $svcdb : [ $svcdb ]; + my %svcdb = map { $_=>1 } @$svcdb; + my @svcdb_pkg_svc = - grep { ( $svcdb eq $_->part_svc->svcdb || !$svcdb ) } $self->pkg_svc; - my @pkg_svc = (); - @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc - if dbdef->table('pkg_svc')->column('primary_svc'); + grep { !scalar(@$svcdb) || $svcdb{ $_->part_svc->svcdb } } + $self->pkg_svc; + + my @pkg_svc = grep { $_->primary_svc =~ /^Y/i } @svcdb_pkg_svc; @pkg_svc = grep {$_->quantity == 1 } @svcdb_pkg_svc unless @pkg_svc; return '' if scalar(@pkg_svc) != 1; - $pkg_svc[0]->svcpart; + $pkg_svc[0]; +} + +=item svcpart_unique_svcdb SVCDB + +Returns the svcpart of a service definition (see L) matching +SVCDB associated with this package definition (see L). Returns +false if there not a primary service definition for SVCDB or there are multiple +service definitions for SVCDB. + +=cut + +sub svcpart_unique_svcdb { + my( $self, $svcdb ) = @_; + my @svcdb_pkg_svc = grep { ( $svcdb eq $_->part_svc->svcdb ) } $self->pkg_svc; + return '' if scalar(@svcdb_pkg_svc) != 1; + $svcdb_pkg_svc[0]->svcpart; } =item payby @@ -583,32 +790,12 @@ sub is_free { } } +sub can_discount { 0; } sub freqs_href { - #method, class method or sub? #my $self = shift; - - tie my %freq, 'Tie::IxHash', - '0' => '(no recurring fee)', - '1h' => 'hourly', - '1d' => 'daily', - '2d' => 'every two days', - '1w' => 'weekly', - '2w' => 'biweekly (every 2 weeks)', - '1' => 'monthly', - '45d' => 'every 45 days', - '2' => 'bimonthly (every 2 months)', - '3' => 'quarterly (every 3 months)', - '6' => 'semiannually (every 6 months)', - '12' => 'annually', - '24' => 'biannually (every 2 years)', - '36' => 'triannually (every 3 years)', - '48' => '(every 4 years)', - '60' => '(every 5 years)', - '120' => '(every 10 years)', - ; - - \%freq; - + # moved to FS::Misc to make this accessible to other packages + # at initialization + FS::Misc::pkg_freqs(); } =item freq_pretty @@ -641,6 +828,43 @@ sub freq_pretty { } } +=item add_freq TIMESTAMP [ FREQ ] + +Adds a billing period of some frequency to the provided timestamp and +returns the resulting timestamp, or -1 if the frequency could not be +parsed (shouldn't happen). By default, the frequency of this package +will be used; to override this, pass a different frequency as a second +argument. + +=cut + +sub add_freq { + my( $self, $date, $freq ) = @_; + $freq = $self->freq unless $freq; + + #change this bit to use Date::Manip? CAREFUL with timezones (see + # mailing list archive) + my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5]; + + if ( $freq =~ /^\d+$/ ) { + $mon += $freq; + until ( $mon < 12 ) { $mon -= 12; $year++; } + } elsif ( $freq =~ /^(\d+)w$/ ) { + my $weeks = $1; + $mday += $weeks * 7; + } elsif ( $freq =~ /^(\d+)d$/ ) { + my $days = $1; + $mday += $days; + } elsif ( $freq =~ /^(\d+)h$/ ) { + my $hours = $1; + $hour += $hours; + } else { + return -1; + } + + timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year); +} + =item plandata For backwards compatibility, returns the plandata field as well as all options @@ -684,9 +908,11 @@ sub options { map { $_->optionname => $_->optionvalue } $self->part_pkg_option; } -=item option OPTIONNAME +=item option OPTIONNAME [ QUIET ] -Returns the option value for the given name, or the empty string. +Returns the option value for the given name, or the empty string. If a true +value is passed as the second argument, warnings about missing the option +will be suppressed. =cut @@ -707,6 +933,231 @@ sub option { ''; } +=item bill_part_pkg_link + +Returns the associated part_pkg_link records (see L). + +=cut + +sub bill_part_pkg_link { + shift->_part_pkg_link('bill', @_); +} + +=item svc_part_pkg_link + +Returns the associated part_pkg_link records (see L). + +=cut + +sub svc_part_pkg_link { + shift->_part_pkg_link('svc', @_); +} + +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", + }); +} + +sub self_and_bill_linked { + shift->_self_and_linked('bill', @_); +} + +sub _self_and_linked { + my( $self, $type, $hidden ) = @_; + $hidden ||= ''; + + my @result = (); + foreach ( ( $self, map { $_->dst_pkg->_self_and_linked($type, $_->hidden) } + $self->_part_pkg_link($type) ) ) + { + $_->hidden($hidden) if $hidden; + push @result, $_; + } + + (@result); +} + +=item part_pkg_taxoverride [ CLASS ] + +Returns all associated FS::part_pkg_taxoverride objects (see +L). Limits the returned set to those +of class CLASS if defined. Class may be one of 'setup', 'recur', +the empty string (default), or a usage class number (see L). +When a class is specified, the empty string class (default) is returned +if no more specific values exist. + +=cut + +sub part_pkg_taxoverride { + my $self = shift; + my $class = shift; + + my $hashref = { 'pkgpart' => $self->pkgpart }; + $hashref->{'usage_class'} = $class if defined($class); + my @overrides = qsearch('part_pkg_taxoverride', $hashref ); + + unless ( scalar(@overrides) || !defined($class) || !$class ){ + $hashref->{'usage_class'} = ''; + @overrides = qsearch('part_pkg_taxoverride', $hashref ); + } + + @overrides; +} + +=item has_taxproduct + +Returns true if this package has any taxproduct associated with it. + +=cut + +sub has_taxproduct { + my $self = shift; + + $self->taxproductnum || + scalar( grep { $_ =~/^usage_taxproductnum_/ && $self->option($_) } + keys %{ {$self->options} } + ) + +} + + +=item taxproduct [ CLASS ] + +Returns the associated tax product for this package definition (see +L). CLASS may be one of 'setup', 'recur' or +the usage classnum (see L). Returns the default +tax product for this record if the more specific CLASS value does +not exist. + +=cut + +sub taxproduct { + my $self = shift; + my $class = shift; + + my $part_pkg_taxproduct; + + my $taxproductnum = $self->taxproductnum; + if ($class) { + my $class_taxproductnum = $self->option("usage_taxproductnum_$class", 1); + $taxproductnum = $class_taxproductnum + if $class_taxproductnum + } + + $part_pkg_taxproduct = + qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } ); + + unless ($part_pkg_taxproduct || $taxproductnum eq $self->taxproductnum ) { + $taxproductnum = $self->taxproductnum; + $part_pkg_taxproduct = + qsearchs( 'part_pkg_taxproduct', { 'taxproductnum' => $taxproductnum } ); + } + + $part_pkg_taxproduct; +} + +=item taxproduct_description [ CLASS ] + +Returns the description of the associated tax product for this package +definition (see L). + +=cut + +sub taxproduct_description { + my $self = shift; + my $part_pkg_taxproduct = $self->taxproduct(@_); + $part_pkg_taxproduct ? $part_pkg_taxproduct->description : ''; +} + +=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ] + +Returns the package to taxrate m2m records for this package in the location +specified by GEOCODE (see L) and usage class CLASS. +CLASS may be one of 'setup', 'recur', or one of the usage classes numbers +(see L). + +=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 { + my $self = shift; + my ($data_vendor, $geocode, $class) = @_; + + my $dbh = dbh; + my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '. + dbh->quote($data_vendor); + + # CCH oddness in m2m + $extra_sql .= ' AND ('. + join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) } + qw(10 5 2) + ). + ')'; + # much more CCH oddness in m2m -- this is kludgy + my @tpnums = $self->_expand_cch_taxproductnum($class); + if (scalar(@tpnums)) { + $extra_sql .= ' AND ('. + join(' OR ', map{ "taxproductnum = $_" } @tpnums ). + ')'; + } else { + $extra_sql .= ' AND ( 0 = 1 )'; + } + + my $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'; + + # 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, + } ); +} + +=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 _rebless Reblesses the object into the FS::part_pkg::PLAN class (if available), where @@ -719,7 +1170,7 @@ sub _rebless { my $self = shift; my $plan = $self->plan; unless ( $plan ) { - confess "no price plan found for pkgpart ". $self->pkgpart. "\n" + cluck "no price plan found for pkgpart ". $self->pkgpart. "\n" if $DEBUG; return $self; } @@ -766,6 +1217,210 @@ sub _calc_eval { sub calc_remain { 0; } sub calc_cancel { 0; } +sub calc_units { 0; } + +#fallback for everything except bulk.pm +sub hide_svc_detail { 0; } + +=item recur_cost_permonth CUST_PKG + +recur_cost divided by freq (only supported for monthly and longer frequencies) + +=cut + +sub recur_cost_permonth { + my($self, $cust_pkg) = @_; + return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0; + sprintf('%.2f', $self->recur_cost / $self->freq ); +} + +=item format OPTION DATA + +Returns data formatted according to the function 'format' described +in the plan info. Returns DATA if no such function exists. + +=cut + +sub format { + my ($self, $option, $data) = (shift, shift, shift); + if (exists($plans{$self->plan}->{fields}->{$option}{format})) { + &{$plans{$self->plan}->{fields}->{$option}{format}}($data); + }else{ + $data; + } +} + +=item parse OPTION DATA + +Returns data parsed according to the function 'parse' described +in the plan info. Returns DATA if no such function exists. + +=cut + +sub parse { + my ($self, $option, $data) = (shift, shift, shift); + if (exists($plans{$self->plan}->{fields}->{$option}{parse})) { + &{$plans{$self->plan}->{fields}->{$option}{parse}}($data); + }else{ + $data; + } +} + +=back + +=cut + +=head1 CLASS METHODS + +=over 4 + +=cut + +# _upgrade_data +# +# Used by FS::Upgrade to migrate to a new database. + +sub _upgrade_data { # class method + my($class, %opts) = @_; + + warn "[FS::part_pkg] upgrading $class\n" if $DEBUG; + + 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 = '' ", + ), + }); + + foreach my $part_pkg (@part_pkg) { + + unless ( $part_pkg->plan ) { + $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? + + } + + # now upgrade to the explicit custom flag + + @part_pkg = qsearch({ + 'table' => 'part_pkg', + 'hashref' => { disabled => 'Y', custom => '' }, + 'extra_sql' => "AND comment LIKE '(CUSTOM) %'", + }); + + foreach my $part_pkg (@part_pkg) { + my $new = new FS::part_pkg { $part_pkg->hash }; + $new->custom('Y'); + my $comment = $part_pkg->comment; + $comment =~ s/^\(CUSTOM\) //; + $comment = '(none)' unless $comment =~ /\S/; + $new->comment($comment); + + my $pkg_svc = { map { $_->svcpart => $_->quantity } $part_pkg->pkg_svc }; + my $primary = $part_pkg->svcpart; + my $options = { $part_pkg->options }; + + my $error = $new->replace( $part_pkg, + 'pkg_svc' => $pkg_svc, + 'primary_svc' => $primary, + 'options' => $options, + ); + die $error if $error; + } + +} + +=item curuser_pkgs_sql + +Returns an SQL fragment for searching for packages the current user can +use, either via part_pkg.agentnum directly, or via agent type (see +L). + +=cut + +sub curuser_pkgs_sql { + my $class = shift; + + $class->_pkgs_sql( $FS::CurrentUser::CurrentUser->agentnums ); + +} + +=item agent_pkgs_sql AGENT | AGENTNUM, ... + +Returns an SQL fragment for searching for packages the provided agent or agents +can use, either via part_pkg.agentnum directly, or via agent type (see +L). + +=cut + +sub agent_pkgs_sql { + my $class = shift; #i'm a class method, not a sub (the question is... why??) + my @agentnums = map { ref($_) ? $_->agentnum : $_ } @_; + + $class->_pkgs_sql(@agentnums); #is this why + +} + +sub _pkgs_sql { + my( $class, @agentnums ) = @_; + my $agentnums = join(',', @agentnums); + + " + ( + ( agentnum IS NOT NULL AND agentnum IN ($agentnums) ) + OR ( agentnum IS NULL + AND EXISTS ( SELECT 1 + FROM type_pkgs + LEFT JOIN agent_type USING ( typenum ) + LEFT JOIN agent AS typeagent USING ( typenum ) + WHERE type_pkgs.pkgpart = part_pkg.pkgpart + AND typeagent.agentnum IN ($agentnums) + ) + ) + ) + "; + +} =back @@ -777,6 +1432,7 @@ sub calc_cancel { 0; } =cut +#false laziness w/part_export & cdr my %info; foreach my $INC ( @INC ) { warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG; @@ -794,8 +1450,7 @@ foreach my $INC ( @INC ) { next; } unless ( keys %$info ) { - warn "no %info hash found in FS::part_pkg::$mod, skipping\n" - unless $mod =~ /^(passwdfile|null)$/; #hack but what the heck + warn "no %info hash found in FS::part_pkg::$mod, skipping\n"; next; } warn "got plan info from FS::part_pkg::$mod: $info\n" if $DEBUG; @@ -808,7 +1463,7 @@ foreach my $INC ( @INC ) { } tie %plans, 'Tie::IxHash', - map { $_ => $info{$_} } + map { $_ => $info{$_} } sort { $info{$a}->{'weight'} <=> $info{$b}->{'weight'} } keys %info; @@ -816,6 +1471,7 @@ sub plan_info { \%plans; } + =back =head1 NEW PLAN CLASSES @@ -833,6 +1489,10 @@ FS::cust_bill. hmm.). now they're deprecated and need to go. plandata should go +part_pkg_taxrate is Pg specific + +replace should be smarter about managing the related tables (options, pkg_svc) + =head1 SEE ALSO L, L, L, L, L.