[freeside-commits] branch master updated. fa978560e3b0473728ebf2fb32625765465c230a

Mark Wells mark at 420.am
Thu Jun 26 16:05:04 PDT 2014


The branch, master has been updated
       via  fa978560e3b0473728ebf2fb32625765465c230a (commit)
      from  3e3441036353ea99dc85548bbdbe810edc81b181 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit fa978560e3b0473728ebf2fb32625765465c230a
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Jun 26 15:47:22 2014 -0700

    NENA2 E911 export and batch-oriented exports in general, #14049

diff --git a/FS/FS/Cron/export_batch.pm b/FS/FS/Cron/export_batch.pm
new file mode 100644
index 0000000..cb16eee
--- /dev/null
+++ b/FS/FS/Cron/export_batch.pm
@@ -0,0 +1,64 @@
+package FS::Cron::export_batch;
+
+use strict;
+use vars qw( @ISA @EXPORT_OK $me $DEBUG );
+use Exporter;
+use FS::UID qw(dbh);
+use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
+use FS::export_batch;
+
+ at ISA = qw( Exporter );
+ at EXPORT_OK = qw ( export_batch_submit );
+$DEBUG = 0;
+$me = '[FS::Cron::export_batch]';
+
+#freeside-daily %opt:
+#  -v: enable debugging
+#  -l: debugging level
+#  -m: Experimental multi-process mode uses the job queue for multi-process and/or multi-machine billing.
+#  -r: Multi-process mode dry run option
+#  -a: Only process customers with the specified agentnum
+
+sub export_batch_submit {
+  my %opt = @_;
+  local $DEBUG = ($opt{l} || 1) if $opt{v};
+  
+  warn "$me batch_submit\n" if $DEBUG;
+
+  # like pay_batch, none of this is per-agent
+  if ( $opt{a} ) {
+    warn "Export batch processing skipped in per-agent mode.\n" if $DEBUG;
+    return;
+  }
+  my @batches = qsearch({
+      table     => 'export_batch',
+      extra_sql => "WHERE status IN ('open', 'closed')",
+  });
+
+  foreach my $batch (@batches) {
+    my $export = $batch->part_export;
+    next if $export->disabled;
+    warn "processing batchnum ".$batch->batchnum.
+         " via ".$export->exporttype.  "\n"
+      if $DEBUG;
+    local $@;
+    eval {
+      $export->process($batch);
+    };
+    if ($@) {
+      dbh->rollback;
+      warn "export batch ".$batch->batchnum." failed: $@\n";
+      $batch->set(status => 'failed');
+      $batch->set(statustext => $@);
+      my $error = $batch->replace;
+      die "error recording batch status: $error"
+        if $error;
+      dbh->commit;
+    }
+  }
+}
+
+# currently there's no batch_receive() or anything of that sort
+
+1;
diff --git a/FS/FS/Cron/pay_batch.pm b/FS/FS/Cron/pay_batch.pm
index 0ab37dd..432271d 100644
--- a/FS/FS/Cron/pay_batch.pm
+++ b/FS/FS/Cron/pay_batch.pm
@@ -11,7 +11,7 @@ use FS::queue;
 use FS::agent;
 
 @ISA = qw( Exporter );
- at EXPORT_OK = qw ( batch_submit batch_receive );
+ at EXPORT_OK = qw ( pay_batch_submit pay_batch_receive );
 $DEBUG = 0;
 $me = '[FS::Cron::pay_batch]';
 
@@ -22,7 +22,7 @@ $me = '[FS::Cron::pay_batch]';
 #  -r: Multi-process mode dry run option
 #  -a: Only process customers with the specified agentnum
 
