update address standardization for cust_location changes
[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 );
6 use Locale::Country;
7 use FS::UID qw( dbh driver_name );
8 use FS::Record qw( qsearch ); #qsearchs );
9 use FS::Conf;
10 use FS::prospect_main;
11 use FS::cust_main;
12 use FS::cust_main_county;
13
14 $import = 0;
15
16 =head1 NAME
17
18 FS::cust_location - Object methods for cust_location records
19
20 =head1 SYNOPSIS
21
22   use FS::cust_location;
23
24   $record = new FS::cust_location \%hash;
25   $record = new FS::cust_location { 'column' => 'value' };
26
27   $error = $record->insert;
28
29   $error = $new_record->replace($old_record);
30
31   $error = $record->delete;
32
33   $error = $record->check;
34
35 =head1 DESCRIPTION
36
37 An FS::cust_location object represents a customer location.  FS::cust_location
38 inherits from FS::Record.  The following fields are currently supported:
39
40 =over 4
41
42 =item locationnum
43
44 primary key
45
46 =item custnum
47
48 custnum
49
50 =item address1
51
52 Address line one (required)
53
54 =item address2
55
56 Address line two (optional)
57
58 =item city
59
60 City
61
62 =item county
63
64 County (optional, see L<FS::cust_main_county>)
65
66 =item state
67
68 State (see L<FS::cust_main_county>)
69
70 =item zip
71
72 Zip
73
74 =item country
75
76 Country (see L<FS::cust_main_county>)
77
78 =item geocode
79
80 Geocode
81
82 =item district
83
84 Tax district code (optional)
85
86 =item disabled
87
88 Disabled flag; set to 'Y' to disable the location.
89
90 =back
91
92 =head1 METHODS
93
94 =over 4
95
96 =item new HASHREF
97
98 Creates a new location.  To add the location to the database, see L<"insert">.
99
100 Note that this stores the hash reference, not a distinct copy of the hash it
101 points to.  You can ask the object for a copy with the I<hash> method.
102
103 =cut
104
105 sub table { 'cust_location'; }
106
107 =item insert
108
109 Adds this record to the database.  If there is an error, returns the error,
110 otherwise returns false.
111
112 =cut
113
114 sub insert {
115   my $self = shift;
116   my $conf = new FS::Conf;
117
118   if ( $self->censustract ) {
119     $self->set('censusyear' => $conf->config('census_year') || 2012);
120   }
121
122   my $error = $self->SUPER::insert(@_);
123
124   #false laziness with cust_main, will go away eventually
125   if ( !$import and !$error and $conf->config('tax_district_method') ) {
126
127     my $queue = new FS::queue {
128       'job' => 'FS::geocode_Mixin::process_district_update'
129     };
130     $error = $queue->insert( ref($self), $self->locationnum );
131
132   }
133
134   $error || '';
135 }
136
137 =item delete
138
139 Delete this record from the database.
140
141 =item replace OLD_RECORD
142
143 Replaces the OLD_RECORD with this one in the database.  If there is an error,
144 returns the error, otherwise returns false.
145
146 =cut
147
148 sub replace {
149   my $self = shift;
150   my $old = shift;
151   $old ||= $self->replace_old;
152   # the following fields are immutable
153   foreach (qw(address1 address2 city state zip country)) {
154     if ( $self->$_ ne $old->$_ ) {
155       return "can't change cust_location field $_";
156     }
157   }
158
159   $self->SUPER::replace($old);
160 }
161
162
163 =item check
164
165 Checks all fields to make sure this is a valid location.  If there is
166 an error, returns the error, otherwise returns false.  Called by the insert
167 and replace methods.
168
169 =cut
170
171 #some false laziness w/cust_main, but since it should eventually lose these
172 #fields anyway...
173 sub check {
174   my $self = shift;
175   my $conf = new FS::Conf;
176
177   my $error = 
178     $self->ut_numbern('locationnum')
179     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
180     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
181     || $self->ut_text('address1')
182     || $self->ut_textn('address2')
183     || $self->ut_text('city')
184     || $self->ut_textn('county')
185     || $self->ut_textn('state')
186     || $self->ut_country('country')
187     || (!$import && $self->ut_zip('zip', $self->country))
188     || $self->ut_coordn('latitude')
189     || $self->ut_coordn('longitude')
190     || $self->ut_enum('coord_auto', [ '', 'Y' ])
191     || $self->ut_enum('addr_clean', [ '', 'Y' ])
192     || $self->ut_alphan('location_type')
193     || $self->ut_textn('location_number')
194     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
195     || $self->ut_alphan('geocode')
196     || $self->ut_alphan('district')
197     || $self->ut_numbern('censusyear')
198   ;
199   return $error if $error;
200   if ( $self->censustract ne '' ) {
201     $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
202       or return "Illegal census tract: ". $self->censustract;
203
204     $self->censustract("$1.$2");
205   }
206
207   if ( $conf->exists('cust_main-require_address2') and 
208        !$self->ship_address2 =~ /\S/ ) {
209     return "Unit # is required";
210   }
211
212   $self->set_coord
213     unless $import || ($self->latitude && $self->longitude);
214
215   # tricky...we have to allow for the customer to not be inserted yet
216   return "No prospect or customer!" unless $self->prospectnum 
217                                         || $self->custnum
218                                         || $self->get('custnum_pending');
219   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
220
221   return 'Location kind is required'
222     if $self->prospectnum
223     && $conf->exists('prospect_main-alt_address_format')
224     && ! $self->location_kind;
225
226   unless ( $import or qsearch('cust_main_county', {
227     'country' => $self->country,
228     'state'   => '',
229    } ) ) {
230     return "Unknown state/county/country: ".
231       $self->state. "/". $self->county. "/". $self->country
232       unless qsearch('cust_main_county',{
233         'state'   => $self->state,
234         'county'  => $self->county,
235         'country' => $self->country,
236       } );
237   }
238
239   $self->SUPER::check;
240 }
241
242 =item country_full
243
244 Returns this locations's full country name
245
246 =cut
247
248 sub country_full {
249   my $self = shift;
250   code2country($self->country);
251 }
252
253 =item line
254
255 Synonym for location_label
256
257 =cut
258
259 sub line {
260   my $self = shift;
261   $self->location_label;
262 }
263
264 =item has_ship_address
265
266 Returns false since cust_location objects do not have a separate shipping
267 address.
268
269 =cut
270
271 sub has_ship_address {
272   '';
273 }
274
275 =item location_hash
276
277 Returns a list of key/value pairs, with the following keys: address1, address2,
278 city, county, state, zip, country, geocode, location_type, location_number,
279 location_kind.
280
281 =cut
282
283 =item disable_if_unused
284
285 Sets the "disabled" flag on the location if it is no longer in use as a 
286 prospect location, package location, or a customer's billing or default
287 service address.
288
289 =cut
290
291 sub disable_if_unused {
292
293   my $self = shift;
294   my $locationnum = $self->locationnum;
295   return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
296             or FS::cust_main->count('ship_locationnum = '.$locationnum)
297             or FS::contact->count(      'locationnum  = '.$locationnum)
298             or FS::cust_pkg->count('cancel IS NULL AND 
299                                          locationnum  = '.$locationnum)
300           ;
301   $self->disabled('Y');
302   $self->replace;
303
304 }
305
306 =item move_to
307
308 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
309 existing location to the new one, then sets the "disabled" flag on the old
310 location.  Returns nothing on success, an error message on error.
311
312 =cut
313
314 sub move_to {
315   my $old = shift;
316   my $new = shift;
317
318   local $SIG{HUP} = 'IGNORE';
319   local $SIG{INT} = 'IGNORE';
320   local $SIG{QUIT} = 'IGNORE';
321   local $SIG{TERM} = 'IGNORE';
322   local $SIG{TSTP} = 'IGNORE';
323   local $SIG{PIPE} = 'IGNORE';
324
325   my $oldAutoCommit = $FS::UID::AutoCommit;
326   local $FS::UID::AutoCommit = 0;
327   my $dbh = dbh;
328   my $error = '';
329
330   if ( !$new->locationnum ) {
331     $error = $new->insert;
332     if ( $error ) {
333       $dbh->rollback if $oldAutoCommit;
334       return "Error creating location: $error";
335     }
336   }
337
338   my @pkgs = qsearch('cust_pkg', { 
339       'locationnum' => $old->locationnum,
340       'cancel' => '' 
341     });
342   foreach my $cust_pkg (@pkgs) {
343     $error = $cust_pkg->change(
344       'locationnum' => $new->locationnum,
345       'keep_dates'  => 1
346     );
347     if ( $error and not ref($error) ) {
348       $dbh->rollback if $oldAutoCommit;
349       return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
350     }
351   }
352
353   $error = $old->disable_if_unused;
354   if ( $error ) {
355     $dbh->rollback if $oldAutoCommit;
356     return "Error disabling old location: $error";
357   }
358
359   $dbh->commit if $oldAutoCommit;
360   '';
361 }
362
363 =item alternize
364
365 Attempts to parse data for location_type and location_number from address1
366 and address2.
367
368 =cut
369
370 sub alternize {
371   my $self = shift;
372
373   return '' if $self->get('location_type')
374             || $self->get('location_number');
375
376   my %parse;
377   if ( 1 ) { #ikano, switch on via config
378     { no warnings 'void';
379       eval { 'use FS::part_export::ikano;' };
380       die $@ if $@;
381     }
382     %parse = FS::part_export::ikano->location_types_parse;
383   } else {
384     %parse = (); #?
385   }
386
387   foreach my $from ('address1', 'address2') {
388     foreach my $parse ( keys %parse ) {
389       my $value = $self->get($from);
390       if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
391         $self->set('location_type', $parse{$parse});
392         $self->set('location_number', $2);
393         $self->set($from, $value);
394         return '';
395       }
396     }
397   }
398
399   #nothing matched, no changes
400   $self->get('address2')
401     ? "Can't parse unit type and number from address2"
402     : '';
403 }
404
405 =item dealternize
406
407 Moves data from location_type and location_number to the end of address1.
408
409 =cut
410
411 sub dealternize {
412   my $self = shift;
413
414   #false laziness w/geocode_Mixin.pm::line
415   my $lt = $self->get('location_type');
416   if ( $lt ) {
417
418     my %location_type;
419     if ( 1 ) { #ikano, switch on via config
420       { no warnings 'void';
421         eval { 'use FS::part_export::ikano;' };
422         die $@ if $@;
423       }
424       %location_type = FS::part_export::ikano->location_types;
425     } else {
426       %location_type = (); #?
427     }
428
429     $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
430     $self->location_type('');
431   }
432
433   if ( length($self->location_number) ) {
434     $self->address1( $self->address1. ' '. $self->location_number );
435     $self->location_number('');
436   }
437  
438   '';
439 }
440
441 =item location_label
442
443 Returns the label of the location object, with an optional site ID
444 string (based on the cust_location-label_prefix config option).
445
446 =cut
447
448 sub location_label {
449   my $self = shift;
450   my %opt = @_;
451   my $conf = new FS::Conf;
452   my $prefix = '';
453   my $format = $conf->config('cust_location-label_prefix') || '';
454   my $cust_or_prospect;
455   if ( $self->custnum ) {
456     $cust_or_prospect = FS::cust_main->by_key($self->custnum);
457   }
458   elsif ( $self->prospectnum ) {
459     $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
460   }
461
462   if ( $format eq 'CoStAg' ) {
463     my $agent = $conf->config('cust_main-custnum-display_prefix',
464                   $cust_or_prospect->agentnum)
465                 || $cust_or_prospect->agent->agent;
466     # else this location is invalid
467     $prefix = uc( join('',
468         $self->country,
469         ($self->state =~ /^(..)/),
470         ($agent =~ /^(..)/),
471         sprintf('%05d', $self->locationnum)
472     ) );
473   }
474   elsif ( $self->custnum and 
475           $self->locationnum == $cust_or_prospect->ship_locationnum ) {
476     $prefix = 'Default service location';
477   }
478   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
479   $prefix . $self->SUPER::location_label(%opt);
480 }
481
482 =back
483
484 =head1 CLASS METHODS
485
486 =item in_county_sql OPTIONS
487
488 Returns an SQL expression to test membership in a cust_main_county 
489 geographic area.  By default, this requires district, city, county,
490 state, and country to match exactly.  Pass "ornull => 1" to allow 
491 partial matches where some fields are NULL in the cust_main_county 
492 record but not in the location.
493
494 Pass "param => 1" to receive a parameterized expression (rather than
495 one that requires a join to cust_main_county) and a list of parameter
496 names in order.
497
498 =cut
499
500 sub in_county_sql {
501   # replaces FS::cust_pkg::location_sql
502   my ($class, %opt) = @_;
503   my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
504   my $x = $ornull ? 3 : 2;
505   my @fields = (('district') x 3,
506                 ('city') x 3,
507                 ('county') x $x,
508                 ('state') x $x,
509                 'country');
510
511   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
512
513   my @where = (
514     "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
515     "cust_location.city     = ? OR ? = '' OR CAST(? AS $text) IS NULL",
516     "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
517     "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
518     "cust_location.country = ?"
519   );
520   my $sql = join(' AND ', map "($_)\n", @where);
521   if ( $opt{param} ) {
522     return $sql, @fields;
523   }
524   else {
525     # do the substitution here
526     foreach (@fields) {
527       $sql =~ s/\?/cust_main_county.$_/;
528       $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
529     }
530     return $sql;
531   }
532 }
533
534 =head1 BUGS
535
536 =head1 SEE ALSO
537
538 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
539 schema.html from the base documentation.
540
541 =cut
542
543 1;
544