X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_pkg.pm;h=16cecdfdb4784be6d400bcc090c390d953282f17;hp=87acf0e52c8d2e937082bb8a8682f298df61189e;hb=7516e3da0f17eeecba27219ef96a8b5f46af2083;hpb=7af9fc1ac91f9e8673f12849153d7ac2a5f900f2 diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 87acf0e52..16cecdfdb 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -1,22 +1,25 @@ package FS::cust_pkg; +use base qw( FS::cust_pkg::Search FS::cust_pkg::API + FS::otaker_Mixin FS::cust_main_Mixin FS::Sales_Mixin + FS::contact_Mixin FS::location_Mixin + FS::m2m_Common FS::option_Common + ); use strict; -use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin - FS::m2m_Common FS::option_Common ); -use vars qw($disable_agentcheck $DEBUG $me); use Carp qw(cluck); use Scalar::Util qw( blessed ); use List::Util qw(min max); use Tie::IxHash; use Time::Local qw( timelocal timelocal_nocheck ); use MIME::Entity; -use FS::UID qw( getotaker dbh driver_name ); +use FS::UID qw( dbh driver_name ); use FS::Misc qw( send_email ); use FS::Record qw( qsearch qsearchs fields ); use FS::CurrentUser; use FS::cust_svc; use FS::part_pkg; use FS::cust_main; +use FS::contact; use FS::cust_location; use FS::pkg_svc; use FS::cust_bill_pkg; @@ -29,10 +32,12 @@ use FS::reg_code; use FS::part_svc; use FS::cust_pkg_reason; use FS::reason; +use FS::cust_pkg_usageprice; use FS::cust_pkg_discount; use FS::discount; -use FS::UI::Web; -use Data::Dumper; +use FS::sales; +# for modify_charge +use FS::cust_credit; # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend, # setup } @@ -45,10 +50,9 @@ use FS::svc_forward; # for sending cancel emails in sub cancel use FS::Conf; -$DEBUG = 0; -$me = '[FS::cust_pkg]'; +our ($disable_agentcheck, $DEBUG, $me, $import) = (0, 0, '[FS::cust_pkg]', 0); -$disable_agentcheck = 0; +our $upgrade = 0; #go away after setup+start dates cleaned up for old customers sub _cache { my $self = shift; @@ -208,6 +212,11 @@ The pkgnum of the package that this package is supplemental to, if any. The package link (L) that defines this supplemental package, if it is one. +=item change_to_pkgnum + +The pkgnum of the package this one will be "changed to" in the future +(on its expiration date). + =back Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date @@ -225,13 +234,46 @@ Create a new billing item. To add the item to the database, see L<"insert">. =cut sub table { 'cust_pkg'; } -sub cust_linked { $_[0]->cust_main_custnum; } +sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } sub cust_unlinked_msg { my $self = shift; "WARNING: can't find cust_main.custnum ". $self->custnum. ' (cust_pkg.pkgnum '. $self->pkgnum. ')'; } +=item set_initial_timers + +If required by the package definition, sets any automatic expire, adjourn, +or contract_end timers to some number of months after the start date +(or setup date, if the package has already been setup). If the package has +a delayed setup fee after a period of "free days", will also set the +start date to the end of that period. + +=cut + +sub set_initial_timers { + my $self = shift; + my $part_pkg = $self->part_pkg; + foreach my $action ( qw(expire adjourn contract_end) ) { + my $months = $part_pkg->option("${action}_months",1); + if($months and !$self->get($action)) { + my $start = $self->start_date || $self->setup || time; + $self->set($action, $part_pkg->add_freq($start, $months) ); + } + } + + # if this package has "free days" and delayed setup fee, then + # set start date that many days in the future. + # (this should have been set in the UI, but enforce it here) + if ( $part_pkg->option('free_days',1) + && $part_pkg->option('delay_setup',1) + ) + { + $self->start_date( $part_pkg->default_start_date ); + } + ''; +} + =item insert [ OPTION => VALUE ... ] Adds this billing item to the database ("Orders" the item). If there is an @@ -247,13 +289,21 @@ setting I to an array reference of refnums or a hash reference with refnums as keys. If no I is defined, a default FS::pkg_referral record will be created corresponding to cust_main.refnum. +If the additional field I is defined, it will be treated +as an arrayref of FS::cust_pkg_usageprice objects, which will be inserted. +(Note that this field cannot be set with a usual ->cust_pkg_usageprice method. +It can be set as part of the hash when creating the object, or with the B +method.) + The following options are available: =over 4 =item change -If set true, supresses any referral credit to a referring customer. +If set true, supresses actions that should only be taken for new package +orders. (Currently this includes: intro periods when delay_setup is on, +auto-adding a 1st start date, auto-adding expiration/adjourn/contract_end dates) =item options @@ -267,6 +317,12 @@ a ticket will be added to this customer with this subject an optional queue name for ticket additions +=item allow_pkgpart + +Don't check the legality of the package definition. This should be used +when performing a package change that doesn't change the pkgpart (i.e. +a location change). + =back =cut @@ -274,42 +330,36 @@ an optional queue name for ticket additions sub insert { my( $self, %options ) = @_; - my $error = $self->check_pkgpart; + my $error; + $error = $self->check_pkgpart unless $options{'allow_pkgpart'}; return $error if $error; my $part_pkg = $self->part_pkg; - if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) { - my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5]; - $mon += 1 unless $mday == 1; - until ( $mon < 12 ) { $mon -= 12; $year++; } - $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) ); - } - - foreach my $action ( qw(expire adjourn contract_end) ) { - my $months = $part_pkg->option("${action}_months",1); - if($months and !$self->$action) { - my $start = $self->start_date || $self->setup || time; - $self->$action( $part_pkg->add_freq($start, $months) ); - } - } + if ( ! $import && ! $options{'change'} ) { - my $free_days = $part_pkg->option('free_days',1); - if ( $free_days && $part_pkg->option('delay_setup',1) ) { #&& !$self->start_date - my ($mday,$mon,$year) = (localtime(time) )[3,4,5]; - #my $start_date = ($self->start_date || timelocal(0,0,0,$mday,$mon,$year)) + 86400 * $free_days; - my $start_date = timelocal(0,0,0,$mday,$mon,$year) + 86400 * $free_days; - $self->start_date($start_date); - } + # set order date to now + $self->order_date(time) unless ($import && $self->order_date); - $self->order_date(time); + # if the package def says to start only on the first of the month: + if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) { + my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5]; + $mon += 1 unless $mday == 1; + until ( $mon < 12 ) { $mon -= 12; $year++; } + $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) ); + } - local $SIG{HUP} = 'IGNORE'; - local $SIG{INT} = 'IGNORE'; - local $SIG{QUIT} = 'IGNORE'; - local $SIG{TERM} = 'IGNORE'; - local $SIG{TSTP} = 'IGNORE'; - local $SIG{PIPE} = 'IGNORE'; + if ($self->susp eq 'now' or $part_pkg->start_on_hold) { + # if the package was ordered on hold: + # - suspend it + # - don't set the start date (it will be started manually) + $self->set('susp', $self->order_date); + $self->set('start_date', ''); + } else { + # set expire/adjourn/contract_end timers, and free days, if appropriate + $self->set_initial_timers; + } + } # else this is a package change, and shouldn't have "new package" behavior my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; @@ -328,6 +378,17 @@ sub insert { 'params' => $self->refnum, ); + if ( $self->hashref->{cust_pkg_usageprice} ) { + for my $cust_pkg_usageprice ( @{ $self->hashref->{cust_pkg_usageprice} } ) { + $cust_pkg_usageprice->pkgnum( $self->pkgnum ); + my $error = $cust_pkg_usageprice->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + } + if ( $self->discountnum ) { my $error = $self->insert_discount(); if ( $error ) { @@ -336,18 +397,9 @@ sub insert { } } - #if ( $self->reg_code ) { - # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } ); - # $error = $reg_code->delete; - # if ( $error ) { - # $dbh->rollback if $oldAutoCommit; - # return $error; - # } - #} - my $conf = new FS::Conf; - if ( $conf->config('ticket_system') && $options{ticket_subject} ) { + if ( ! $import && $conf->config('ticket_system') && $options{ticket_subject} ) { #this init stuff is still inefficient, but at least its limited to # the small number (any?) folks using ticket emailing on pkg order @@ -377,7 +429,7 @@ sub insert { ); } - if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) { + if (! $import && $conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) { my $queue = new FS::queue { 'job' => 'FS::cust_main::queueable_print', }; @@ -410,13 +462,6 @@ hide cancelled packages. sub delete { 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; @@ -527,13 +572,6 @@ sub replace { local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart; - 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; @@ -567,9 +605,12 @@ sub replace { } - my $error = $new->SUPER::replace($old, - $options->{options} ? $options->{options} : () - ); + my $error = $new->export_pkg_change($old) + || $new->SUPER::replace( $old, + $options->{options} + ? $options->{options} + : () + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -613,8 +654,10 @@ sub check { $self->ut_numbern('pkgnum') || $self->ut_foreign_key('custnum', 'cust_main', 'custnum') || $self->ut_numbern('pkgpart') - || $self->check_pkgpart + || $self->ut_foreign_keyn('contactnum', 'contact', 'contactnum' ) || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum') + || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum') + || $self->ut_numbern('quantity') || $self->ut_numbern('start_date') || $self->ut_numbern('setup') || $self->ut_numbern('bill') @@ -626,16 +669,17 @@ sub check { || $self->ut_numbern('dundate') || $self->ut_enum('no_auto', [ '', 'Y' ]) || $self->ut_enum('waive_setup', [ '', 'Y' ]) - || $self->ut_numbern('agent_pkgid') + || $self->ut_textn('agent_pkgid') || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ]) || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ]) || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum') || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum') + || $self->ut_foreign_keyn('change_to_pkgnum', 'cust_pkg', 'pkgnum') ; return $error if $error; return "A package with both start date (future start) and setup date (already started) will never bill" - if $self->start_date && $self->setup; + if $self->start_date && $self->setup && ! $upgrade; return "A future unsuspend date can only be set for a package with a suspend date" if $self->resume and !$self->susp and !$self->adjourn; @@ -654,14 +698,19 @@ sub check { =item check_pkgpart +Check the pkgpart to make sure it's allowed with the reg_code and/or +promo_code of the package (if present) and with the customer's agent. +Called from C, unless we are doing a package change that doesn't +affect pkgpart. + =cut sub check_pkgpart { my $self = shift; - my $error = $self->ut_numbern('pkgpart'); - return $error if $error; + # my $error = $self->ut_numbern('pkgpart'); # already done + my $error; if ( $self->reg_code ) { unless ( grep { $self->pkgpart == $_->pkgpart } @@ -756,13 +805,6 @@ sub cancel { join(', ', map { "$_: $options{$_}" } keys %options ). "\n" if $DEBUG; - 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; @@ -847,9 +889,19 @@ sub cancel { } #unless $date my %hash = $self->hash; - $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time); + if ( $date ) { + $hash{'expire'} = $date; + } else { + $hash{'cancel'} = $cancel_time; + } + $hash{'change_custnum'} = $options{'change_custnum'}; + my $new = new FS::cust_pkg ( \%hash ); $error = $new->replace( $self, options => { $self->options } ); + if ( $self->change_to_pkgnum ) { + my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum); + $error ||= $change_to->cancel || $change_to->delete; + } if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -891,6 +943,8 @@ sub cancel { 'to' => \@invoicing_list, 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ), 'body' => [ map "$_\n", $conf->config('cancelmessage') ], + 'custnum' => $self->custnum, + 'msgtype' => '', #admin? ); } #should this do something on errors? @@ -949,13 +1003,6 @@ sub uncancel { # Transaction-alize ## - 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; @@ -981,6 +1028,7 @@ sub uncancel { my $error = $cust_pkg->insert( 'change' => 1, #supresses any referral credit to a referring customer + 'allow_pkgpart' => 1, # allow this even if the package def is disabled ); if ($error) { $dbh->rollback if $oldAutoCommit; @@ -1022,15 +1070,20 @@ sub uncancel { $dbh->rollback if $oldAutoCommit; return $svc_error; } else { + # if we've failed to insert the svc_x object, svc_Common->insert + # will have removed the cust_svc already. if not, then both records + # were inserted but we failed for some other reason (export, most + # likely). in that case, report the error and delete the records. push @svc_errors, $svc_error; - # is this necessary? svc_Common::insert already deletes the - # cust_svc if inserting svc_x fails. my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum }); if ( $cust_svc ) { - my $cs_error = $cust_svc->delete; - if ( $cs_error ) { + # except if export_insert failed, export_delete probably won't be + # much better + local $FS::svc_Common::noexport_hack = 1; + my $cleanup_error = $svc_x->delete; # also deletes cust_svc + if ( $cleanup_error ) { # and if THAT fails, then run away $dbh->rollback if $oldAutoCommit; - return $cs_error; + return $cleanup_error; } } } # svc_fatal @@ -1104,13 +1157,6 @@ sub unexpire { my( $self, %options ) = @_; my $error; - 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; @@ -1187,13 +1233,6 @@ sub suspend { return $self->main_pkg->suspend(%options); } - 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; @@ -1326,6 +1365,8 @@ sub suspend { 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n", ( map { "Service : $_\n" } @labels ), ], + 'custnum' => $self->custnum, + 'msgtype' => 'admin' ); if ( $error ) { @@ -1412,10 +1453,8 @@ field). Can be set true to adjust the next bill date forward by the amount of time the account was inactive. This was set true by default -since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be -explicitly requested. Price plans for which this makes sense (anniversary-date -based than prorate or subscription) could have an option to enable this -behaviour? +in the past (from 1.4.2 and 1.5.0pre6 through 1.7.0), but now needs to be +explicitly requested with this option or in the price plan. =back @@ -1432,13 +1471,6 @@ sub unsuspend { return $self->main_pkg->unsuspend(%opt); } - 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; @@ -1456,6 +1488,8 @@ sub unsuspend { return ""; # no error # complain instead? } + # handle the case of setting a future unsuspend (resume) date + # and do not continue to actually unsuspend the package my $date = $opt{'date'}; if ( $date and $date > time ) { # return an error if $date <= time? @@ -1479,6 +1513,11 @@ sub unsuspend { } #if $date + if (!$self->setup) { + # then this package is being released from on-hold status + $self->set_initial_timers; + } + my @labels = (); foreach my $cust_svc ( @@ -1514,16 +1553,20 @@ sub unsuspend { my $conf = new FS::Conf; - if ( $inactive > 0 && - ( $hash{'bill'} || $hash{'setup'} ) && - ( $opt{'adjust_next_bill'} || - $conf->exists('unsuspend-always_adjust_next_bill_date') || - $self->part_pkg->option('unsuspend_adjust_bill', 1) ) - ) { - - $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive; - - } + #adjust the next bill date forward + $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive + if $inactive > 0 + && ( $hash{'bill'} || $hash{'setup'} ) + && ( $opt{'adjust_next_bill'} + || $conf->exists('unsuspend-always_adjust_next_bill_date') + || $self->part_pkg->option('unsuspend_adjust_bill', 1) + ) + && ! $self->option('suspend_bill',1) + && ( ! $self->part_pkg->option('suspend_bill',1) + || $self->option('no_suspend_bill',1) + ) + && $hash{'order_date'} != $hash{'susp'} + ; $hash{'susp'} = ''; $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time; @@ -1581,6 +1624,8 @@ sub unsuspend { : '' ), ], + 'custnum' => $self->custnum, + 'msgtype' => 'admin', ); if ( $error ) { @@ -1615,13 +1660,6 @@ sub unadjourn { my( $self, %options ) = @_; my $error; - 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; @@ -1683,6 +1721,11 @@ New locationnum, to change the location for this package. New FS::cust_location object, to create a new location and assign it to this package. +=item cust_main + +New FS::cust_main object, to create a new customer and assign the new package +to it. + =item pkgpart New pkgpart (see L). @@ -1691,15 +1734,32 @@ New pkgpart (see L). New refnum (see L). +=item quantity + +New quantity; if unspecified, the new package will have the same quantity +as the old. + +=item cust_pkg + +"New" (existing) FS::cust_pkg object. The package's services and other +attributes will be transferred to this package. + =item keep_dates Set to true to transfer billing dates (start_date, setup, last_bill, bill, susp, adjourn, cancel, expire, and contract_end) to the new package. +=item unprotect_svcs + +Normally, change() will rollback and return an error if some services +can't be transferred (also see the I config option). +If unprotect_svcs is true, this method will transfer as many services as +it can and then unconditionally cancel the old package. + =back -At least one of locationnum, cust_location, pkgpart, refnum must be specified -(otherwise, what's the point?) +At least one of locationnum, cust_location, pkgpart, refnum, cust_main, or +cust_pkg must be specified (otherwise, what's the point?) Returns either the new FS::cust_pkg object or a scalar error. @@ -1714,19 +1774,9 @@ sub change { my $self = shift; my $opt = ref($_[0]) ? shift : { @_ }; -# my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_; -# - my $conf = new FS::Conf; # Transactionize this whole mess - 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; @@ -1737,26 +1787,33 @@ sub change { my $time = time; - #$hash{$_} = $self->$_() foreach qw( last_bill bill ); - - #$hash{$_} = $self->$_() foreach qw( setup ); - $hash{'setup'} = $time if $self->setup; $hash{'change_date'} = $time; $hash{"change_$_"} = $self->$_() foreach qw( pkgnum pkgpart locationnum ); - if ( $opt->{'cust_location'} && - ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) { - $error = $opt->{'cust_location'}->insert; + if ( $opt->{'cust_location'} ) { + $error = $opt->{'cust_location'}->find_or_insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "inserting cust_location (transaction rolled back): $error"; + return "creating location record: $error"; } $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum; } + if ( $opt->{'cust_pkg'} ) { + # treat changing to a package with a different pkgpart as a + # pkgpart change (because it is) + $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart; + } + + # whether to override pkgpart checking on the new package + my $same_pkgpart = 1; + if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) { + $same_pkgpart = 0; + } + my $unused_credit = 0; my $keep_dates = $opt->{'keep_dates'}; # Special case. If the pkgpart is changing, and the customer is @@ -1777,22 +1834,63 @@ sub change { $hash{$date} = $self->getfield($date); } } + # always keep this date, regardless of anything + # (the date of the package change is in a different field) + $hash{'order_date'} = $self->getfield('order_date'); + # allow $opt->{'locationnum'} = '' to specifically set it to null # (i.e. customer default location) $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'}); - # Create the new package. - my $cust_pkg = new FS::cust_pkg { - custnum => $self->custnum, - pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ), - refnum => ( $opt->{'refnum'} || $self->refnum ), - locationnum => ( $opt->{'locationnum'} ), - %hash, - }; - $error = $cust_pkg->insert( 'change' => 1 ); + # usually this doesn't matter. the two cases where it does are: + # 1. unused_credit_change + pkgpart change + setup fee on the new package + # and + # 2. (more importantly) changing a package before it's billed + $hash{'waive_setup'} = $self->waive_setup; + + my $custnum = $self->custnum; + if ( $opt->{cust_main} ) { + my $cust_main = $opt->{cust_main}; + unless ( $cust_main->custnum ) { + my $error = $cust_main->insert( @{ $opt->{cust_main_insert_args}||[] } ); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting customer record: $error"; + } + } + $custnum = $cust_main->custnum; + } + + $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'}; + + my $cust_pkg; + if ( $opt->{'cust_pkg'} ) { + # The target package already exists; update it to show that it was + # changed from this package. + $cust_pkg = $opt->{'cust_pkg'}; + + foreach ( qw( pkgnum pkgpart locationnum ) ) { + $cust_pkg->set("change_$_", $self->get($_)); + } + $cust_pkg->set('change_date', $time); + $error = $cust_pkg->replace; + + } else { + # Create the new package. + $cust_pkg = new FS::cust_pkg { + custnum => $custnum, + locationnum => $opt->{'locationnum'}, + ( map { $_ => ( $opt->{$_} || $self->$_() ) } + qw( pkgpart quantity refnum salesnum ) + ), + %hash, + }; + $error = $cust_pkg->insert( 'change' => 1, + 'allow_pkgpart' => $same_pkgpart ); + } if ($error) { $dbh->rollback if $oldAutoCommit; - return $error; + return "inserting new package: $error"; } # Transfer services and cancel old package. @@ -1801,7 +1899,7 @@ sub change { if ($error and $error == 0) { # $old_pkg->transfer failed. $dbh->rollback if $oldAutoCommit; - return $error; + return "transferring $error"; } if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) { @@ -1810,15 +1908,19 @@ sub change { if ($error and $error == 0) { # $old_pkg->transfer failed. $dbh->rollback if $oldAutoCommit; - return $error; + return "converting $error"; } } - if ($error > 0) { + # We set unprotect_svcs when executing a "future package change". It's + # not a user-interactive operation, so returning an error means the + # package change will just fail. Rather than have that happen, we'll + # let leftover services be deleted. + if ($error > 0 and !$opt->{'unprotect_svcs'}) { # Transfers were successful, but we still had services left on the old # package. We can't change the package under this circumstances, so abort. $dbh->rollback if $oldAutoCommit; - return "Unable to transfer all services from package ". $self->pkgnum; + return "unable to transfer all services"; } #reset usage if changing pkgpart @@ -1833,7 +1935,7 @@ sub change { if ($error) { $dbh->rollback if $oldAutoCommit; - return "Error setting usage values: $error"; + return "setting usage values: $error"; } } else { # if NOT changing pkgpart, transfer any usage pools over @@ -1842,61 +1944,111 @@ sub change { $error = $usage->replace; if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "Error transferring usage pools: $error"; + return "transferring usage pools: $error"; } } } - # Order any supplemental packages. - my $part_pkg = $cust_pkg->part_pkg; - my @old_supp_pkgs = $self->supplemental_pkgs; - my @new_supp_pkgs; - foreach my $link ($part_pkg->supp_part_pkg_link) { - my $old; - foreach (@old_supp_pkgs) { - if ($_->pkgpart == $link->dst_pkgpart) { - $old = $_; - $_->pkgpart(0); # so that it can't match more than once + # transfer usage pricing add-ons, if we're not changing pkgpart + if ( $same_pkgpart ) { + foreach my $old_cust_pkg_usageprice ($self->cust_pkg_usageprice) { + my $new_cust_pkg_usageprice = new FS::cust_pkg_usageprice { + 'pkgnum' => $cust_pkg->pkgnum, + 'usagepricepart' => $old_cust_pkg_usageprice->usagepricepart, + 'quantity' => $old_cust_pkg_usageprice->quantity, + }; + $error = $new_cust_pkg_usageprice->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error transferring usage pricing add-on: $error"; } - last if $old; } - # false laziness with FS::cust_main::Packages::order_pkg - my $new = FS::cust_pkg->new({ - pkgpart => $link->dst_pkgpart, - pkglinknum => $link->pkglinknum, - custnum => $self->custnum, - main_pkgnum => $cust_pkg->pkgnum, - locationnum => $cust_pkg->locationnum, - start_date => $cust_pkg->start_date, - order_date => $cust_pkg->order_date, - expire => $cust_pkg->expire, - adjourn => $cust_pkg->adjourn, - contract_end => $cust_pkg->contract_end, - refnum => $cust_pkg->refnum, - discountnum => $cust_pkg->discountnum, - waive_setup => $cust_pkg->waive_setup - }); - if ( $old and $opt->{'keep_dates'} ) { - foreach (qw(setup bill last_bill)) { - $new->set($_, $old->get($_)); + } + + # transfer discounts, if we're not changing pkgpart + if ( $same_pkgpart ) { + foreach my $old_discount ($self->cust_pkg_discount_active) { + # don't remove the old discount, we may still need to bill that package. + my $new_discount = new FS::cust_pkg_discount { + 'pkgnum' => $cust_pkg->pkgnum, + 'discountnum' => $old_discount->discountnum, + 'months_used' => $old_discount->months_used, + }; + $error = $new_discount->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "transferring discounts: $error"; } } - $error = $new->insert; - # transfer services - if ( $old ) { - $error ||= $old->transfer($new); - } - if ( $error and $error > 0 ) { - # no reason why this should ever fail, but still... - $error = "Unable to transfer all services from supplemental package ". - $old->pkgnum; - } + } + + # transfer (copy) invoice details + foreach my $detail ($self->cust_pkg_detail) { + my $new_detail = FS::cust_pkg_detail->new({ $detail->hash }); + $new_detail->set('pkgdetailnum', ''); + $new_detail->set('pkgnum', $cust_pkg->pkgnum); + $error = $new_detail->insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; - return $error; + return "transferring package notes: $error"; } - push @new_supp_pkgs, $new; } + + my @new_supp_pkgs; + + if ( !$opt->{'cust_pkg'} ) { + # Order any supplemental packages. + my $part_pkg = $cust_pkg->part_pkg; + my @old_supp_pkgs = $self->supplemental_pkgs; + foreach my $link ($part_pkg->supp_part_pkg_link) { + my $old; + foreach (@old_supp_pkgs) { + if ($_->pkgpart == $link->dst_pkgpart) { + $old = $_; + $_->pkgpart(0); # so that it can't match more than once + } + last if $old; + } + # false laziness with FS::cust_main::Packages::order_pkg + my $new = FS::cust_pkg->new({ + pkgpart => $link->dst_pkgpart, + pkglinknum => $link->pkglinknum, + custnum => $custnum, + main_pkgnum => $cust_pkg->pkgnum, + locationnum => $cust_pkg->locationnum, + start_date => $cust_pkg->start_date, + order_date => $cust_pkg->order_date, + expire => $cust_pkg->expire, + adjourn => $cust_pkg->adjourn, + contract_end => $cust_pkg->contract_end, + refnum => $cust_pkg->refnum, + discountnum => $cust_pkg->discountnum, + waive_setup => $cust_pkg->waive_setup, + }); + if ( $old and $opt->{'keep_dates'} ) { + foreach (qw(setup bill last_bill)) { + $new->set($_, $old->get($_)); + } + } + $error = $new->insert( allow_pkgpart => $same_pkgpart ); + # transfer services + if ( $old ) { + $error ||= $old->transfer($new); + } + if ( $error and $error > 0 ) { + # no reason why this should ever fail, but still... + $error = "Unable to transfer all services from supplemental package ". + $old->pkgnum; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + push @new_supp_pkgs, $new; + } + } # if !$opt->{'cust_pkg'} + # because if there is one, then supplemental packages would already + # have been created for it. #Good to go, cancel old package. Notify 'cancel' of whether to credit #remaining time. @@ -1904,14 +2056,20 @@ sub change { #outstanding usage) if we are keeping dates (i.e. location changing), #because the new package will be billed for the same date range. #Supplemental packages are also canceled here. + + # during scheduled changes, avoid canceling the package we just + # changed to (duh) + $self->set('change_to_pkgnum' => ''); + $error = $self->cancel( - quiet => 1, - unused_credit => $unused_credit, - nobill => $keep_dates + quiet => 1, + unused_credit => $unused_credit, + nobill => $keep_dates, + change_custnum => ( $self->custnum != $custnum ? $custnum : '' ), ); if ($error) { $dbh->rollback if $oldAutoCommit; - return $error; + return "canceling old package: $error"; } if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) { @@ -1921,7 +2079,7 @@ sub change { ); if ( $error ) { $dbh->rollback if $oldAutoCommit; - return $error; + return "billing new package: $error"; } } @@ -1931,113 +2089,474 @@ sub change { } -use Storable 'thaw'; -use MIME::Base64; -sub process_bulk_cust_pkg { - my $job = shift; - my $param = thaw(decode_base64(shift)); - warn Dumper($param) if $DEBUG; +=item change_later OPTION => VALUE... - my $old_part_pkg = qsearchs('part_pkg', - { pkgpart => $param->{'old_pkgpart'} }); - my $new_part_pkg = qsearchs('part_pkg', - { pkgpart => $param->{'new_pkgpart'} }); - die "Must select a new package type\n" unless $new_part_pkg; - #my $keep_dates = $param->{'keep_dates'} || 0; - my $keep_dates = 1; # there is no good reason to turn this off +Schedule a package change for a later date. This actually orders the new +package immediately, but sets its start date for a future date, and sets +the current package to expire on the same date. + +If the package is already scheduled for a change, this can be called with +'start_date' to change the scheduled date, or with pkgpart and/or +locationnum to modify the package change. To cancel the scheduled change +entirely, see C. + +Options include: + +=over 4 + +=item start_date + +The date for the package change. Required, and must be in the future. + +=item pkgpart + +=item locationnum - local $SIG{HUP} = 'IGNORE'; - local $SIG{INT} = 'IGNORE'; - local $SIG{QUIT} = 'IGNORE'; - local $SIG{TERM} = 'IGNORE'; - local $SIG{TSTP} = 'IGNORE'; - local $SIG{PIPE} = 'IGNORE'; +=item quantity + +The pkgpart. locationnum, and quantity of the new package, with the same +meaning as in C. + +=back + +=cut + +sub change_later { + my $self = shift; + my $opt = ref($_[0]) ? shift : { @_ }; my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; my $dbh = dbh; - my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } ); + my $cust_main = $self->cust_main; - my $i = 0; - foreach my $old_cust_pkg ( @cust_pkgs ) { - $i++; - $job->update_statustext(int(100*$i/(scalar @cust_pkgs))); - if ( $old_cust_pkg->getfield('cancel') ) { - warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '. - $old_cust_pkg->pkgnum."\n" - if $DEBUG; - next; + my $date = delete $opt->{'start_date'} or return 'start_date required'; + + if ( $date <= time ) { + $dbh->rollback if $oldAutoCommit; + return "start_date $date is in the past"; + } + + my $error; + + if ( $self->change_to_pkgnum ) { + my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum); + my $new_pkgpart = $opt->{'pkgpart'} + if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart; + my $new_locationnum = $opt->{'locationnum'} + if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum; + my $new_quantity = $opt->{'quantity'} + if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity; + if ( $new_pkgpart or $new_locationnum or $new_quantity ) { + # it hasn't been billed yet, so in principle we could just edit + # it in place (w/o a package change), but that's bad form. + # So change the package according to the new options... + my $err_or_pkg = $change_to->change(%$opt); + if ( ref $err_or_pkg ) { + # Then set that package up for a future start. + $self->set('change_to_pkgnum', $err_or_pkg->pkgnum); + $self->set('expire', $date); # in case it's different + $err_or_pkg->set('start_date', $date); + $err_or_pkg->set('change_date', ''); + $err_or_pkg->set('change_pkgnum', ''); + + $error = $self->replace || + $err_or_pkg->replace || + $change_to->cancel || + $change_to->delete; + } else { + $error = $err_or_pkg; + } + } else { # change the start date only. + $self->set('expire', $date); + $change_to->set('start_date', $date); + $error = $self->replace || $change_to->replace; } - warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n" - if $DEBUG; - my $error = $old_cust_pkg->change( - 'pkgpart' => $param->{'new_pkgpart'}, - 'keep_dates' => $keep_dates - ); - if ( !ref($error) ) { # change returns the cust_pkg on success - $dbh->rollback; - die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n"; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } else { + $dbh->commit if $oldAutoCommit; + return ''; } + } # if $self->change_to_pkgnum + + my $new_pkgpart = $opt->{'pkgpart'} + if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart; + my $new_locationnum = $opt->{'locationnum'} + if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum; + my $new_quantity = $opt->{'quantity'} + if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity; + + return '' unless $new_pkgpart or $new_locationnum or $new_quantity; # wouldn't do anything + + # allow $opt->{'locationnum'} = '' to specifically set it to null + # (i.e. customer default location) + $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'}); + + my $new = FS::cust_pkg->new( { + custnum => $self->custnum, + locationnum => $opt->{'locationnum'}, + start_date => $date, + map { $_ => ( $opt->{$_} || $self->$_() ) } + qw( pkgpart quantity refnum salesnum ) + } ); + $error = $new->insert('change' => 1, + 'allow_pkgpart' => ($new_pkgpart ? 0 : 1)); + if ( !$error ) { + $self->set('change_to_pkgnum', $new->pkgnum); + $self->set('expire', $date); + $error = $self->replace; } - $dbh->commit if $oldAutoCommit; - return; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + } else { + $dbh->commit if $oldAutoCommit; + } + + $error; } -=item last_bill +=item abort_change -Returns the last bill date, or if there is no last bill date, the setup date. -Useful for billing metered services. +Cancels a future package change scheduled by C. =cut -sub last_bill { +sub abort_change { my $self = shift; - return $self->setfield('last_bill', $_[0]) if @_; - return $self->getfield('last_bill') if $self->getfield('last_bill'); - my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum, - 'edate' => $self->bill, } ); - $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0; + my $pkgnum = $self->change_to_pkgnum; + my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum; + my $error; + if ( $change_to ) { + $error = $change_to->cancel || $change_to->delete; + return $error if $error; + } + $self->set('change_to_pkgnum', ''); + $self->set('expire', ''); + $self->replace; } -=item last_cust_pkg_reason ACTION +=item set_quantity QUANTITY -Returns the most recent ACTION FS::cust_pkg_reason associated with the package. -Returns false if there is no reason or the package is not currenly ACTION'd -ACTION is one of adjourn, susp, cancel, or expire. +Change the package's quantity field. This is one of the few package properties +that can safely be changed without canceling and reordering the package +(because it doesn't affect tax eligibility). Returns an error or an +empty string. =cut -sub last_cust_pkg_reason { - my ( $self, $action ) = ( shift, shift ); - my $date = $self->get($action); - qsearchs( { - 'table' => 'cust_pkg_reason', - 'hashref' => { 'pkgnum' => $self->pkgnum, - 'action' => substr(uc($action), 0, 1), - 'date' => $date, - }, - 'order_by' => 'ORDER BY num DESC LIMIT 1', - } ); +sub set_quantity { + my $self = shift; + $self = $self->replace_old; # just to make sure + $self->quantity(shift); + $self->replace; } -=item last_reason ACTION +=item set_salesnum SALESNUM -Returns the most recent ACTION FS::reason associated with the package. -Returns false if there is no reason or the package is not currenly ACTION'd -ACTION is one of adjourn, susp, cancel, or expire. +Change the package's salesnum (sales person) field. This is one of the few +package properties that can safely be changed without canceling and reordering +the package (because it doesn't affect tax eligibility). Returns an error or +an empty string. =cut -sub last_reason { - my $cust_pkg_reason = shift->last_cust_pkg_reason(@_); - $cust_pkg_reason->reason - if $cust_pkg_reason; +sub set_salesnum { + my $self = shift; + $self = $self->replace_old; # just to make sure + $self->salesnum(shift); + $self->replace; + # XXX this should probably reassign any credit that's already been given } -=item part_pkg +=item modify_charge OPTIONS -Returns the definition for this billing item, as an FS::part_pkg object (see +Change the properties of a one-time charge. The following properties can +be changed this way: +- pkg: the package description +- classnum: the package class +- additional: arrayref of additional invoice details to add to this package + +and, I: +- start_date: the date when it will be billed +- amount: the setup fee to be charged +- quantity: the multiplier for the setup fee + +If you pass 'adjust_commission' => 1, and the classnum changes, and there are +commission credits linked to this charge, they will be recalculated. + +=cut + +sub modify_charge { + my $self = shift; + my %opt = @_; + my $part_pkg = $self->part_pkg; + my $pkgnum = $self->pkgnum; + + my $dbh = dbh; + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + + return "Can't use modify_charge except on one-time charges" + unless $part_pkg->freq eq '0'; + + if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) { + $part_pkg->set('pkg', $opt{'pkg'}); + } + + my %pkg_opt = $part_pkg->options; + my $pkg_opt_modified = 0; + + $opt{'additional'} ||= []; + my $i; + my @old_additional; + foreach (grep /^additional/, keys %pkg_opt) { + ($i) = ($_ =~ /^additional_info(\d+)$/); + $old_additional[$i] = $pkg_opt{$_} if $i; + delete $pkg_opt{$_}; + } + + for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) { + $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i]; + if (!exists($old_additional[$i]) + or $old_additional[$i] ne $opt{'additional'}->[$i]) + { + $pkg_opt_modified = 1; + } + } + $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i; + $pkg_opt{'additional_count'} = $i if $i > 0; + + my $old_classnum; + if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} ) + { + # remember it + $old_classnum = $part_pkg->classnum; + $part_pkg->set('classnum', $opt{'classnum'}); + } + + if ( !$self->get('setup') ) { + # not yet billed, so allow amount and quantity + if ( exists($opt{'quantity'}) + and $opt{'quantity'} != $self->quantity + and $opt{'quantity'} > 0 ) { + + $self->set('quantity', $opt{'quantity'}); + } + if ( exists($opt{'start_date'}) + and $opt{'start_date'} != $self->start_date ) { + + $self->set('start_date', $opt{'start_date'}); + } + + if ( exists($opt{'amount'}) + and $part_pkg->option('setup_fee') != $opt{'amount'} + and $opt{'amount'} > 0 ) { + + $pkg_opt{'setup_fee'} = $opt{'amount'}; + $pkg_opt_modified = 1; + + } + } # else simply ignore them; the UI shouldn't allow editing the fields + + my $error; + if ( $part_pkg->modified or $pkg_opt_modified ) { + # can we safely modify the package def? + # Yes, if it's not available for purchase, and this is the only instance + # of it. + if ( $part_pkg->disabled + and FS::cust_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 1 + and FS::quotation_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 0 + ) { + $error = $part_pkg->replace( options => \%pkg_opt ); + } else { + # clone it + $part_pkg = $part_pkg->clone; + $part_pkg->set('disabled' => 'Y'); + $error = $part_pkg->insert( options => \%pkg_opt ); + # and associate this as yet-unbilled package to the new package def + $self->set('pkgpart' => $part_pkg->pkgpart); + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ($self->modified) { # for quantity or start_date change, or if we had + # to clone the existing package def + my $error = $self->replace; + return $error if $error; + } + if (defined $old_classnum) { + # fix invoice grouping records + my $old_catname = $old_classnum + ? FS::pkg_class->by_key($old_classnum)->categoryname + : ''; + my $new_catname = $opt{'classnum'} + ? $part_pkg->pkg_class->categoryname + : ''; + if ( $old_catname ne $new_catname ) { + foreach my $cust_bill_pkg ($self->cust_bill_pkg) { + # (there should only be one...) + my @display = qsearch( 'cust_bill_pkg_display', { + 'billpkgnum' => $cust_bill_pkg->billpkgnum, + 'section' => $old_catname, + }); + foreach (@display) { + $_->set('section', $new_catname); + $error = $_->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + } # foreach $cust_bill_pkg + } + + if ( $opt{'adjust_commission'} ) { + # fix commission credits...tricky. + foreach my $cust_event ($self->cust_event) { + my $part_event = $cust_event->part_event; + foreach my $table (qw(sales agent)) { + my $class = + "FS::part_event::Action::Mixin::credit_${table}_pkg_class"; + my $credit = qsearchs('cust_credit', { + 'eventnum' => $cust_event->eventnum, + }); + if ( $part_event->isa($class) ) { + # Yes, this results in current commission rates being applied + # retroactively to a one-time charge. For accounting purposes + # there ought to be some kind of time limit on doing this. + my $amount = $part_event->_calc_credit($self); + if ( $credit and $credit->amount ne $amount ) { + # Void the old credit. + $error = $credit->void('Package class changed'); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "$error (adjusting commission credit)"; + } + } + # redo the event action to recreate the credit. + local $@ = ''; + eval { $part_event->do_action( $self, $cust_event ) }; + if ( $@ ) { + $dbh->rollback if $oldAutoCommit; + return $@; + } + } # if $part_event->isa($class) + } # foreach $table + } # foreach $cust_event + } # if $opt{'adjust_commission'} + } # if defined $old_classnum + + $dbh->commit if $oldAutoCommit; + ''; +} + + + +use Data::Dumper; +sub process_bulk_cust_pkg { + my $job = shift; + my $param = shift; + warn Dumper($param) if $DEBUG; + + my $old_part_pkg = qsearchs('part_pkg', + { pkgpart => $param->{'old_pkgpart'} }); + my $new_part_pkg = qsearchs('part_pkg', + { pkgpart => $param->{'new_pkgpart'} }); + die "Must select a new package type\n" unless $new_part_pkg; + #my $keep_dates = $param->{'keep_dates'} || 0; + my $keep_dates = 1; # there is no good reason to turn this off + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } ); + + my $i = 0; + foreach my $old_cust_pkg ( @cust_pkgs ) { + $i++; + $job->update_statustext(int(100*$i/(scalar @cust_pkgs))); + if ( $old_cust_pkg->getfield('cancel') ) { + warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '. + $old_cust_pkg->pkgnum."\n" + if $DEBUG; + next; + } + warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n" + if $DEBUG; + my $error = $old_cust_pkg->change( + 'pkgpart' => $param->{'new_pkgpart'}, + 'keep_dates' => $keep_dates + ); + if ( !ref($error) ) { # change returns the cust_pkg on success + $dbh->rollback; + die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n"; + } + } + $dbh->commit if $oldAutoCommit; + return; +} + +=item last_bill + +Returns the last bill date, or if there is no last bill date, the setup date. +Useful for billing metered services. + +=cut + +sub last_bill { + my $self = shift; + return $self->setfield('last_bill', $_[0]) if @_; + return $self->getfield('last_bill') if $self->getfield('last_bill'); + my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum, + 'edate' => $self->bill, } ); + $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0; +} + +=item last_cust_pkg_reason ACTION + +Returns the most recent ACTION FS::cust_pkg_reason associated with the package. +Returns false if there is no reason or the package is not currenly ACTION'd +ACTION is one of adjourn, susp, cancel, or expire. + +=cut + +sub last_cust_pkg_reason { + my ( $self, $action ) = ( shift, shift ); + my $date = $self->get($action); + qsearchs( { + 'table' => 'cust_pkg_reason', + 'hashref' => { 'pkgnum' => $self->pkgnum, + 'action' => substr(uc($action), 0, 1), + 'date' => $date, + }, + 'order_by' => 'ORDER BY num DESC LIMIT 1', + } ); +} + +=item last_reason ACTION + +Returns the most recent ACTION FS::reason associated with the package. +Returns false if there is no reason or the package is not currenly ACTION'd +ACTION is one of adjourn, susp, cancel, or expire. + +=cut + +sub last_reason { + my $cust_pkg_reason = shift->last_cust_pkg_reason(@_); + $cust_pkg_reason->reason + if $cust_pkg_reason; +} + +=item part_pkg + +Returns the definition for this billing item, as an FS::part_pkg object (see L). =cut @@ -2061,6 +2580,18 @@ sub old_cust_pkg { qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } ); } +=item change_cust_main + +Returns the customter this package was detached to, if any. + +=cut + +sub change_cust_main { + my $self = shift; + return '' unless $self->change_custnum; + qsearchs('cust_main', { 'custnum' => $self->change_custnum } ); +} + =item calc_setup Calls the I of the FS::part_pkg object associated with this billing @@ -2085,6 +2616,18 @@ sub calc_recur { $self->part_pkg->calc_recur($self, @_); } +=item base_setup + +Calls the I of the FS::part_pkg object associated with this billing +item. + +=cut + +sub base_setup { + my $self = shift; + $self->part_pkg->base_setup($self, @_); +} + =item base_recur Calls the I of the FS::part_pkg object associated with this billing @@ -2165,13 +2708,6 @@ If there is an error, returns the error, otherwise returns false. sub set_cust_pkg_detail { my( $self, $detailtype, @details ) = @_; - 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; @@ -2205,7 +2741,7 @@ sub set_cust_pkg_detail { =item cust_event -Returns the new-style customer billing events (see L) for this invoice. +Returns the customer billing events (see L) for this invoice. =cut @@ -2222,35 +2758,93 @@ sub cust_event { =item num_cust_event -Returns the number of new-style customer billing events (see L) for this invoice. +Returns the number of customer billing events (see L) for this package. =cut #false laziness w/cust_bill.pm sub num_cust_event { my $self = shift; - my $sql = - "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ". - " WHERE tablenum = ? AND eventtable = 'cust_pkg'"; + my $sql = "SELECT COUNT(*) ". $self->_from_cust_event_where; + $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref->[0]; +} + +=item exists_cust_event + +Returns true if there are customer billing events (see L) for this package. More efficient than using num_cust_event. + +=cut + +sub exists_cust_event { + my $self = shift; + my $sql = "SELECT 1 ". $self->_from_cust_event_where. " LIMIT 1"; + my $row = $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref; + $row ? $row->[0] : ''; +} + +sub _from_cust_event_where { + #my $self = shift; + " FROM cust_event JOIN part_event USING ( eventpart ) ". + " WHERE tablenum = ? AND eventtable = 'cust_pkg' "; +} + +sub _prep_ex { + my( $self, $sql, @args ) = @_; my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql"; - $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql"; - $sth->fetchrow_arrayref->[0]; + $sth->execute(@args) or die $sth->errstr. " executing $sql"; + $sth; +} + +=item part_pkg_currency_option OPTIONNAME + +Returns a two item list consisting of the currency of this customer, if any, +and a value for the provided option. If the customer has a currency, the value +is the option value the given name and the currency (see +L). Otherwise, if the customer has no currency, is the +regular option value for the given name (see L). + +=cut + +sub part_pkg_currency_option { + my( $self, $optionname ) = @_; + my $part_pkg = $self->part_pkg; + if ( my $currency = $self->cust_main->currency ) { + ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) ); + } else { + ('', $part_pkg->option($optionname) ); + } } =item cust_svc [ SVCPART ] (old, deprecated usage) =item cust_svc [ OPTION => VALUE ... ] (current usage) +=item cust_svc_unsorted [ OPTION => VALUE ... ] + Returns the services for this package, as FS::cust_svc objects (see L). Available options are svcpart and svcdb. If either is spcififed, returns only the matching services. +As an optimization, use the cust_svc_unsorted version if you are not displaying +the results. + =cut sub cust_svc { my $self = shift; + cluck "cust_pkg->cust_svc called" if $DEBUG > 2; + $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) ); +} - return () unless $self->num_cust_svc(@_); +sub cust_svc_unsorted { + my $self = shift; + @{ $self->cust_svc_unsorted_arrayref(@_) }; +} + +sub cust_svc_unsorted_arrayref { + my $self = shift; + + return [] unless $self->num_cust_svc(@_); my %opt = (); if ( @_ && $_[0] =~ /^\d+/ ) { @@ -2273,13 +2867,7 @@ sub cust_svc { $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} ); } - cluck "cust_pkg->cust_svc called" if $DEBUG > 2; - - #if ( $self->{'_svcnum'} ) { - # values %{ $self->{'_svcnum'}->cache }; - #} else { - $self->_sort_cust_svc( [ qsearch(\%search) ] ); - #} + [ qsearch(\%search) ]; } @@ -2332,11 +2920,13 @@ sub _sort_cust_svc { my $sort = sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }; + my %pkg_svc = map { $_->svcpart => $_ } + qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } ); + map { $_->[0] } sort $sort map { - my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart, - 'svcpart' => $_->svcpart } ); + my $pkg_svc = $pkg_svc{ $_->svcpart } || ''; [ $_, $pkg_svc ? $pkg_svc->primary_svc : '', $pkg_svc ? $pkg_svc->quantity : 0, @@ -2432,17 +3022,35 @@ following extra fields: =over 4 -=item num_cust_svc (count) +=item num_cust_svc + +(count) -=item num_avail (quantity - count) +=item num_avail -=item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects +(quantity - count) + +=item cust_pkg_svc + +(services) - array reference containing the provisioned services, as cust_svc objects =back -Accepts one option: summarize_size. If specified and non-zero, will omit the -extra cust_pkg_svc option for objects where num_cust_svc is this size or -greater. +Accepts two options: + +=over 4 + +=item summarize_size + +If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc +is this size or greater. + +=item hide_discontinued + +If true, will omit looking for services that are no longer avaialble in the +package definition. + +=back =cut @@ -2471,16 +3079,18 @@ sub part_svc { $part_svc; } $self->part_pkg->pkg_svc; - #extras - push @part_svc, map { - my $part_svc = $_; - my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart); - $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail - $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ? - $part_svc->{'Hash'}{'cust_pkg_svc'} = - $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []; - $part_svc; - } $self->extra_part_svc; + unless ( $opt{hide_discontinued} ) { + #extras + push @part_svc, map { + my $part_svc = $_; + my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart); + $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail + $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ? + $part_svc->{'Hash'}{'cust_pkg_svc'} = + $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []; + $part_svc; + } $self->extra_part_svc; + } @part_svc; @@ -2547,6 +3157,8 @@ Returns a short status string for this package, currently: =over 4 +=item on hold + =item not yet billed =item one-time charge @@ -2567,6 +3179,7 @@ sub status { my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq; return 'cancelled' if $self->get('cancel'); + return 'on hold' if $self->susp && ! $self->setup; return 'suspended' if $self->susp; return 'not yet billed' unless $self->setup; return 'one-time charge' if $freq =~ /^(0|$)/; @@ -2593,8 +3206,9 @@ Class method that returns the list of possible status strings for packages =cut tie my %statuscolor, 'Tie::IxHash', + 'on hold' => '7E0079', #purple! 'not yet billed' => '009999', #teal? cyan? - 'one-time charge' => '000000', + 'one-time charge' => '0000CC', #blue #'000000', 'active' => '00CC00', 'suspended' => 'FF9900', 'cancelled' => 'FF0000', @@ -2607,6 +3221,11 @@ sub statuses { keys %statuscolor; } +sub statuscolors { + #my $self = shift; + \%statuscolor; +} + =item statuscolor Returns a hex triplet color string for this package's status. @@ -2621,13 +3240,13 @@ sub statuscolor { =item pkg_label Returns a label for this package. (Currently "pkgnum: pkg - comment" or -"pkg-comment" depending on user preference). +"pkg - comment" depending on user preference). =cut sub pkg_label { my $self = shift; - my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 ); + my $label = $self->part_pkg->pkg_comment( cust_pkg=>$self, nopkgpart=>1 ); $label = $self->pkgnum. ": $label" if $FS::CurrentUser::CurrentUser->option('show_pkgnum'); $label; @@ -2648,6 +3267,17 @@ sub pkg_label_long { $label; } +=item pkg_locale + +Returns a customer-localized label for this package. + +=cut + +sub pkg_locale { + my $self = shift; + $self->part_pkg->pkg_locale( $self->cust_main->locale ); +} + =item primary_cust_svc Returns a primary service (as FS::cust_svc object) if one can be identified. @@ -2795,13 +3425,6 @@ sub _labels_short { Returns the parent customer object (see L). -=cut - -sub cust_main { - my $self = shift; - qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); -} - =item balance Returns the balance for this specific package, when using @@ -2863,7 +3486,16 @@ Returns the L object for tax_locationnum. sub tax_location { my $self = shift; - FS::cust_location->by_key( $self->tax_locationnum ) + my $conf = FS::Conf->new; + if ( $conf->exists('tax-pkg_address') and $self->locationnum ) { + return FS::cust_location->by_key($self->locationnum); + } + elsif ( $conf->exists('tax-ship_address') ) { + return $self->cust_main->ship_location; + } + else { + return $self->cust_main->bill_location; + } } =item seconds_since TIMESTAMP @@ -2943,8 +3575,7 @@ sub attribute_since_sqlradacct { foreach my $cust_svc ( grep { my $part_svc = $_->part_svc; - $part_svc->svcdb eq 'svc_acct' - && scalar($part_svc->part_export_usage); + scalar($part_svc->part_export_usage); } $self->cust_svc ) { $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib); @@ -3005,7 +3636,7 @@ sub transfer { 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; + $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 ); } foreach my $cust_svc ($dest->cust_svc) { @@ -3040,14 +3671,15 @@ sub transfer { } } + my $error; foreach my $cust_svc ($self->cust_svc) { + my $svcnum = $cust_svc->svcnum; if($target{$cust_svc->svcpart} > 0 or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option $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; + $error = $new->replace($cust_svc); } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) { if ( $DEBUG ) { warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n"; @@ -3067,40 +3699,71 @@ sub transfer { 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; + $error = $new->replace($cust_svc); } else { $remaining++; } } else { $remaining++ } + if ( $error ) { + my @label = $cust_svc->label; + return "$label[0] $label[1]: $error"; + } } return $remaining; } -=item reexport +=item grab_svcnums SVCNUM, SVCNUM ... -This method is deprecated. See the I option to the insert and -order_pkgs methods in FS::cust_main for a better way to defer provisioning. +Change the pkgnum for the provided services to this packages. If there is an +error, returns the error, otherwise returns false. =cut -sub reexport { +sub grab_svcnums { 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 @svcnum = @_; my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; my $dbh = dbh; - foreach my $cust_svc ( $self->cust_svc ) { + foreach my $svcnum (@svcnum) { + my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do { + $dbh->rollback if $oldAutoCommit; + return "unknown svcnum $svcnum"; + }; + $cust_svc->pkgnum( $self->pkgnum ); + my $error = $cust_svc->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + +=item reexport + +This method is deprecated. See the I option to the insert and +order_pkgs methods in FS::cust_main for a better way to defer provisioning. + +=cut + +#looks like this is still used by the order_pkg and change_pkg methods in +# ClientAPI/MyAccount, need to look into those before removing +sub reexport { + my $self = shift; + + 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 ) { @@ -3117,6 +3780,32 @@ sub reexport { } +=item export_pkg_change OLD_CUST_PKG + +Calls the "pkg_change" export action for all services attached to this package. + +=cut + +sub export_pkg_change { + my( $self, $old ) = ( shift, shift ); + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) { + my $error = $svc_x->export('pkg_change', $self, $old); + 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 @@ -3271,15 +3960,36 @@ sub recharge { } } -=item cust_pkg_discount +=item apply_usageprice =cut -sub cust_pkg_discount { +sub apply_usageprice { my $self = shift; - qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } ); + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error = ''; + + foreach my $cust_pkg_usageprice ( $self->cust_pkg_usageprice ) { + $error ||= $cust_pkg_usageprice->apply; + } + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + die "error applying part_pkg_usageprice add-ons, pkgnum ". $self->pkgnum. + ": $error\n"; + } else { + $dbh->commit if $oldAutoCommit; + } + + } +=item cust_pkg_discount + =item cust_pkg_discount_active =cut @@ -3293,13 +4003,6 @@ sub cust_pkg_discount_active { Returns a list of all voice usage counters attached to this package. -=cut - -sub cust_pkg_usage { - my $self = shift; - qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum }); -} - =item apply_usage OPTIONS Takes the following options: @@ -3325,16 +4028,10 @@ sub apply_usage { my $pkgnum = $self->pkgnum; my $custnum = $self->custnum; - 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; + my $order = FS::Conf->new->config('cdr-minutes_priority'); my $is_classnum; @@ -3368,7 +4065,7 @@ sub apply_usage { minutes => min($cust_pkg_usage->minutes, $minutes), }); $cust_pkg_usage->set('minutes', - sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes) + $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes ); $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert; $minutes -= $cdr_cust_pkg_usage->minutes; @@ -3568,6 +4265,21 @@ sub inactive_sql { " AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 ) "; } +=item on_hold_sql + +Returns an SQL expression identifying on-hold packages. + +=cut + +sub on_hold_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 + AND ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 ) + "; +} + =item susp_sql =item suspended_sql @@ -3581,6 +4293,7 @@ sub susp_sql { " ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 ) AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0 + AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0 "; } @@ -3606,6 +4319,7 @@ Returns an SQL expression to give the package status as a string. sub status_sql { "CASE WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled' + WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold' 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' @@ -3613,450 +4327,6 @@ sub status_sql { 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; if true, returns only packages with more than 0 FCC phone lines. - -=item state, country - -Limit to packages with a service location in the specified state and country. -For FCC 477 reporting, mostly. - -=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_location.censustract = '$1'"; - $censustract .= ' OR cust_location.censustract is NULL' unless $1; - push @where, "( $censustract )"; - } - - ### - # parse censustract2 - ### - if ( exists($params->{'censustract2'}) - && $params->{'censustract2'} =~ /^(\d*)$/ - ) - { - if ($1) { - push @where, "cust_location.censustract LIKE '$1%'"; - } else { - push @where, - "( cust_location.censustract = '' OR cust_location.censustract IS NULL )"; - } - } - - ### - # parse country/state - ### - for (qw(state country)) { # parsing rules are the same for these - if ( exists($params->{$_}) - && uc($params->{$_}) =~ /^([A-Z]{2})$/ ) - { - # XXX post-2.3 only--before that, state/country may be in cust_main - push @where, "cust_location.$_ = '$1'"; - } - } - - ### - # 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 change_date cancel )) { - - next unless exists($params->{$field}); - - my($beginning, $ending) = @{$params->{$field}}; - - next if $beginning == 0 && $ending == 4294967295; - - push @where, - "cust_pkg.$field IS NOT NULL", - "cust_pkg.$field >= $beginning", - "cust_pkg.$field <= $ending"; - - $orderby ||= "ORDER BY cust_pkg.$field"; - - } - } - - $orderby ||= 'ORDER BY bill'; - - ### - # parse magic, legacy, etc. - ### - - if ( $params->{'magic'} && - $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/ - ) { - - $orderby = 'ORDER BY pkgnum'; - - if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) { - push @where, "pkgpart = $1"; - } - - } elsif ( $params->{'query'} eq 'pkgnum' ) { - - $orderby = 'ORDER BY pkgnum'; - - } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) { - - $orderby = 'ORDER BY pkgnum'; - - push @where, '0 < ( - SELECT count(*) FROM pkg_svc - WHERE pkg_svc.pkgpart = cust_pkg.pkgpart - AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc - WHERE cust_svc.pkgnum = cust_pkg.pkgnum - AND cust_svc.svcpart = pkg_svc.svcpart - ) - )'; - - } - - ## - # setup queries, links, subs, etc. for the search - ## - - # here is the agent virtualization - if ($params->{CurrentUser}) { - my $access_user = - qsearchs('access_user', { username => $params->{CurrentUser} }); - - if ($access_user) { - push @where, $access_user->agentnums_sql('table'=>'cust_main'); - } else { - push @where, "1=0"; - } - } else { - push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main'); - } - - my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : ''; - - my $addl_from = 'LEFT JOIN part_pkg USING ( pkgpart ) '. - 'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '. - 'LEFT JOIN cust_location USING ( locationnum ) '. - FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg'); - - my $select; - my $count_query; - if ( $params->{'select_zip5'} ) { - my $zip = 'cust_location.zip'; - - $select = "DISTINCT substr($zip,1,5) as zip"; - $orderby = "ORDER BY substr($zip,1,5)"; - $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )"; - } else { - $select = join(', ', - 'cust_pkg.*', - ( map "part_pkg.$_", qw( pkg freq ) ), - 'pkg_class.classname', - 'cust_main.custnum AS cust_main_custnum', - FS::UI::Web::cust_sql_fields( - $params->{'cust_fields'} - ), - ); - $count_query = 'SELECT COUNT(*)'; - } - - $count_query .= " FROM cust_pkg $addl_from $extra_sql"; - - my $sql_query = { - 'table' => 'cust_pkg', - 'hashref' => {}, - 'select' => $select, - 'extra_sql' => $extra_sql, - 'order_by' => $orderby, - 'addl_from' => $addl_from, - 'count_query' => $count_query, - }; - -} - =item fcc_477_count Returns a list of two package counts. The first is a count of packages @@ -4247,13 +4517,6 @@ sub order { my $conf = new FS::Conf; # Transactionize this whole mess - 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; @@ -4393,13 +4656,6 @@ sub bulk_change { my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_; # Transactionize this whole mess - 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;