-sub batch_submit {
+sub pay_batch_submit {
   my %opt = @_;
   local $DEBUG = ($opt{l} || 1) if $opt{v};
   # if anything goes wrong, don't try to roll back previously submitted batches
@@ -71,7 +71,7 @@ sub batch_submit {
   1;
 }
 
-sub batch_receive {
+sub pay_batch_receive {
   my %opt = @_;
   local $DEBUG = ($opt{l} || 1) if $opt{v};
   local $FS::UID::AutoCommit = 0;
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 4b50e97..ede7259 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -379,6 +379,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::part_fee_usage;
   use FS::sched_item;
   use FS::sched_avail;
+  use FS::export_batch;
+  use FS::export_batch_item;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 93691e7..387f508 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -5561,6 +5561,8 @@ sub tables_hashref {
         'sms_carrierid',                  'int', 'NULL',      '', '', '',
         'sms_account',                'varchar', 'NULL', $char_d, '', '',
         'max_simultaneous',               'int', 'NULL',      '', '', '',
+        'e911_class',                    'char', 'NULL',       1, '', '',
+        'e911_type',                     'char', 'NULL',       1, '', '', 
       ],
       'primary_key'  => 'svcnum',
       'unique'       => [ [ 'sms_carrierid', 'sms_account'] ],
@@ -6590,6 +6592,49 @@ sub tables_hashref {
                         ],
     },
 
+    'export_batch' => {
+      'columns' => [
+        'batchnum',    'serial',     '',      '', '', '',
+        'exportnum',      'int',     '',      '', '', '',
+        '_date',          'int',     '',      '', '', '',
+        'status',     'varchar', 'NULL',      32, '', '',
+        'statustext', 'varchar', 'NULL', $char_d, '', '',
+      ],
+      'primary_key'  => 'batchnum',
+      'unique'       => [],
+      'index'        => [ [ 'exportnum' ], [ 'status' ] ],
+      'foreign_keys' => [
+                          { columns    => [ 'exportnum' ],
+                            table      => 'part_export',
+                            references => [ 'exportnum' ]
+                          },
+                        ],
+    },
+
+    'export_batch_item' => {
+      'columns' => [
+        'itemnum',     'serial',     '',      '', '', '',
+        'batchnum',       'int',     '',      '', '', '',
+        'svcnum',         'int',     '',      '', '', '',
+        'action',     'varchar',     32,      '', '', '',
+        'data',          'text', 'NULL',      '', '', '',
+        'frozen',        'char', 'NULL',       1, '', '',
+      ],
+      'primary_key'  => 'itemnum',
+      'unique'       => [],
+      'index'        => [ [ 'batchnum' ], [ 'svcnum' ] ],
+      'foreign_keys' => [
+                          { columns    => [ 'batchnum' ],
+                            table      => 'export_batch',
+                            references => [ 'batchnum' ]
+                          },
+                          { columns    => [ 'svcnum' ],
+                            table      => 'cust_svc',
+                            references => [ 'svcnum' ]
+                          },
+                        ],
+    },
+
     # name type nullability length default local
 
     #'new_table' => {
diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm
index 936e821..60c5216 100644
--- a/FS/FS/contact.pm
+++ b/FS/FS/contact.pm
@@ -466,6 +466,11 @@ sub line {
   $data;
 }
 
+sub firstlast {
+  my $self = shift;
+  $self->first . ' ' . $self->last;
+}
+
 sub contact_classname {
   my $self = shift;
   my $contact_class = $self->contact_class or return '';
diff --git a/FS/FS/cust_svc.pm b/FS/FS/cust_svc.pm
index b01ed84..8fc929f 100644
--- a/FS/FS/cust_svc.pm
+++ b/FS/FS/cust_svc.pm
@@ -322,14 +322,24 @@ sub replace {
     my $error = $new->svc_x->export('pkg_change', $new->cust_pkg,
                                                   $old->cust_pkg,
                                    );
+
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error if $error;
     }
-  }
+  } # if pkgnum is changing
 
   #my $error = $new->SUPER::replace($old, @_);
   my $error = $new->SUPER::replace($old);
+
+  #trigger a relocate export on location changes
+  if ( $new->cust_pkg->locationnum != $old->cust_pkg->locationnum ) {
+    $error ||= $new->svc_x->export('relocate',
+                                   $new->cust_pkg->cust_location,
+                                   $old->cust_pkg->cust_location,
+                                  );
+  }
+
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error if $error;
diff --git a/FS/FS/export_batch.pm b/FS/FS/export_batch.pm
new file mode 100644
index 0000000..84b7c71
--- /dev/null
+++ b/FS/FS/export_batch.pm
@@ -0,0 +1,132 @@
+package FS::export_batch;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::export_batch - Object methods for export_batch records
+
+=head1 SYNOPSIS
+
+  use FS::export_batch;
+
+  $record = new FS::export_batch \%hash;
+  $record = new FS::export_batch { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::export_batch object represents a batch of records being processed
+by an export.  This mechanism allows exports to process multiple pending
+service changes at the end of day or some other scheduled time, rather 
+than doing everything in realtime or near-realtime (via the job queue).
+
+FS::export_batch inherits from FS::Record.  The following fields are 
+currently supported:
+
+=over 4
+
+=item batchnum
+
+primary key
+
+=item exportnum
+
+The L<FS::part_export> object that created this batch.
+
+=item _date
+
+The time the batch was created.
+
+=item status
+
+A status string.  Allowed values are "open" (for a newly created batch that
+can receive additional items), "closed" (for a batch that is no longer 
+allowed to receive items but is still being processed), "done" (for a batch
+that is finished processing), and "failed" (if there has been an error 
+exporting the batch).
+
+=item statustext
+
+Free-text field for any status information from the remote machine or whatever
+else the export is doing.  If status is "failed" this MUST contain a value.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new batch.  To add the example to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'export_batch'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.  Don't ever do this.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid batch.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  $self->set('status' => 'open') unless $self->get('status');
+  $self->set('_date' => time) unless $self->get('_date');
+
+  my $error = 
+    $self->ut_numbern('batchnum')
+    || $self->ut_number('exportnum')
+    || $self->ut_foreign_key('exportnum', 'part_export', 'exportnum')
+    || $self->ut_number('_date')
+    || $self->ut_enum('status', [ qw(open closed done failed) ])
+    || $self->ut_textn('statustext')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::part_export>, L<FS::export_batch_item>
+
+=cut
+
+1;
+
diff --git a/FS/FS/export_batch_item.pm b/FS/FS/export_batch_item.pm
new file mode 100644
index 0000000..accb3f1
--- /dev/null
+++ b/FS/FS/export_batch_item.pm
@@ -0,0 +1,130 @@
+package FS::export_batch_item;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::export_batch_item - Object methods for export_batch_item records
+
+=head1 SYNOPSIS
+
+  use FS::export_batch_item;
+
+  $record = new FS::export_batch_item \%hash;
+  $record = new FS::export_batch_item { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::export_batch_item object represents a service change (insert, delete,
+replace, suspend, unsuspend, or relocate) queued for processing by a 
+batch-oriented export.
+
+FS::export_batch_item inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item itemnum
+
+primary key
+
+=item batchnum
+
+L<FS::export_batch> foreign key; the batch that this item belongs to.
+
+=item svcnum
+
+L<FS::cust_svc> foreign key; the service that is being exported.
+
+=item action
+
+One of 'insert', 'delete', 'replace', 'suspend', 'unsuspend', or 'relocate'.
+
+=item data
+
+A place for the export to store data relating to the service change.
+
+=item frozen
+
+A flag indicating that C<data> is a base64-Storable encoded object rather
+than a simple string.
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new batch item.  To add the example to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'export_batch_item'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('itemnum')
+    || $self->ut_number('batchnum')
+    || $self->ut_foreign_key('batchnum', 'export_batch', 'batchnum')
+    || $self->ut_number('svcnum')
+    || $self->ut_foreign_key('svcnum', 'cust_svc', 'svcnum')
+    || $self->ut_enum('action',
+      [ qw(insert delete replace suspend unsuspend relocate) ]
+    )
+    || $self->ut_anything('data')
+    || $self->ut_flag('frozen')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::export_batch>, L<FS::cust_svc>
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_export/batch_Common.pm b/FS/FS/part_export/batch_Common.pm
new file mode 100644
index 0000000..f489497
--- /dev/null
+++ b/FS/FS/part_export/batch_Common.pm
@@ -0,0 +1,202 @@
+package FS::part_export::batch_Common;
+
+use strict;
+use base 'FS::part_export';
+use FS::Record qw(qsearch qsearchs);
+use FS::export_batch;
+use FS::export_batch_item;
+use Storable qw(nfreeze thaw);
+use MIME::Base64 qw(encode_base64 decode_base64);
+
+=head1 DESCRIPTION
+
+FS::part_export::batch_Common should be inherited by any export that stores
+pending service changes and processes them all at once.  It provides the 
+external interface, and has an internal interface that the subclass must 
+implement.
+
+=head1 INTERFACE
+
+ACTION in all of these methods is one of 'insert', 'delete', 'replace',
+'suspend', 'unsuspend', 'pkg_change', or 'relocate'.
+
+ARGUMENTS is the arguments to the export_* method:
+
+- for insert, the new service
+
+- for suspend, unsuspend, or delete, the service to act on
+
+- for replace, the new service, followed by the old service
+
+- for pkg_change, the service, followed by the new and old packages 
+  (as L<FS::cust_pkg> objects)
+
+- for relocate, the service, followed by the new location and old location
+  (as L<FS::cust_location> objects)
+
+=over 4
+
+=item immediate ACTION, ARGUMENTS
+
+This is called immediately from the export_* method, and does anything
+that needs to happen right then, except for inserting the 
+L<FS::export_batch_item> record.  Optional.  If it exists, it can return
+a non-empty error string to cause the export to fail.
+
+=item data ACTION, ARGUMENTS
+
+This is called just before inserting the batch item, and returns a scalar
+to store in the item's C<data> field.  If the export needs to remember 
+anything about the service for the later batch-processing stage, it goes 
+here.  Remember that if the service is being deleted, the export will need
+to remember enough information to unprovision it when it's no longer in the 
+database.
+
+If this returns a reference, it will be frozen down with Base64-Storable.
+
+=item process BATCH
+
+This is called from freeside-daily, once for each batch still in the 'open'
+or 'closed' state.  It's expected to do whatever needs to be done with the 
+batch, and report failure via die().
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+sub export_insert {
+  my $self = shift;
+  my $svc = shift;
+
+  $self->immediate('insert', $svc) || $self->create_item('insert', $svc);
+}
+
+sub export_delete {
+  my $self = shift;
+  my $svc = shift;
+
+  $self->immediate('delete', $svc) || $self->create_item('delete', $svc);
+}
+
+sub export_suspend {
+  my $self = shift;
+  my $svc = shift;
+
+  $self->immediate('suspend', $svc) || $self->create_item('suspend', $svc);
+}
+
+sub export_unsuspend {
+  my $self = shift;
+  my $svc = shift;
+
+  $self->immediate('unsuspend', $svc) || $self->create_item('unsuspend', $svc);
+}
+
+sub export_replace {
+  my $self = shift;
+  my $new = shift;
+  my $old = shift;
+
+  $self->immediate('replace', $new, $old) 
+  || $self->create_item('replace', $new, $old)
+}
+
+sub export_relocate {
+  my $self = shift;
+  my $svc = shift;
+  my $new_loc = shift;
+  my $old_loc = shift;
+
+  $self->immediate('relocate', $svc, $new_loc, $old_loc)
+  || $self->create_item('relocate', $svc, $new_loc, $old_loc)
+}
+
+sub export_pkg_change {
+  my $self = shift;
+  my $svc = shift;
+  my $new_pkg = shift;
+  my $old_pkg = shift;
+
+  $self->immediate('pkg_change', $svc, $new_pkg)
+  || $self->create_item('pkg_change', $svc, $new_pkg)
+}
+
+=item create_item ACTION, ARGUMENTS
+
+Creates and inserts the L<FS::export_batch_item> record for the action.
+
+=cut
+
+sub create_item {
+  my $self = shift;
+  my $action = shift;
+  my $svc = shift;
+
+  # get memo field
+  my $data = $self->data($action, $svc, @_);
+  my $frozen = '';
+  if (ref $data) {
+    $data = base64_encode(nfreeze($data));
+    $frozen = 'Y';
+  }
+  my $batch_item = FS::export_batch_item->new({
+      'svcnum'    => $svc->svcnum,
+      'action'    => $action,
+      'data'      => $data,
+      'frozen'    => $frozen,
+  });
+  return $self->add_to_batch($batch_item);
+}
+
+sub immediate { # stub
+  '';
+}
+
+=item add_to_batch ITEM
+
+Actually inserts ITEM into the appropriate open batch.  All fields in ITEM
+will be populated except for 'batchnum'.  By default, all items for a 
+single export will go into the same batch, but subclass exports may override
+this method.
+
+=cut
+
+sub add_to_batch {
+  my $self = shift;
+  my $batch_item = shift;
+  $batch_item->set( 'batchnum', $self->open_batch->batchnum );
+
+  $batch_item->insert;
+}
+
+=item open_batch
+
+Returns the current open batch for this export.  If there isn't one yet,
+this will create one.
+
+=cut
+
+sub open_batch {
+  my $self = shift;
+  my $batch = qsearchs('export_batch', { status => 'open',
+                                         exportnum => $self->exportnum });
+  if (!$batch) {
+    $batch = FS::export_batch->new({
+        status    => 'open',
+        exportnum => $self->exportnum
+    });
+    my $error = $batch->insert;
+    die $error if $error;
+  }
+  $batch;
+}
+
+=back
+
+=cut
+
+1;
diff --git a/FS/FS/part_export/nena2.pm b/FS/FS/part_export/nena2.pm
new file mode 100644
index 0000000..71d753a
--- /dev/null
+++ b/FS/FS/part_export/nena2.pm
@@ -0,0 +1,496 @@
+package FS::part_export::nena2;
+
+use base 'FS::part_export::batch_Common';
+use strict;
+use FS::Record qw(qsearch qsearchs dbh);
+use FS::svc_phone;
+use FS::upload_target;
+use Tie::IxHash;
+use Date::Format qw(time2str);
+use Parse::FixedLength;
+use File::Temp qw(tempfile);
+use vars qw(%info %options $initial_load_hack $DEBUG);
+
+my %upload_targets;
+
+tie %options, 'Tie::IxHash', (
+  'company_name'    => {  label => 'Company name for header record',
+                          type  => 'text'
+                       },
+  'company_id'      => {  label => 'NENA company ID',
+                          type  => 'text',
+                       },
+  'prefix'          => {  label => 'File name prefix',
+                          type  => 'text',
+                       },
+  'format'          => {  label => 'Format variant',
+                          type  => 'select',
+                          options => [ '', 'Intrado' ],
+                       },
+  'target'          => {  label => 'Upload destination',
+                          type => 'select',
+                          option_values => sub {
+                            %upload_targets = 
+                              map { $_->targetnum, $_->label } 
+                              qsearch('upload_target');
+                            sort keys (%upload_targets);
+                          },
+                          option_label => sub {
+                            $upload_targets{$_[0]}
+                          },
+                        },
+  'cycle_counter'   => { label => 'Cycle counter',
+                         type => 'text',
+                         default => '1'
+                       },
+  'debug'           => { label => 'Enable debugging',
+                         type => 'checkbox' },
+);
+
+%info = (
+  'svc'       => 'svc_phone',
+  'desc'      => 'Export a NENA 2 E911 data file',
+  'options'   => \%options,
+  'nodomain'  => 'Y',
+  'no_machine'=> 1,
+  'notes'     => qq!
+<p>Export the physical location of a telephone service to a NENA 2.1 file
+for use by an ALI database provider.</p>
+<p>Options:
+<ul>
+<li><b>Company name</b> is the company name that should appear in your header
+and trailer records.<li>
+<li><b>Company ID</b> is your <a href="http://www.nena.org/?CompanyID">NENA 
+assigned company ID</a>.</li>
+<li><b>File name prefix</b> is the prefix to use in your upload file names.
+The rest of the file name will be the date (in mmddyy format) followed by 
+".dat".</li>
+<li><b>Format variant</b> is the modification of the NENA format required 
+by your database provider.  We support the Intrado variant used by
+Qwest/CenturyLink.  To produce a pure standard-compliant file, leave this
+blank.</li>
+<li><b>Upload destination</b> is the <a href="../browse/upload_target.html">
+upload target</a> to send the file to.</li>
+<li><b>Cycle counter</b> is the sequence number of the next batch to be sent.
+This will be automatically incremented with each batch.</li>
+</ul>
+</p>
+  !,
+);
+
+$initial_load_hack = 0; # set to 1 if running from a re-export script
+
+# All field names and sizes are taken from the NENA-2-010 standard, May 1999 
+# version.
+
+my $item_format = Parse::FixedLength->new([ qw(
+    function_code:1:1:1
+    npa:3:2:4
+    calling_number:7:5:11
+    house_number:10:12:21
+    house_number_suffix:4:22:25
+    prefix_directional:2:26:27
+    street_name:60:28:87
+    street_suffix:4:88:91
+    post_directional:2:92:93
+    community_name:32:94:125
+    state:2:126:127
+    location:60:128:187
+    customer_name:32:188:219
+    class_of_service:1:220:220
+    type_of_service:1:221:221
+    exchange:4:222:225
+    esn:5:226:230
+    main_npa:3:231:233
+    main_number:7:234:240
+    order_number:10:241:250
+    extract_date:6:251:256
+    county_id:4:257:260
+    company_id:5:261:265
+    source_id:1:266:266
+    zip_code:5:267:271
+    zip_4:4:272:275
+    general_use:11:276:286
+    customer_code:3:287:289
+    comments:30:290:319
+    x_coordinate:9:320:328
+    y_coordinate:9:329:337
+    z_coordinate:5:338:342
+    cell_id:6:343:348
+    sector_id:1:349:349
+    tar_code:6:350:355
+    reserved:21:356:376
+    alt:10:377:386
+    expanded_extract_date:8:387:394
+    nena_reserved:86:395:480
+    dbms_reserved:31:481:511
+    end_of_record:1:512:512
+  )]
+);
+
+my $header_format = Parse::FixedLength->new([ qw(
+    header_indicator:5:1:5
+    extract_date:6:6:11
+    company_name:50:12:61
+    cycle_counter:6R:62:67
+    county_id:4:68:71
+    state:2:72:73
+    general_use:20:74:93
+    release_number:3:94:96
+    format_version:1:97:97
+    expanded_extract_date:8:98:105
+    reserved:406:106:511
+    end_of_record:1:512:512
+  )]
+);
+
+my $trailer_format = Parse::FixedLength->new([ qw(
+    trailer_indicator:5:1:5
+    extract_date:6:6:11
+    company_name:50:12:61
+    record_count:9R:62:70
+    expanded_extract_date:8:71:78
+    reserved:433:79:511
+    end_of_record:1:512:512
+  )]
+);
+
+my %function_code = (
+  'insert'    => 'I',
+  'delete'    => 'D',
+  'replace'   => 'C',
+  'relocate'  => 'C',
+);
+
+sub immediate {
+  local $@;
+  eval "use Geo::StreetAddress::US";
+  if ($@) {
+    if ($@ =~ /^Can't locate/) {
+      return "Geo::StreetAddress::US must be installed to use the NENA2 export.";
+    } else {
+      die $@;
+    }
+  }
+
+  # validate some things
+  my ($self, $action, $svc) = @_;
+  if ( $svc->phonenum =~ /\D/ ) {
+    return "Can't export E911 information for a non-numeric phone number";
+  } elsif ( $svc->phonenum =~ /^011/ ) {
+    return "Can't export E911 information for a non-North American phone number";
+  }
+  '';
+}
+
+sub create_item {
+  my $self = shift;
+  my $action = shift;
+  my $svc = shift;
+  # pkg_change, suspend, unsuspend actions don't trigger anything here
+  return '' if !exists( $function_code{$action} ); 
+  if ( $action eq 'replace' ) {
+    my $old = shift;
+    # the one case where the old service is relevant: phone number change
+    # in that case, insert a batch item to delete the old number, then 
+    # continue as if this were an insert.
+    if ($old->phonenum ne $svc->phonenum) {
+      return $self->create_item('delete', $old)
+          || $self->create_item('insert', $svc);
+    }
+  }
+  $self->SUPER::create_item($action, $svc, @_);
+}
+
+sub data {
+  # generate the entire record here.  reconciliation of multiple updates to 
+  # the same service can be done at process time.
+  my $self = shift;
+  my $action = shift;
+
+  my $svc = shift;
+
+  my $locationnum =    $svc->locationnum
+                    || $svc->cust_svc->cust_pkg->locationnum;
+  my $cust_location = FS::cust_location->by_key($locationnum);
+
+  # initialize with empty strings
+  my %hash = map { $_ => '' } $item_format->names;
+
+  $hash{function_code} = $function_code{$action};
+
+  # phone number 
+  $svc->phonenum =~ /^(\d{3})(\d*)$/;
+  $hash{npa} = $1;
+  $hash{calling_number} = $2;
+
+  # street address
+  my $location_hash = Geo::StreetAddress::US->parse_address(
+    uc( join(', ', $cust_location->address1,
+                   $cust_location->address2,
+                   $cust_location->city,
+                   $cust_location->state,
+                   $cust_location->zip
+    ) )
+  );
+  $hash{house_number}         = $location_hash->{number};
+  $hash{house_number_suffix}  = ''; # we don't support this, do we?
+  $hash{prefix_directional}   = $location_hash->{prefix};
+  $hash{street_name}          = $location_hash->{street};
+  $hash{street_suffix}        = $location_hash->{type};
+  $hash{post_directional}     = $location_hash->{suffix};
+  $hash{community_name}       = $location_hash->{city};
+  $hash{state}                = $location_hash->{state};
+  if ($location_hash->{sec_unit_type}) {
+    $hash{location} = $location_hash->{sec_unit_type} . ' ' .
+                      $location_hash->{sec_unit_num};
+  } else {
+    $hash{location} = $cust_location->address2;
+  }
+  $hash{location}             = $location_hash->{address2};
+
+  # customer name and class
+  $hash{customer_name} = $svc->phone_name_or_cust;
+  $hash{class_of_service} = $svc->e911_class;
+  $hash{type_of_service}  = $svc->e911_type || '0';
+
+  $hash{exchange} = '';
+  # the routing number for the local emergency service call center; 
+  # will be filled in by the service provider
+  $hash{esn} = '';
+
+  # Main Number (I guess for callbacks?)
+  # XXX this is probably not right, but we don't have a concept of "main 
+  # number for the site".
+  $hash{main_npa} = $hash{npa};
+  $hash{main_number} = $hash{calling_number};
+
+  # Order Number...is a foreign concept to us.  It's supposed to be the 
+  # transaction number that ordered this service change.  (Maybe the 
+  # number of the batch item?  That's really hard for a user to do anything
+  # with.)
+  $hash{order_number} = $svc->svcnum;
+  $hash{extract_date} = time2str('%m%d%y', time);
+
+  # $hash{county_id} is supposed to be the FIPS code for the county,
+  # but it's a four-digit field.  INCITS 31 county codes are 5 digits,
+  # so we can't comply.  NENA 3 fixed this...
+
+  $hash{company_id} = $self->option('company_id');
+  $hash{source_id} = $initial_load_hack ? 'C' : ' ';
+
+  @hash{'zip', 'zip_'} = split('-', $cust_location->zip);
+  
+  # $hash{customer_code} is supposed to "uniquely identify a customer" but 
+  # they give us 3 alphanumeric characters.  Not sure how that works.
+
+  $hash{x_coordinate} = $cust_location->longitude;
+  $hash{y_coordinate} = $cust_location->latitude;
+  # $hash{z_coordinate} = $cust_location->altitude; # not implemented, sadly
+
+  $hash{expanded_extract_date} = time2str('%Y%m%d', time);
+
+  # quirks mode
+  if ( $self->option('format') eq 'Intrado' ) { 
+    my $century = substr($hash{expanded_extract_date}, 0, 2);
+    $hash{expanded_extract_date} = '';
+    $hash{nena_reserved} = '   '.$century;
+    $hash{x_coordinate} = '';
+    $hash{y_coordinate} = '';
+  }
+  $hash{end_of_record} = '*';
+  return $item_format->pack(\%hash);
+}
+
+sub process {
+  my $self = shift;
+  my $batch = shift;
+  local $DEBUG = $self->option('debug');
+  local $FS::UID::AutoCommit = 0;
+  my $error;
+
+  my $cycle = $self->option('cycle_counter');
+  die "invalid cycle counter value '$cycle'" if $cycle =~ /\D/;
+
+  # mark the batch as closed
+  if ($batch->status eq 'open') {
+    $batch->set(status => 'closed');
+    $error = $batch->replace;
+    die "can't close batch: $error" if $error;
+    dbh->commit;
+  }
+
+  my @items = $batch->export_batch_item;
+  return unless @items;
+
+  my ($fh, $local_file) = tempfile();
+  warn "writing batch to $local_file\n" if $DEBUG;
+
+  # intrado documentation is inconsistent on this, but NENA 2.1 says to use
+  # leading spaces, not zeroes, for the cycle counter and record count
+
+  my %hash = ('header_indicator'      => 'UHL',
+              'extract_date'          => time2str('%m%d%y', $batch->_date),
+              'company_name'          => $self->option('company_name'),
+              'cycle_counter'         => $cycle,
+              # can add these fields if they're really necessary but it's
+              # a lot of work
+              'county_id'             => '',
+              'state'                 => '',
+              'general_use'           => '',
+              'release_number'        => '',
+              'format_version'        => '',
+              'expanded_extract_date' => time2str('%Y%m%d', $batch->_date),
+              'reserved'              => '',
+              'end_of_record'         => '*'
+             );
+
+  my $header = $header_format->pack(\%hash);
+  warn "HEADER: $header\n" if $DEBUG;
+  print $fh $header,"\r\n";
+
+  my %phonenum_item; # phonenum => batch item
+  foreach my $item (@items) {
+
+    # ignore items that have no data to add to the batch
+    next if $item->action eq 'suspend' or $item->action eq 'unsuspend';
+    
+    my $svcnum = $item->svcnum;
+    my $data = $item->data;
+    %hash = %{ $item_format->parse($data) };
+    my $phonenum = $hash{npa} . $hash{calling_number};
+
+    # reconcile multiple updates that affect a single phone number
+    # set 'data' to undef here to cancel the current update.
+    # we will ALWAYS remove the previous item, though.
+    my $prev_item = $phonenum_item{ $phonenum };
+    if ($prev_item) {
+      warn "$phonenum: reconciling ".
+            $prev_item->action.'#'.$prev_item->itemnum . ' with '.
+            $item->action.'#'.$item->itemnum . "\n"
+            if $DEBUG;
+
+      $error = $prev_item->delete;
+      delete $phonenum_item{ $phonenum };
+
+      if ($prev_item->action eq 'delete') {
+        if ( $item->action eq 'delete' ) {
+          warn "$phonenum was deleted, then deleted again; ignoring first delete\n";
+        } elsif ( $item->action eq 'insert' ) {
+          # delete + insert = replace
+          $item->action('replace');
+          $data =~ s/^I/C/;
+        } else {
+          # it's a replace action, which isn't really valid after the phonenum
+          # was deleted, but assume the delete was an error
+          warn "$phonenum was deleted, then replaced; ignoring delete action\n";
+        }
+      } elsif ($prev_item->action eq 'insert') {
+        if ( $item->action eq 'delete' ) {
+          # then negate both actions (this isn't an anomaly, don't warn)
+          undef $data;
+        } elsif ( $item->action eq 'insert' ) {
+          # assume this insert is correct
+          warn "$phonenum was inserted, then inserted again; ignoring first insert\n";
+        } else {
+          # insert + change = insert (with updated data)
+          $item->action('insert');
+          $data =~ s/^C/I/;
+        }
+      } else { # prev_item->action is replace/relocate
+        if ( $item->action eq 'delete' ) {
+          # then the previous replace doesn't matter
+        } elsif ( $item->action eq 'insert' ) {
+          # it was changed and then inserted...not sure what to do.
+          # assume the actions were queued out of order?  or there are multiple
+          # svcnums with this phone number? both are pretty nasty...
+          warn "$phonenum was replaced, then inserted; ignoring insert\n";
+          undef $data;
+        } else {
+          # replaced, then replaced again; perfectly normal, and the second
+          # replace will prevail
+        }
+      }
+    } # if $prev_item
+
+    # now, if reconciliation has changed this action, replace it
+    if (!defined $data) {
+      $error ||= $item->delete;
+    } elsif ($data ne $item->data) {
+      $item->set('data' => $data);
+      $error ||= $item->replace;
+    }
+    if ($error) {
+      dbh->rollback;
+      die "error reconciling NENA2 batch actions for $phonenum: $error\n";
+    }
+
+    next if !defined $data;
+    # set this action as the "current" update to perform on $phonenum
+    $phonenum_item{$phonenum} = $item;
+  }
+
+  # now, go through %phonenum_item and emit exactly one batch line affecting
+  # each phonenum
+
+  my $rows = 0;
+  foreach my $phonenum (sort {$a cmp $b} keys(%phonenum_item)) {
+    my $item = $phonenum_item{$phonenum};
+    print $fh $item->data, "\r\n";
+    $rows++;
+  }
+
+  # create trailer
+  %hash = ( 'trailer_indicator'     => 'UTL',
+            'extract_date'          => time2str('%m%d%y', $batch->_date),
+            'company_name'          => $self->option('company_name'),
+            'record_count'          => $rows,
+            'expanded_extract_date' => time2str('%Y%m%d', $batch->_date),
+            'reserved'              => '',
+            'end_of_record'         => '*',
+          );
+  my $trailer = $trailer_format->pack(\%hash);
+  print "TRAILER: $trailer\n\n" if $DEBUG;
+  print $fh $trailer, "\r\n";
+
+  close $fh;
+
+  return unless $self->option('target');
+
+  # appears to be correct for Intrado; maybe the config option should
+  # allow specifying the whole string, as the argument to time2str?
+  my $dest_file = $self->option('prefix') . time2str("%m%d%y", $batch->_date)
+                 . '.dat';
+
+  my $upload_target = FS::upload_target->by_key($self->option('target'))
+    or die "can't upload batch (target does not exist)\n";
+  warn "Uploading to ".$upload_target->label.".\n" if $DEBUG;
+  $error = $upload_target->put($local_file, $dest_file);
+
+  if ( $error ) {
+    dbh->rollback;
+    die "error uploading batch: $error" if $error;
+  }
+  warn "Success.\n" if $DEBUG;
+
+  # if it was successfully uploaded, check off the batch:
+  $batch->status('done');
+  $error = $batch->replace;
+
+  # and increment the cycle counter
+  $cycle++;
+  my $opt = qsearchs('part_export_option', {
+      optionname  => 'cycle_counter',
+      exportnum   => $self->exportnum,
+  });
+  $opt->set(optionvalue => $cycle);
+  $error ||= $opt->replace;
+  if ($error) {
+    dbh->rollback;
+    die "error recording batch status: $error\n";
+  }
+
+  dbh->commit;
+}
+
+1;
diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm
index 9a7bc47..4ca8d82 100644
--- a/FS/FS/svc_phone.pm
+++ b/FS/FS/svc_phone.pm
@@ -11,6 +11,7 @@ use vars qw( $DEBUG $me @pw_set $conf $phone_name_max
 use Data::Dumper;
 use Scalar::Util qw( blessed );
 use List::Util qw( min );
+use Tie::IxHash;
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::PagedSearch qw( psearch );
@@ -127,6 +128,14 @@ Account number of other provider. See lnp_other_provider.
 See lnp_status. If lnp_status is portin-reject or portout-reject, this is an
 optional reject reason.
 
+=item e911_class
+
+Class of Service for E911 service (per the NENA 2.1 standard).
+
+=item e911_type
+
+Type of Service for E911 service.
+
 =back
 
 =head1 METHODS
@@ -224,6 +233,18 @@ sub table_info {
 			{	label => 'LNP Other Provider Account #', 
 				%dis2 
 			},
+        'e911_class' => {
+                                label => 'E911 Service Class',
+                                type  => 'select-e911_class',
+                                disable_inventory => 1,
+                                multiple => 1,
+                        },
+        'e911_type' => {
+                                label => 'E911 Service Type',
+                                type  => 'select-e911_type',
+                                disable_inventory => 1,
+                                multiple => 1,
+                        },
     },
   };
 }
@@ -431,11 +452,20 @@ sub replace {
 	);
 
   my $error = $new->SUPER::replace($old, %options);
+
+  # if this changed the e911 location, notify exports
+  if ($new->locationnum ne $old->locationnum) {
+    my $new_location = $new->cust_location_or_main;
+    my $old_location = $new->cust_location_or_main;
+    $error ||= $new->export('relocate', $new_location, $old_location);
+  }
+
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error if $error;
   }
 
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 }
@@ -561,6 +591,13 @@ sub check {
 
   }
 
+  if ($self->e911_class and !exists(e911_classes()->{$self->e911_class})) {
+    return "undefined e911 class '".$self->e911_class."'";
+  }
+  if ($self->e911_type and !exists(e911_types()->{$self->e911_type})) {
+    return "undefined e911 type '".$self->e911_type."'";
+  }
+
   $self->SUPER::check;
 }
 
