+ return ('Package does not exist: '.$dest_pkgnum) unless $dest;
+
+ foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
+ $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
+ }
+
+ foreach my $cust_svc ($dest->cust_svc) {
+ $target{$cust_svc->svcpart}--;
+ }
+
+ my %svcpart2svcparts = ();
+ if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
+ warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
+ foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
+ next if exists $svcpart2svcparts{$svcpart};
+ my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
+ $svcpart2svcparts{$svcpart} = [
+ map { $_->[0] }
+ sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
+ map {
+ my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
+ 'svcpart' => $_ } );
+ [ $_,
+ $pkg_svc ? $pkg_svc->primary_svc : '',
+ $pkg_svc ? $pkg_svc->quantity : 0,
+ ];
+ }
+
+ grep { $_ != $svcpart }
+ map { $_->svcpart }
+ qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
+ ];
+ warn "alternates for svcpart $svcpart: ".
+ join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
+ if $DEBUG;
+ }
+ }
+
+ foreach my $cust_svc ($self->cust_svc) {
+ if($target{$cust_svc->svcpart} > 0) {
+ $target{$cust_svc->svcpart}--;
+ my $new = new FS::cust_svc { $cust_svc->hash };
+ $new->pkgnum($dest_pkgnum);
+ my $error = $new->replace($cust_svc);
+ return $error if $error;
+ } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
+ if ( $DEBUG ) {
+ warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
+ warn "alternates to consider: ".
+ join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
+ }
+ my @alternate = grep {
+ warn "considering alternate svcpart $_: ".
+ "$target{$_} available in new package\n"
+ if $DEBUG;
+ $target{$_} > 0;
+ } @{$svcpart2svcparts{$cust_svc->svcpart}};
+ if ( @alternate ) {
+ warn "alternate(s) found\n" if $DEBUG;
+ my $change_svcpart = $alternate[0];
+ $target{$change_svcpart}--;
+ my $new = new FS::cust_svc { $cust_svc->hash };
+ $new->svcpart($change_svcpart);
+ $new->pkgnum($dest_pkgnum);
+ my $error = $new->replace($cust_svc);
+ return $error if $error;
+ } else {
+ $remaining++;
+ }
+ } else {
+ $remaining++
+ }
+ }
+ return $remaining;
+}
+
+=item reexport
+
+This method is deprecated. See the I<depend_jobnum> option to the insert and
+order_pkgs methods in FS::cust_main for a better way to defer provisioning.
+
+=cut
+
+sub reexport {
+ my $self = shift;
+
+ local $SIG{HUP} = 'IGNORE';
+ local $SIG{INT} = 'IGNORE';
+ local $SIG{QUIT} = 'IGNORE';
+ local $SIG{TERM} = 'IGNORE';
+ local $SIG{TSTP} = 'IGNORE';
+ local $SIG{PIPE} = 'IGNORE';
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ foreach my $cust_svc ( $self->cust_svc ) {
+ #false laziness w/svc_Common::insert
+ my $svc_x = $cust_svc->svc_x;
+ foreach my $part_export ( $cust_svc->part_svc->part_export ) {
+ my $error = $part_export->export_insert($svc_x);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
+
+}
+
+=item insert_reason
+
+Associates this package with a (suspension or cancellation) reason (see
+L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
+L<FS::reason>).
+
+Available options are:
+
+=over 4
+
+=item reason
+
+can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
+
+=item reason_otaker
+
+the access_user (see L<FS::access_user>) providing the reason
+
+=item date
+
+a unix timestamp
+
+=item action
+
+the action (cancel, susp, adjourn, expire) associated with the reason
+
+=back
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub insert_reason {
+ my ($self, %options) = @_;
+
+ my $otaker = $options{reason_otaker} ||
+ $FS::CurrentUser::CurrentUser->username;
+
+ my $reasonnum;
+ if ( $options{'reason'} =~ /^(\d+)$/ ) {
+
+ $reasonnum = $1;
+
+ } elsif ( ref($options{'reason'}) ) {
+
+ return 'Enter a new reason (or select an existing one)'
+ unless $options{'reason'}->{'reason'} !~ /^\s*$/;
+
+ my $reason = new FS::reason({
+ 'reason_type' => $options{'reason'}->{'typenum'},
+ 'reason' => $options{'reason'}->{'reason'},
+ });
+ my $error = $reason->insert;
+ return $error if $error;
+
+ $reasonnum = $reason->reasonnum;
+
+ } else {
+ return "Unparsable reason: ". $options{'reason'};
+ }
+
+ my $cust_pkg_reason =
+ new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
+ 'reasonnum' => $reasonnum,
+ 'otaker' => $otaker,
+ 'action' => substr(uc($options{'action'}),0,1),
+ 'date' => $options{'date'}
+ ? $options{'date'}
+ : time,
+ });
+
+ $cust_pkg_reason->insert;
+}
+
+=item insert_discount
+
+Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
+inserting a new discount on the fly (see L<FS::discount>).
+
+Available options are:
+
+=over 4
+
+=item discountnum
+
+=back
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub insert_discount {
+ #my ($self, %options) = @_;
+ my $self = shift;
+
+ my $cust_pkg_discount = new FS::cust_pkg_discount {
+ 'pkgnum' => $self->pkgnum,
+ 'discountnum' => $self->discountnum,
+ 'months_used' => 0,
+ 'end_date' => '', #XXX
+ #for the create a new discount case
+ '_type' => $self->discountnum__type,
+ 'amount' => $self->discountnum_amount,
+ 'percent' => $self->discountnum_percent,
+ 'months' => $self->discountnum_months,
+ 'setup' => $self->discountnum_setup,
+ #'disabled' => $self->discountnum_disabled,
+ };
+
+ $cust_pkg_discount->insert;
+}
+
+=item set_usage USAGE_VALUE_HASHREF
+
+USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
+to which they should be set (see L<FS::svc_acct>). Currently seconds,
+upbytes, downbytes, and totalbytes are appropriate keys.
+
+All svc_accts which are part of this package have their values reset.
+
+=cut
+
+sub set_usage {
+ my ($self, $valueref, %opt) = @_;
+
+ #only svc_acct can set_usage for now
+ foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
+ my $svc_x = $cust_svc->svc_x;
+ $svc_x->set_usage($valueref, %opt)
+ if $svc_x->can("set_usage");
+ }
+}
+
+=item recharge USAGE_VALUE_HASHREF
+
+USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
+to which they should be set (see L<FS::svc_acct>). Currently seconds,
+upbytes, downbytes, and totalbytes are appropriate keys.
+
+All svc_accts which are part of this package have their values incremented.
+
+=cut
+
+sub recharge {
+ my ($self, $valueref) = @_;
+
+ #only svc_acct can set_usage for now
+ foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
+ my $svc_x = $cust_svc->svc_x;
+ $svc_x->recharge($valueref)
+ if $svc_x->can("recharge");
+ }
+}
+
+=item cust_pkg_discount
+
+=cut
+
+sub cust_pkg_discount {
+ my $self = shift;
+ qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
+}
+
+=item cust_pkg_discount_active
+
+=cut
+
+sub cust_pkg_discount_active {
+ my $self = shift;
+ grep { $_->status eq 'active' } $self->cust_pkg_discount;
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item recurring_sql
+
+Returns an SQL expression identifying recurring packages.
+
+=cut
+
+sub recurring_sql { "
+ '0' != ( select freq from part_pkg
+ where cust_pkg.pkgpart = part_pkg.pkgpart )
+"; }
+
+=item onetime_sql
+
+Returns an SQL expression identifying one-time packages.
+
+=cut
+
+sub onetime_sql { "
+ '0' = ( select freq from part_pkg
+ where cust_pkg.pkgpart = part_pkg.pkgpart )
+"; }
+
+=item ordered_sql
+
+Returns an SQL expression identifying ordered packages (recurring packages not
+yet billed).
+
+=cut
+
+sub ordered_sql {
+ $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
+}
+
+=item active_sql
+
+Returns an SQL expression identifying active packages.
+
+=cut
+
+sub active_sql {
+ $_[0]->recurring_sql. "
+ AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
+ AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+ AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
+"; }
+
+=item not_yet_billed_sql
+
+Returns an SQL expression identifying packages which have not yet been billed.
+
+=cut
+
+sub not_yet_billed_sql { "
+ ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 )
+ AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+ AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
+"; }
+
+=item inactive_sql
+
+Returns an SQL expression identifying inactive packages (one-time packages
+that are otherwise unsuspended/uncancelled).
+
+=cut
+
+sub inactive_sql { "
+ ". $_[0]->onetime_sql(). "
+ AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
+ AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+ AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
+"; }
+
+=item susp_sql
+=item suspended_sql
+
+Returns an SQL expression identifying suspended packages.
+
+=cut
+
+sub suspended_sql { susp_sql(@_); }
+sub susp_sql {
+ #$_[0]->recurring_sql(). ' AND '.
+ "
+ ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+ AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
+ ";
+}
+
+=item cancel_sql
+=item cancelled_sql
+
+Returns an SQL exprression identifying cancelled packages.
+
+=cut
+
+sub cancelled_sql { cancel_sql(@_); }
+sub cancel_sql {
+ #$_[0]->recurring_sql(). ' AND '.
+ "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
+}
+
+=item status_sql
+
+Returns an SQL expression to give the package status as a string.
+
+=cut
+
+sub status_sql {
+"CASE
+ WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
+ WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
+ WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
+ WHEN ".onetime_sql()." THEN 'one-time charge'
+ ELSE 'active'
+END"
+}
+
+=item search HASHREF
+
+(Class method)
+
+Returns a qsearch hash expression to search for parameters specified in HASHREF.
+Valid parameters are
+
+=over 4
+
+=item agentnum
+
+=item magic
+
+active, inactive, suspended, cancel (or cancelled)
+
+=item status
+
+active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
+
+=item custom
+
+ boolean selects custom packages
+
+=item classnum
+
+=item pkgpart
+
+pkgpart or arrayref or hashref of pkgparts
+
+=item setup
+
+arrayref of beginning and ending epoch date
+
+=item last_bill
+
+arrayref of beginning and ending epoch date
+
+=item bill
+
+arrayref of beginning and ending epoch date
+
+=item adjourn
+
+arrayref of beginning and ending epoch date
+
+=item susp
+
+arrayref of beginning and ending epoch date
+
+=item expire
+
+arrayref of beginning and ending epoch date
+
+=item cancel
+
+arrayref of beginning and ending epoch date
+
+=item query
+
+pkgnum or APKG_pkgnum
+
+=item cust_fields
+
+a value suited to passing to FS::UI::Web::cust_header
+
+=item CurrentUser
+
+specifies the user for agent virtualization
+
+=item fcc_line
+
+ boolean selects packages containing fcc form 477 telco lines
+
+=back
+
+=cut
+
+sub search {
+ my ($class, $params) = @_;
+ my @where = ();
+
+ ##
+ # parse agent
+ ##
+
+ if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+ push @where,
+ "cust_main.agentnum = $1";
+ }
+
+ ##
+ # parse custnum
+ ##
+
+ if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
+ push @where,
+ "cust_pkg.custnum = $1";
+ }
+
+ ##
+ # custbatch
+ ##
+
+ if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+ push @where,
+ "cust_pkg.pkgbatch = '$1'";
+ }
+
+ ##
+ # parse status
+ ##
+
+ if ( $params->{'magic'} eq 'active'
+ || $params->{'status'} eq 'active' ) {
+
+ push @where, FS::cust_pkg->active_sql();
+
+ } elsif ( $params->{'magic'} =~ /^not[ _]yet[ _]billed$/
+ || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
+
+ push @where, FS::cust_pkg->not_yet_billed_sql();
+
+ } elsif ( $params->{'magic'} =~ /^(one-time charge|inactive)/
+ || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
+
+ push @where, FS::cust_pkg->inactive_sql();
+
+ } elsif ( $params->{'magic'} eq 'suspended'
+ || $params->{'status'} eq 'suspended' ) {
+
+ push @where, FS::cust_pkg->suspended_sql();
+
+ } elsif ( $params->{'magic'} =~ /^cancell?ed$/
+ || $params->{'status'} =~ /^cancell?ed$/ ) {
+
+ push @where, FS::cust_pkg->cancelled_sql();
+
+ }
+
+ ###
+ # parse package class
+ ###
+
+ if ( exists($params->{'classnum'}) ) {
+
+ my @classnum = ();
+ if ( ref($params->{'classnum'}) ) {
+
+ if ( ref($params->{'classnum'}) eq 'HASH' ) {
+ @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
+ } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
+ @classnum = @{ $params->{'classnum'} };
+ } else {
+ die 'unhandled classnum ref '. $params->{'classnum'};
+ }
+
+
+ } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
+ @classnum = ( $1 );
+ }
+
+ if ( @classnum ) {
+
+ my @c_where = ();
+ my @nums = grep $_, @classnum;
+ push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
+ my $null = scalar( grep { $_ eq '' } @classnum );
+ push @c_where, 'part_pkg.classnum IS NULL' if $null;
+
+ if ( scalar(@c_where) == 1 ) {
+ push @where, @c_where;
+ } elsif ( @c_where ) {
+ push @where, ' ( '. join(' OR ', @c_where). ' ) ';
+ }
+
+ }
+
+
+ }
+
+ ###
+ # parse package report options
+ ###
+
+ my @report_option = ();
+ if ( exists($params->{'report_option'}) ) {
+ if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
+ @report_option = @{ $params->{'report_option'} };
+ } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
+ @report_option = split(',', $1);
+ }
+
+ }
+
+ if (@report_option) {
+ # this will result in the empty set for the dangling comma case as it should
+ push @where,
+ map{ "0 < ( SELECT count(*) FROM part_pkg_option
+ WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+ AND optionname = 'report_option_$_'
+ AND optionvalue = '1' )"
+ } @report_option;
+ }
+
+ foreach my $any ( grep /^report_option_any/, keys %$params ) {
+
+ my @report_option_any = ();
+ if ( ref($params->{$any}) eq 'ARRAY' ) {
+ @report_option_any = @{ $params->{$any} };
+ } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
+ @report_option_any = split(',', $1);
+ }
+
+ if (@report_option_any) {
+ # this will result in the empty set for the dangling comma case as it should
+ push @where, ' ( '. join(' OR ',
+ map{ "0 < ( SELECT count(*) FROM part_pkg_option
+ WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+ AND optionname = 'report_option_$_'
+ AND optionvalue = '1' )"
+ } @report_option_any
+ ). ' ) ';
+ }
+
+ }
+
+ ###
+ # parse custom
+ ###
+
+ push @where, "part_pkg.custom = 'Y'" if $params->{custom};
+
+ ###
+ # parse fcc_line
+ ###
+
+ push @where, "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)"
+ if $params->{fcc_line};
+
+ ###
+ # parse censustract
+ ###
+
+ if ( exists($params->{'censustract'}) ) {
+ $params->{'censustract'} =~ /^([.\d]*)$/;
+ my $censustract = "cust_main.censustract = '$1'";
+ $censustract .= ' OR cust_main.censustract is NULL' unless $1;
+ push @where, "( $censustract )";
+ }
+
+ ###
+ # parse censustract2
+ ###
+ if ( exists($params->{'censustract2'})
+ && $params->{'censustract2'} =~ /^(\d*)$/
+ )
+ {
+ if ($1) {
+ push @where, "cust_main.censustract LIKE '$1%'";
+ } else {
+ push @where,
+ "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
+ }
+ }
+
+ ###
+ # parse part_pkg
+ ###
+
+ if ( ref($params->{'pkgpart'}) ) {
+
+ my @pkgpart = ();
+ if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
+ @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
+ } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
+ @pkgpart = @{ $params->{'pkgpart'} };
+ } else {
+ die 'unhandled pkgpart ref '. $params->{'pkgpart'};
+ }
+
+ @pkgpart = grep /^(\d+)$/, @pkgpart;
+
+ push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
+
+ } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
+ push @where, "pkgpart = $1";
+ }
+
+ ###
+ # parse dates
+ ###
+
+ my $orderby = '';
+
+ #false laziness w/report_cust_pkg.html
+ my %disable = (
+ 'all' => {},
+ 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
+ 'active' => { 'susp'=>1, 'cancel'=>1 },
+ 'suspended' => { 'cancel' => 1 },
+ 'cancelled' => {},
+ '' => {},
+ );
+
+ if( exists($params->{'active'} ) ) {
+ # This overrides all the other date-related fields
+ my($beginning, $ending) = @{$params->{'active'}};
+ push @where,
+ "cust_pkg.setup IS NOT NULL",
+ "cust_pkg.setup <= $ending",
+ "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
+ "NOT (".FS::cust_pkg->onetime_sql . ")";
+ }
+ else {
+ foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
+
+ next unless exists($params->{$field});
+
+ my($beginning, $ending) = @{$params->{$field}};
+
+ next if $beginning == 0 && $ending == 4294967295;