summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2014-06-26 15:47:22 -0700
committerMark Wells <mark@freeside.biz>2014-06-26 16:04:51 -0700
commitfa978560e3b0473728ebf2fb32625765465c230a (patch)
tree07f90f1a1980f0f1fee00ad736a4b1adb5ca723e
parent3e3441036353ea99dc85548bbdbe810edc81b181 (diff)
NENA2 E911 export and batch-oriented exports in general, #14049
-rw-r--r--FS/FS/Cron/export_batch.pm64
-rw-r--r--FS/FS/Cron/pay_batch.pm6
-rw-r--r--FS/FS/Mason.pm2
-rw-r--r--FS/FS/Schema.pm45
-rw-r--r--FS/FS/contact.pm5
-rw-r--r--FS/FS/cust_svc.pm12
-rw-r--r--FS/FS/export_batch.pm132
-rw-r--r--FS/FS/export_batch_item.pm130
-rw-r--r--FS/FS/part_export/batch_Common.pm202
-rw-r--r--FS/FS/part_export/nena2.pm496
-rw-r--r--FS/FS/svc_phone.pm118
-rw-r--r--FS/FS/upload_target.pm13
-rw-r--r--FS/MANIFEST4
-rwxr-xr-xFS/bin/freeside-daily10
-rw-r--r--FS/t/export_batch.t5
-rw-r--r--FS/t/export_batch_item.t5
-rw-r--r--httemplate/edit/svc_phone.cgi10
-rw-r--r--httemplate/elements/select-e911_class.html6
-rw-r--r--httemplate/elements/select-e911_type.html6
19 files changed, 1259 insertions, 12 deletions
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;
+
+@ISA = qw( Exporter );
+@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 );
-@EXPORT_OK = qw ( batch_submit batch_receive );
+@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,
+ @_
+&>