From: Mark Wells Date: Thu, 26 Jun 2014 22:47:22 +0000 (-0700) Subject: NENA2 E911 export and batch-oriented exports in general, #14049 X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=fa978560e3b0473728ebf2fb32625765465c230a 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 000000000..cb16eeed2 --- /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 0ab37dd13..432271dc3 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 4b50e9780..ede7259fb 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 93691e76f..387f50853 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 936e82132..60c521612 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 b01ed8459..8fc929f29 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 000000000..84b7c718c --- /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 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 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, L + +=cut + +1; + diff --git a/FS/FS/export_batch_item.pm b/FS/FS/export_batch_item.pm new file mode 100644 index 000000000..accb3f1e2 --- /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 foreign key; the batch that this item belongs to. + +=item svcnum + +L 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 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 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, L + +=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 000000000..f4894975f --- /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 objects) + +- for relocate, the service, followed by the new location and old location + (as L 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 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 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 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 000000000..71d753aa1 --- /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! +

Export the physical location of a telephone service to a NENA 2.1 file +for use by an ALI database provider.

+

Options: +

    +
  • Company name is the company name that should appear in your header +and trailer records.
  • +
  • Company ID is your NENA +assigned company ID.
  • +
  • File name prefix 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".
  • +
  • Format variant 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.
  • +
  • Upload destination is the +upload target to send the file to.
  • +
  • Cycle counter is the sequence number of the next batch to be sent. +This will be automatically incremented with each batch.
  • +
+

+ !, +); + +$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 9a7bc4757..4ca8d82fa 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 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) 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 +field. + +=item e911_types + +Returns a hashref of allowed values and descriptions for the C +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 f3486d393..33088cbd2 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 c2882fef7..504b9bd64 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 14d797f49..294099ad5 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 000000000..efce89d45 --- /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 000000000..480b188b8 --- /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 d48e6353b..f8582057e 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 000000000..8173c1d69 --- /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 000000000..249ad9b32 --- /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, + @_ +&>