diff options
-rw-r--r-- | FS/FS/API.pm | 5 | ||||
-rw-r--r-- | FS/FS/Password_Mixin.pm | 20 | ||||
-rw-r--r-- | FS/FS/Schema.pm | 4 | ||||
-rw-r--r-- | FS/FS/cdr/FS/FS/cdr/vvs.pm | 7 | ||||
-rw-r--r-- | FS/FS/cdr/vss.pm | 33 | ||||
-rw-r--r-- | FS/FS/cust_bill_pkg.pm | 29 | ||||
-rw-r--r-- | FS/FS/cust_main/Billing.pm | 11 | ||||
-rw-r--r-- | FS/FS/cust_main/Billing_Realtime.pm | 2 | ||||
-rw-r--r-- | FS/FS/part_pkg/sql_external.pm | 71 | ||||
-rwxr-xr-x | bin/bulk_void | 75 | ||||
-rwxr-xr-x | bin/rate-intl.import | 113 | ||||
-rwxr-xr-x | httemplate/edit/process/cust_main.cgi | 4 | ||||
-rw-r--r-- | httemplate/elements/errorpage.html | 4 | ||||
-rw-r--r-- | httemplate/misc/make_appointment.html | 32 | ||||
-rw-r--r-- | httemplate/misc/process/payment.cgi | 5 | ||||
-rwxr-xr-x | httemplate/search/cust_main.cgi | 20 | ||||
-rw-r--r-- | httemplate/search/report_cust_bill_void.html | 2 |
17 files changed, 364 insertions, 73 deletions
diff --git a/FS/FS/API.pm b/FS/FS/API.pm index 4e6cb6cd7..4ff1a3ab5 100644 --- a/FS/FS/API.pm +++ b/FS/FS/API.pm @@ -65,6 +65,10 @@ Amount paid Option date for payment +=item order_number + +Optional order number + =back Example: @@ -77,6 +81,7 @@ Example: #optional '_date' => 1397977200, #UNIX timestamp + 'order_number' => '12345', ); if ( $result->{'error'} ) { diff --git a/FS/FS/Password_Mixin.pm b/FS/FS/Password_Mixin.pm index b80708116..2e400ec9d 100644 --- a/FS/FS/Password_Mixin.pm +++ b/FS/FS/Password_Mixin.pm @@ -60,7 +60,7 @@ sub is_password_allowed { # basic checks using Data::Password; # options for Data::Password - $DICTIONARY = 4; # minimum length of disallowed words + $DICTIONARY = 0; # minimum length of disallowed words, false value disables dictionary checking $MINLEN = $conf->config('passwordmin') || 6; $MAXLEN = $conf->config('passwordmax') || 8; $GROUPS = 4; # must have all 4 'character groups': numbers, symbols, uppercase, lowercase @@ -70,9 +70,23 @@ sub is_password_allowed { # # lists of disallowed words # @DICTIONARIES = qw( /usr/share/dict/web2 /usr/share/dict/words /usr/share/dict/linux.words ); + # first, no dictionary checking but require 4 char groups my $error = IsBadPassword($password); - $error = 'must contain at least one each of numbers, symbols, and lowercase and uppercase letters' - if $error eq 'contains less than 4 character groups'; # avoid confusion + + # but they can get away with 3 char groups, so long as they're not using a word + if ($error eq 'contains less than 4 character groups') { + $DICTIONARY = 4; # default from Data::Password is 5 + $GROUPS = 3; + $error = IsBadPassword($password); + # take note--we never actually report dictionary word errors; + # 4 char groups is the rule, 3 char groups and no dictionary words is an acceptable exception + $error = 'should contain at least one each of numbers, symbols, lowercase and uppercase letters' + if $error; + } + + # maybe also at some point add an exception for any passwords of sufficient length, + # see https://xkcd.com/936/ + $error = 'Invalid password - ' . $error if $error; return $error if $error; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index c40f6c747..c8f8c815e 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1774,7 +1774,7 @@ sub tables_hashref { 'gatewaynum', 'int', 'NULL', '', '', '', # payment_gateway FK 'processor', 'varchar', 'NULL', $char_d, '', '', # module name 'auth', 'varchar','NULL',16, '', '', # CC auth number - 'order_number','varchar','NULL',$char_d, '', '', # transaction number + 'order_number','varchar','NULL',256, '', '', # transaction number ], 'primary_key' => 'paynum', #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it# 'unique' => [ [ 'payunique' ] ], @@ -1809,7 +1809,7 @@ sub tables_hashref { 'gatewaynum', 'int', 'NULL', '', '', '', # payment_gateway FK 'processor', 'varchar', 'NULL', $char_d, '', '', # module name 'auth', 'varchar','NULL',16, '', '', # CC auth number - 'order_number', 'varchar','NULL',$char_d, '', '', # transaction number + 'order_number', 'varchar','NULL',256, '', '', # transaction number #void fields 'void_date', @date_type, '', '', diff --git a/FS/FS/cdr/FS/FS/cdr/vvs.pm b/FS/FS/cdr/FS/FS/cdr/vvs.pm index 63a647ee8..db7e72ac6 100644 --- a/FS/FS/cdr/FS/FS/cdr/vvs.pm +++ b/FS/FS/cdr/FS/FS/cdr/vvs.pm @@ -18,12 +18,11 @@ use FS::cdr qw(_cdr_date_parser_maker); 'src', # caller 'dst', # called skip(2), # reason - # call id + # call id _cdr_date_parser_maker('startdate'), # time 'billsec', # duration - skip(3), # ringtime - # status - # resller_charge + skip(2), # ringtime + # reseller_charge 'upstream_price',# customer_charge ], ); diff --git a/FS/FS/cdr/vss.pm b/FS/FS/cdr/vss.pm deleted file mode 100644 index a550303df..000000000 --- a/FS/FS/cdr/vss.pm +++ /dev/null @@ -1,33 +0,0 @@ -package FS::cdr::vss; - -use strict; -use vars qw( @ISA %info $tmp_mon $tmp_mday $tmp_year ); -use Time::Local; -use FS::cdr qw(_cdr_date_parser_maker); - -@ISA = qw(FS::cdr); - -%info = ( - 'name' => 'VSS', - 'weight' => 120, - 'header' => 1, - 'import_fields' => [ - - skip(1), # i_customer - 'accountcode', # account_id - 'src', # caller - 'dst', # called - skip(2), # reason - # call id - _cdr_date_parser_maker('startdate'), # time - 'billsec', # duration - skip(3), # ringtime - # status - # resller_charge - 'upstream_price',# customer_charge - ], -); - -sub skip { map {''} (1..$_[0]) } - -1; diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index 4448da62e..a15d7a260 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -1102,17 +1102,34 @@ sub cust_bill_pkg_tax_Xlocation { =item recur_show_zero -=cut +Whether to show a zero recurring amount. This is true if the package or its +definition has the recur_show_zero flag, and the recurring fee is actually +zero for this period. -sub recur_show_zero { shift->_X_show_zero('recur'); } -sub setup_show_zero { shift->_X_show_zero('setup'); } +=cut -sub _X_show_zero { +sub recur_show_zero { my( $self, $what ) = @_; - return 0 unless $self->$what() == 0 && $self->pkgnum; + return 0 unless $self->get('recur') == 0 && $self->pkgnum; + + $self->cust_pkg->_X_show_zero('recur'); +} + +=item setup_show_zero - $self->cust_pkg->_X_show_zero($what); +Whether to show a zero setup charge. This requires the package or its +definition to have the setup_show_zero flag, but it also returns false if +the package's setup date is before this line item's start date. + +=cut + +sub setup_show_zero { + my $self = shift; + return 0 unless $self->get('setup') == 0 && $self->pkgnum; + my $cust_pkg = $self->cust_pkg; + return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 ); + return $cust_pkg->_X_show_zero('setup'); } =item credited [ BEFORE, AFTER, OPTIONS ] diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 2f9eecdf5..77529562f 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -1261,6 +1261,9 @@ sub _make_lines { my $unitrecur = 0; my @recur_discounts = (); my $sdate; + + my $override_quantity; + # Conditions for billing the recurring fee: # - the package doesn't have a future start date # - and it's not suspended @@ -1356,6 +1359,10 @@ sub _make_lines { #base_cancel??? $unitrecur = $cust_pkg->base_recur( \$sdate ) || $recur; #XXX uuh, better + if ( $param{'override_quantity'} ) { + $override_quantity = $param{'override_quantity'}; + } + if ( $increment_next_bill ) { my $next_bill; @@ -1410,7 +1417,7 @@ sub _make_lines { } } - } + } # end of recurring fee warn "\$setup is undefined" unless defined($setup); warn "\$recur is undefined" unless defined($recur); @@ -1477,7 +1484,7 @@ sub _make_lines { 'unitsetup' => sprintf('%.2f', $unitsetup), 'recur' => $recur, 'unitrecur' => sprintf('%.2f', $unitrecur), - 'quantity' => $cust_pkg->quantity, + 'quantity' => $override_quantity || $cust_pkg->quantity, 'details' => \@details, 'discounts' => [ @setup_discounts, @recur_discounts ], 'hidden' => $part_pkg->hidden, diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 6c0b655a2..2a9e86924 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -522,6 +522,8 @@ sub realtime_bop { ? uc($options{'paytype'}) : uc($self->getfield('paytype')) || 'PERSONAL CHECKING'; + $content{company} = $self->company if $self->company; + if ( $content{account_type} =~ /BUSINESS/i && $self->company ) { $content{account_name} = $self->company; } else { diff --git a/FS/FS/part_pkg/sql_external.pm b/FS/FS/part_pkg/sql_external.pm index 813e8085c..48d89a0e3 100644 --- a/FS/FS/part_pkg/sql_external.pm +++ b/FS/FS/part_pkg/sql_external.pm @@ -6,6 +6,14 @@ use vars qw( %info ); use DBI; #use FS::Record qw(qsearch qsearchs); +tie our %query_style, 'Tie::IxHash', ( + 'simple' => 'Simple (a single value for the recurring charge)', + 'detailed' => 'Detailed (multiple rows for invoice details)', +); + +our @detail_cols = ( qw(amount format duration phonenum accountcode + startdate regionname detail) + ); %info = ( 'name' => 'Base charge plus additional fees for external services from a configurable SQL query', 'shortname' => 'External SQL query', @@ -34,10 +42,17 @@ use DBI; 'query' => { 'name' => 'SQL query', 'default' => '', }, + + 'query_style' => { + 'name' => 'Query output style', + 'type' => 'select', + 'select_options' => \%query_style, + }, + }, 'fieldorder' => [qw( recur_method cutoff_day ), FS::part_pkg::prorate_Mixin::fieldorder, - qw( datasrc db_username db_password query + qw( datasrc db_username db_password query query_style )], 'weight' => '58', ); @@ -53,6 +68,7 @@ sub calc_recur { my $self = shift; my($cust_pkg, $sdate, $details, $param ) = @_; my $price = 0; + my $quantity; # can be overridden; if not we use the default my $dbh = DBI->connect( map { $self->option($_) } qw( datasrc db_username db_password ) @@ -67,9 +83,60 @@ sub calc_recur { ) { my $id = $cust_svc->svc_x->id; $sth->execute($id) or die $sth->errstr; - $price += $sth->fetchrow_arrayref->[0]; + + if ( $self->option('query_style') eq 'detailed' ) { + + while (my $row = $sth->fetchrow_hashref) { + if (exists $row->{amount}) { + if ( $row->{amount} eq '' ) { + # treat as zero + } elsif ( $row->{amount} =~ /^\d+(?:\.\d+)?$/ ) { + $price += $row->{amount}; + } else { + die "sql_external query returned non-numeric amount: $row->{amount}"; + } + } + if (exists $row->{quantity}) { + $quantity ||= 0; + if ( $row->{quantity} eq '' ) { + # treat as zero + } elsif ( $row->{quantity} =~ /^\d+$/ ) { + $quantity += $row->{quantity}; + } else { + die "sql_external query returned non-integer quantity: $row->{quantity}"; + } + } + + my $detail = FS::cust_bill_pkg_detail->new; + foreach my $field (@detail_cols) { + if (exists $row->{$field}) { + $detail->set($field, $row->{$field}); + } + } + if (!$detail->get('detail')) { + die "sql_external query did not return detail description"; + # or make something up? + # or just don't insert the detail? + } + + push @$details, $detail; + } # while $row + + } else { + + # simple style: returns only a single value, which is the price + $price += $sth->fetchrow_arrayref->[0]; + + } + } + $price = sprintf('%.2f', $price); + + # XXX probably shouldn't allow package quantity > 1 on these packages. + if ($cust_pkg->quantity > 1) { + warn "sql_external package #".$cust_pkg->pkgnum." has quantity > 1\n"; } + $param->{'override_quantity'} = $quantity; $param->{'override_charges'} = $price; ($cust_pkg->quantity || 1) * $self->calc_recur_Common($cust_pkg,$sdate,$details,$param); } diff --git a/bin/bulk_void b/bin/bulk_void new file mode 100755 index 000000000..a1428180e --- /dev/null +++ b/bin/bulk_void @@ -0,0 +1,75 @@ +#!/usr/bin/perl + +use FS::Misc::Getopt; +use FS::Record qw(qsearch qsearchs dbh); + +getopts('cpifXr:'); +my $dbh = dbh; +$FS::UID::AutoCommit = 0; + +sub usage() { + "Usage: bulk_void -s start -e end + -r void_reason + { -c | -p | -i } + [ -X ] + <user> +-s, -e: date range (required) +-r: void reason text (required) +-c, -p, -i, -f: void credits, payments, invoices +-X: commit changes +"; +} + +if (!$opt{start} or !$opt{end} or !$opt{r}) { + die usage; +} + +print "DRY RUN--changes will not be committed.\n" unless $opt{X}; + +my $date = " WHERE _date >= $opt{start} AND _date <= $opt{end}"; + +my %tables = ( + c => 'cust_credit', + p => 'cust_pay', + i => 'cust_bill', +); + +my $reason = $opt{r}; + +foreach my $k (keys %tables) { + next unless $opt{$k}; + my $table = $tables{$k}; + debug("$table:"); + my $done_count = 0; + my $error_count = 0; + + my $cursor = FS::Cursor->new({ + table => $table, + extra_sql => $date, + }); + my $error; + while (my $record = $cursor->fetch) { + $error = $record->void($reason); + if ( $error ) { + $error = "$table #" . $record->get($record->primary_key) . ": $error"; + print "$error\n"; + $error_count++; + if ( $opt{X} ) { + $dbh->rollback; + exit(1); + } + } else { + $done_count++; + } + } + print " $table voided: $done_count\n errors: $error_count\n"; +} + +if ( $opt{X} ) { + $dbh->commit; + print "Committed changes.\n"; +} else { + $dbh->rollback; + print "Reverted changes.\n"; +} + diff --git a/bin/rate-intl.import b/bin/rate-intl.import new file mode 100755 index 000000000..7eef5878f --- /dev/null +++ b/bin/rate-intl.import @@ -0,0 +1,113 @@ +#!/usr/bin/perl + +use strict; +use Text::CSV; +use FS::Misc::Getopt; +use FS::Record qw(qsearchs qsearch dbh); +use FS::rate; +use FS::rate_region; +use FS::rate_prefix; +use FS::rate_detail; + +getopts(''); + +$FS::UID::AutoCommit = 0; +my $dbh = dbh; + +my $file = shift or usage(); +open my $in, '<', $file or die "$file: $!\n"; +my $csv = Text::CSV->new({ binary => 1, auto_diag => 2 }); +# set header row +$csv->column_names($csv->getline($in)); + +my $error; + +my $granularity = 1; +# default is to charge per second; edit this if needed + +while (my $row = $csv->getline_hr($in)) { + print $csv->string; + + # ProfileKey is just a number + my $rate = qsearchs('rate', { 'ratename' => $row->{'ProfileKey'} }); + if (!$rate) { + $rate = FS::rate->new({ 'ratename' => $row->{'ProfileKey'} }); + $error = $rate->insert; + die $error if $error; + } + + # DestinationId looks like "Country - City" or "Country - Mobile - + # Carrier" (or sometimes just "Country - Mobile"). + my $region = qsearchs('rate_region', { + 'regionname' => $row->{'DestinationId'} + }); + if (!$region) { + $region = FS::rate_region->new({ + 'regionname' => $row->{'DestinationId'} + }); + $error = $region->insert; + die $error if $error; + } + + # Prefix strings found in there look like + # "e164:123-45-6nnnnnnn-" + # The first group of digits is the country code, any others are the + # prefix. Sometimes the nnnn's are NNNN's. The dashes are not guaranteed + # to be anywhere specific. + # Catchall prefixes start with "-A", which has a meaning like "match + # anything, but at a lower priority than a digit match". + # NANPA numbers use "1-", and for a catchall area code use "1-AAA-". + my $cc_long = $row->{CountryCodeLong}; + $cc_long =~ /^e164:(\d+)-([\d-]*)A*-?n+-$/i; + my $countrycode = $1; + if (!$countrycode) { # totally legit reasons for this, e.g. 1-AAA-411 + warn "can't parse number prefix:\n$cc_long\n"; + next; + } + my $prefix = $2; + $prefix =~ s/-//g; + + my %prefix = ( + 'regionnum' => $region->regionnum, + 'countrycode' => $countrycode, + 'npa' => $prefix, + ); + my $rate_prefix = qsearchs('rate_prefix', \%prefix); + if (!$rate_prefix) { + $rate_prefix = FS::rate_prefix->new(\%prefix); + $error = $rate_prefix->insert; + die $error if $error; + } + + # enough to identify the detail + my %detail = ( + 'ratenum' => $rate->ratenum, + 'dest_regionnum' => $region->regionnum, + 'cdrtypenum' => '', + 'ratetimenum' => '', + ); + my $dest_detail = qsearchs('rate_detail', \%detail); + # ProfileRate is 5 decimal places, same as rate_detail.min_charge + if (!$dest_detail) { + $dest_detail = FS::rate_detail->new({ + %detail, + 'min_included' => 0, + 'min_charge' => $row->{ProfileRate}, + 'sec_granularity' => $granularity, + }); + $error = $dest_detail->insert; + } else { + local $FS::Record::nowarn_identical = 1; + $dest_detail->set('min_charge' => $row->{ProfileRate}); + $error = $dest_detail->replace; + } + die $error if $error; +} +dbh->commit; +print "Finished.\n"; + + +sub usage { + die "Usage: rate-intl.import <user> <file>.csv\n\n"; +} + diff --git a/httemplate/edit/process/cust_main.cgi b/httemplate/edit/process/cust_main.cgi index 82ec50c36..6961d18c0 100755 --- a/httemplate/edit/process/cust_main.cgi +++ b/httemplate/edit/process/cust_main.cgi @@ -97,8 +97,8 @@ if ( ($cgi->param('same') || '') eq 'Y' ) { # but explicitly avoid setting ship_ fields my $new = new FS::cust_main ( { - map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main')), - map { ( "ship_$_", '' ) } (FS::cust_main->location_fields) + (map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main'))), + (map { ( "ship_$_", '' ) } (FS::cust_main->location_fields)) } ); $new->invoice_noemail( ($cgi->param('invoice_email') eq 'Y') ? '' : 'Y' ); diff --git a/httemplate/elements/errorpage.html b/httemplate/elements/errorpage.html index 7d66e7ce0..d001bfa89 100644 --- a/httemplate/elements/errorpage.html +++ b/httemplate/elements/errorpage.html @@ -1,10 +1,10 @@ -% my $error = shift; +% my $error = $_[0]; % $m->notes('error', $error); <& /elements/header.html, mt("Error") &> % while (@_) { -<P><FONT SIZE="+1" COLOR="#ff0000"><% $error |h %></FONT> +<P><FONT SIZE="+1" COLOR="#ff0000"><% shift |h %></FONT> %} % $m->flush_buffer(); diff --git a/httemplate/misc/make_appointment.html b/httemplate/misc/make_appointment.html index 6f308e0a8..79c3c2c89 100644 --- a/httemplate/misc/make_appointment.html +++ b/httemplate/misc/make_appointment.html @@ -6,13 +6,10 @@ <INPUT TYPE="hidden" NAME="custnum" VALUE="<% $cgi->param('custnum') |h %>"> -% my @sched_item = qsearch('sched_item', { 'disabled' => '', }); -% my @username = map $_->access_user->username, @sched_item; -% foreach my $username (@username) { - <INPUT TYPE="hidden" NAME="username" VALUE="<% $username |h %>"> -% } - -Length: +<TABLE> +<TR> +<TD STYLE="text-align: right">Length:</TD> +<TD> <SELECT NAME="LengthMin"> % for ( my $hours = .5; $hours < 10.5; $hours += .5 ) { % my $min = $hours * 60; @@ -21,9 +18,26 @@ Length: ><% $hours %> hour<% $hours > 1 ? 's' : '' %> % } </SELECT> -<BR> -<BR> +</TD> +</TR> + +% my @sched_item = qsearch('sched_item', { 'disabled' => '', }); +% my @username = map $_->access_user->username, @sched_item; + +<TR> +<TD STYLE="text-align: right">Installer:</TD> +<TD> +<SELECT NAME="username" ID="username_select" MULTIPLE> +% foreach my $username (@username) { + <OPTION SELECTED><% $username |h %></OPTION> +% } +</SELECT> +</TD> +</TR> +</TABLE> + +<BR> <INPUT TYPE="submit" VALUE="Schedule appointment"> </FORM> diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi index a475786dd..dcfcc0b85 100644 --- a/httemplate/misc/process/payment.cgi +++ b/httemplate/misc/process/payment.cgi @@ -164,7 +164,10 @@ if ( $cgi->param('save') ) { #false laziness w/FS:;cust_main::realtime_bop - check both to make sure # working correctly if ( $payby eq 'CARD' && - grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save') ) { + ( (grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')) + || $conf->exists('business-onlinepayment-verification') + ) + ) { $new->set( 'paycvv' => $paycvv ); } else { $new->set( 'paycvv' => ''); diff --git a/httemplate/search/cust_main.cgi b/httemplate/search/cust_main.cgi index 4e624eb51..aa8c079e1 100755 --- a/httemplate/search/cust_main.cgi +++ b/httemplate/search/cust_main.cgi @@ -704,12 +704,20 @@ sub address2search { or errorpage(emt("Illegal address2")); my $address2 = $1; - push @cust_main, qsearch( 'cust_main', - { 'address2' => { 'op' => 'ILIKE', - 'value' => $address2 } } ); - push @cust_main, qsearch( 'cust_main', - { 'ship_address2' => { 'op' => 'ILIKE', - 'value' => $address2 } } ); + # matching at the start or end of an address, but not in the middle + my @where; + foreach my $toggle (0,1) { + push @where, 'LOWER(cust_location.address2) LIKE LOWER(' + . dbh->quote($toggle ? $address2 . '%' : '%' . $address2) + . ')'; + } + + push @cust_main, qsearch({ + 'debug' => 1, + 'table' => 'cust_main', + 'addl_from' => 'JOIN cust_location ON (cust_location.locationnum IN (cust_main.bill_locationnum, cust_main.ship_locationnum))', + 'extra_sql' => 'WHERE ' . join(' OR ',@where), + }); \@cust_main; } diff --git a/httemplate/search/report_cust_bill_void.html b/httemplate/search/report_cust_bill_void.html index cb13b785a..9e9f590b1 100644 --- a/httemplate/search/report_cust_bill_void.html +++ b/httemplate/search/report_cust_bill_void.html @@ -24,7 +24,7 @@ label => mt('Customer Class'), field => 'cust_classnum', multiple => 1, - 'pre_options' => [ '' => emt('(none)') ], + 'pre_options' => [ '0' => emt('(none)') ], 'all_selected' => 1, &> |