merging RT 4.0.6
[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_alphan('location_type')
192     || $self->ut_textn('location_number')
193     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
194     || $self->ut_alphan('geocode')
195     || $self->ut_alphan('district')
196     || $self->ut_numbern('censusyear')
197   ;
198   return $error if $error;
199   if ( $self->censustract ne '' ) {
200     $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
201       or return "Illegal census tract: ". $self->censustract;
202
203     $self->censustract("$1.$2");
204   }
205
206   if ( $conf->exists('cust_main-require_address2') and 
207        !$self->ship_address2 =~ /\S/ ) {
208     return "Unit # is required";
209   }
210
211   $self->set_coord
212     unless $import || ($self->latitude && $self->longitude);
213
214   # tricky...we have to allow for the customer to not be inserted yet
215   return "No prospect or customer!" unless $self->prospectnum 
216                                         || $self->custnum
217                                         || $self->get('custnum_pending');
218   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
219
220   return 'Location kind is required'
221     if $self->prospectnum
222     && $conf->exists('prospect_main-alt_address_format')
223     && ! $self->location_kind;
224
225   unless ( $import or qsearch('cust_main_county', {
226     'country' => $self->country,
227     'state'   => '',
228    } ) ) {
229     return "Unknown state/county/country: ".
230       $self->state. "/". $self->county. "/". $self->country
231       unless qsearch('cust_main_county',{
232         'state'   => $self->state,
233         'county'  => $self->county,
234         'country' => $self->country,
235       } );
236   }
237
238   $self->SUPER::check;
239 }
240
241 =item country_full
242
243 Returns this locations's full country name
244
245 =cut
246
247 sub country_full {
248   my $self = shift;
249   code2country($self->country);
250 }
251
252 =item line
253
254 Synonym for location_label
255
256 =cut
257
258 sub line {
259   my $self = shift;
260   $self->location_label;
261 }
262
263 =item has_ship_address
264
265 Returns false since cust_location objects do not have a separate shipping
266 address.
267
268 =cut
269
270 sub has_ship_address {
271   '';
272 }
273
274 =item location_hash
275
276 Returns a list of key/value pairs, with the following keys: address1, address2,
277 city, county, state, zip, country, geocode, location_type, location_number,
278 location_kind.
279
280 =cut
281
282 =item disable_if_unused
283
284 Sets the "disabled" flag on the location if it is no longer in use as a 
285 prospect location, package location, or a customer's billing or default
286 service address.
287
288 =cut
289
290 sub disable_if_unused {
291
292   my $self = shift;
293   my $locationnum = $self->locationnum;
294   return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
295             or FS::cust_main->count('ship_locationnum = '.$locationnum)
296             or FS::contact->count(      'locationnum  = '.$locationnum)
297             or FS::cust_pkg->count('cancel IS NULL AND 
298                                          locationnum  = '.$locationnum)
299           ;
300   $self->disabled('Y');
301   $self->replace;
302
303 }
304
305 =item move_to
306
307 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
308 existing location to the new one, then sets the "disabled" flag on the old
309 location.  Returns nothing on success, an error message on error.
310
311 =cut
312
313 sub move_to {
314   my $old = shift;
315   my $new = shift;
316
317   local $SIG{HUP} = 'IGNORE';
318   local $SIG{INT} = 'IGNORE';
319   local $SIG{QUIT} = 'IGNORE';
320   local $SIG{TERM} = 'IGNORE';
321   local $SIG{TSTP} = 'IGNORE';
322   local $SIG{PIPE} = 'IGNORE';
323
324   my $oldAutoCommit = $FS::UID::AutoCommit;
325   local $FS::UID::AutoCommit = 0;
326   my $dbh = dbh;
327   my $error = '';
328
329   if ( !$new->locationnum ) {
330     $error = $new->insert;
331     if ( $error ) {
332       $dbh->rollback if $oldAutoCommit;
333       return "Error creating location: $error";
334     }
335   }
336
337   my @pkgs = qsearch('cust_pkg', { 
338       'locationnum' => $old->locationnum,
339       'cancel' => '' 
340     });
341   foreach my $cust_pkg (@pkgs) {
342     $error = $cust_pkg->change(
343       'locationnum' => $new->locationnum,
344       'keep_dates'  => 1
345     );
346     if ( $error and not ref($error) ) {
347       $dbh->rollback if $oldAutoCommit;
348       return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
349     }
350   }
351
352   $error = $old->disable_if_unused;
353   if ( $error ) {
354     $dbh->rollback if $oldAutoCommit;
355     return "Error disabling old location: $error";
356   }
357
358   $dbh->commit if $oldAutoCommit;
359   '';
360 }
361
362 =item alternize
363
364 Attempts to parse data for location_type and location_number from address1
365 and address2.
366
367 =cut
368
369 sub alternize {
370   my $self = shift;
371
372   return '' if $self->get('location_type')
373             || $self->get('location_number');
374
375   my %parse;
376   if ( 1 ) { #ikano, switch on via config
377     { no warnings 'void';
378       eval { 'use FS::part_export::ikano;' };
379       die $@ if $@;
380     }
381     %parse = FS::part_export::ikano->location_types_parse;
382   } else {
383     %parse = (); #?
384   }
385
386   foreach my $from ('address1', 'address2') {
387     foreach my $parse ( keys %parse ) {
388       my $value = $self->get($from);
389       if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
390         $self->set('location_type', $parse{$parse});
391         $self->set('location_number', $2);
392         $self->set($from, $value);
393         return '';
394       }
395     }
396   }
397
398   #nothing matched, no changes
399   $self->get('address2')
400     ? "Can't parse unit type and number from address2"
401     : '';
402 }
403
404 =item dealternize
405
406 Moves data from location_type and location_number to the end of address1.
407
408 =cut
409
410 sub dealternize {
411   my $self = shift;
412
413   #false laziness w/geocode_Mixin.pm::line
414   my $lt = $self->get('location_type');
415   if ( $lt ) {
416
417     my %location_type;
418     if ( 1 ) { #ikano, switch on via config
419       { no warnings 'void';
420         eval { 'use FS::part_export::ikano;' };
421         die $@ if $@;
422       }
423       %location_type = FS::part_export::ikano->location_types;
424     } else {
425       %location_type = (); #?
426     }
427
428     $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
429     $self->location_type('');
430   }
431
432   if ( length($self->location_number) ) {
433     $self->address1( $self->address1. ' '. $self->location_number );
434     $self->location_number('');
435   }
436  
437   '';
438 }
439
440 =item location_label
441
442 Returns the label of the location object, with an optional site ID
443 string (based on the cust_location-label_prefix config option).
444
445 =cut
446
447 sub location_label {
448   my $self = shift;
449   my %opt = @_;
450   my $conf = new FS::Conf;
451   my $prefix = '';
452   my $format = $conf->config('cust_location-label_prefix') || '';
453   my $cust_or_prospect;
454   if ( $self->custnum ) {
455     $cust_or_prospect = FS::cust_main->by_key($self->custnum);
456   }
457   elsif ( $self->prospectnum ) {
458     $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
459   }
460
461   if ( $format eq 'CoStAg' ) {
462     my $agent = $conf->config('cust_main-custnum-display_prefix',
463                   $cust_or_prospect->agentnum)
464                 || $cust_or_prospect->agent->agent;
465     # else this location is invalid
466     $prefix = uc( join('',
467         $self->country,
468         ($self->state =~ /^(..)/),
469         ($agent =~ /^(..)/),
470         sprintf('%05d', $self->locationnum)
471     ) );
472   }
473   elsif ( $self->custnum and 
474           $self->locationnum == $cust_or_prospect->ship_locationnum ) {
475     $prefix = 'Default service location';
476   }
477   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
478   $prefix . $self->SUPER::location_label(%opt);
479 }
480
481 =back
482
483 =head1 CLASS METHODS
484
485 =item in_county_sql OPTIONS
486
487 Returns an SQL expression to test membership in a cust_main_county 
488 geographic area.  By default, this requires district, city, county,
489 state, and country to match exactly.  Pass "ornull => 1" to allow 
490 partial matches where some fields are NULL in the cust_main_county 
491 record but not in the location.
492
493 Pass "param => 1" to receive a parameterized expression (rather than
494 one that requires a join to cust_main_county) and a list of parameter
495 names in order.
496
497 =cut
498
499 sub in_county_sql {
500   # replaces FS::cust_pkg::location_sql
501   my ($class, %opt) = @_;
502   my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
503   my $x = $ornull ? 3 : 2;
504   my @fields = (('district') x 3,
505                 ('city') x 3,
506                 ('county') x $x,
507                 ('state') x $x,
508                 'country');
509
510   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
511
512   my @where = (
513     "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
514     "cust_location.city     = ? OR ? = '' OR CAST(? AS $text) IS NULL",
515     "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
516     "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
517     "cust_location.country = ?"
518   );
519   my $sql = join(' AND ', map "($_)\n", @where);
520   if ( $opt{param} ) {
521     return $sql, @fields;
522   }
523   else {
524     # do the substitution here
525     foreach (@fields) {
526       $sql =~ s/\?/cust_main_county.$_/;
527       $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
528     }
529     return $sql;
530   }
531 }
532
533 =head1 BUGS
534
535 =head1 SEE ALSO
536
537 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
538 schema.html from the base documentation.
539
540 =cut
541
542 1;
543