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