summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FS/FS/Mason.pm3
-rw-r--r--FS/FS/Record.pm98
-rw-r--r--FS/FS/Schema.pm4
-rw-r--r--FS/FS/cust_pkg.pm9
-rw-r--r--FS/FS/cust_pkg/Import.pm373
-rw-r--r--FS/bin/freeside-queued2
-rw-r--r--Makefile2
-rw-r--r--httemplate/elements/menu.html3
-rw-r--r--httemplate/misc/cust_main-import.cgi2
-rw-r--r--httemplate/misc/cust_pkg-import.html132
-rw-r--r--httemplate/misc/process/cust_pkg-import.html10
-rwxr-xr-xhttemplate/search/cust_pkg.cgi5
12 files changed, 607 insertions, 36 deletions
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 2a4b42f..f5d7c85 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -3,6 +3,7 @@ package FS::Mason;
use strict;
use vars qw( @ISA @EXPORT_OK $addl_handler_use );
use Exporter;
+use Carp;
use File::Slurp qw( slurp );
use HTML::Mason 1.27; #http://www.masonhq.com/?ApacheModPerl2Redirect
use HTML::Mason::Interp;
@@ -146,6 +147,7 @@ if ( -e $addl_handler_use_file ) {
use FS::cust_location;
use FS::cust_pay;
use FS::cust_pkg;
+ use FS::cust_pkg::Import;
use FS::part_pkg_taxclass;
use FS::cust_pkg_reason;
use FS::cust_refund;
@@ -361,6 +363,7 @@ if ( -e $addl_handler_use_file ) {
sub include {
use vars qw($m);
+ #carp #should just switch to <& &> syntax
$m->scomp(@_);
}
diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm
index bc075dd..758e0f9 100644
--- a/FS/FS/Record.pm
+++ b/FS/FS/Record.pm
@@ -1611,6 +1611,8 @@ Class method for batch imports. Available params:
=item table
+=item format - usual way to specify import, with this format string selecting data from the formats and format_* info hashes
+
=item formats
=item format_types
@@ -1623,6 +1625,10 @@ Class method for batch imports. Available params:
=item format_row_callbacks
+=item fields - Alternate way to specify import, specifying import fields directly as a listref
+
+=item postinsert_callback
+
=item params
=item job
@@ -1635,8 +1641,6 @@ FS::queue object, will be updated with progress
csv, xls or fixedlength
-=item format
-
=item empty_ok
=back
@@ -1647,21 +1651,64 @@ sub batch_import {
my $param = shift;
warn "$me batch_import call with params: \n". Dumper($param)
- ;# if $DEBUG;
+ if $DEBUG;
my $table = $param->{table};
- my $formats = $param->{formats};
my $job = $param->{job};
my $file = $param->{file};
- my $format = $param->{'format'};
my $params = $param->{params} || {};
- die "unknown format $format" unless exists $formats->{ $format };
+ my( $type, $header, $sep_char, $fixedlength_format, $row_callback, @fields );
+ my $postinsert_callback = '';
+ if ( $param->{'format'} ) {
+
+ my $format = $param->{'format'};
+ my $formats = $param->{formats};
+ die "unknown format $format" unless exists $formats->{ $format };
+
+ $type = $param->{'format_types'}
+ ? $param->{'format_types'}{ $format }
+ : $param->{type} || 'csv';
+
+
+ $header = $param->{'format_headers'}
+ ? $param->{'format_headers'}{ $param->{'format'} }
+ : 0;
+
+ $sep_char = $param->{'format_sep_chars'}
+ ? $param->{'format_sep_chars'}{ $param->{'format'} }
+ : ',';
+
+ $fixedlength_format =
+ $param->{'format_fixedlength_formats'}
+ ? $param->{'format_fixedlength_formats'}{ $param->{'format'} }
+ : '';
+
+ $row_callback =
+ $param->{'format_row_callbacks'}
+ ? $param->{'format_row_callbacks'}{ $param->{'format'} }
+ : '';
+
+ @fields = @{ $formats->{ $format } };
+
+ } elsif ( $param->{'fields'} ) {
+
+ $type = ''; #infer from filename
+ $header = 0;
+ $sep_char = ',';
+ $fixedlength_format = '';
+ $row_callback = '';
+ @fields = @{ $param->{'fields'} };
- my $type = $param->{'format_types'}
- ? $param->{'format_types'}{ $format }
- : $param->{type} || 'csv';
+ $postinsert_callback = $param->{'postinsert_callback'}
+ if $param->{'postinsert_callback'}
+
+ } else {
+ die "neither format nor fields specified";
+ }
+
+ #my $file = $param->{file};
unless ( $type ) {
if ( $file =~ /\.(\w+)$/i ) {
@@ -1675,25 +1722,6 @@ sub batch_import {
if $param->{'default_csv'} && $type ne 'xls';
}
- my $header = $param->{'format_headers'}
- ? $param->{'format_headers'}{ $param->{'format'} }
- : 0;
-
- my $sep_char = $param->{'format_sep_chars'}
- ? $param->{'format_sep_chars'}{ $param->{'format'} }
- : ',';
-
- my $fixedlength_format =
- $param->{'format_fixedlength_formats'}
- ? $param->{'format_fixedlength_formats'}{ $param->{'format'} }
- : '';
-
- my $row_callback =
- $param->{'format_row_callbacks'}
- ? $param->{'format_row_callbacks'}{ $param->{'format'} }
- : '';
-
- my @fields = @{ $formats->{ $format } };
my $row = 0;
my $count;
@@ -1757,6 +1785,7 @@ sub batch_import {
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
+ #my $params = $param->{params} || {};
if ( $param->{'batch_namecol'} && $param->{'batch_namevalue'} ) {
my $batch_col = $param->{'batch_keycol'};
@@ -1774,7 +1803,8 @@ sub batch_import {
$params->{ $batch_col } = $batch_value;
}
-
+
+ #my $job = $param->{job};
my $line;
my $imported = 0;
my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
@@ -1832,6 +1862,7 @@ sub batch_import {
}
+ #my $table = $param->{table};
my $class = "FS::$table";
my $record = $class->new( \%hash );
@@ -1855,6 +1886,15 @@ sub batch_import {
$row++;
$imported++;
+ if ( $postinsert_callback ) {
+ my $error = &{$postinsert_callback}($record, $param);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "postinsert_callback error". ( $line ? " for $line" : '' ).
+ ": $error";
+ }
+ }
+
if ( $job && time - $min_sec > $last ) { #progress bar
$job->update_statustext( int(100 * $imported / $count) );
$last = time;
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index d7d5a04..dc8f2f3 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1289,6 +1289,7 @@ sub tables_hashref {
'pkgnum', 'serial', '', '', '', '',
'custnum', 'int', '', '', '', '',
'pkgpart', 'int', '', '', '', '',
+ 'pkgbatch', 'varchar', 'NULL', $char_d, '', '',
'locationnum', 'int', 'NULL', '', '', '',
'otaker', 'varchar', 'NULL', 32, '', '',
'usernum', 'int', 'NULL', '', '', '',
@@ -1310,7 +1311,8 @@ sub tables_hashref {
],
'primary_key' => 'pkgnum',
'unique' => [],
- 'index' => [ ['custnum'], ['pkgpart'], [ 'locationnum' ], [ 'usernum' ],
+ 'index' => [ ['custnum'], ['pkgpart'], [ 'pkgbatch' ], [ 'locationnum' ],
+ [ 'usernum' ],
[ 'start_date' ], ['setup'], ['last_bill'], ['bill'],
['susp'], ['adjourn'], ['expire'], ['cancel'],
['change_date'],
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 0f9a611..c3ee4e4 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -2624,6 +2624,15 @@ sub search {
}
##
+ # custbatch
+ ##
+
+ if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+ push @where,
+ "cust_pkg.pkgbatch = '$1'";
+ }
+
+ ##
# parse status
##
diff --git a/FS/FS/cust_pkg/Import.pm b/FS/FS/cust_pkg/Import.pm
new file mode 100644
index 0000000..7a4b9d5
--- /dev/null
+++ b/FS/FS/cust_pkg/Import.pm
@@ -0,0 +1,373 @@
+package FS::cust_pkg::Import;
+
+use strict;
+use vars qw( $DEBUG ); #$conf );
+use Storable qw(thaw);
+use Data::Dumper;
+use MIME::Base64;
+use FS::Misc::DateTime qw( parse_datetime );
+use FS::Record qw( qsearchs );
+use FS::cust_pkg;
+use FS::cust_main;
+use FS::svc_acct;
+use FS::svc_external;
+use FS::svc_phone;
+
+$DEBUG = 0;
+
+#install_callback FS::UID sub {
+# $conf = new FS::Conf;
+#};
+
+=head1 NAME
+
+FS::cust_pkg::Import - Batch customer importing
+
+=head1 SYNOPSIS
+
+ use FS::cust_pkg::Import;
+
+ #import
+ FS::cust_pkg::Import::batch_import( {
+ file => $file, #filename
+ type => $type, #csv or xls
+ format => $format, #extended, extended-plus_company, svc_external,
+ # or svc_external_svc_phone
+ agentnum => $agentnum,
+ job => $job, #optional job queue job, for progressbar updates
+ pkgbatch => $pkgbatch, #optional batch unique identifier
+ } );
+ die $error if $error;
+
+ #ajax helper
+ use FS::UI::Web::JSRPC;
+ my $server =
+ new FS::UI::Web::JSRPC 'FS::cust_pkg::Import::process_batch_import', $cgi;
+ print $server->process;
+
+=head1 DESCRIPTION
+
+Batch package importing.
+
+=head1 SUBROUTINES
+
+=item process_batch_import
+
+Load a batch import as a queued JSRPC job
+
+=cut
+
+sub process_batch_import {
+ my $job = shift;
+
+ my $param = thaw(decode_base64(shift));
+ warn Dumper($param) if $DEBUG;
+
+ my $files = $param->{'uploaded_files'}
+ or die "No files provided.\n";
+
+ my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
+
+ my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
+ my $file = $dir. $files{'file'};
+
+ my $type;
+ if ( $file =~ /\.(\w+)$/i ) {
+ $type = lc($1);
+ } else {
+ #or error out???
+ warn "can't parse file type from filename $file; defaulting to CSV";
+ $type = 'csv';
+ }
+
+ my $error =
+ FS::cust_pkg::Import::batch_import( {
+ job => $job,
+ file => $file,
+ type => $type,
+ 'params' => { pkgbatch => $param->{pkgbatch} },
+ agentnum => $param->{'agentnum'},
+ 'format' => $param->{'format'},
+ } );
+
+ unlink $file;
+
+ die "$error\n" if $error;
+
+}
+
+=item batch_import
+
+=cut
+
+my %formatfields = (
+ 'default' => [],
+ 'svc_acct' => [qw( username _password )],
+ 'svc_phone' => [qw( countrycode phonenum sip_password pin )],
+ 'svc_external' => [qw( id title )],
+);
+
+sub _formatfields {
+ \%formatfields;
+}
+
+my %import_options = (
+ 'table' => 'cust_pkg',
+
+ 'postinsert_callback' => sub {
+ my( $record, $param ) = @_;
+
+ my $formatfields = _formatfields;
+ foreach my $svc_x ( grep { $_ ne 'default' } keys %$formatfields ) {
+
+ my $ff = $formatfields->{$svc_x};
+
+ if ( grep $param->{"$svc_x.$_"}, @$ff ) {
+ my $svc_x = "FS::$svc_x"->new( {
+ 'pkgnum' => $record->pkgnum,
+ 'svcpart' => $record->part_pkg->svcpart($svc_x),
+ map { $_ => $param->{"$svc_x.$_"} } @$ff
+ } );
+ my $error = $svc_x->insert;
+ return $error if $error;
+ }
+
+ }
+
+ return ''; #no error
+
+ },
+);
+
+sub _import_options {
+ \%import_options;
+}
+
+sub batch_import {
+ my $opt = shift;
+
+ my $iopt = _import_options;
+ $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
+
+ my $agentnum = delete $opt->{agentnum}; # i like closures (delete though?)
+
+ my $format = delete $opt->{'format'};
+ my @fields = ();
+
+ if ( $format =~ /^(.*)-agent_custid$/ ) {
+ $format = $1;
+ @fields = (
+ sub {
+ my( $self, $value ) = @_; # $conf, $param
+ my $cust_main = qsearchs('cust_main', {
+ 'agentnum' => $agentnum,
+ 'agent_custid' => $value,
+ });
+ $self->custnum($cust_main->custnum) if $cust_main;
+ },
+ );
+ } else {
+ @fields = ( 'custnum' );
+ }
+
+ push @fields, ( 'pkgpart', 'discountnum' );
+
+ foreach my $field (
+ qw( start_date setup bill last_bill susp adjourn cancel expire )
+ ) {
+ push @fields, sub {
+ my( $self, $value ) = @_; # $conf, $param
+ #->$field has undesirable effects
+ $self->set($field, parse_datetime($value) ); #$field closure
+ };
+ }
+
+ my $formatfields = _formatfields();
+
+ die "unknown format $format" unless $formatfields->{$format};
+
+ foreach my $field ( @{ $formatfields->{$format} } ) {
+
+ push @fields, sub {
+ my( $self, $value, $conf, $param ) = @_;
+ $param->{"$format.$field"} = $value;
+ };
+
+ }
+
+ $opt->{'fields'} = \@fields;
+
+ FS::Record::batch_import( $opt );
+
+}
+
+=for comment
+
+ my $billtime = time;
+ my %cust_pkg = ( pkgpart => $pkgpart );
+ my %svc_x = ();
+ foreach my $field ( @fields ) {
+
+ if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
+
+ #$cust_pkg{$1} = parse_datetime( shift @$columns );
+ if ( $1 eq 'pkgpart' ) {
+ $cust_pkg{$1} = shift @columns;
+ } elsif ( $1 eq 'setup' ) {
+ $billtime = parse_datetime(shift @columns);
+ } else {
+ $cust_pkg{$1} = parse_datetime( shift @columns );
+ }
+
+ } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
+
+ $svc_x{$1} = shift @columns;
+
+ } elsif ( $field =~ /^svc_external\.(id|title)$/ ) {
+
+ $svc_x{$1} = shift @columns;
+
+ } elsif ( $field =~ /^svc_phone\.(countrycode|phonenum|sip_password|pin)$/ ) {
+ $svc_x{$1} = shift @columns;
+
+ } else {
+
+ #refnum interception
+ if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
+
+ my $referral = $columns[0];
+ my %hash = ( 'referral' => $referral,
+ 'agentnum' => $agentnum,
+ 'disabled' => '',
+ );
+
+ my $part_referral = qsearchs('part_referral', \%hash )
+ || new FS::part_referral \%hash;
+
+ unless ( $part_referral->refnum ) {
+ my $error = $part_referral->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't auto-insert advertising source: $referral: $error";
+ }
+ }
+
+ $columns[0] = $part_referral->refnum;
+ }
+
+ my $value = shift @columns;
+ $cust_main{$field} = $value if length($value);
+ }
+ }
+
+ $cust_main{'payby'} = 'CARD'
+ if defined $cust_main{'payinfo'}
+ && length $cust_main{'payinfo'};
+
+ my $invoicing_list = $cust_main{'invoicing_list'}
+ ? [ delete $cust_main{'invoicing_list'} ]
+ : [];
+
+ my $cust_main = new FS::cust_main ( \%cust_main );
+
+ use Tie::RefHash;
+ tie my %hash, 'Tie::RefHash'; #this part is important
+
+ if ( $cust_pkg{'pkgpart'} ) {
+ my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
+
+ my @svc_x = ();
+ my $svcdb = '';
+ if ( $svc_x{'username'} ) {
+ $svcdb = 'svc_acct';
+ } elsif ( $svc_x{'id'} || $svc_x{'title'} ) {
+ $svcdb = 'svc_external';
+ }
+
+ my $svc_phone = '';
+ if ( $svc_x{'countrycode'} || $svc_x{'phonenum'} ) {
+ $svc_phone = FS::svc_phone->new( {
+ map { $_ => delete($svc_x{$_}) }
+ qw( countrycode phonenum sip_password pin)
+ } );
+ }
+
+ if ( $svcdb || $svc_phone ) {
+ my $part_pkg = $cust_pkg->part_pkg;
+ unless ( $part_pkg ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
+ }
+ if ( $svcdb ) {
+ $svc_x{svcpart} = $part_pkg->svcpart_unique_svcdb( $svcdb );
+ my $class = "FS::$svcdb";
+ push @svc_x, $class->new( \%svc_x );
+ }
+ if ( $svc_phone ) {
+ $svc_phone->svcpart( $part_pkg->svcpart_unique_svcdb('svc_phone') );
+ push @svc_x, $svc_phone;
+ }
+ }
+
+ $hash{$cust_pkg} = \@svc_x;
+ }
+
+ my $error = $cust_main->insert( \%hash, $invoicing_list );
+
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't insert customer". ( $line ? " for $line" : '' ). ": $error";
+ }
+
+ if ( $format eq 'simple' ) {
+
+ #false laziness w/bill.cgi
+ $error = $cust_main->bill( 'time' => $billtime );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't bill customer for $line: $error";
+ }
+
+ $error = $cust_main->apply_payments_and_credits;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't bill customer for $line: $error";
+ }
+
+ $error = $cust_main->collect();
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't collect customer for $line: $error";
+ }
+
+ }
+
+ $row++;
+
+ if ( $job && time - $min_sec > $last ) { #progress bar
+ $job->update_statustext( int(100 * $row / $count) );
+ $last = time;
+ }
+
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
+
+ return "Empty file!" unless $row;
+
+ ''; #no error
+
+}
+
+=head1 BUGS
+
+Not enough documentation.
+
+=head1 SEE ALSO
+
+L<FS::cust_main>, L<FS::cust_pkg>,
+L<FS::svc_acct>, L<FS::svc_external>, L<FS::svc_phone>
+
+=cut
+
+1;
diff --git a/FS/bin/freeside-queued b/FS/bin/freeside-queued
index c9b0edb..756b699 100644
--- a/FS/bin/freeside-queued
+++ b/FS/bin/freeside-queued
@@ -186,7 +186,7 @@ while (1) {
dbh->{'private_profile'} = {} if UNIVERSAL::can(dbh, 'sprintProfile');
#auto-use classes...
- if ( $ljob->job =~ /(FS::(part_export|cust_main)::\w+)::/
+ if ( $ljob->job =~ /(FS::(part_export|cust_main|cust_pkg)::\w+)::/
|| $ljob->job =~ /(FS::\w+)::/
)
{
diff --git a/Makefile b/Makefile
index bf3f3d7..110cfa0 100644
--- a/Makefile
+++ b/Makefile
@@ -210,7 +210,7 @@ perl-modules:
" blib/lib/FS/part_export/*.pm;\
perl -p -i -e "\
s|%%%FREESIDE_CACHE%%%|${FREESIDE_CACHE}|g;\
- " blib/lib/FS/cust_main/*.pm;\
+ " blib/lib/FS/cust_main/*.pm blib/lib/FS/cust_pkg/*.pm;\
perl -p -i -e "\
s|%%%FREESIDE_CONF%%%|${FREESIDE_CONF}|g;\
s|%%%FREESIDE_LOG%%%|${FREESIDE_LOG}|g;\
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index 2404ef2..5c48e1c 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -318,6 +318,7 @@ $report_menu{'SQL Query'} = [ $fsurl.'search/report_sql.html', 'SQL Query' ]
tie my %tools_importing, 'Tie::IxHash',
'Customers' => [ $fsurl.'misc/cust_main-import.cgi', '' ],
+ 'Customer packages' => [ $fsurl.'misc/cust_pkg-import.html', '' ],
'Customer comments from CSV file' => [ $fsurl.'misc/cust_main_note-import.html', '' ],
'One-time charges from CSV file' => [ $fsurl.'misc/cust_main-import_charges.cgi', '' ],
'Payments from CSV file' => [ $fsurl.'misc/cust_pay-import.cgi', '' ],
@@ -399,7 +400,7 @@ if ( $curuser->access_right('Configuration') ) {
#package grouping sub-menu?
$config_pkg{'Package classes'} = [ $fsurl.'browse/pkg_class.html', 'Package classes define groups of packages, for taxation, ordering convenience and reporting.' ];
- $config_pkg{'Package categories'} = [ $fsurl.'browse/pkg_category.html', 'Package categories define groups of package classes.' ];
+ $config_pkg{'Package categories'} = [ $fsurl.'browse/pkg_category.html', 'Package categories define groups of package classes, for invoice sections.' ];
$config_pkg{'Package report classes'} = [ $fsurl.'browse/part_pkg_report_option.html', 'Package classes define optional groups of packages for reporting only.' ];
#eo package grouping sub-menu
diff --git a/httemplate/misc/cust_main-import.cgi b/httemplate/misc/cust_main-import.cgi
index 9c1f984..2ccf997 100644
--- a/httemplate/misc/cust_main-import.cgi
+++ b/httemplate/misc/cust_main-import.cgi
@@ -119,7 +119,7 @@ advertising source table.
<li><i>invoicing_list</i>: Email address for invoices, or POST for postal invoices.
- <li><i>pkgpart</i>: Package definition. Configuration -&gt; Provisioning, services and packages -&gt; View/Edit package definitions
+ <li><i>pkgpart</i>: Package definition. Configuration -&gt; Packages -&gt; Package definitions
<li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified. (Extended and Extended plus company formats)
diff --git a/httemplate/misc/cust_pkg-import.html b/httemplate/misc/cust_pkg-import.html
new file mode 100644
index 0000000..ad582b0
--- /dev/null
+++ b/httemplate/misc/cust_pkg-import.html
@@ -0,0 +1,132 @@
+<% include("/elements/header.html",'Batch Package Import') %>
+
+Import a file containing package records.
+<BR><BR>
+
+<% include( '/elements/form-file_upload.html',
+ 'name' => 'PackageImportForm',
+ 'action' => 'process/cust_pkg-import.html',
+ 'num_files' => 1,
+ 'fields' => [ 'agentnum', 'pkgbatch', 'format' ],
+ 'message' => 'Package import successful',
+ 'url' => $p."search/cust_pkg.cgi?pkgbatch=$pkgbatch",
+ )
+%>
+
+<% &ntable("#cccccc", 2) %>
+
+ <% include( '/elements/tr-select-agent.html',
+ #'curr_value' => '', #$agentnum,
+ 'label' => "<B>Agent</B>",
+ 'empty_label' => 'Select agent',
+ )
+ %>
+
+ <INPUT TYPE="hidden" NAME="pkgbatch" VALUE="<% $pkgbatch %>"%>
+
+ <TR>
+ <TH ALIGN="right">Format</TH>
+ <TD>
+ <SELECT NAME="format">
+ <OPTION VALUE="default" SELECTED>Default
+ <OPTION VALUE="default-agent_custid">Default with agent_custid
+ <OPTION VALUE="svc_acct">Account service
+ <OPTION VALUE="svc_acct-agent_custid">Account service with agent_custid
+ <OPTION VALUE="svc_phone">Phone service
+ <OPTION VALUE="svc_phone-agent_custid">Phone service with agent_custid
+ <OPTION VALUE="svc_external">External service
+ <OPTION VALUE="svc_external-agent_custid">External service with agent_custid
+ </SELECT>
+ </TD>
+ </TR>
+
+ <% include( '/elements/file-upload.html',
+ 'field' => 'file',
+ 'label' => 'Filename',
+ )
+ %>
+
+ <TR>
+ <TD COLSPAN=2 ALIGN="center" STYLE="padding-top:6px">
+ <INPUT TYPE = "submit"
+ ID = "submit"
+ VALUE = "Import file"
+ onClick = "document.PackageImportForm.submit.disabled=true;"
+ >
+ </TD>
+ </TR>
+
+</TABLE>
+
+</FORM>
+
+<BR>
+Uploaded files can be CSV (comma-separated value) files or Excel spreadsheets. The file should have a .CSV or .XLS extension.
+<BR><BR>
+
+<b>Default</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire</i>
+<BR><BR>
+
+<b>Default with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire</i>
+<BR><BR>
+
+<b>Account service</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, username, _password</i>
+<BR><BR>
+
+<b>Account service with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, username, _password</i>
+<BR><BR>
+
+<b>Phone sevice</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, countrycode, phonenum, sip_password, pin</i>
+<BR><BR>
+
+<b>Phone service with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, countrycode, phonenum, sip_password, pin</i>
+<BR><BR>
+
+<b>External sevice</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, id, title</i>
+<BR><BR>
+
+<b>External service with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, id, title</i>
+<BR><BR>
+
+<%$req%> Required fields
+<BR><BR>
+
+Field information:
+
+<ul>
+
+ <li><i>custnum</i>: This specifies an existing customer by custnum.
+
+ <li><i>agent_custid</i>: This specifies an existing customer record by agent_custid.
+
+ <li><i>pkgpart</i>: Package definition. Configuration -&gt; Packages -&gt; Package definitions
+
+ <li><i>pkgpart</i>: Optional discount. Configuration -&gt; Packages -&gt; Discounts
+
+<!--
+ <li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified. (Extended and Extended plus company formats)
+-->
+
+ <li><i>id</i>: External service id, integer
+
+ <li><i>title</i>: External service identifier, text
+
+</ul>
+
+<BR>
+
+<% include('/elements/footer.html') %>
+
+<%once>
+
+my $req = qq!<font color="#ff0000">*</font>!;
+
+</%once>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+my $pkgbatch = time2str('webimport-%Y/%m/%d-%T'. "-$$-". rand() * 2**32, time);
+
+</%init>
diff --git a/httemplate/misc/process/cust_pkg-import.html b/httemplate/misc/process/cust_pkg-import.html
new file mode 100644
index 0000000..1021817
--- /dev/null
+++ b/httemplate/misc/process/cust_pkg-import.html
@@ -0,0 +1,10 @@
+<% $server->process %>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+my $server =
+ new FS::UI::Web::JSRPC 'FS::cust_pkg::Import::process_batch_import', $cgi;
+
+</%init>
diff --git a/httemplate/search/cust_pkg.cgi b/httemplate/search/cust_pkg.cgi
index 74a3a6d..bd8ea9a 100755
--- a/httemplate/search/cust_pkg.cgi
+++ b/httemplate/search/cust_pkg.cgi
@@ -175,8 +175,9 @@ my %search_hash = ();
#some false laziness w/misc/bulk_change_pkg.cgi
$search_hash{'query'} = $cgi->keywords;
-
-for (qw( agentnum custnum magic status classnum custom cust_fields )) {
+
+#scalars
+for (qw( agentnum custnum magic status classnum custom cust_fields pkgbatch )) {
$search_hash{$_} = $cgi->param($_) if $cgi->param($_);
}