@@ -709,6 +746,26 @@ sub cust_location_or_main {
   $cust_pkg ? $cust_pkg->cust_location_or_main : '';
 }
 
+=item phone_name_or_cust
+
+Returns the C<phone_name> field if it has a value, or the package contact
+name if there is one, or the customer contact name.
+
+=cut
+
+sub phone_name_or_cust {
+  my $self = shift;
+  if ( $self->phone_name ) {
+    return $self->phone_name;
+  }
+  my $cust_pkg = $self->cust_svc->cust_pkg or return '';
+  if ( $cust_pkg->contactnum ) {
+    return $cust_pkg->contact->firstlast;
+  } else {
+    return $cust_pkg->cust_main->name_short;
+  }
+}
+
 =item psearch_cdrs OPTIONS
 
 Returns a paged search (L<FS::PagedSearch>) for Call Detail Records 
@@ -869,6 +926,67 @@ sub sum_cdrs {
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item e911_classes
+
+Returns a hashref of allowed values and descriptions for the C<e911_class>
+field.
+
+=item e911_types
+
+Returns a hashref of allowed values and descriptions for the C<e911_type>
+field.
+
+=cut
+
+sub e911_classes {
+  tie my %x, 'Tie::IxHash', (
+    1 => 'Residence',
+    2 => 'Business',
+    3 => 'Residence PBX',
+    4 => 'Business PBX',
+    5 => 'Centrex',
+    6 => 'Coin 1 Way out',
+    7 => 'Coin 2 Way',
+    8 => 'Mobile',
+    9 => 'Residence OPX',
+    0 => 'Business OPX',
+    A => 'Customer Operated Coin Telephone',
+    #B => not available
+    G => 'Wireless Phase I',
+    H => 'Wireless Phase II',
+    I => 'Wireless Phase II with Phase I information',
+    V => 'VoIP Services Default',
+    C => 'VoIP Residence',
+    D => 'VoIP Business',
+    E => 'VoIP Coin/Pay Phone',
+    F => 'VoIP Wireless',
+    J => 'VoIP Nomadic',
+    K => 'VoIP Enterprise Services',
+    T => 'Telematics',
+  );
+  \%x;
+}
+
+sub e911_types {
+  tie my %x, 'Tie::IxHash', (
+    0 => 'Not FX nor Non-Published',
+    1 => 'FX in 911 serving area',
+    2 => 'FX outside 911 serving area',
+    3 => 'Non-Published',
+    4 => 'Non-Published FX in serving area',
+    5 => 'Non-Published FX outside serving area',
+    6 => 'Local Ported Number',
+    7 => 'Interim Ported Number',
+  );
+  \%x;
+}
+
+=back
+
 =head1 BUGS
 
 =head1 SEE ALSO
diff --git a/FS/FS/upload_target.pm b/FS/FS/upload_target.pm
index f3486d3..33088cb 100644
--- a/FS/FS/upload_target.pm
+++ b/FS/FS/upload_target.pm
@@ -153,7 +153,8 @@ sub put {
     local $@;
     my $connection = eval { $self->connect };
     return $@ if $@;
-    $connection->put($localname, $remotename) or return $connection->error;
+    $connection->put($localname, $remotename);
+    return $connection->error || '';
   } elsif ( $self->protocol eq 'email' ) {
 
     my $to = join('@', $self->username, $self->hostname);
@@ -199,13 +200,15 @@ sub connect {
     eval "use Net::SFTP::Foreign;";
     die $@ if $@;
     my %args = (
-      port      => $self->port,
       user      => $self->username,
-      password  => $self->password,
-      more      => ($DEBUG ? '-v' : ''),
       timeout   => 30,
-      autodie   => 1, #we're doing this anyway
+      autodie   => 0, #we're doing this anyway
     );
+    # Net::SFTP::Foreign does not deal well with args that are defined
+    # but empty
+    $args{port} = $self->port if $self->port and $self->port != 22;
+    $args{password} = $self->password if length($self->password) > 0;
+    $args{more} = '-v' if $DEBUG;
     my $sftp = Net::SFTP::Foreign->new($self->hostname, %args);
     $sftp->setcwd($self->path);
     return $sftp;
diff --git a/FS/MANIFEST b/FS/MANIFEST
index c2882fe..504b9bd 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -796,3 +796,7 @@ t/sched_item.t
 FS/sched_avail.pm
 t/sched_avail.t
 FS/svc_Torrus_Mixin.pm
+FS/export_batch.pm
+t/export_batch.t
+FS/export_batch_item.pm
+t/export_batch_item.t
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index 14d797f..294099a 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -62,9 +62,13 @@ use FS::Cron::rt_tasks qw(rt_daily);
 rt_daily(%opt);
 
 #does nothing unless batch-gateway-* configs are set
-use FS::Cron::pay_batch qw(batch_submit batch_receive);
-batch_submit(%opt);
-batch_receive(%opt);
+use FS::Cron::pay_batch qw(pay_batch_submit pay_batch_receive);
+pay_batch_submit(%opt);
+pay_batch_receive(%opt);
+
+#does nothing unless there are batch-style exports with batches
+use FS::Cron::export_batch qw(export_batch_submit);
+export_batch_submit(%opt);
 
 #you can skip this by not having the config
 use FS::Cron::agent_email qw(agent_email);
diff --git a/FS/t/export_batch.t b/FS/t/export_batch.t
new file mode 100644
index 0000000..efce89d
--- /dev/null
+++ b/FS/t/export_batch.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::export_batch;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/export_batch_item.t b/FS/t/export_batch_item.t
new file mode 100644
index 0000000..480b188
--- /dev/null
+++ b/FS/t/export_batch_item.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::export_batch_item;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/edit/svc_phone.cgi b/httemplate/edit/svc_phone.cgi
index d48e635..f858205 100644
--- a/httemplate/edit/svc_phone.cgi
+++ b/httemplate/edit/svc_phone.cgi
@@ -91,6 +91,16 @@ my $begin_callback = sub {
                   );
                 },
               },
+              { field   => 'e911_class',
+                type    => 'select',
+                options => [ keys(%{ FS::svc_phone->e911_classes }) ],
+                labels  => FS::svc_phone->e911_classes,
+              },
+              { field   => 'e911_type',
+                type    => 'select',
+                options => [ keys(%{ FS::svc_phone->e911_types }) ],
+                labels  => FS::svc_phone->e911_types,
+              },
               { field => 'custnum', type=> 'hidden' }, #for new cust_locations
     ;
   }
