optimize ordering a package for a customer with hundreds of existing locations, RT...
[freeside.git] / FS / FS / cust_location.pm
1 package FS::cust_location;
2 use base qw( FS::geocode_Mixin FS::Record );
3
4 use strict;
5 use vars qw( $import $DEBUG $conf $label_prefix );
6 use Data::Dumper;
7 use Date::Format qw( time2str );
8 use Locale::Country;
9 use FS::UID qw( dbh driver_name );
10 use FS::Record qw( qsearch qsearchs );
11 use FS::Conf;
12 use FS::prospect_main;
13 use FS::cust_main;
14 use FS::cust_main_county;
15 use FS::GeocodeCache;
16
17 $import = 0;
18
19 $DEBUG = 0;
20
21 FS::UID->install_callback( sub {
22   $conf = FS::Conf->new;
23   $label_prefix = $conf->config('cust_location-label_prefix') || '';
24 });
25
26 =head1 NAME
27
28 FS::cust_location - Object methods for cust_location records
29
30 =head1 SYNOPSIS
31
32   use FS::cust_location;
33
34   $record = new FS::cust_location \%hash;
35   $record = new FS::cust_location { 'column' => 'value' };
36
37   $error = $record->insert;
38
39   $error = $new_record->replace($old_record);
40
41   $error = $record->delete;
42
43   $error = $record->check;
44
45 =head1 DESCRIPTION
46
47 An FS::cust_location object represents a customer location.  FS::cust_location
48 inherits from FS::Record.  The following fields are currently supported:
49
50 =over 4
51
52 =item locationnum
53
54 primary key
55
56 =item custnum
57
58 custnum
59
60 =item address1
61
62 Address line one (required)
63
64 =item address2
65
66 Address line two (optional)
67
68 =item city
69
70 City
71
72 =item county
73
74 County (optional, see L<FS::cust_main_county>)
75
76 =item state
77
78 State (see L<FS::cust_main_county>)
79
80 =item zip
81
82 Zip
83
84 =item country
85
86 Country (see L<FS::cust_main_county>)
87
88 =item geocode
89
90 Geocode
91
92 =item district
93
94 Tax district code (optional)
95
96 =item disabled
97
98 Disabled flag; set to 'Y' to disable the location.
99
100 =back
101
102 =head1 METHODS
103
104 =over 4
105
106 =item new HASHREF
107
108 Creates a new location.  To add the location to the database, see L<"insert">.
109
110 Note that this stores the hash reference, not a distinct copy of the hash it
111 points to.  You can ask the object for a copy with the I<hash> method.
112
113 =cut
114
115 sub table { 'cust_location'; }
116
117 =item find_or_insert
118
119 Finds an existing location matching the customer and address values in this
120 location, if one exists, and sets the contents of this location equal to that
121 one (including its locationnum).
122
123 If an existing location is not found, this one I<will> be inserted.  (This is a
124 change from the "new_or_existing" method that this replaces.)
125
126 The following fields are considered "essential" and I<must> match: custnum,
127 address1, address2, city, county, state, zip, country, location_number,
128 location_type, location_kind.  Disabled locations will be found only if this
129 location is set to disabled.
130
131 All other fields are considered "non-essential" and will be ignored in 
132 finding a matching location.  If the existing location doesn't match 
133 in these fields, it will be updated in-place to match.
134
135 Returns an error string if inserting or updating a location failed.
136
137 It is unfortunately hard to determine if this created a new location or not.
138
139 =cut
140
141 sub find_or_insert {
142   my $self = shift;
143
144   warn "find_or_insert:\n".Dumper($self) if $DEBUG;
145
146   my @essential = (qw(custnum address1 address2 city county state zip country
147     location_number location_type location_kind disabled));
148
149   # I don't think this is necessary
150   #if ( !$self->coord_auto and $self->latitude and $self->longitude ) {
151   #  push @essential, qw(latitude longitude);
152   #  # but NOT coord_auto; if the latitude and longitude match the geocoded
153   #  # values then that's good enough
154   #}
155
156   # put nonempty, nonessential fields/values into this hash
157   my %nonempty = map { $_ => $self->get($_) }
158                  grep {$self->get($_)} $self->fields;
159   delete @nonempty{@essential};
160   delete $nonempty{'locationnum'};
161
162   my %hash = map { $_ => $self->get($_) } @essential;
163   my @matches = qsearch('cust_location', \%hash);
164
165   # we no longer reject matches for having different values in nonessential
166   # fields; we just alter the record to match
167   if ( @matches ) {
168     my $old = $matches[0];
169     warn "found existing location #".$old->locationnum."\n" if $DEBUG;
170     foreach my $field (keys %nonempty) {
171       if ($old->get($field) ne $nonempty{$field}) {
172         warn "altering $field to match requested location" if $DEBUG;
173         $old->set($field, $nonempty{$field});
174       }
175     } # foreach $field
176
177     if ( $old->modified ) {
178       warn "updating non-essential fields\n" if $DEBUG;
179       my $error = $old->replace;
180       return $error if $error;
181     }
182     # set $self equal to $old
183     foreach ($self->fields) {
184       $self->set($_, $old->get($_));
185     }
186     return "";
187   }
188
189   # didn't find a match
190   warn "not found; inserting new location\n" if $DEBUG;
191   return $self->insert;
192 }
193
194 =item insert
195
196 Adds this record to the database.  If there is an error, returns the error,
197 otherwise returns false.
198
199 =cut
200
201 sub insert {
202   my $self = shift;
203
204   if ( $self->censustract ) {
205     $self->set('censusyear' => $conf->config('census_year') || 2012);
206   }
207
208   my $error = $self->SUPER::insert(@_);
209
210   #false laziness with cust_main, will go away eventually
211   if ( !$import and !$error and $conf->config('tax_district_method') ) {
212
213     my $queue = new FS::queue {
214       'job' => 'FS::geocode_Mixin::process_district_update'
215     };
216     $error = $queue->insert( ref($self), $self->locationnum );
217
218   }
219
220   $error || '';
221 }
222
223 =item delete
224
225 Delete this record from the database.
226
227 =item replace OLD_RECORD
228
229 Replaces the OLD_RECORD with this one in the database.  If there is an error,
230 returns the error, otherwise returns false.
231
232 =cut
233
234 sub replace {
235   my $self = shift;
236   my $old = shift;
237   $old ||= $self->replace_old;
238   # the following fields are immutable
239   foreach (qw(address1 address2 city state zip country)) {
240     if ( $self->$_ ne $old->$_ ) {
241       return "can't change cust_location field $_";
242     }
243   }
244
245   $self->SUPER::replace($old);
246 }
247
248
249 =item check
250
251 Checks all fields to make sure this is a valid location.  If there is
252 an error, returns the error, otherwise returns false.  Called by the insert
253 and replace methods.
254
255 =cut
256
257 sub check {
258   my $self = shift;
259
260   return '' if $self->disabled; # so that disabling locations never fails
261
262   my $error = 
263     $self->ut_numbern('locationnum')
264     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
265     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
266     || $self->ut_text('address1')
267     || $self->ut_textn('address2')
268     || $self->ut_text('city')
269     || $self->ut_textn('county')
270     || $self->ut_textn('state')
271     || $self->ut_country('country')
272     || (!$import && $self->ut_zip('zip', $self->country))
273     || $self->ut_coordn('latitude')
274     || $self->ut_coordn('longitude')
275     || $self->ut_enum('coord_auto', [ '', 'Y' ])
276     || $self->ut_enum('addr_clean', [ '', 'Y' ])
277     || $self->ut_alphan('location_type')
278     || $self->ut_textn('location_number')
279     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
280     || $self->ut_alphan('geocode')
281     || $self->ut_alphan('district')
282     || $self->ut_numbern('censusyear')
283   ;
284   return $error if $error;
285   if ( $self->censustract ne '' ) {
286     $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
287       or return "Illegal census tract: ". $self->censustract;
288
289     $self->censustract("$1.$2");
290   }
291
292   if ( $conf->exists('cust_main-require_address2') and 
293        !$self->ship_address2 =~ /\S/ ) {
294     return "Unit # is required";
295   }
296
297   # tricky...we have to allow for the customer to not be inserted yet
298   return "No prospect or customer!" unless $self->prospectnum 
299                                         || $self->custnum
300                                         || $self->get('custnum_pending');
301   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
302
303   return 'Location kind is required'
304     if $self->prospectnum
305     && $conf->exists('prospect_main-alt_address_format')
306     && ! $self->location_kind;
307
308   unless ( $import or qsearch('cust_main_county', {
309     'country' => $self->country,
310     'state'   => '',
311    } ) ) {
312     return "Unknown state/county/country: ".
313       $self->state. "/". $self->county. "/". $self->country
314       unless qsearch('cust_main_county',{
315         'state'   => $self->state,
316         'county'  => $self->county,
317         'country' => $self->country,
318       } );
319   }
320
321   # set coordinates, unless we already have them
322   if (!$import and !$self->latitude and !$self->longitude) {
323     $self->set_coord;
324   }
325
326   $self->SUPER::check;
327 }
328
329 =item country_full
330
331 Returns this locations's full country name
332
333 =cut
334
335 sub country_full {
336   my $self = shift;
337   code2country($self->country);
338 }
339
340 =item line
341
342 Synonym for location_label
343
344 =cut
345
346 sub line {
347   my $self = shift;
348   $self->location_label(@_);
349 }
350
351 =item has_ship_address
352
353 Returns false since cust_location objects do not have a separate shipping
354 address.
355
356 =cut
357
358 sub has_ship_address {
359   '';
360 }
361
362 =item location_hash
363
364 Returns a list of key/value pairs, with the following keys: address1, address2,
365 city, county, state, zip, country, geocode, location_type, location_number,
366 location_kind.
367
368 =cut
369
370 =item disable_if_unused
371
372 Sets the "disabled" flag on the location if it is no longer in use as a 
373 prospect location, package location, or a customer's billing or default
374 service address.
375
376 =cut
377
378 sub disable_if_unused {
379
380   my $self = shift;
381   my $locationnum = $self->locationnum;
382   return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
383             or FS::cust_main->count('ship_locationnum = '.$locationnum)
384             or FS::contact->count(      'locationnum  = '.$locationnum)
385             or FS::cust_pkg->count('cancel IS NULL AND 
386                                          locationnum  = '.$locationnum)
387           ;
388   $self->disabled('Y');
389   $self->replace;
390
391 }
392
393 =item move_to
394
395 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
396 existing location to the new one, then sets the "disabled" flag on the old
397 location.  Returns nothing on success, an error message on error.
398
399 =cut
400
401 sub move_to {
402   my $old = shift;
403   my $new = shift;
404   
405   warn "move_to:\nFROM:".Dumper($old)."\nTO:".Dumper($new) if $DEBUG;
406
407   local $SIG{HUP} = 'IGNORE';
408   local $SIG{INT} = 'IGNORE';
409   local $SIG{QUIT} = 'IGNORE';
410   local $SIG{TERM} = 'IGNORE';
411   local $SIG{TSTP} = 'IGNORE';
412   local $SIG{PIPE} = 'IGNORE';
413
414   my $oldAutoCommit = $FS::UID::AutoCommit;
415   local $FS::UID::AutoCommit = 0;
416   my $dbh = dbh;
417   my $error = '';
418
419   # prevent this from failing because of pkg_svc quantity limits
420   local( $FS::cust_svc::ignore_quantity ) = 1;
421
422   if ( !$new->locationnum ) {
423     $error = $new->insert;
424     if ( $error ) {
425       $dbh->rollback if $oldAutoCommit;
426       return "Error creating location: $error";
427     }
428   } elsif ( $new->locationnum == $old->locationnum ) {
429     # then they're the same location; the normal result of doing a minor
430     # location edit
431     $dbh->commit if $oldAutoCommit;
432     return '';
433   }
434
435   # find all packages that have the old location as their service address,
436   # and aren't canceled,
437   # and aren't supplemental to another package.
438   my @pkgs = qsearch('cust_pkg', { 
439       'locationnum' => $old->locationnum,
440       'cancel'      => '',
441       'main_pkgnum' => '',
442     });
443   foreach my $cust_pkg (@pkgs) {
444     # don't move one-time charges that have already been charged
445     next if $cust_pkg->part_pkg->freq eq '0'
446             and ($cust_pkg->setup || 0) > 0;
447
448     $error = $cust_pkg->change(
449       'locationnum' => $new->locationnum,
450       'keep_dates'  => 1
451     );
452     if ( $error and not ref($error) ) {
453       $dbh->rollback if $oldAutoCommit;
454       return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
455     }
456   }
457
458   $error = $old->disable_if_unused;
459   if ( $error ) {
460     $dbh->rollback if $oldAutoCommit;
461     return "Error disabling old location: $error";
462   }
463
464   $dbh->commit if $oldAutoCommit;
465   '';
466 }
467
468 =item alternize
469
470 Attempts to parse data for location_type and location_number from address1
471 and address2.
472
473 =cut
474
475 sub alternize {
476   my $self = shift;
477
478   return '' if $self->get('location_type')
479             || $self->get('location_number');
480
481   my %parse;
482   if ( 1 ) { #ikano, switch on via config
483     { no warnings 'void';
484       eval { 'use FS::part_export::ikano;' };
485       die $@ if $@;
486     }
487     %parse = FS::part_export::ikano->location_types_parse;
488   } else {
489     %parse = (); #?
490   }
491
492   foreach my $from ('address1', 'address2') {
493     foreach my $parse ( keys %parse ) {
494       my $value = $self->get($from);
495       if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
496         $self->set('location_type', $parse{$parse});
497         $self->set('location_number', $2);
498         $self->set($from, $value);
499         return '';
500       }
501     }
502   }
503
504   #nothing matched, no changes
505   $self->get('address2')
506     ? "Can't parse unit type and number from address2"
507     : '';
508 }
509
510 =item dealternize
511
512 Moves data from location_type and location_number to the end of address1.
513
514 =cut
515
516 sub dealternize {
517   my $self = shift;
518
519   #false laziness w/geocode_Mixin.pm::line
520   my $lt = $self->get('location_type');
521   if ( $lt ) {
522
523     my %location_type;
524     if ( 1 ) { #ikano, switch on via config
525       { no warnings 'void';
526         eval { 'use FS::part_export::ikano;' };
527         die $@ if $@;
528       }
529       %location_type = FS::part_export::ikano->location_types;
530     } else {
531       %location_type = (); #?
532     }
533
534     $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
535     $self->location_type('');
536   }
537
538   if ( length($self->location_number) ) {
539     $self->address1( $self->address1. ' '. $self->location_number );
540     $self->location_number('');
541   }
542  
543   '';
544 }
545
546 =item location_label
547
548 Returns the label of the location object, with an optional site ID
549 string (based on the cust_location-label_prefix config option).
550
551 =cut
552
553 sub location_label {
554   my( $self, %opt ) = @_;
555
556   my $cust_or_prospect = $opt{cust_main} || $opt{prospect_main};
557   unless ( $cust_or_prospect ) {
558     if ( $self->custnum ) {
559       $cust_or_prospect = FS::cust_main->by_key($self->custnum);
560     } elsif ( $self->prospectnum ) {
561       $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
562     }
563   }
564
565   my $prefix = '';
566   if ( $label_prefix eq 'CoStAg' ) {
567     my $agent = $conf->config('cust_main-custnum-display_prefix',
568                   $cust_or_prospect->agentnum)
569                 || $cust_or_prospect->agent->agent;
570     # else this location is invalid
571     $prefix = uc( join('',
572         $self->country,
573         ($self->state =~ /^(..)/),
574         ($agent =~ /^(..)/),
575         sprintf('%05d', $self->locationnum)
576     ) );
577   }
578   elsif (    ( $opt{'cust_main'} || $self->custnum )
579           && $self->locationnum == $cust_or_prospect->ship_locationnum ) {
580     $prefix = 'Default service location';
581   }
582
583   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
584   $prefix . $self->SUPER::location_label(%opt);
585 }
586
587 =item county_state_county
588
589 Returns a string consisting of just the county, state and country.
590
591 =cut
592
593 sub county_state_country {
594   my $self = shift;
595   my $label = $self->country;
596   $label = $self->state.", $label" if $self->state;
597   $label = $self->county." County, $label" if $self->county;
598   $label;
599 }
600
601 =back
602
603 =head1 CLASS METHODS
604
605 =item in_county_sql OPTIONS
606
607 Returns an SQL expression to test membership in a cust_main_county 
608 geographic area.  By default, this requires district, city, county,
609 state, and country to match exactly.  Pass "ornull => 1" to allow 
610 partial matches where some fields are NULL in the cust_main_county 
611 record but not in the location.
612
613 Pass "param => 1" to receive a parameterized expression (rather than
614 one that requires a join to cust_main_county) and a list of parameter
615 names in order.
616
617 =cut
618
619 sub in_county_sql {
620   # replaces FS::cust_pkg::location_sql
621   my ($class, %opt) = @_;
622   my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
623   my $x = $ornull ? 3 : 2;
624   my @fields = (('district') x 3,
625                 ('city') x 3,
626                 ('county') x $x,
627                 ('state') x $x,
628                 'country');
629
630   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
631
632   my @where = (
633     "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
634     "cust_location.city     = ? OR ? = '' OR CAST(? AS $text) IS NULL",
635     "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
636     "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
637     "cust_location.country = ?"
638   );
639   my $sql = join(' AND ', map "($_)\n", @where);
640   if ( $opt{param} ) {
641     return $sql, @fields;
642   }
643   else {
644     # do the substitution here
645     foreach (@fields) {
646       $sql =~ s/\?/cust_main_county.$_/;
647       $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
648     }
649     return $sql;
650   }
651 }
652
653 =back
654
655 =head2 SUBROUTINES
656
657 =over 4
658
659 =item process_censustract_update LOCATIONNUM
660
661 Queueable function to update the census tract to the current year (as set in 
662 the 'census_year' configuration variable) and retrieve the new tract code.
663
664 =cut
665
666 sub process_censustract_update {
667   eval "use FS::GeocodeCache";
668   die $@ if $@;
669   my $locationnum = shift;
670   my $cust_location = 
671     qsearchs( 'cust_location', { locationnum => $locationnum })
672       or die "locationnum '$locationnum' not found!\n";
673
674   my $new_year = $conf->config('census_year') or return;
675   my $loc = FS::GeocodeCache->new( $cust_location->location_hash );
676   $loc->set_censustract;
677   my $error = $loc->get('censustract_error');
678   die $error if $error;
679   $cust_location->set('censustract', $loc->get('censustract'));
680   $cust_location->set('censusyear',  $new_year);
681   $error = $cust_location->replace;
682   die $error if $error;
683   return;
684 }
685
686 =item process_set_coord
687
688 Queueable function to find and fill in coordinates for all locations that 
689 lack them.  Because this uses the Google Maps API, it's internally rate
690 limited and must run in a single process.
691
692 =cut
693
694 sub process_set_coord {
695   my $job = shift;
696   # avoid starting multiple instances of this job
697   my @others = qsearch('queue', {
698       'status'  => 'locked',
699       'job'     => $job->job,
700       'jobnum'  => {op=>'!=', value=>$job->jobnum},
701   });
702   return if @others;
703
704   $job->update_statustext('finding locations to update');
705   my @missing_coords = qsearch('cust_location', {
706       'disabled'  => '',
707       'latitude'  => '',
708       'longitude' => '',
709   });
710   my $i = 0;
711   my $n = scalar @missing_coords;
712   for my $cust_location (@missing_coords) {
713     $cust_location->set_coord;
714     my $error = $cust_location->replace;
715     if ( $error ) {
716       warn "error geocoding location#".$cust_location->locationnum.": $error\n";
717     } else {
718       $i++;
719       $job->update_statustext("updated $i / $n locations");
720       dbh->commit; # so that we don't have to wait for the whole thing to finish
721       # Rate-limit to stay under the Google Maps usage limit (2500/day).
722       # 86,400 / 35 = 2,468 lookups per day.
723     }
724     sleep 35;
725   }
726   if ( $i < $n ) {
727     die "failed to update ".$n-$i." locations\n";
728   }
729   return;
730 }
731
732 =item process_standardize [ LOCATIONNUMS ]
733
734 Performs address standardization on locations with unclean addresses,
735 using whatever method you have configured.  If the standardize_* method 
736 returns a I<clean> address match, the location will be updated.  This is 
737 always an in-place update (because the physical location is the same, 
738 and is just being referred to by a more accurate name).
739
740 Disabled locations will be skipped, as nobody cares.
741
742 If any LOCATIONNUMS are provided, only those locations will be updated.
743
744 =cut
745
746 sub process_standardize {
747   my $job = shift;
748   my @others = qsearch('queue', {
749       'status'  => 'locked',
750       'job'     => $job->job,
751       'jobnum'  => {op=>'!=', value=>$job->jobnum},
752   });
753   return if @others;
754   my @locationnums = grep /^\d+$/, @_;
755   my $where = "AND locationnum IN(".join(',',@locationnums).")"
756     if scalar(@locationnums);
757   my @locations = qsearch({
758       table     => 'cust_location',
759       hashref   => { addr_clean => '', disabled => '' },
760       extra_sql => $where,
761   });
762   my $n_todo = scalar(@locations);
763   my $n_done = 0;
764
765   # special: log this
766   my $log;
767   eval "use Text::CSV";
768   open $log, '>', "$FS::UID::cache_dir/process_standardize-" . 
769                   time2str('%Y%m%d',time) .
770                   ".csv";
771   my $csv = Text::CSV->new({binary => 1, eol => "\n"});
772
773   foreach my $cust_location (@locations) {
774     $job->update_statustext( int(100 * $n_done/$n_todo) . ",$n_done / $n_todo locations" ) if $job;
775     my $result = FS::GeocodeCache->standardize($cust_location);
776     if ( $result->{addr_clean} and !$result->{error} ) {
777       my @cols = ($cust_location->locationnum);
778       foreach (keys %$result) {
779         push @cols, $cust_location->get($_), $result->{$_};
780         $cust_location->set($_, $result->{$_});
781       }
782       # bypass immutable field restrictions
783       my $error = $cust_location->FS::Record::replace;
784       warn "location ".$cust_location->locationnum.": $error\n" if $error;
785       $csv->print($log, \@cols);
786     }
787     $n_done++;
788     dbh->commit; # so that we can resume if interrupted
789   }
790   close $log;
791 }
792
793 =head1 BUGS
794
795 =head1 SEE ALSO
796
797 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
798 schema.html from the base documentation.
799
800 =cut
801
802 1;
803