[freeside-commits] branch FREESIDE_3_BRANCH updated. acc311a412cd8660572eb6de6712a1e0b2768ec1

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


The branch, FREESIDE_3_BRANCH has been updated
       via  acc311a412cd8660572eb6de6712a1e0b2768ec1 (commit)
      from  e27a48a0a49737055b3b7b7d21ec9760c75add61 (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 acc311a412cd8660572eb6de6712a1e0b2768ec1
Author: Mark Wells <mark at freeside.biz>
Date:   Thu Jun 26 16:04:18 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 d1fa3cf..7c9dad6 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -360,6 +360,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 f340979..4ad9cec 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -3956,6 +3956,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'] ],
@@ -4543,6 +4545,49 @@ sub tables_hashref {
       'index'  => [ ],
     },
 
+    '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 b025892..c9cc69f 100644
--- a/FS/FS/contact.pm
+++ b/FS/FS/contact.pm
@@ -483,6 +483,11 @@ sub contact_class {
   qsearchs('contact_class', { 'classnum' => $self->classnum } );
 }
 
+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 7e34f7d..4c38aae 100644
--- a/FS/FS/cust_svc.pm
+++ b/FS/FS/cust_svc.pm
@@ -323,14 +323,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..eb0b1a0
--- /dev/null
+++ b/FS/FS/export_batch.pm
@@ -0,0 +1,146 @@
+package FS::export_batch;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::part_export;
+use FS::export_batch_item;
+
+=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;
+}
+
+# stubs, removed in 4.x
+
+sub export_batch_item {
+  my $self = shift;
+  qsearch('export_batch_item', { batchnum => $self->batchnum });
+}
+
+sub part_export {
+  my $self = shift;
+  FS::part_export->by_key($self->exportnum);
+}
+
+=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 0677688..647412f 100644
--- a/FS/FS/svc_phone.pm
+++ b/FS/FS/svc_phone.pm
@@ -8,6 +8,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 );
@@ -125,6 +126,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
@@ -222,6 +231,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;
 }
 
@@ -714,6 +751,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 
@@ -874,6 +931,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 c89eee1..8a49ddb 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -755,3 +755,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 e6cb0dc..f04faea 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                       |  146 ++++++++
 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, 1265 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