NENA2 E911 export and batch-oriented exports in general, #14049
[freeside.git] / FS / FS / part_export / nena2.pm
1 package FS::part_export::nena2;
2
3 use base 'FS::part_export::batch_Common';
4 use strict;
5 use FS::Record qw(qsearch qsearchs dbh);
6 use FS::svc_phone;
7 use FS::upload_target;
8 use Tie::IxHash;
9 use Date::Format qw(time2str);
10 use Parse::FixedLength;
11 use File::Temp qw(tempfile);
12 use vars qw(%info %options $initial_load_hack $DEBUG);
13
14 my %upload_targets;
15
16 tie %options, 'Tie::IxHash', (
17   'company_name'    => {  label => 'Company name for header record',
18                           type  => 'text'
19                        },
20   'company_id'      => {  label => 'NENA company ID',
21                           type  => 'text',
22                        },
23   'prefix'          => {  label => 'File name prefix',
24                           type  => 'text',
25                        },
26   'format'          => {  label => 'Format variant',
27                           type  => 'select',
28                           options => [ '', 'Intrado' ],
29                        },
30   'target'          => {  label => 'Upload destination',
31                           type => 'select',
32                           option_values => sub {
33                             %upload_targets = 
34                               map { $_->targetnum, $_->label } 
35                               qsearch('upload_target');
36                             sort keys (%upload_targets);
37                           },
38                           option_label => sub {
39                             $upload_targets{$_[0]}
40                           },
41                         },
42   'cycle_counter'   => { label => 'Cycle counter',
43                          type => 'text',
44                          default => '1'
45                        },
46   'debug'           => { label => 'Enable debugging',
47                          type => 'checkbox' },
48 );
49
50 %info = (
51   'svc'       => 'svc_phone',
52   'desc'      => 'Export a NENA 2 E911 data file',
53   'options'   => \%options,
54   'nodomain'  => 'Y',
55   'no_machine'=> 1,
56   'notes'     => qq!
57 <p>Export the physical location of a telephone service to a NENA 2.1 file
58 for use by an ALI database provider.</p>
59 <p>Options:
60 <ul>
61 <li><b>Company name</b> is the company name that should appear in your header
62 and trailer records.<li>
63 <li><b>Company ID</b> is your <a href="http://www.nena.org/?CompanyID">NENA 
64 assigned company ID</a>.</li>
65 <li><b>File name prefix</b> is the prefix to use in your upload file names.
66 The rest of the file name will be the date (in mmddyy format) followed by 
67 ".dat".</li>
68 <li><b>Format variant</b> is the modification of the NENA format required 
69 by your database provider.  We support the Intrado variant used by
70 Qwest/CenturyLink.  To produce a pure standard-compliant file, leave this
71 blank.</li>
72 <li><b>Upload destination</b> is the <a href="../browse/upload_target.html">
73 upload target</a> to send the file to.</li>
74 <li><b>Cycle counter</b> is the sequence number of the next batch to be sent.
75 This will be automatically incremented with each batch.</li>
76 </ul>
77 </p>
78   !,
79 );
80
81 $initial_load_hack = 0; # set to 1 if running from a re-export script
82
83 # All field names and sizes are taken from the NENA-2-010 standard, May 1999 
84 # version.
85
86 my $item_format = Parse::FixedLength->new([ qw(
87     function_code:1:1:1
88     npa:3:2:4
89     calling_number:7:5:11
90     house_number:10:12:21
91     house_number_suffix:4:22:25
92     prefix_directional:2:26:27
93     street_name:60:28:87
94     street_suffix:4:88:91
95     post_directional:2:92:93
96     community_name:32:94:125
97     state:2:126:127
98     location:60:128:187
99     customer_name:32:188:219
100     class_of_service:1:220:220
101     type_of_service:1:221:221
102     exchange:4:222:225
103     esn:5:226:230
104     main_npa:3:231:233
105     main_number:7:234:240
106     order_number:10:241:250
107     extract_date:6:251:256
108     county_id:4:257:260
109     company_id:5:261:265
110     source_id:1:266:266
111     zip_code:5:267:271
112     zip_4:4:272:275
113     general_use:11:276:286
114     customer_code:3:287:289
115     comments:30:290:319
116     x_coordinate:9:320:328
117     y_coordinate:9:329:337
118     z_coordinate:5:338:342
119     cell_id:6:343:348
120     sector_id:1:349:349
121     tar_code:6:350:355
122     reserved:21:356:376
123     alt:10:377:386
124     expanded_extract_date:8:387:394
125     nena_reserved:86:395:480
126     dbms_reserved:31:481:511
127     end_of_record:1:512:512
128   )]
129 );
130
131 my $header_format = Parse::FixedLength->new([ qw(
132     header_indicator:5:1:5
133     extract_date:6:6:11
134     company_name:50:12:61
135     cycle_counter:6R:62:67
136     county_id:4:68:71
137     state:2:72:73
138     general_use:20:74:93
139     release_number:3:94:96
140     format_version:1:97:97
141     expanded_extract_date:8:98:105
142     reserved:406:106:511
143     end_of_record:1:512:512
144   )]
145 );
146
147 my $trailer_format = Parse::FixedLength->new([ qw(
148     trailer_indicator:5:1:5
149     extract_date:6:6:11
150     company_name:50:12:61
151     record_count:9R:62:70
152     expanded_extract_date:8:71:78
153     reserved:433:79:511
154     end_of_record:1:512:512
155   )]
156 );
157
158 my %function_code = (
159   'insert'    => 'I',
160   'delete'    => 'D',
161   'replace'   => 'C',
162   'relocate'  => 'C',
163 );
164
165 sub immediate {
166   local $@;
167   eval "use Geo::StreetAddress::US";
168   if ($@) {
169     if ($@ =~ /^Can't locate/) {
170       return "Geo::StreetAddress::US must be installed to use the NENA2 export.";
171     } else {
172       die $@;
173     }
174   }
175
176   # validate some things
177   my ($self, $action, $svc) = @_;
178   if ( $svc->phonenum =~ /\D/ ) {
179     return "Can't export E911 information for a non-numeric phone number";
180   } elsif ( $svc->phonenum =~ /^011/ ) {
181     return "Can't export E911 information for a non-North American phone number";
182   }
183   '';
184 }
185
186 sub create_item {
187   my $self = shift;
188   my $action = shift;
189   my $svc = shift;
190   # pkg_change, suspend, unsuspend actions don't trigger anything here
191   return '' if !exists( $function_code{$action} ); 
192   if ( $action eq 'replace' ) {
193     my $old = shift;
194     # the one case where the old service is relevant: phone number change
195     # in that case, insert a batch item to delete the old number, then 
196     # continue as if this were an insert.
197     if ($old->phonenum ne $svc->phonenum) {
198       return $self->create_item('delete', $old)
199           || $self->create_item('insert', $svc);
200     }
201   }
202   $self->SUPER::create_item($action, $svc, @_);
203 }
204
205 sub data {
206   # generate the entire record here.  reconciliation of multiple updates to 
207   # the same service can be done at process time.
208   my $self = shift;
209   my $action = shift;
210
211   my $svc = shift;
212
213   my $locationnum =    $svc->locationnum
214                     || $svc->cust_svc->cust_pkg->locationnum;
215   my $cust_location = FS::cust_location->by_key($locationnum);
216
217   # initialize with empty strings
218   my %hash = map { $_ => '' } $item_format->names;
219
220   $hash{function_code} = $function_code{$action};
221
222   # phone number 
223   $svc->phonenum =~ /^(\d{3})(\d*)$/;
224   $hash{npa} = $1;
225   $hash{calling_number} = $2;
226
227   # street address
228   my $location_hash = Geo::StreetAddress::US->parse_address(
229     uc( join(', ', $cust_location->address1,
230                    $cust_location->address2,
231                    $cust_location->city,
232                    $cust_location->state,
233                    $cust_location->zip
234     ) )
235   );
236   $hash{house_number}         = $location_hash->{number};
237   $hash{house_number_suffix}  = ''; # we don't support this, do we?
238   $hash{prefix_directional}   = $location_hash->{prefix};
239   $hash{street_name}          = $location_hash->{street};
240   $hash{street_suffix}        = $location_hash->{type};
241   $hash{post_directional}     = $location_hash->{suffix};
242   $hash{community_name}       = $location_hash->{city};
243   $hash{state}                = $location_hash->{state};
244   if ($location_hash->{sec_unit_type}) {
245     $hash{location} = $location_hash->{sec_unit_type} . ' ' .
246                       $location_hash->{sec_unit_num};
247   } else {
248     $hash{location} = $cust_location->address2;
249   }
250   $hash{location}             = $location_hash->{address2};
251
252   # customer name and class
253   $hash{customer_name} = $svc->phone_name_or_cust;
254   $hash{class_of_service} = $svc->e911_class;
255   $hash{type_of_service}  = $svc->e911_type || '0';
256
257   $hash{exchange} = '';
258   # the routing number for the local emergency service call center; 
259   # will be filled in by the service provider
260   $hash{esn} = '';
261
262   # Main Number (I guess for callbacks?)
263   # XXX this is probably not right, but we don't have a concept of "main 
264   # number for the site".
265   $hash{main_npa} = $hash{npa};
266   $hash{main_number} = $hash{calling_number};
267
268   # Order Number...is a foreign concept to us.  It's supposed to be the 
269   # transaction number that ordered this service change.  (Maybe the 
270   # number of the batch item?  That's really hard for a user to do anything
271   # with.)
272   $hash{order_number} = $svc->svcnum;
273   $hash{extract_date} = time2str('%m%d%y', time);
274
275   # $hash{county_id} is supposed to be the FIPS code for the county,
276   # but it's a four-digit field.  INCITS 31 county codes are 5 digits,
277   # so we can't comply.  NENA 3 fixed this...
278
279   $hash{company_id} = $self->option('company_id');
280   $hash{source_id} = $initial_load_hack ? 'C' : ' ';
281
282   @hash{'zip', 'zip_'} = split('-', $cust_location->zip);
283   
284   # $hash{customer_code} is supposed to "uniquely identify a customer" but 
285   # they give us 3 alphanumeric characters.  Not sure how that works.
286
287   $hash{x_coordinate} = $cust_location->longitude;
288   $hash{y_coordinate} = $cust_location->latitude;
289   # $hash{z_coordinate} = $cust_location->altitude; # not implemented, sadly
290
291   $hash{expanded_extract_date} = time2str('%Y%m%d', time);
292
293   # quirks mode
294   if ( $self->option('format') eq 'Intrado' ) { 
295     my $century = substr($hash{expanded_extract_date}, 0, 2);
296     $hash{expanded_extract_date} = '';
297     $hash{nena_reserved} = '   '.$century;
298     $hash{x_coordinate} = '';
299     $hash{y_coordinate} = '';
300   }
301   $hash{end_of_record} = '*';
302   return $item_format->pack(\%hash);
303 }
304
305 sub process {
306   my $self = shift;
307   my $batch = shift;
308   local $DEBUG = $self->option('debug');
309   local $FS::UID::AutoCommit = 0;
310   my $error;
311
312   my $cycle = $self->option('cycle_counter');
313   die "invalid cycle counter value '$cycle'" if $cycle =~ /\D/;
314
315   # mark the batch as closed
316   if ($batch->status eq 'open') {
317     $batch->set(status => 'closed');
318     $error = $batch->replace;
319     die "can't close batch: $error" if $error;
320     dbh->commit;
321   }
322
323   my @items = $batch->export_batch_item;
324   return unless @items;
325
326   my ($fh, $local_file) = tempfile();
327   warn "writing batch to $local_file\n" if $DEBUG;
328
329   # intrado documentation is inconsistent on this, but NENA 2.1 says to use
330   # leading spaces, not zeroes, for the cycle counter and record count
331
332   my %hash = ('header_indicator'      => 'UHL',
333               'extract_date'          => time2str('%m%d%y', $batch->_date),
334               'company_name'          => $self->option('company_name'),
335               'cycle_counter'         => $cycle,
336               # can add these fields if they're really necessary but it's
337               # a lot of work
338               'county_id'             => '',
339               'state'                 => '',
340               'general_use'           => '',
341               'release_number'        => '',
342               'format_version'        => '',
343               'expanded_extract_date' => time2str('%Y%m%d', $batch->_date),
344               'reserved'              => '',
345               'end_of_record'         => '*'
346              );
347
348   my $header = $header_format->pack(\%hash);
349   warn "HEADER: $header\n" if $DEBUG;
350   print $fh $header,"\r\n";
351
352   my %phonenum_item; # phonenum => batch item
353   foreach my $item (@items) {
354
355     # ignore items that have no data to add to the batch
356     next if $item->action eq 'suspend' or $item->action eq 'unsuspend';
357     
358     my $svcnum = $item->svcnum;
359     my $data = $item->data;
360     %hash = %{ $item_format->parse($data) };
361     my $phonenum = $hash{npa} . $hash{calling_number};
362
363     # reconcile multiple updates that affect a single phone number
364     # set 'data' to undef here to cancel the current update.
365     # we will ALWAYS remove the previous item, though.
366     my $prev_item = $phonenum_item{ $phonenum };
367     if ($prev_item) {
368       warn "$phonenum: reconciling ".
369             $prev_item->action.'#'.$prev_item->itemnum . ' with '.
370             $item->action.'#'.$item->itemnum . "\n"
371             if $DEBUG;
372
373       $error = $prev_item->delete;
374       delete $phonenum_item{ $phonenum };
375
376       if ($prev_item->action eq 'delete') {
377         if ( $item->action eq 'delete' ) {
378           warn "$phonenum was deleted, then deleted again; ignoring first delete\n";
379         } elsif ( $item->action eq 'insert' ) {
380           # delete + insert = replace
381           $item->action('replace');
382           $data =~ s/^I/C/;
383         } else {
384           # it's a replace action, which isn't really valid after the phonenum
385           # was deleted, but assume the delete was an error
386           warn "$phonenum was deleted, then replaced; ignoring delete action\n";
387         }
388       } elsif ($prev_item->action eq 'insert') {
389         if ( $item->action eq 'delete' ) {
390           # then negate both actions (this isn't an anomaly, don't warn)
391           undef $data;
392         } elsif ( $item->action eq 'insert' ) {
393           # assume this insert is correct
394           warn "$phonenum was inserted, then inserted again; ignoring first insert\n";
395         } else {
396           # insert + change = insert (with updated data)
397           $item->action('insert');
398           $data =~ s/^C/I/;
399         }
400       } else { # prev_item->action is replace/relocate
401         if ( $item->action eq 'delete' ) {
402           # then the previous replace doesn't matter
403         } elsif ( $item->action eq 'insert' ) {
404           # it was changed and then inserted...not sure what to do.
405           # assume the actions were queued out of order?  or there are multiple
406           # svcnums with this phone number? both are pretty nasty...
407           warn "$phonenum was replaced, then inserted; ignoring insert\n";
408           undef $data;
409         } else {
410           # replaced, then replaced again; perfectly normal, and the second
411           # replace will prevail
412         }
413       }
414     } # if $prev_item
415
416     # now, if reconciliation has changed this action, replace it
417     if (!defined $data) {
418       $error ||= $item->delete;
419     } elsif ($data ne $item->data) {
420       $item->set('data' => $data);
421       $error ||= $item->replace;
422     }
423     if ($error) {
424       dbh->rollback;
425       die "error reconciling NENA2 batch actions for $phonenum: $error\n";
426     }
427
428     next if !defined $data;
429     # set this action as the "current" update to perform on $phonenum
430     $phonenum_item{$phonenum} = $item;
431   }
432
433   # now, go through %phonenum_item and emit exactly one batch line affecting
434   # each phonenum
435
436   my $rows = 0;
437   foreach my $phonenum (sort {$a cmp $b} keys(%phonenum_item)) {
438     my $item = $phonenum_item{$phonenum};
439     print $fh $item->data, "\r\n";
440     $rows++;
441   }
442
443   # create trailer
444   %hash = ( 'trailer_indicator'     => 'UTL',
445             'extract_date'          => time2str('%m%d%y', $batch->_date),
446             'company_name'          => $self->option('company_name'),
447             'record_count'          => $rows,
448             'expanded_extract_date' => time2str('%Y%m%d', $batch->_date),
449             'reserved'              => '',
450             'end_of_record'         => '*',
451           );
452   my $trailer = $trailer_format->pack(\%hash);
453   print "TRAILER: $trailer\n\n" if $DEBUG;
454   print $fh $trailer, "\r\n";
455
456   close $fh;
457
458   return unless $self->option('target');
459
460   # appears to be correct for Intrado; maybe the config option should
461   # allow specifying the whole string, as the argument to time2str?
462   my $dest_file = $self->option('prefix') . time2str("%m%d%y", $batch->_date)
463                  . '.dat';
464
465   my $upload_target = FS::upload_target->by_key($self->option('target'))
466     or die "can't upload batch (target does not exist)\n";
467   warn "Uploading to ".$upload_target->label.".\n" if $DEBUG;
468   $error = $upload_target->put($local_file, $dest_file);
469
470   if ( $error ) {
471     dbh->rollback;
472     die "error uploading batch: $error" if $error;
473   }
474   warn "Success.\n" if $DEBUG;
475
476   # if it was successfully uploaded, check off the batch:
477   $batch->status('done');
478   $error = $batch->replace;
479
480   # and increment the cycle counter
481   $cycle++;
482   my $opt = qsearchs('part_export_option', {
483       optionname  => 'cycle_counter',
484       exportnum   => $self->exportnum,
485   });
486   $opt->set(optionvalue => $cycle);
487   $error ||= $opt->replace;
488   if ($error) {
489     dbh->rollback;
490     die "error recording batch status: $error\n";
491   }
492
493   dbh->commit;
494 }
495
496 1;