summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FS/FS/Conf.pm7
-rw-r--r--FS/FS/Report/Table.pm38
-rw-r--r--FS/FS/Schema.pm2
-rw-r--r--FS/FS/cdr/netsapiens.pm17
-rw-r--r--FS/FS/cust_main/Billing.pm20
-rw-r--r--FS/FS/cust_pkg.pm347
-rw-r--r--FS/FS/part_export/freeswitch.pm2
-rw-r--r--FS/FS/part_export/freeswitch_multifile.pm2
-rw-r--r--FS/FS/part_pkg.pm37
-rw-r--r--FS/FS/part_pkg/delayed_Mixin.pm12
-rw-r--r--FS/FS/pay_batch.pm2
-rw-r--r--FS/FS/pay_batch/eft_canada.pm8
-rw-r--r--bin/sqlradius-reexport-group34
-rwxr-xr-xhttemplate/edit/part_pkg.cgi73
-rw-r--r--httemplate/edit/process/change-cust_pkg.html31
-rw-r--r--httemplate/elements/order_pkg.js25
-rw-r--r--httemplate/elements/select-part_pkg.html1
-rw-r--r--httemplate/elements/tr-justtitle.html3
-rw-r--r--httemplate/elements/tr-select-cust-part_pkg.html18
-rw-r--r--httemplate/graph/cust_bill_pkg.cgi92
-rw-r--r--httemplate/graph/report_cust_bill_pkg.html62
-rwxr-xr-xhttemplate/misc/change_pkg.cgi29
-rw-r--r--httemplate/misc/change_pkg_now.cgi22
-rw-r--r--httemplate/misc/cust-part_pkg.cgi13
-rw-r--r--httemplate/misc/do_not_change_pkg.cgi20
-rw-r--r--httemplate/misc/order_pkg.html10
-rw-r--r--httemplate/search/cust_bill_pkg.cgi12
-rw-r--r--httemplate/search/customer_accounting_summary.html9
-rwxr-xr-xhttemplate/view/cust_main/packages.html21
-rw-r--r--httemplate/view/cust_main/packages/location.html35
-rw-r--r--httemplate/view/cust_main/packages/package.html26
-rwxr-xr-xhttemplate/view/cust_main/packages/section.html4
-rw-r--r--httemplate/view/cust_main/packages/status.html125
-rw-r--r--rt/etc/initialdata90
-rw-r--r--rt/lib/RT/Action/ExtractCustomFieldValues.pm234
-rw-r--r--rt/lib/RT/Action/ExtractCustomFieldValuesWithCodeInTemplate.pm30
-rw-r--r--rt/lib/RT/Extension/ExtractCustomFieldValues.pm116
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' => ' &nbsp; ',
@@ -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'} ) {
(&nbsp;<%pkg_change_location_link($cust_pkg)%>&nbsp;)
-% }
-% if ( $cust_pkg->locationnum && ! $opt{no_links} ) {
+% }
+% if ( $cust_pkg->locationnum && ! $opt{no_links} ) {
(&nbsp;<%edit_location_link($cust_pkg->locationnum)%>&nbsp;)
-% }
+% }
</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">
(&nbsp;<% pkg_change_quantity_link($cust_pkg) %>&nbsp;)
@@ -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') ) {
+ (&nbsp;<% pkg_change_now_link($cust_pkg) %>&nbsp;)
+ (&nbsp;<% pkg_change_later_link($cust_pkg) %>&nbsp;)
+ (&nbsp;<% pkg_unchange_link($cust_pkg) %>&nbsp;)
+ <BR>
+% }
+% }
% if ( $curuser->access_right('Unsuspend customer package') ) {
(&nbsp;<% pkg_unsuspend_link($cust_pkg) %>&nbsp;)
(&nbsp;<% pkg_resume_link($cust_pkg) %>&nbsp;)
% }
-% if ( $curuser->access_right('Cancel customer package immediately') ) {
+% if ( !$cust_pkg->change_to_pkgnum and
+% $curuser->access_right('Cancel customer package immediately')
+% ) {
(&nbsp;<% pkg_cancel_link($cust_pkg) %>&nbsp;)
% }
</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') ) {
- (&nbsp;<% pkg_suspend_link($cust_pkg) %>&nbsp;)
-% }
-% if ( $curuser->access_right('Suspend customer package later') ) {
- (&nbsp;<% pkg_adjourn_link($cust_pkg) %>&nbsp;)
-% }
-% if ( $curuser->access_right('Delay suspension events') ) {
- (&nbsp;<% pkg_delay_link($cust_pkg) %>&nbsp;)
-% }
+% # 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') ) {
+ (&nbsp;<% pkg_change_now_link($cust_pkg) %>&nbsp;)
+ (&nbsp;<% pkg_change_later_link($cust_pkg) %>&nbsp;)
+ (&nbsp;<% pkg_unchange_link($cust_pkg) %>&nbsp;)
+ <BR>
+% }
+% }
+
+% # suspension actions--always available
+% if ( $curuser->access_right('Suspend customer package') ) {
+ (&nbsp;<% pkg_suspend_link($cust_pkg) %>&nbsp;)
+% }
+% if ( $curuser->access_right('Suspend customer package later') ) {
+ (&nbsp;<% pkg_adjourn_link($cust_pkg) %>&nbsp;)
+% }
+% if ( $curuser->access_right('Delay suspension events') ) {
+ (&nbsp;<% pkg_delay_link($cust_pkg) %>&nbsp;)
+% }
+%
+% 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') ) {
(&nbsp;<% pkg_cancel_link($cust_pkg) %>&nbsp;)
-% }
+% }
% if ( $curuser->access_right('Cancel customer package later') ) {
(&nbsp;<% pkg_expire_link($cust_pkg) %>&nbsp;)
% }
+% }
<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>(&nbsp;'. pkg_unexpire_link($cust_pkg). '&nbsp;)&nbsp;</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>(&nbsp;'. pkg_unexpire_link($cust_pkg). '&nbsp;)&nbsp;</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