diff options
37 files changed, 1408 insertions, 221 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index f76c72ff4..ae1fd4be8 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -4349,6 +4349,13 @@ and customer address. Include units.', }, { + 'key' => 'part_pkg-delay_start', + 'section' => '', + 'description' => 'Enabled "delayed start" option for packages.', + 'type' => 'checkbox', + }, + + { 'key' => 'mcp_svcpart', 'section' => '', 'description' => 'Master Control Program svcpart. Leave this blank.', diff --git a/FS/FS/Report/Table.pm b/FS/FS/Report/Table.pm index 2e202e5d9..c5a6503c3 100644 --- a/FS/FS/Report/Table.pm +++ b/FS/FS/Report/Table.pm @@ -443,6 +443,7 @@ sub cust_bill_pkg_setup { my @where = ( 'pkgnum != 0', $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), + $self->with_report_option($opt{'report_optionnum'}, $opt{'use_override'}), $self->in_time_period_and_agent($speriod, $eperiod, $agentnum), ); @@ -474,6 +475,7 @@ sub cust_bill_pkg_recur { my @where = ( 'pkgnum != 0', $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), + $self->with_report_option($opt{'report_optionnum'}, $opt{'use_override'}), ); push @where, 'cust_main.refnum = '. $opt{'refnum'} if $opt{'refnum'}; @@ -552,6 +554,7 @@ sub cust_bill_pkg_detail { push @where, $self->with_classnum($opt{'classnum'}, $opt{'use_override'}), $self->with_usageclass($opt{'usageclass'}), + $self->with_report_option($opt{'report_optionnum'}, $opt{'use_override'}), ; if ( $opt{'distribute'} ) { @@ -733,6 +736,41 @@ sub with_usageclass { return "cust_bill_pkg_detail.classnum $comparison"; } +sub with_report_option { + my $self = shift; + # $num can be a single number, or a comma-delimited list of numbers, + # or '0' to match only the empty set. + # + # or the word 'multiple' for all packages with more than one report class + my ($num, $use_override) = @_; + return '' if !defined($num); + + # stringify the set of report options for each pkgpart + my $table = $use_override ? 'override' : 'part_pkg'; + my $subselect = " + SELECT replace(optionname, 'report_option_', '') AS num + FROM part_pkg_option + WHERE optionname like 'report_option_%' + AND part_pkg_option.pkgpart = $table.pkgpart + ORDER BY num"; + + my $comparison; + if ( $num eq 'multiple' ) { + $comparison = "(SELECT COUNT(*) FROM ($subselect) AS x) > 1"; + } elsif ( $num eq '0' ) { + $comparison = "NOT EXISTS ($subselect)"; + } else { + $comparison = "(SELECT COALESCE(string_agg(num, ','), '') FROM ( + $subselect + ) AS x) = '$num'"; + } + if ( $use_override ) { + # then also allow the non-override package to match + $comparison = "( $comparison OR " . $self->with_report_option($num) . ")"; + } + $comparison; +} + sub scalar_sql { my( $self, $sql ) = ( shift, shift ); my $sth = dbh->prepare($sql) or die dbh->errstr; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 2b7db26f3..21af3a4d1 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1826,6 +1826,7 @@ sub tables_hashref { 'waive_setup', 'char', 'NULL', 1, '', '', 'recur_show_zero', 'char', 'NULL', 1, '', '', 'setup_show_zero', 'char', 'NULL', 1, '', '', + 'change_to_pkgnum', 'int', 'NULL', '', '', '', ], 'primary_key' => 'pkgnum', 'unique' => [], @@ -2078,6 +2079,7 @@ sub tables_hashref { 'setup_show_zero', 'char', 'NULL', 1, '', '', 'successor', 'int', 'NULL', '', '', '', 'family_pkgpart','int', 'NULL', '', '', '', + 'delay_start', 'int', 'NULL', '', '', '', ], 'primary_key' => 'pkgpart', 'unique' => [], diff --git a/FS/FS/cdr/netsapiens.pm b/FS/FS/cdr/netsapiens.pm index bcaa3496d..9d07aef7e 100644 --- a/FS/FS/cdr/netsapiens.pm +++ b/FS/FS/cdr/netsapiens.pm @@ -15,11 +15,11 @@ use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker ); 'disabled' => 0, #0 default, set to 1 to disable 'import_fields' => [ - + sub { my ($cdr, $direction) = @_; - if ($direction =~ /^o/) { # 'origination' + if ($direction =~ /^t/) { # 'origination' # leave src and dst as they are - } elsif ($direction =~ /^t/) { + } elsif ($direction =~ /^o/) { my ($local, $remote) = ($cdr->src, $cdr->dst); $cdr->set('dst', $local); $cdr->set('src', $remote); @@ -28,7 +28,7 @@ use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker ); '', #Domain '', #user 'src', #local party (src/dst, based on direction) - _cdr_date_parser_maker('startddate'), + _cdr_date_parser_maker('startdate'), _cdr_date_parser_maker('answerdate'), sub { my ($cdr, $duration) = @_; $cdr->set('duration', $duration); @@ -37,14 +37,15 @@ use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker ); if $cdr->answerdate; }, 'dst', #remote party - '', #dialed number + sub { my ($cdr, $dialednum) = @_; + $cdr->set('dst',$dialednum) if $dialednum =~ /^(\+?1)?8(8|([02-7])\3)/; + }, #dialed number 'uniqueid', #CallID (timestamp + '-' + 32 char hex string) - 'src_ip_addr', - 'dst_ip_addr', + '', + '', 'disposition', ], ); 1; - diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 220f66a0c..081dd70f7 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -192,14 +192,30 @@ sub cancel_expired_pkgs { my @errors = (); - foreach my $cust_pkg ( @cancel_pkgs ) { + CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) { my $cpr = $cust_pkg->last_cust_pkg_reason('expire'); - my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, + my $error; + + if ( $cust_pkg->change_to_pkgnum ) { + + my $new_pkg = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum); + if ( !$new_pkg ) { + push @errors, 'can\'t change pkgnum '.$cust_pkg->pkgnum.' to pkgnum '. + $cust_pkg->change_to_pkgnum.'; not expiring'; + next CUST_PKG; + } + $error = $cust_pkg->change( 'cust_pkg' => $new_pkg, + 'unprotect_svcs' => 1 ); + $error = '' if ref $error eq 'FS::cust_pkg'; + + } else { # just cancel it + $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum, 'reason_otaker' => $cpr->otaker, 'time' => $time, ) : () ); + } push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error; } diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index ddfab5dcb..01eaf6253 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -210,6 +210,11 @@ The pkgnum of the package that this package is supplemental to, if any. The package link (L<FS::part_pkg_link>) 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 @@ -289,6 +294,7 @@ sub insert { my $part_pkg = $self->part_pkg; + # 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; @@ -296,6 +302,8 @@ sub insert { $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) ); } + # set up any automatic expire/adjourn/contract_end timers + # based on the start date foreach my $action ( qw(expire adjourn contract_end) ) { my $months = $part_pkg->option("${action}_months",1); if($months and !$self->$action) { @@ -304,16 +312,16 @@ sub insert { } } + # if this package has "free days" and delayed setup fee, tehn + # set start date that many days in the future. + # (this should have been set in the UI, but enforce it here) if ( ! $options{'change'} && ( my $free_days = $part_pkg->option('free_days',1) ) && $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); + $self->start_date( $part_pkg->default_start_date ); } $self->order_date(time); @@ -350,15 +358,6 @@ 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} ) { @@ -648,6 +647,7 @@ sub check { || $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; @@ -869,10 +869,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; @@ -1725,15 +1734,27 @@ New pkgpart (see L<FS::part_pkg>). New refnum (see L<FS::part_referral>). +=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<cust_pkg-change_svcpart> 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. @@ -1790,6 +1811,12 @@ sub change { $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 ) ) { @@ -1841,16 +1868,30 @@ sub change { $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'}; - # Create the new package. - my $cust_pkg = new FS::cust_pkg { - custnum => $custnum, - pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ), - refnum => ( $opt->{'refnum'} || $self->refnum ), - locationnum => ( $opt->{'locationnum'} ), - %hash, - }; - $error = $cust_pkg->insert( 'change' => 1, - 'allow_pkgpart' => $same_pkgpart ); + 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, + pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ), + refnum => ( $opt->{'refnum'} || $self->refnum ), + locationnum => ( $opt->{'locationnum'} ), + %hash, + }; + $error = $cust_pkg->insert( 'change' => 1, + 'allow_pkgpart' => $same_pkgpart ); + } if ($error) { $dbh->rollback if $oldAutoCommit; return $error; @@ -1875,7 +1916,11 @@ sub change { } } - 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; @@ -1936,57 +1981,62 @@ sub change { return "Error transferring package notes: $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 + + 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; } - 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($_)); + # 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; } - $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. @@ -1994,6 +2044,11 @@ 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, @@ -2022,6 +2077,144 @@ sub change { } +=item change_later OPTION => VALUE... + +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<abort_change>. + +Options include: + +=over 4 + +=item start_date + +The date for the package change. Required, and must be in the future. + +=item pkgpart + +=item locationnum + +The pkgpart and locationnum of the new package, with the same +meaning as in C<change>. + +=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_main = $self->cust_main; + + 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; + if ( $new_pkgpart or $new_locationnum ) { + # 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; + } + 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; + return '' unless $new_pkgpart or $new_locationnum; # wouldn't do anything + + my %hash = ( + 'custnum' => $self->custnum, + 'pkgpart' => ($opt->{'pkgpart'} || $self->pkgpart), + 'locationnum' => ($opt->{'locationnum'} || $self->locationnum), + 'start_date' => $date, + ); + my $new = FS::cust_pkg->new(\%hash); + $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; + } + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + } else { + $dbh->commit if $oldAutoCommit; + } + + $error; +} + +=item abort_change + +Cancels a future package change scheduled by C<change_later>. + +=cut + +sub abort_change { + my $self = shift; + 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 set_quantity QUANTITY Change the package's quantity field. This is the one package property @@ -2485,11 +2678,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, diff --git a/FS/FS/part_export/freeswitch.pm b/FS/FS/part_export/freeswitch.pm index eb490fd85..ff0d243bb 100644 --- a/FS/FS/part_export/freeswitch.pm +++ b/FS/FS/part_export/freeswitch.pm @@ -27,6 +27,8 @@ tie my %options, 'Tie::IxHash', <user id="<% $phonenum %>"> <params> <param name="password" value="<% $sip_password %>"/> + <param name="nibble_account" value="<% $phonenum %>"/> + <param name="nibble_rate" value="<% $nibble_rate %>"/> </params> </user> </domain> diff --git a/FS/FS/part_export/freeswitch_multifile.pm b/FS/FS/part_export/freeswitch_multifile.pm index 90a2b0469..7f79a0e68 100644 --- a/FS/FS/part_export/freeswitch_multifile.pm +++ b/FS/FS/part_export/freeswitch_multifile.pm @@ -26,6 +26,8 @@ tie my %options, 'Tie::IxHash', <user id="<% $phonenum %>"> <params> <param name="password" value="<% $sip_password %>"/> + <param name="nibble_account" value="<% $phonenum %>"/> + <param name="nibble_rate" value="<% $nibble_rate %>"/> </params> </user> </domain> diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm index 22e8828d6..0722647b4 100644 --- a/FS/FS/part_pkg.pm +++ b/FS/FS/part_pkg.pm @@ -5,7 +5,7 @@ use strict; use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack ); use Carp qw(carp cluck confess); use Scalar::Util qw( blessed ); -use Time::Local qw( timelocal_nocheck ); +use Time::Local qw( timelocal timelocal_nocheck ); use Tie::IxHash; use FS::Conf; use FS::Record qw( qsearch qsearchs dbh dbdef ); @@ -116,6 +116,8 @@ If this record is not obsolete, will be null. ancestor of this record. If this record is not a successor to another part_pkg, will be equal to pkgpart. +=item delay_start - Number of days to delay package start, by default + =back =head1 METHODS @@ -682,6 +684,7 @@ sub check { ) || $self->ut_numbern('fcc_ds0s') || $self->ut_numbern('fcc_voip_class') + || $self->ut_numbern('delay_start') || $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart') || $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart') || $self->SUPER::check @@ -1072,9 +1075,39 @@ sub is_free { } } +# whether the plan allows discounts to be applied to this package sub can_discount { 0; } - + +# whether the plan allows changing the start date sub can_start_date { 1; } + +# the default start date; takes an FS::cust_main as an argument +sub default_start_date { + my $self = shift; + my $cust_main = shift; + my $conf = FS::Conf->new; + + if ( $self->delay_start ) { + my $delay = $self->delay_start; + + my ($mday,$mon,$year) = (localtime(time))[3,4,5]; + my $start_date = timelocal(0,0,0,$mday,$mon,$year) + 86400 * $delay; + return $start_date; + + } elsif ( $conf->exists('order_pkg-no_start_date') ) { + + return ''; + + } elsif ( $cust_main ) { + + return $cust_main->next_bill_date; + + } else { + + return ''; + + } +} sub can_currency_exchange { 0; } diff --git a/FS/FS/part_pkg/delayed_Mixin.pm b/FS/FS/part_pkg/delayed_Mixin.pm index ab53bda06..ae286d351 100644 --- a/FS/FS/part_pkg/delayed_Mixin.pm +++ b/FS/FS/part_pkg/delayed_Mixin.pm @@ -2,6 +2,7 @@ package FS::part_pkg::delayed_Mixin; use strict; use vars qw(%info); +use Time::Local qw(timelocal); use NEXT; %info = ( @@ -52,4 +53,15 @@ sub calc_remain { sub can_start_date { ! shift->option('delay_setup', 1) } +sub default_start_date { + my $self = shift; + if ( $self->option('delay_setup') and $self->option('free_days') ) { + my $delay = $self->option('free_days'); + + my ($mday, $mon, $year) = (localtime(time))[3,4,5]; + return timelocal(0,0,0,$mday,$mon,$year) + 86400 * $self->option('free_days'); + } + return $self->NEXT::default_start_date(@_); +} + 1; diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index 2a048a115..3a069149f 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -946,7 +946,7 @@ sub export_batch { my $info = $export_info{$format} or die "Format not found: '$format'\n"; - &{$info->{'init'}}($conf) if exists($info->{'init'}); + &{$info->{'init'}}($conf, $self->agentnum) if exists($info->{'init'}); my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; diff --git a/FS/FS/pay_batch/eft_canada.pm b/FS/FS/pay_batch/eft_canada.pm index b24c9c3a4..64fd2f971 100644 --- a/FS/FS/pay_batch/eft_canada.pm +++ b/FS/FS/pay_batch/eft_canada.pm @@ -58,7 +58,13 @@ my %holiday = ( init => sub { my $conf = shift; - my @config = $conf->config('batchconfig-eft_canada'); + my $agentnum = shift; + my @config; + if ( $conf->exists('batch-spoolagent') ) { + @config = $conf->config('batchconfig-eft_canada', $agentnum); + } else { + @config = $conf->config('batchconfig-eft_canada'); + } # SFTP login, password, trans code, delay time my $process_delay; ($trans_code, $process_delay) = @config[2,3]; diff --git a/bin/sqlradius-reexport-group b/bin/sqlradius-reexport-group new file mode 100644 index 000000000..70a517c62 --- /dev/null +++ b/bin/sqlradius-reexport-group @@ -0,0 +1,34 @@ +#!/usr/bin/perl + +use FS::UID 'adminsuidsetup'; +use FS::Record qw( qsearch qsearchs ); +use FS::part_export; +use FS::radius_group; + +my ($user, $exportnum, $group) = @ARGV; +die "usage: +sqlradius-reexport-group <username> <exportnum> <group> +" unless $user and $exportnum and $group; + + +my $dbh = adminsuidsetup($user) or die; +$FS::UID::AutoCommit = 0; +my $radius_group; +if ( $group =~ /^\d+$/ ) { + $radius_group = FS::radius_group->by_key($group); +} else { + $radius_group = qsearchs('radius_group',{'groupname' => $group}); +} +die "no radius group $group" unless $radius_group; + +my $export = FS::part_export->by_key($exportnum) + or die "no export with exportnum '$exportnum'"; + +my @attrs = qsearch('radius_attr', {groupnum => $radius_group->groupnum}); +foreach my $attr (@attrs) { + print $attr->attrname."\n"; + my $error = $export->export_attr_insert($attr); + die $error if $error; +} + +$dbh->commit; diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi index 89f16158f..7e67c833a 100755 --- a/httemplate/edit/part_pkg.cgi +++ b/httemplate/edit/part_pkg.cgi @@ -13,7 +13,8 @@ 'html_bottom' => $html_bottom, 'body_etc' => 'onLoad="agent_changed(document.edit_topform.agentnum); - aux_planchanged(document.edit_topform.plan)"', + aux_planchanged(document.edit_topform.plan); + hide_supp_pkgs()"', 'begin_callback' => $begin_callback, 'end_callback' => $end_callback, @@ -61,10 +62,11 @@ 'discountnum' => 'Offer discounts for longer terms', 'bill_dst_pkgpart' => 'Include line item(s) from package', 'svc_dst_pkgpart' => 'Include services of package', - 'supp_dst_pkgpart' => 'Include complete package', + 'supp_dst_pkgpart' => 'When ordering package, also order', 'report_option' => 'Report classes', 'fcc_ds0s' => 'Voice-grade equivalents', 'fcc_voip_class' => 'Category', + 'delay_start' => 'Default delay (days)', }, 'fields' => [ @@ -199,6 +201,16 @@ { field=>'setup_cost', type=>'money', }, { field=>'recur_cost', type=>'money', }, + ( $conf->exists('part_pkg-delay_start') + ? ( { type => 'tablebreak-tr-title', + value => 'Delayed start', + }, + { field => 'delay_start', + type => 'text', size => 6 }, + ) + : () + ), + { type => 'columnnext' }, { field => 'agent_type', @@ -264,19 +276,6 @@ }, { 'type' => 'tablebreak-tr-title', - 'value' => 'Supplemental packages', - 'colspan' => '4', - }, - { 'field' => 'supp_dst_pkgpart', - 'type' => 'select-part_pkg', - 'm2_label' => 'Include complete package', - 'm2m_method' => 'supp_part_pkg_link', - 'm2m_dstcol' => 'dst_pkgpart', - 'm2_error_callback' => - &{$m2_error_callback_maker}('supp'), - }, - - { 'type' => 'tablebreak-tr-title', 'value' => 'Pricing add-ons', 'colspan' => 4, }, @@ -319,6 +318,22 @@ &{$m2_error_callback_maker}('svc'), }, + { 'type' => 'tablebreak-tr-title', + 'value' => 'Supplemental packages', + 'colspan' => '4', + 'include_opt_callback' => sub { + 'id' => 'show_supp_pkgs', + }, + }, + { 'field' => 'supp_dst_pkgpart', + 'type' => 'select-part_pkg', + 'm2_label' => 'When ordering package, also order', + 'm2m_method' => 'supp_part_pkg_link', + 'm2m_dstcol' => 'dst_pkgpart', + 'm2_error_callback' => + &{$m2_error_callback_maker}('supp'), + }, + { type => 'tablebreak-tr-title', value => 'Price plan options', }, @@ -782,6 +797,34 @@ my $javascript = <<'END'; } + // some magic to make "supplemental packages" less obvious + var supp_pkg_rows = []; + function show_supp_pkgs_click() { + supp_pkg_rows[0].style.display = ''; + this.onclick = ''; + this.style.backgroundColor = ''; + this.style.border = ''; + this.style.padding = ''; + } + + function hide_supp_pkgs() { + var all_selects = document.getElementsByTagName('select'); + for (var i=0; i < all_selects.length; i++) { + if ( all_selects[i].id.match(/^supp_dst_pkgpart/) ) { + supp_pkg_rows.push( all_selects[i].parentNode.parentNode ); + } + } + if ( supp_pkg_rows.length == 1 ) { + // there are none configured, so hide the row to create a new one + supp_pkg_rows[0].style.display = 'none'; + var button = document.getElementById('show_supp_pkgs'); + button.onclick = show_supp_pkgs_click; + button.style.backgroundColor = '#cccccc'; + button.style.border = '1px solid #7e0079'; + button.style.padding = '1px'; + } + } + END my $warning = diff --git a/httemplate/edit/process/change-cust_pkg.html b/httemplate/edit/process/change-cust_pkg.html index c893f13a2..9d06d8e1a 100644 --- a/httemplate/edit/process/change-cust_pkg.html +++ b/httemplate/edit/process/change-cust_pkg.html @@ -40,8 +40,35 @@ if ( $cgi->param('locationnum') == -1 ) { $change{'cust_location'} = $cust_location; } -my $pkg_or_error = $cust_pkg->change( \%change ); +my $error; +if ( $cgi->param('delay') ) { + my $date = parse_datetime($cgi->param('start_date')); + if (!$date) { + $error = "Invalid change date '".$cgi->param('start_date')."'."; + } elsif ( $date < time ) { + $error = "Change date ".$cgi->param('start_date')." is in the past."; + } else { + # schedule the change + $change{'start_date'} = $date; + $error = $cust_pkg->change_later(\%change); + } +} else { + # special case: if there's a package change scheduled, and it matches + # the parameters the user requested this time, then change to the existing + # future package. + if ( $cust_pkg->change_to_pkgnum ) { + my $change_to = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum); + if ( $change_to->pkgpart == $change{'pkgpart'} and + $change_to->locationnum == $change{'locationnum'} ) { -my $error = ref($pkg_or_error) ? '' : $pkg_or_error; + %change = ( 'cust_pkg' => $change_to ); + + } + } + + # do a package change right now + my $pkg_or_error = $cust_pkg->change( \%change ); + $error = ref($pkg_or_error) ? '' : $pkg_or_error; +} </%init> diff --git a/httemplate/elements/order_pkg.js b/httemplate/elements/order_pkg.js index 1069a0ee4..762b2ddde 100644 --- a/httemplate/elements/order_pkg.js +++ b/httemplate/elements/order_pkg.js @@ -4,9 +4,15 @@ function pkg_changed () { if ( form.pkgpart.selectedIndex > 0 ) { + var opt = form.pkgpart.options[form.pkgpart.selectedIndex]; + var date_button = document.getElementById('start_date_button'); + var date_button_disabled = document.getElementById('start_date_button_disabled'); + var date_text = document.getElementById('start_date_text'); + + form.submitButton.disabled = false; if ( discountnum ) { - if ( form.pkgpart.options[form.pkgpart.selectedIndex].getAttribute('data-can_discount') == 1 ) { + if ( opt.getAttribute('data-can_discount') == 1 ) { form.discountnum.disabled = false; discountnum_changed(form.discountnum); } else { @@ -15,14 +21,17 @@ function pkg_changed () { } } - if ( form.pkgpart.options[form.pkgpart.selectedIndex].getAttribute('data-can_start_date') == 1 ) { - form.start_date_text.disabled = false; - form.start_date.style.backgroundColor = '#ffffff'; - form.start_date_button.style.display = ''; + form.start_date_text.value = opt.getAttribute('data-start_date'); + if ( opt.getAttribute('data-can_start_date') == 1 ) { + date_text.style.backgroundColor = '#ffffff'; + date_text.disabled = false; + date_button.style.display = ''; + date_button_disabled.style.display = 'none'; } else { - form.start_date_text.disabled = true; - form.start_date.style.backgroundColor = '#dddddd'; - form.start_date_button.style.display = 'none'; + date_text.style.backgroundColor = '#dddddd'; + date_text.disabled = true; + date_button.style.display = 'none'; + date_button_disabled.style.display = ''; } } else { diff --git a/httemplate/elements/select-part_pkg.html b/httemplate/elements/select-part_pkg.html index 439c4b53e..9d41b07dc 100644 --- a/httemplate/elements/select-part_pkg.html +++ b/httemplate/elements/select-part_pkg.html @@ -23,7 +23,6 @@ Example: 'empty_label' => 'Select package', #should this be the default? 'label_callback' => sub { shift->pkg_comment }, 'hashref' => \%hash, - 'extra_option_attributes' => [ 'can_discount', 'can_start_date' ], %opt, ) %> diff --git a/httemplate/elements/tr-justtitle.html b/httemplate/elements/tr-justtitle.html index e9eda8b18..b87f7e128 100644 --- a/httemplate/elements/tr-justtitle.html +++ b/httemplate/elements/tr-justtitle.html @@ -1,5 +1,5 @@ <TR> - <TH CLASS="background" COLSPAN=<% $opt{colspan} || 2 %> ALIGN="left"> + <TH CLASS="background" COLSPAN=<% $opt{colspan} || 2 %> ALIGN="left" <%$id%>> <FONT SIZE="+1"><% $opt{value} %></FONT> </TH> </TR> @@ -7,5 +7,6 @@ <%init> my %opt = @_; +my $id = 'ID="'.$opt{id}.'"' if $opt{id}; </%init> diff --git a/httemplate/elements/tr-select-cust-part_pkg.html b/httemplate/elements/tr-select-cust-part_pkg.html index 848ab0a4b..488f04a13 100644 --- a/httemplate/elements/tr-select-cust-part_pkg.html +++ b/httemplate/elements/tr-select-cust-part_pkg.html @@ -7,10 +7,11 @@ <SCRIPT TYPE="text/javascript"> - function part_pkg_opt(what, value, text, can_discount, can_start_date) { + function part_pkg_opt(what, value, text, can_discount, can_start_date, start_date) { var optionName = new Option(text, value, false, false); optionName.setAttribute('data-can_discount', can_discount); optionName.setAttribute('data-can_start_date', can_start_date); + optionName.setAttribute('data-start_date', start_date || ''); var length = what.length; what.options[length] = optionName; } @@ -19,7 +20,7 @@ what.form.pkgpart.disabled = 'disabled'; //disable part_pkg dropdown var submitButton = what.form.submitButton; // || what.form.submit; - if ( submitButton ) { + if ( submitButton && <% $opt{'curr_value'} ? 0 : 1 %> ) { submitButton.disabled = true; //disable the submit button } var discountnum = what.form.discountnum; @@ -38,16 +39,21 @@ // add the new packages opt(what.form.pkgpart, '', 'Select package'); var packagesArray = eval('(' + part_pkg + ')' ); - for ( var s = 0; s < packagesArray.length; s=s+4 ) { + for ( var s = 0; s < packagesArray.length; s=s+5 ) { + //surely this should be some kind of JSON structure var packagesLabel = packagesArray[s+1]; var can_discount = packagesArray[s+2]; var can_start_date = packagesArray[s+3]; + var start_date = packagesArray[s+4]; part_pkg_opt( - what.form.pkgpart, packagesArray[s], packagesLabel, can_discount, can_start_date + what.form.pkgpart, packagesArray[s], packagesLabel, can_discount, can_start_date, start_date ); } what.form.pkgpart.disabled = ''; //re-enable part_pkg dropdown +% if ( $opt{'curr_value'} ) { + what.form.pkgpart.value = <% $opt{'curr_value'} %>; +% } } @@ -58,6 +64,10 @@ ); } + window.onload = function() { + classnum_changed(document.getElementById('classnum')); + } + </SCRIPT> <TR> diff --git a/httemplate/graph/cust_bill_pkg.cgi b/httemplate/graph/cust_bill_pkg.cgi index 91bedf3fe..96404a438 100644 --- a/httemplate/graph/cust_bill_pkg.cgi +++ b/httemplate/graph/cust_bill_pkg.cgi @@ -83,35 +83,67 @@ $bottom_link .= "cust_classnum=$_;" foreach @cust_classnums; #false lazinessish w/FS::cust_pkg::search_sql (previously search/cust_pkg.cgi) my $classnum = 0; -my @pkg_class = (); +my (@classnums, @classnames); my $all_class = ''; -if ( $cgi->param('classnum') eq 'all' ) { - $all_class = 'ALL'; - @pkg_class = (''); + +my ($class_table, $name_col, $value_col, $class_param); + +if ( $cgi->param('mode') eq 'report' ) { + $class_param = 'report_optionnum'; # CGI param name, also used in the report engine + $class_table = 'part_pkg_report_option'; # table containing classes + $name_col = 'name'; # the column of that table containing the label + $value_col = 'num'; # the column containing the class number +} else { + $class_param = 'classnum'; + $class_table = 'pkg_class'; + $name_col = 'classname'; + $value_col = 'classnum'; } -elsif ( $cgi->param('classnum') =~ /^(\d*)$/ ) { + +if ( $cgi->param($class_param) eq 'all' ) { # all, aggregated + $all_class = 'ALL'; + @classnums = (''); + @classnames = (''); +} elsif ( $cgi->param($class_param) =~ /^(\d*)$/ ) { + $classnum = $1; if ( $classnum ) { #a specific class + my $class = qsearchs($class_table, { $value_col => $classnum }) + or die "$class_table #$classnum not found"; - @pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) ); - die "classnum $classnum not found!" unless $pkg_class[0]; - $title .= ' '.$pkg_class[0]->classname.' '; - $bottom_link .= "classnum=$classnum;"; + $title .= ' '.$class->get($name_col); + $bottom_link .= "$class_param=$classnum;"; - } elsif ( $classnum eq '' ) { #the empty class + @classnums = ($classnum); + @classnames = ($class->get($name_col)); - $title .= 'Empty class '; - @pkg_class = ( '(empty class)' ); - $bottom_link .= "classnum=0;"; + } elsif ( $classnum eq '0' ) { #the empty class - } elsif ( $classnum eq '0' ) { #all classes + $title .= ' Empty class '; + @classnums = ( '' ); + @classnames = ( '(empty class)' ); + $bottom_link .= "$class_param=0;"; - @pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } ); - push @pkg_class, '(empty class)'; + } elsif ( $classnum eq '' ) { #all, breakdown + my @classes = qsearch($class_table, {}); + @classnames = map { $_->get($name_col) } @classes; + @classnums = map { $_->get($value_col) } @classes; + + push @classnames, '(empty class)'; + push @classnums, '0'; + + if ( $cgi->param('mode') eq 'report' ) { + # In theory, a package can belong to any subset of the report classes, + # so the report groups should be all the _subsets_, but for now we're + # handling the simple case where each package belongs to one report + # class. Packages with multiple classes will go into one bin at the + # end. + push @classnames, '(multiple classes)'; + push @classnums, 'multiple'; + } } -} -#eslaf +} #eslaf my $hue = 0; #my $hue_increment = 170; @@ -163,7 +195,9 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' => qsearch('part_referral', { 'disabled' => '' } ) ) { - foreach my $pkg_class ( @pkg_class ) { + for (my $i = 0; $i < scalar @classnums; $i++) { + my $row_classnum = $classnums[$i]; + my $row_classname = $classnames[$i]; foreach my $component ( @components ) { push @items, 'cust_bill_pkg'; @@ -171,16 +205,11 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' => push @labels, ( $all_agent || $sel_agent ? '' : $agent->agent.' ' ). ( $all_part_referral || $sel_part_referral ? '' : $part_referral->referral.' ' ). - ( $classnum eq '0' - ? ( ref($pkg_class) ? $pkg_class->classname : $pkg_class ) - : '' - ). - ' '.$charge_labels{$component}; + $row_classname . ' ' . $charge_labels{$component}; - my $row_classnum = ref($pkg_class) ? $pkg_class->classnum : 0; my $row_agentnum = $all_agent || $agent->agentnum; my $row_refnum = $all_part_referral || $part_referral->refnum; - push @params, [ ($all_class ? () : ('classnum' => $row_classnum) ), + push @params, [ ($all_class ? () : ($class_param => $row_classnum) ), ($all_agent ? () : ('agentnum' => $row_agentnum) ), ($all_part_referral ? () : ('refnum' => $row_refnum) ), 'use_override' => $use_override, @@ -193,7 +222,7 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' => ($all_agent ? '' : "agentnum=$row_agentnum;"). ($all_part_referral ? '' : "refnum=$row_refnum;"). (join('',map {"cust_classnum=$_;"} @cust_classnums)). - ($all_class ? '' : "classnum=$row_classnum;"). + ($all_class ? '' : "$class_param=$row_classnum;"). "distribute=$distribute;". "use_override=$use_override;charges=$component;"; @@ -205,7 +234,7 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' => push @no_graph, 0; } #foreach $component - } #foreach $pkg_class + } #foreach $row_classnum } #foreach $part_referral if ( $cgi->param('agent_totals') and !$all_agent ) { @@ -226,11 +255,10 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' => "charges=$component"; # Also apply any refnum/classnum filters - if ( !$all_class and scalar(@pkg_class) == 1 ) { + if ( !$all_class and scalar(@classnums) == 1 ) { # then a specific class has been chosen, but it may be the empty class - my $row_classnum = ref($pkg_class[0]) ? $pkg_class[0]->classnum : 0; - push @row_params, 'classnum' => $row_classnum; - $row_link .= ";classnum=$row_classnum"; + push @row_params, $class_param => $classnums[0]; + $row_link .= ";$class_param=".$classnums[0]; } if ( $sel_part_referral ) { push @row_params, 'refnum' => $sel_part_referral->refnum; diff --git a/httemplate/graph/report_cust_bill_pkg.html b/httemplate/graph/report_cust_bill_pkg.html index 251e7d36e..d3d8e664d 100644 --- a/httemplate/graph/report_cust_bill_pkg.html +++ b/httemplate/graph/report_cust_bill_pkg.html @@ -23,6 +23,27 @@ function enable_agent_totals(obj) { ) ); } + +function mode_changed() { + var options = document.getElementsByName('mode'); + var mode; + for(var i=0; i < options.length; i++) { + if (options[i].checked) { + mode = options[i].value; + } + } + + var div_pkg = document.getElementById('pkg_class'); + var div_report = document.getElementById('report_class'); + if (mode == 'pkg') { + div_pkg.style.display = ''; + div_report.style.display = 'none'; + } else if (mode == 'report') { + div_pkg.style.display = 'none'; + div_report.style.display = ''; + } +} +window.onload = mode_changed; </SCRIPT> <& /elements/tr-select-agent.html, @@ -49,13 +70,40 @@ function enable_agent_totals(obj) { 'onchange' => 'enable_agent_totals' &> -<& /elements/tr-select-pkg_class.html, - 'field' => 'classnum', - 'pre_options' => [ 'all' => 'all (aggregate)', - '0' => 'all (breakdown)' ], - 'empty_label' => '(empty class)', - 'onchange' => 'enable_agent_totals', -&> +<TR> + <TD ALIGN="right"> + <INPUT TYPE="radio" NAME="mode" VALUE="pkg" onchange="mode_changed('pkg')" CHECKED> + <% emt('Package class') %> + <BR> + <INPUT TYPE="radio" NAME="mode" VALUE="report" onchange="mode_changed('report')"> + <% emt('Report class') %> + </TD> + <TD> + <DIV ID="pkg_class"> + <& /elements/select-pkg_class.html, + 'field' => 'classnum', + 'pre_options' => [ 'all' => 'all (aggregate)', + '' => 'all (breakdown)', + '0' => '(empty class)' ], + 'disable_empty' => 1, + 'onchange' => 'enable_agent_totals', + &> + </DIV> + <DIV ID="report_class" STYLE="display: none"> + <& /elements/select-table.html, + 'field' => 'report_optionnum', + 'table' => 'part_pkg_report_option', + 'name_col' => 'name', + 'value_col' => 'num', + 'pre_options' => [ 'all' => 'all (aggregate)', + '' => 'all (breakdown)', + '0' => '(empty class)' ], + 'disable_empty' => 1, + 'onchange' => 'enable_agent_totals', + &> + </DIV> + </TD> +</TR> <!-- <TR> diff --git a/httemplate/misc/change_pkg.cgi b/httemplate/misc/change_pkg.cgi index 03e336cba..7425fbfaf 100755 --- a/httemplate/misc/change_pkg.cgi +++ b/httemplate/misc/change_pkg.cgi @@ -1,7 +1,6 @@ -<& /elements/header-popup.html, mt("Change Package") &> +<& /elements/header-popup.html, mt($title) &> <SCRIPT TYPE="text/javascript" SRC="../elements/order_pkg.js"></SCRIPT> - <& /elements/error.html &> <FORM NAME="OrderPkgForm" ACTION="<% $p %>edit/process/change-cust_pkg.html" METHOD=POST> @@ -30,6 +29,21 @@ </TABLE> +<TABLE> + <TR> + <TD> Apply this change: </TD> + <TD> <INPUT TYPE="radio" NAME="delay" VALUE="0" \ + <% !$cgi->param('delay') ? 'CHECKED' : '' %>> now </TD> + <TD> <INPUT TYPE="radio" NAME="delay" VALUE="1" \ + <% $cgi->param('delay') ? 'CHECKED' : '' %>> in the future + <& /elements/input-date-field.html, { + 'name' => 'start_date', + 'value' => ($cgi->param('start_date') || $cust_main->next_bill_date), + } &> + </TD> + </TR> +</TABLE> + <& /elements/standardize_locations.html, 'form' => "OrderPkgForm", 'callback' => 'document.OrderPkgForm.submit();', @@ -74,4 +88,15 @@ my $cust_main = $cust_pkg->cust_main my $part_pkg = $cust_pkg->part_pkg; +my $title = "Change Package"; + +# if there's already a package change ordered, preload it +if ( $cust_pkg->change_to_pkgnum ) { + my $change_to = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum); + $cgi->param('delay', 1); + foreach(qw( start_date pkgpart locationnum )) { + $cgi->param($_, $change_to->get($_)); + } + $title = "Edit Scheduled Package Change"; +} </%init> diff --git a/httemplate/misc/change_pkg_now.cgi b/httemplate/misc/change_pkg_now.cgi new file mode 100644 index 000000000..73ee74020 --- /dev/null +++ b/httemplate/misc/change_pkg_now.cgi @@ -0,0 +1,22 @@ +%if ( $error ) { +% errorpage($error); +%} else { +<% $cgi->redirect(popurl(2). "view/cust_main.cgi?".$cust_pkg->getfield('custnum')) %> +%} +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Change customer package'); + +#untaint pkgnum +my ($query) = $cgi->keywords; +$query =~ /^(\d+)$/ || die "Illegal pkgnum"; +my $pkgnum = $1; + +my $cust_pkg = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum}); +my $change_to = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum); + +my $err_or_pkg = $cust_pkg->change({ 'cust_pkg' => $change_to }); +my $error = $err_or_pkg unless ref($err_or_pkg); + +</%init> diff --git a/httemplate/misc/cust-part_pkg.cgi b/httemplate/misc/cust-part_pkg.cgi index 43b92297e..7aebda40c 100644 --- a/httemplate/misc/cust-part_pkg.cgi +++ b/httemplate/misc/cust-part_pkg.cgi @@ -5,8 +5,9 @@ my( $custnum, $prospectnum, $classnum ) = $cgi->param('arg'); my $agent; +my $cust_main; if ( $custnum ) { - my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) + $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) or die 'unknown custnum'; $agent = $cust_main->agent; } else { @@ -31,12 +32,18 @@ my @part_pkg = qsearch({ 'order_by' => 'ORDER BY pkg', }); -my @return = map { warn $_->can_start_date; +my $date_format = FS::Conf->new->config('date_format') || '%m/%d/%Y'; + +my @return = map { + my $start_date = $_->default_start_date($cust_main); + $start_date = time2str($date_format, $start_date) + if $start_date; ( $_->pkgpart, $_->pkg_comment, $_->can_discount, $_->can_start_date, - ); + $start_date, + ) } #sort { $a->pkg_comment cmp $b->pkg_comment } @part_pkg; diff --git a/httemplate/misc/do_not_change_pkg.cgi b/httemplate/misc/do_not_change_pkg.cgi new file mode 100644 index 000000000..c164c5c15 --- /dev/null +++ b/httemplate/misc/do_not_change_pkg.cgi @@ -0,0 +1,20 @@ +%if ( $error ) { +% errorpage($error); +%} else { +<% $cgi->redirect(popurl(2). "view/cust_main.cgi?".$cust_pkg->getfield('custnum')) %> +%} +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Change customer package'); + +#untaint pkgnum +my ($query) = $cgi->keywords; +$query =~ /^(\d+)$/ || die "Illegal pkgnum"; +my $pkgnum = $1; + +my $cust_pkg = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum}); + +my $error = $cust_pkg->abort_change; + +</%init> diff --git a/httemplate/misc/order_pkg.html b/httemplate/misc/order_pkg.html index 39734427e..a257e53e3 100644 --- a/httemplate/misc/order_pkg.html +++ b/httemplate/misc/order_pkg.html @@ -54,9 +54,12 @@ <& /elements/input-date-field.html,{ 'name' => 'start_date', 'format' => $date_format, - 'value' => $start_date, + 'value' => '', 'noinit' => 1, } &> + <IMG SRC = "<%$fsurl%>images/calendar-disabled.png" + ID = "start_date_button_disabled" + STYLE = "display:none"> <FONT SIZE=-1>(<% mt('leave blank to start immediately') |h %>)</FONT> </TD> </TR> @@ -213,11 +216,6 @@ if ( $cgi->param('quantity') =~ /^\s*(\d+)\s*$/ ) { } my $format = $date_format. ' %T %z (%Z)'; #false laziness w/REAL_cust_pkg.cgi? -my $start_date = ''; -if( ! $conf->exists('order_pkg-no_start_date') && $cust_main ) { - $start_date = $cust_main->next_bill_date; - $start_date = $start_date ? time2str($format, $start_date) : ''; -} my $svcpart = scalar($cgi->param('svcpart')); diff --git a/httemplate/search/cust_bill_pkg.cgi b/httemplate/search/cust_bill_pkg.cgi index 7d9172aca..bf73d74bd 100644 --- a/httemplate/search/cust_bill_pkg.cgi +++ b/httemplate/search/cust_bill_pkg.cgi @@ -131,6 +131,10 @@ Filtering parameters: - classnum: Filter on package class. +- report_optionnum: Filter on package report class. Can be a single report + class number, a comma-separated list, the word "multiple", or an empty + string (for "no report class"). + - use_override: Apply "classnum" and "taxclass" filtering based on the override (bundle) pkgpart, rather than always using the true pkgpart. @@ -331,6 +335,14 @@ if ( $cgi->param('nottax') ) { push @where, "COALESCE($part_pkg.classnum, 0) = $1"; } + if ( $cgi->param('report_optionnum') =~ /^(\w+)$/ ) { + # code reuse FTW + my $num = $1; + push @where, + FS::Report::Table->with_report_option( $1, $cgi->param('use_override') ) + ; + } + # taxclass if ( $cgi->param('taxclassNULL') ) { # a little different from 'taxclass' in that it applies to the diff --git a/httemplate/search/customer_accounting_summary.html b/httemplate/search/customer_accounting_summary.html index b48ff21e3..c9cfa4088 100644 --- a/httemplate/search/customer_accounting_summary.html +++ b/httemplate/search/customer_accounting_summary.html @@ -121,10 +121,7 @@ die "access denied" unless $FS::CurrentUser::CurrentUser->access_right('Financial reports'); my ($agentnum,$sel_agent); -if ( $cgi->param('agentnum') eq 'all' ) { - $agentnum = 0; -} -elsif ( $cgi->param('agentnum') =~ /^(\d+)$/ ) { +if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) { $agentnum = $1; $sel_agent = qsearchs('agent', { 'agentnum' => $agentnum } ); die "agentnum $agentnum not found!" unless $sel_agent; @@ -177,10 +174,6 @@ my $query = FS::cust_main::Search->search(\%search_hash); my @custs = qsearch($query); foreach my $cust_main ( @custs ) { - # XXX should do this in the qsearch - next unless ($status eq '' || $status eq $cust_main->status); - next unless ($agentnum == 0 || $cust_main->agentnum eq $agentnum); - next unless ($refnum == 0 || $cust_main->refnum eq $refnum); push @custnames, $cust_main->name; diff --git a/httemplate/view/cust_main/packages.html b/httemplate/view/cust_main/packages.html index e32fe4c03..566ab2943 100755 --- a/httemplate/view/cust_main/packages.html +++ b/httemplate/view/cust_main/packages.html @@ -3,7 +3,6 @@ td.package { vertical-align: top; border-width: 0; border-style: solid; - border-color: #bbbbff; } table.package { border: none; @@ -199,11 +198,30 @@ sub get_packages { } ); my $num_old_packages = scalar(@packages); + my %change_to_from; # target pkgnum => current cust_pkg, for future changes + foreach my $cust_pkg ( @packages ) { my %hash = $cust_pkg->hash; my %part_pkg = map { /^part_pkg_(.+)$/ or die; ( $1 => $hash{$_} ); } grep { /^part_pkg_/ } keys %hash; $cust_pkg->{'_pkgpart'} = new FS::part_pkg \%part_pkg; + if ( $cust_pkg->change_to_pkgnum ) { + $change_to_from{$cust_pkg->change_to_pkgnum} = $cust_pkg; + } + } + + if ( keys %change_to_from ) { + my @not_future_packages; + foreach my $cust_pkg (@packages) { + if ( exists( $change_to_from{$cust_pkg->pkgnum} ) ) { + my $change_from = $change_to_from{ $cust_pkg->pkgnum }; + $cust_pkg->set('change_from_pkg', $change_from); + $change_from->set('change_to_pkg', $cust_pkg); + } else { + push @not_future_packages, $cust_pkg; + } + } + @packages = @not_future_packages; } unless ( $cgi->param('showoldpackages') ) { @@ -225,6 +243,7 @@ sub get_packages { # don't include supplemental packages in this list; they'll be found from # their main packages + # (as will change-target packages) @packages = grep !$_->main_pkgnum, @packages; ( \@packages, $num_old_packages ); diff --git a/httemplate/view/cust_main/packages/location.html b/httemplate/view/cust_main/packages/location.html index ab961b79e..01cbc0ffb 100644 --- a/httemplate/view/cust_main/packages/location.html +++ b/httemplate/view/cust_main/packages/location.html @@ -1,6 +1,11 @@ -% if ( $default ) { - <DIV STYLE="font-style: italic; font-size: small"> -% } +% if ( $cust_pkg->change_from_pkg +% and $cust_pkg->change_from_pkg->locationnum == $cust_pkg->locationnum ) +% { +% # don't show the location +% } else { +% if ( $default ) { + <DIV STYLE="font-style: italic; font-size: small"> +% } <% $loc->location_label( 'join_string' => '<BR>', 'double_space' => ' ', @@ -22,25 +27,25 @@ </FONT> % } -% if ( $default ) { - </DIV> -% } +% if ( $default ) { + </DIV> +% } -% if ( ! $cust_pkg->get('cancel') +% if ( ! $cust_pkg->get('cancel') % && $FS::CurrentUser::CurrentUser->access_right('Change customer package') -% ) -% { +% ) +% { <BR> <FONT SIZE=-1> -% unless ( $opt{no_links} ) { +% unless ( $opt{no_links} or $opt{'change_from'} ) { ( <%pkg_change_location_link($cust_pkg)%> ) -% } -% if ( $cust_pkg->locationnum && ! $opt{no_links} ) { +% } +% if ( $cust_pkg->locationnum && ! $opt{no_links} ) { ( <%edit_location_link($cust_pkg->locationnum)%> ) -% } +% } </FONT> -% } - +% } +% } <%init> my $conf = new FS::Conf; diff --git a/httemplate/view/cust_main/packages/package.html b/httemplate/view/cust_main/packages/package.html index 7aad9a44e..596a47391 100644 --- a/httemplate/view/cust_main/packages/package.html +++ b/httemplate/view/cust_main/packages/package.html @@ -1,5 +1,4 @@ -<TD CLASS="inv package" BGCOLOR="<% $bgcolor %>" VALIGN="top" - STYLE="border-left-width: <% $supplemental * 30 %>px"> +<TD CLASS="inv package" BGCOLOR="<% $bgcolor %>" VALIGN="top" <%$style%>> <TABLE CLASS="inv package"> <TR> <TD COLSPAN=2> @@ -30,7 +29,11 @@ % unless ( $cust_pkg->get('cancel') || $opt{no_links} ) { % -% if ( $supplemental or $part_pkg->freq eq '0' ) { +% if ( $change_from ) { +% # This is the target package for a future change. +% # Nothing you can do with it besides modify/cancel the +% # future change, and that's on the current package. +% } elsif ( $supplemental or $part_pkg->freq eq '0' ) { % # Supplemental packages can't be changed independently. % # One-time charges don't need to be changed. % # For both of those, we only show "Add comments", @@ -185,6 +188,7 @@ % ) % { <TR> +% # yeah, I guess we'll let you do this on a future change package % if ( FS::Conf->new->exists('invoice-unitprice') ) { <TD><FONT SIZE="-1"> ( <% pkg_change_quantity_link($cust_pkg) %> ) @@ -233,7 +237,21 @@ my $countrydefault = $opt{'countrydefault'} || 'US'; my $statedefault = $opt{'statedefault'} || ($countrydefault eq 'US' ? 'CA' : ''); +# put a marker on the left edge of this column +# if this package is somehow special my $supplemental = $opt{'supplemental'} || 0; +my $change_from = $opt{'change_from'} || 0; +my $style = ''; +if ( $supplemental or $change_from ) { + $style = 'border-left-width: '.($supplemental + $change_from)*30 . 'px; '. + 'border-color: '; + if ( $supplemental ) { + $style .= '#bbbbff'; + } elsif ( $change_from ) { + $style .= '#bbffbb'; + } + $style = qq!STYLE="$style"!; +} $cust_pkg->pkgnum =~ /^(\d+)$/; my $pkgnum = $1; @@ -263,7 +281,7 @@ sub pkg_change_link { 'actionlabel' => emt('Change'), 'cust_pkg' => $cust_pkg, 'width' => 763, - 'height' => 380, + 'height' => 480, ); } diff --git a/httemplate/view/cust_main/packages/section.html b/httemplate/view/cust_main/packages/section.html index 82d06203b..0383fe892 100755 --- a/httemplate/view/cust_main/packages/section.html +++ b/httemplate/view/cust_main/packages/section.html @@ -36,6 +36,10 @@ <& services.html, %iopt &> </TR> % $row++; +% # show the change target, if there is one +% if ( $cust_pkg->change_to_pkg ) { + <& .packagerow, $cust_pkg->change_to_pkg, %iopt, 'change_from' => 1 &> +% } % # include supplemental packages if any % $iopt{'supplemental'} = ($iopt{'supplemental'} || 0) + 1; % foreach my $supp_pkg ($cust_pkg->supplemental_pkgs) { diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html index ed360cca4..6894a4e02 100644 --- a/httemplate/view/cust_main/packages/status.html +++ b/httemplate/view/cust_main/packages/status.html @@ -76,18 +76,29 @@ <% pkg_status_row_if( $cust_pkg, emt('Next bill'), 'bill', %opt, curuser=>$curuser ) %> % } <% pkg_status_row_if( $cust_pkg, emt('Will resume'), 'resume', %opt, curuser=>$curuser ) %> - <% pkg_status_row_if( $cust_pkg, emt('Expires'), 'expire', %opt, curuser=>$curuser ) %> + <% pkg_status_row_expire($cust_pkg, %opt, curuser=>$curuser) %> <% pkg_status_row_if( $cust_pkg, emt('Contract ends'), 'contract_end', %opt ) %> -% if ( !$supplemental && ! $opt{no_links} ) { +% if ( !$supplemental && ! $opt{no_links} && !$change_from ) { <TR> <TD COLSPAN=<%$opt{colspan}%>> <FONT SIZE=-1> +% if ( $cust_pkg->change_to_pkgnum ) { +% # then you can modify the package change +% if ( $curuser->access_right('Change customer package') ) { + ( <% pkg_change_now_link($cust_pkg) %> ) + ( <% pkg_change_later_link($cust_pkg) %> ) + ( <% pkg_unchange_link($cust_pkg) %> ) + <BR> +% } +% } % if ( $curuser->access_right('Unsuspend customer package') ) { ( <% pkg_unsuspend_link($cust_pkg) %> ) ( <% pkg_resume_link($cust_pkg) %> ) % } -% if ( $curuser->access_right('Cancel customer package immediately') ) { +% if ( !$cust_pkg->change_to_pkgnum and +% $curuser->access_right('Cancel customer package immediately') +% ) { ( <% pkg_cancel_link($cust_pkg) %> ) % } </FONT> @@ -97,9 +108,17 @@ % % } else { #status: active % -% unless ( $cust_pkg->get('setup') ) { #not setup +% if ( $change_from ) { # future change +% + <% pkg_status_row_colspan( $cust_pkg, emt('Waiting for package change'), '', %opt ) %> + <% pkg_status_row( $cust_pkg, + emt('Will be activated on'), + 'start_date', + %opt ) %> % -% unless ( $part_pkg->freq ) { +% } elsif ( ! $cust_pkg->get('setup') ) { # not setup +% +% unless ( $part_pkg->freq ) { # one-time charge <% pkg_status_row_colspan( $cust_pkg, emt('Not yet billed (one-time charge)'), '', %opt ) %> @@ -193,7 +212,7 @@ % } % -% } +% } % % if ( $opt{'cust_pkg-show_autosuspend'} ) { % my $autosuspend = pkg_autosuspend_time( $cust_pkg ); @@ -207,7 +226,7 @@ <% pkg_status_row_if($cust_pkg, emt('Automatic suspension delayed until'), 'dundate', %opt) %> <% pkg_status_row_if( $cust_pkg, emt('Will suspend on'), 'adjourn', %opt, curuser=>$curuser ) %> <% pkg_status_row_if( $cust_pkg, emt('Will resume on'), 'resume', %opt, curuser=>$curuser ) %> - <% pkg_status_row_if( $cust_pkg, emt('Expires'), 'expire', %opt, curuser=>$curuser ) %> + <% pkg_status_row_expire($cust_pkg, %opt, curuser=>$curuser) %> <% pkg_status_row_if( $cust_pkg, emt('Contract ends'), 'contract_end', %opt ) %> % if ( $part_pkg->freq and !$supplemental && ! $opt{no_links} ) { @@ -215,21 +234,41 @@ <TR> <TD COLSPAN=<%$opt{colspan}%>> <FONT SIZE=-1> -% if ( $curuser->access_right('Suspend customer package') ) { - ( <% pkg_suspend_link($cust_pkg) %> ) -% } -% if ( $curuser->access_right('Suspend customer package later') ) { - ( <% pkg_adjourn_link($cust_pkg) %> ) -% } -% if ( $curuser->access_right('Delay suspension events') ) { - ( <% pkg_delay_link($cust_pkg) %> ) -% } +% # action links +% if ( $change_from ) { +% # nothing +% } elsif ( $cust_pkg->change_to_pkgnum ) { +% # then you can modify the package change +% if ( $curuser->access_right('Change customer package') ) { + ( <% pkg_change_now_link($cust_pkg) %> ) + ( <% pkg_change_later_link($cust_pkg) %> ) + ( <% pkg_unchange_link($cust_pkg) %> ) + <BR> +% } +% } + +% # suspension actions--always available +% if ( $curuser->access_right('Suspend customer package') ) { + ( <% pkg_suspend_link($cust_pkg) %> ) +% } +% if ( $curuser->access_right('Suspend customer package later') ) { + ( <% pkg_adjourn_link($cust_pkg) %> ) +% } +% if ( $curuser->access_right('Delay suspension events') ) { + ( <% pkg_delay_link($cust_pkg) %> ) +% } +% +% if ( $change_from or $cust_pkg->change_to_pkgnum ) { +% # you can't cancel the package while in this state +% } else { # the normal case: links to cancel the package + <BR> % if ( $curuser->access_right('Cancel customer package immediately') ) { ( <% pkg_cancel_link($cust_pkg) %> ) -% } +% } % if ( $curuser->access_right('Cancel customer package later') ) { ( <% pkg_expire_link($cust_pkg) %> ) % } +% } <FONT> </TD> @@ -251,6 +290,7 @@ my $part_pkg = $opt{'part_pkg'}; my $curuser = $FS::CurrentUser::CurrentUser; my $width = $opt{'cust_pkg-display_times'} ? '38%' : '56%'; my $supplemental = $opt{'supplemental'}; +my $change_from = $opt{'change_from'}; $opt{colspan} = $opt{'cust_pkg-display_times'} ? 8 : 4; @@ -330,14 +370,41 @@ sub pkg_status_row_if { $opt{curuser}->access_right('Suspend customer package later') ); - $title = '<FONT SIZE=-1>( '. pkg_unexpire_link($cust_pkg). ' ) </FONT>'. $title - if ( $field eq 'expire' && - $opt{curuser}->access_right('Cancel customer package later') - ); - $cust_pkg->get($field) ? pkg_status_row($cust_pkg, $title, $field, %opt) : ''; } +sub pkg_status_row_expire { + my $cust_pkg = shift; + my %opt = @_; + return unless $cust_pkg->get('expire'); + + my $title; + + if ( $cust_pkg->get('change_to_pkg') ) { + if ( $cust_pkg->change_to_pkg->pkgpart != $cust_pkg->pkgpart ) { + $title = mt('Will change to <b>[_1]</b> on', + $cust_pkg->change_to_pkg->part_pkg->pkg); + } elsif ( $cust_pkg->change_to_pkg->locationnum != $cust_pkg->locationnum ) + { + $title = mt('Will <b>change location</b> on'); + } else { + # FS::cust_pkg->change_later should have prevented this, but + # just so that we can display _something_ + $title = '<font color="#ff0000">Unknown package change</font>'; + } + + } else { + + $title = emt('Expires'); + if ( $opt{curuser}->access_right('Cancel customer package later')) { + $title = '<FONT SIZE=-1>( '. pkg_unexpire_link($cust_pkg). ' ) </FONT>'. $title; + } + + } + + pkg_status_row( $cust_pkg, $title, 'expire', %opt ); +} + sub pkg_status_row_changed { my( $cust_pkg, %opt ) = @_; @@ -538,6 +605,8 @@ sub pkg_resume_link { sub pkg_unsuspend_link { pkg_link('misc/unsusp_pkg', emt('Unsuspend now'), @_ ); } sub pkg_unadjourn_link { pkg_link('misc/unadjourn_pkg', emt('Abort'), @_ ); } sub pkg_unexpire_link { pkg_link('misc/unexpire_pkg', emt('Abort'), @_ ); } +sub pkg_unchange_link { pkg_link('misc/do_not_change_pkg', emt('Abort change'), @_ ); } +sub pkg_change_now_link { pkg_link('misc/change_pkg_now', emt('Change now'), @_ ); } sub pkg_cancel_link { include( '/elements/popup_link-cust_pkg.html', @@ -569,6 +638,18 @@ sub pkg_expire_link { ) } +sub pkg_change_later_link { + my $cust_pkg = shift; + include( '/elements/popup_link-cust_pkg.html', + 'action' => $p . 'misc/change_pkg.cgi?', + 'label' => emt('Reschedule'), + 'actionlabel' => emt('Edit scheduled change for'), + 'cust_pkg' => $cust_pkg, + 'width' => 763, + 'height' => 480, + ) +} + sub svc_recharge_link { include( '/elements/popup_link-cust_svc.html', 'action' => $p. 'misc/recharge_svc.html', diff --git a/rt/etc/initialdata b/rt/etc/initialdata index 8b9890550..6d0225f5b 100644 --- a/rt/etc/initialdata +++ b/rt/etc/initialdata @@ -722,3 +722,93 @@ Hour: { $SubscriptionObj->SubValue('Hour') } } ); +# -*- perl -*- + +@ScripActions = ( + + { Name => 'Extract Custom Field Values', # loc + Description => 'extract cf-values out of a message', # loc + ExecModule => 'ExtractCustomFieldValues' }, + + { Name => 'Extract Custom Field Values With Code in Template', # loc + Description => 'extract cf-values out of a message with a Text::Template template', # loc + ExecModule => 'ExtractCustomFieldValuesWithCodeInTemplate' } + +); + +@Templates = ( + { Queue => '0', + Name => 'CustomFieldScannerExample', # loc + Description => 'Example Template for ExtractCustomFieldValues', # loc + Content => <<'EOTEXT' +#### Syntax: +# CF Name | Header name or "Body" | MatchString(re) | Postcmd | Options + +#### Allowed Options: + +# q - (quiet) Don't record a transaction for adding the custom field +# value +# * - (wildcard) The MatchString regex should contain _two_ +# capturing groups, the first of which is the CF name, +# the second of which is the value. If this option is +# given, the <cf-name> field is ignored. + +#### Examples: + +# 1. Put the content of the "X-MI-Test" header into the "testcf" +# custom field: +# testcf|X-MI-Test|.* + +# 2. Scan the body for Host:name and put name into the "bodycf" custom +# field: +# bodycf|Body|Host:\s*(\w+) + +# 3. Scan the "X-MI-IP" header for an IP-Adresse and get the hostname +# by reverse-resolving it: +# Hostname|X-MI-IP|\d+\.\d+\.\d+\.\d+|use Socket; ($value) = gethostbyaddr(inet_aton($value),AF_INET); + +# 4. scan the "CC" header for an many email addresses, and add them to +# a custom field named "parsedCCs". If "parsedCCs" is a multivalue +# CF, then this should yield separate values for all email adress +# found. +# parsedCCs|CC|.*|$value =~ s/^\s+//; $value =~ s/\s+$//; + +# 5. Looks for an "Email:" field in the body of the email, then loads +# up that user and makes them privileged The blank first field +# means the automatic CustomField setting is not invoked. +# |Body|Email:\s*(.+)$|my $u = RT::User->new($RT::SystemUser); $u->LoadByEmail($value); $u->SetPrivileged(1)| + +# 6. Looks for any text of the form "Set CF Name: Value" in the body, +# and sets the CF named "CF Name" to the given value, which may be +# multi-line. The '*' option controls the wildcard nature of this +# example. +# Separator=! +# !Body!^Set ([^\n:]*?):\s*((?s).*?)(?:\Z|\n\Z|\n\n)!!* + +# 7. Looks for the regex anywhere in the headers and stores the match +# in the AllHeaderSearch CF +# AllHeaderSearch|Headers|Site:\s*(\w+) + +# 8. If you need to dynamically build your matching, and want to trigger on headers and body +# and invode some arbitrary code like example 5 +# Separator=~~ +# { +# my $action = 'use My::Site; My::Site::SetSiteID( Ticket => $self->TicketObj, Site => $_ );'; +# +# for my $regex (My::Site::ValidRegexps) { +# for my $from ('headers', 'body') { +# $OUT .= join '~~', +# '', # CF name +# $from, +# $regex, +# $action; +# $OUT .= "\n"; +# } +# } +# } + +EOTEXT + } +); + +1; diff --git a/rt/lib/RT/Action/ExtractCustomFieldValues.pm b/rt/lib/RT/Action/ExtractCustomFieldValues.pm new file mode 100644 index 000000000..15aa469f0 --- /dev/null +++ b/rt/lib/RT/Action/ExtractCustomFieldValues.pm @@ -0,0 +1,234 @@ +package RT::Action::ExtractCustomFieldValues; +require RT::Action; + +use strict; +use warnings; + +use base qw(RT::Action); + +our $VERSION = 2.99_01; + +sub Describe { + my $self = shift; + return ( ref $self ); +} + +sub Prepare { + return (1); +} + +sub FirstAttachment { + my $self = shift; + return $self->TransactionObj->Attachments->First; +} + +sub Queue { + my $self = shift; + return $self->TicketObj->QueueObj->Id; +} + +sub TemplateContent { + my $self = shift; + return $self->TemplateObj->Content; +} + +sub TemplateConfig { + my $self = shift; + + my ($content, $error) = $self->TemplateContent; + if (!defined($content)) { + return (undef, $error); + } + + my $Separator = '\|'; + my @lines = split( /[\n\r]+/, $content); + my @results; + for (@lines) { + chomp; + next if /^#/; + next if /^\s*$/; + if (/^Separator=(.+)$/) { + $Separator = $1; + next; + } + my %line; + @line{qw/CFName Field Match PostEdit Options/} + = split(/$Separator/); + $_ = '' for grep !defined, values %line; + push @results, \%line; + } + return \@results; +} + +sub Commit { + my $self = shift; + return 1 unless $self->FirstAttachment; + + my ($config_lines, $error) = $self->TemplateConfig; + + return 0 if $error; + + for my $config (@$config_lines) { + my %config = %{$config}; + $RT::Logger->debug( "Looking to extract: " + . join( " ", map {"$_=$config{$_}"} sort keys %config ) ); + + if ( $config{Options} =~ /\*/ ) { + $self->FindContent( + %config, + Callback => sub { + my $content = shift; + my $found = 0; + while ( $content =~ /$config{Match}/mg ) { + my ( $cf, $value ) = ( $1, $2 ); + $cf = $self->LoadCF( Name => $cf, Quiet => 1 ); + next unless $cf; + $found++; + $self->ProcessCF( + %config, + CustomField => $cf, + Value => $value + ); + } + return $found; + }, + ); + } else { + my $cf; + $cf = $self->LoadCF( Name => $config{CFName} ) + if $config{CFName}; + + $self->FindContent( + %config, + Callback => sub { + my $content = shift; + return 0 unless $content =~ /($config{Match})/m; + $self->ProcessCF( + %config, + CustomField => $cf, + Value => $2 || $1, + ); + return 1; + } + ); + } + } + return (1); +} + +sub LoadCF { + my $self = shift; + my %args = @_; + my $CustomFieldName = $args{Name}; + $RT::Logger->debug( "Looking for CF $CustomFieldName"); + + # We do this by hand instead of using LoadByNameAndQueue because + # that can find disabled queues + my $cfs = RT::CustomFields->new($RT::SystemUser); + $cfs->LimitToGlobalOrQueue($self->Queue); + $cfs->Limit( + FIELD => 'Name', + VALUE => $CustomFieldName, + CASESENSITIVE => 0 + ); + $cfs->RowsPerPage(1); + + my $cf = $cfs->First; + if ( $cf && $cf->id ) { + $RT::Logger->debug( "Found CF id " . $cf->id ); + } elsif ( not $args{Quiet} ) { + $RT::Logger->error( "Couldn't load CF $CustomFieldName!"); + } + + return $cf; +} + +sub FindContent { + my $self = shift; + my %args = @_; + if ( lc $args{Field} eq "body" ) { + my $Attachments = $self->TransactionObj->Attachments; + my $LastContent = ''; + my $AttachmentCount = 0; + + my @list = @{ $Attachments->ItemsArrayRef }; + while ( my $Message = shift @list ) { + $AttachmentCount++; + $RT::Logger->debug( "Looking at attachment $AttachmentCount, content-type " + . $Message->ContentType ); + my $ct = $Message->ContentType; + unless ( $ct =~ m!^(text/plain|message|text$)!i ) { + # don't skip one attachment that is text/* + next if @list > 1 || $ct !~ m!^text/!; + } + + my $content = $Message->Content; + next unless $content; + next if $LastContent eq $content; + $RT::Logger->debug( "Examining content of body" ); + $LastContent = $content; + $args{Callback}->( $content ); + } + } elsif ( lc $args{Field} eq 'headers' ) { + my $attachment = $self->FirstAttachment; + $RT::Logger->debug( "Looking at the headers of the first attachment" ); + my $content = $attachment->Headers; + return unless $content; + $RT::Logger->debug( "Examining content of headers" ); + $args{Callback}->( $content ); + } else { + my $attachment = $self->FirstAttachment; + $RT::Logger->debug( "Looking at $args{Field} header of first attachment" ); + my $content = $attachment->GetHeader( $args{Field} ); + return unless defined $content; + $RT::Logger->debug( "Examining content of header" ); + $args{Callback}->( $content ); + } +} + +sub ProcessCF { + my $self = shift; + my %args = @_; + + return $self->PostEdit(%args) + unless $args{CustomField}; + + my @values = (); + if ( $args{CustomField}->SingleValue() ) { + push @values, $args{Value}; + } else { + @values = split( ',', $args{Value} ); + } + + foreach my $value ( grep defined && length, @values ) { + $value = $self->PostEdit(%args, Value => $value ); + next unless defined $value && length $value; + + $RT::Logger->debug( "Found value for CF: $value"); + my ( $id, $msg ) = $self->TicketObj->AddCustomFieldValue( + Field => $args{CustomField}, + Value => $value, + RecordTransaction => $args{Options} =~ /q/ ? 0 : 1 + ); + $RT::Logger->info( "CustomFieldValue (" + . $args{CustomField}->Name + . ",$value) added: $id $msg" ); + } +} + +sub PostEdit { + my $self = shift; + my %args = @_; + + return $args{Value} unless $args{Value} && $args{PostEdit}; + + $RT::Logger->debug( "Running PostEdit for '$args{Value}'"); + my $value = $args{Value}; + local $_ = $value; # backwards compatibility + local $@; + eval( $args{PostEdit} ); + $RT::Logger->error("$@") if $@; + return $value; +} + +1; diff --git a/rt/lib/RT/Action/ExtractCustomFieldValuesWithCodeInTemplate.pm b/rt/lib/RT/Action/ExtractCustomFieldValuesWithCodeInTemplate.pm new file mode 100644 index 000000000..e05966be2 --- /dev/null +++ b/rt/lib/RT/Action/ExtractCustomFieldValuesWithCodeInTemplate.pm @@ -0,0 +1,30 @@ +package RT::Action::ExtractCustomFieldValuesWithCodeInTemplate; +use strict; +use warnings; + +use base qw(RT::Action::ExtractCustomFieldValues); + +sub TemplateContent { + my $self = shift; + my $is_broken = 0; + + my $content = $self->TemplateObj->Content; + + my $template = Text::Template->new(TYPE => 'STRING', SOURCE => $content); + my $new_content = $template->fill_in( + BROKEN => sub { + my (%args) = @_; + $RT::Logger->error("Template parsing error: $args{error}") + unless $args{error} =~ /^Died at /; # ignore intentional die() + $is_broken++; + return undef; + }, + ); + + return (undef, $self->loc('Template parsing error')) if $is_broken; + + return $new_content; +} + +1; + diff --git a/rt/lib/RT/Extension/ExtractCustomFieldValues.pm b/rt/lib/RT/Extension/ExtractCustomFieldValues.pm new file mode 100644 index 000000000..6731cf479 --- /dev/null +++ b/rt/lib/RT/Extension/ExtractCustomFieldValues.pm @@ -0,0 +1,116 @@ +use warnings; +use strict; + +package RT::Extension::ExtractCustomFieldValues; + +=head1 NAME + +RT::Extension::ExtractCustomFieldValues - extract CF values from email headers or body + +=cut + +our $VERSION = '3.07'; + +1; + +=head1 DESCRIPTION + +ExtractCustomFieldValues is based on a scrip action +"ExtractCustomFieldValues", which can be used to scan incoming emails +to set values of custom fields. + +=head1 INSTALLATION + + perl Makefile.PL + make + make install + make initdb # first time only, not on upgrades + +When using this extension with RT 3.8, you will need to add +extension to the Plugins configuration: + + Set( @Plugins, qw(... RT::Extension::ExtractCustomFieldValues) ); + +If you are upgrading this extension from 3.05 or earlier, you will +need to read the UPGRADING file after running make install to add +the new Scrip Action. + +=head1 USAGE + +To use the ScripAction, create a Template and a Scrip in RT. +Your new Scrip should use a ScripAction of 'Extract Custom Field Values'. +The Template consists of the lines which control the scanner. All +non-comment lines are of the following format: + + <cf-name>|<Headername>|<MatchString>|<Postcmd>|<Options> + +where: + +=over 4 + +=item <cf-name> - the name of a custom field (must be created in RT) If this +field is blank, the match will be run and Postcmd will be executed, but no +custom field will be updated. Use this if you need to execute other RT code +based on your match. + +=item <Headername> - either a Name of an email header, "body" to scan the body +of the email or "headers" to search all of the headers. + +=item <MatchString> - a regular expression to find a match in the header or +body if the MatchString matches a comma separated list and the CF is a multi +value CF then each item in the list is added as a separate value. + +=item <Postcmd> - a perl code to be evaluated on C<$value>, where C<$value> is +either $1 or full match text from the match performed with <MatchString> + +=item <Options> - a string of letters which may control some aspects. Possible +options include: + +=over 4 + +=item 'q' - (quiet) Don't record a transaction when adding the custom field value + +=item '*' - (wildcard) The MatchString regex should contain _two_ capturing +groups, the first of which is the CF name, the second of which is the value. +If this option is given, the <cf-name> field is ignored. + +=back + +=back + +=head2 Separator + +You can change the separator string (initially "\|") during the +template with: + + Separator=<anyregexp> + +Changing the separator may be necessary, if you want to use a "|" in +one of the patterns in the controlling lines. + +=head2 Example and further reading + +An example template with some further examples is installed during +"make install" or "make insert-template". See the +CustomFieldScannerExample template for examples and further +documentation. + +=head1 AUTHOR + +This extension was originally written by Dirk Pape +E<lt>pape@inf.fu-berlin.deE<gt>. + +This version is modified by Best Practical for customer use +and maintained by Best Practical Solutions. + +=head1 BUGS + +Report bugs using L<http://rt.cpan.org> service, discuss on RT's +mailing lists, see also L</SUPPORT> + +=head1 SUPPORT + +Support requests should be referred to Best Practical +E<lt>sales@bestpractical.comE<gt>. + +=cut |