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