diff --git a/httemplate/elements/select-e911_class.html b/httemplate/elements/select-e911_class.html
new file mode 100644
index 0000000..8173c1d
--- /dev/null
+++ b/httemplate/elements/select-e911_class.html
@@ -0,0 +1,6 @@
+% my $classes = FS::svc_phone->e911_classes;
+<& select.html,
+  options => [ keys(%$classes) ],
+  labels  => $classes,
+  @_
+&>
diff --git a/httemplate/elements/select-e911_type.html b/httemplate/elements/select-e911_type.html
new file mode 100644
index 0000000..249ad9b
--- /dev/null
+++ b/httemplate/elements/select-e911_type.html
@@ -0,0 +1,6 @@
+% my $types = FS::svc_phone->e911_types;
+<& select.html,
+  options => [ keys(%$types) ],
+  labels  => $types,
+  @_
+&>

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Cron/export_batch.pm                  |   64 ++++
 FS/FS/Cron/pay_batch.pm                     |    6 +-
 FS/FS/Mason.pm                              |    2 +
 FS/FS/Schema.pm                             |   45 +++
 FS/FS/contact.pm                            |    5 +
 FS/FS/cust_svc.pm                           |   12 +-
 FS/FS/export_batch.pm                       |  132 +++++++
 FS/FS/export_batch_item.pm                  |  130 +++++++
 FS/FS/part_export/batch_Common.pm           |  202 +++++++++++
 FS/FS/part_export/nena2.pm                  |  496 +++++++++++++++++++++++++++
 FS/FS/svc_phone.pm                          |  118 +++++++
 FS/FS/upload_target.pm                      |   13 +-
 FS/MANIFEST                                 |    4 +
 FS/bin/freeside-daily                       |   10 +-
 FS/t/{AccessRight.t => export_batch.t}      |    2 +-
 FS/t/{AccessRight.t => export_batch_item.t} |    2 +-
 httemplate/edit/svc_phone.cgi               |   10 +
 httemplate/elements/select-e911_class.html  |    6 +
 httemplate/elements/select-e911_type.html   |    6 +
 19 files changed, 1251 insertions(+), 14 deletions(-)
 create mode 100644 FS/FS/Cron/export_batch.pm
 create mode 100644 FS/FS/export_batch.pm
 create mode 100644 FS/FS/export_batch_item.pm
 create mode 100644 FS/FS/part_export/batch_Common.pm
 create mode 100644 FS/FS/part_export/nena2.pm
 copy FS/t/{AccessRight.t => export_batch.t} (82%)
 copy FS/t/{AccessRight.t => export_batch_item.t} (78%)
 create mode 100644 httemplate/elements/select-e911_class.html
 create mode 100644 httemplate/elements/select-e911_type.html




More information about the freeside-commits mailing list