better detection of new locations that are the same as existing locations, #940,...
[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 new_or_existing HASHREF
108
109 Returns an existing location matching the customer and address fields in 
110 HASHREF, if one exists; otherwise returns a new location containing those 
111 fields.  The following fields must match: address1, address2, city, county,
112 state, zip, country, geocode, disabled.  Other fields are only required
113 to match if they're specified in HASHREF.
114
115 The new location will not be inserted; the calling code must call C<insert>
116 (or a method such as C<move_to>) to insert it, and check for errors at that
117 point.
118
119 =cut
120
121 sub new_or_existing {
122   my $class = shift;
123   my %hash = ref($_[0]) ? %{$_[0]} : @_;
124   foreach ( qw(address1 address2 city county state zip country geocode
125               disabled ) ) {
126     # empty fields match only empty fields
127     $hash{$_} = '' if !defined($hash{$_});
128   }
129   return qsearchs('cust_location', \%hash) || $class->new(\%hash);
130 }
131
132 =item insert
133
134 Adds this record to the database.  If there is an error, returns the error,
135 otherwise returns false.
136
137 =cut
138
139 sub insert {
140   my $self = shift;
141   my $conf = new FS::Conf;
142
143   if ( $self->censustract ) {
144     $self->set('censusyear' => $conf->config('census_year') || 2012);
145   }
146
147   my $error = $self->SUPER::insert(@_);
148
149   #false laziness with cust_main, will go away eventually
150   if ( !$import and !$error and $conf->config('tax_district_method') ) {
151
152     my $queue = new FS::queue {
153       'job' => 'FS::geocode_Mixin::process_district_update'
154     };
155     $error = $queue->insert( ref($self), $self->locationnum );
156
157   }
158
159   $error || '';
160 }
161
162 =item delete
163
164 Delete this record from the database.
165
166 =item replace OLD_RECORD
167
168 Replaces the OLD_RECORD with this one in the database.  If there is an error,
169 returns the error, otherwise returns false.
170
171 =cut
172
173 sub replace {
174   my $self = shift;
175   my $old = shift;
176   $old ||= $self->replace_old;
177   # the following fields are immutable
178   foreach (qw(address1 address2 city state zip country)) {
179     if ( $self->$_ ne $old->$_ ) {
180       return "can't change cust_location field $_";
181     }
182   }
183
184   $self->SUPER::replace($old);
185 }
186
187
188 =item check
189
190 Checks all fields to make sure this is a valid location.  If there is
191 an error, returns the error, otherwise returns false.  Called by the insert
192 and replace methods.
193
194 =cut
195
196 #some false laziness w/cust_main, but since it should eventually lose these
197 #fields anyway...
198 sub check {
199   my $self = shift;
200   my $conf = new FS::Conf;
201
202   my $error = 
203     $self->ut_numbern('locationnum')
204     || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
205     || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
206     || $self->ut_text('address1')
207     || $self->ut_textn('address2')
208     || $self->ut_text('city')
209     || $self->ut_textn('county')
210     || $self->ut_textn('state')
211     || $self->ut_country('country')
212     || (!$import && $self->ut_zip('zip', $self->country))
213     || $self->ut_coordn('latitude')
214     || $self->ut_coordn('longitude')
215     || $self->ut_enum('coord_auto', [ '', 'Y' ])
216     || $self->ut_enum('addr_clean', [ '', 'Y' ])
217     || $self->ut_alphan('location_type')
218     || $self->ut_textn('location_number')
219     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
220     || $self->ut_alphan('geocode')
221     || $self->ut_alphan('district')
222     || $self->ut_numbern('censusyear')
223   ;
224   return $error if $error;
225   if ( $self->censustract ne '' ) {
226     $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
227       or return "Illegal census tract: ". $self->censustract;
228
229     $self->censustract("$1.$2");
230   }
231
232   if ( $conf->exists('cust_main-require_address2') and 
233        !$self->ship_address2 =~ /\S/ ) {
234     return "Unit # is required";
235   }
236
237   # tricky...we have to allow for the customer to not be inserted yet
238   return "No prospect or customer!" unless $self->prospectnum 
239                                         || $self->custnum
240                                         || $self->get('custnum_pending');
241   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
242
243   return 'Location kind is required'
244     if $self->prospectnum
245     && $conf->exists('prospect_main-alt_address_format')
246     && ! $self->location_kind;
247
248   unless ( $import or qsearch('cust_main_county', {
249     'country' => $self->country,
250     'state'   => '',
251    } ) ) {
252     return "Unknown state/county/country: ".
253       $self->state. "/". $self->county. "/". $self->country
254       unless qsearch('cust_main_county',{
255         'state'   => $self->state,
256         'county'  => $self->county,
257         'country' => $self->country,
258       } );
259   }
260
261   $self->SUPER::check;
262 }
263
264 =item country_full
265
266 Returns this locations's full country name
267
268 =cut
269
270 sub country_full {
271   my $self = shift;
272   code2country($self->country);
273 }
274
275 =item line
276
277 Synonym for location_label
278
279 =cut
280
281 sub line {
282   my $self = shift;
283   $self->location_label;
284 }
285
286 =item has_ship_address
287
288 Returns false since cust_location objects do not have a separate shipping
289 address.
290
291 =cut
292
293 sub has_ship_address {
294   '';
295 }
296
297 =item location_hash
298
299 Returns a list of key/value pairs, with the following keys: address1, address2,
300 city, county, state, zip, country, geocode, location_type, location_number,
301 location_kind.
302
303 =cut
304
305 =item disable_if_unused
306
307 Sets the "disabled" flag on the location if it is no longer in use as a 
308 prospect location, package location, or a customer's billing or default
309 service address.
310
311 =cut
312
313 sub disable_if_unused {
314
315   my $self = shift;
316   my $locationnum = $self->locationnum;
317   return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
318             or FS::cust_main->count('ship_locationnum = '.$locationnum)
319             or FS::contact->count(      'locationnum  = '.$locationnum)
320             or FS::cust_pkg->count('cancel IS NULL AND 
321                                          locationnum  = '.$locationnum)
322           ;
323   $self->disabled('Y');
324   $self->replace;
325
326 }
327
328 =item move_to
329
330 Takes a new L<FS::cust_location> object.  Moves all packages that use the 
331 existing location to the new one, then sets the "disabled" flag on the old
332 location.  Returns nothing on success, an error message on error.
333
334 =cut
335
336 sub move_to {
337   my $old = shift;
338   my $new = shift;
339
340   local $SIG{HUP} = 'IGNORE';
341   local $SIG{INT} = 'IGNORE';
342   local $SIG{QUIT} = 'IGNORE';
343   local $SIG{TERM} = 'IGNORE';
344   local $SIG{TSTP} = 'IGNORE';
345   local $SIG{PIPE} = 'IGNORE';
346
347   my $oldAutoCommit = $FS::UID::AutoCommit;
348   local $FS::UID::AutoCommit = 0;
349   my $dbh = dbh;
350   my $error = '';
351
352   # prevent this from failing because of pkg_svc quantity limits
353   local( $FS::cust_svc::ignore_quantity ) = 1;
354
355   if ( !$new->locationnum ) {
356     $error = $new->insert;
357     if ( $error ) {
358       $dbh->rollback if $oldAutoCommit;
359       return "Error creating location: $error";
360     }
361   }
362
363   my @pkgs = qsearch('cust_pkg', { 
364       'locationnum' => $old->locationnum,
365       'cancel' => '' 
366     });
367   foreach my $cust_pkg (@pkgs) {
368     $error = $cust_pkg->change(
369       'locationnum' => $new->locationnum,
370       'keep_dates'  => 1
371     );
372     if ( $error and not ref($error) ) {
373       $dbh->rollback if $oldAutoCommit;
374       return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
375     }
376   }
377
378   $error = $old->disable_if_unused;
379   if ( $error ) {
380     $dbh->rollback if $oldAutoCommit;
381     return "Error disabling old location: $error";
382   }
383
384   $dbh->commit if $oldAutoCommit;
385   '';
386 }
387
388 =item alternize
389
390 Attempts to parse data for location_type and location_number from address1
391 and address2.
392
393 =cut
394
395 sub alternize {
396   my $self = shift;
397
398   return '' if $self->get('location_type')
399             || $self->get('location_number');
400
401   my %parse;
402   if ( 1 ) { #ikano, switch on via config
403     { no warnings 'void';
404       eval { 'use FS::part_export::ikano;' };
405       die $@ if $@;
406     }
407     %parse = FS::part_export::ikano->location_types_parse;
408   } else {
409     %parse = (); #?
410   }
411
412   foreach my $from ('address1', 'address2') {
413     foreach my $parse ( keys %parse ) {
414       my $value = $self->get($from);
415       if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
416         $self->set('location_type', $parse{$parse});
417         $self->set('location_number', $2);
418         $self->set($from, $value);
419         return '';
420       }
421     }
422   }
423
424   #nothing matched, no changes
425   $self->get('address2')
426     ? "Can't parse unit type and number from address2"
427     : '';
428 }
429
430 =item dealternize
431
432 Moves data from location_type and location_number to the end of address1.
433
434 =cut
435
436 sub dealternize {
437   my $self = shift;
438
439   #false laziness w/geocode_Mixin.pm::line
440   my $lt = $self->get('location_type');
441   if ( $lt ) {
442
443     my %location_type;
444     if ( 1 ) { #ikano, switch on via config
445       { no warnings 'void';
446         eval { 'use FS::part_export::ikano;' };
447         die $@ if $@;
448       }
449       %location_type = FS::part_export::ikano->location_types;
450     } else {
451       %location_type = (); #?
452     }
453
454     $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
455     $self->location_type('');
456   }
457
458   if ( length($self->location_number) ) {
459     $self->address1( $self->address1. ' '. $self->location_number );
460     $self->location_number('');
461   }
462  
463   '';
464 }
465
466 =item location_label
467
468 Returns the label of the location object, with an optional site ID
469 string (based on the cust_location-label_prefix config option).
470
471 =cut
472
473 sub location_label {
474   my $self = shift;
475   my %opt = @_;
476   my $conf = new FS::Conf;
477   my $prefix = '';
478   my $format = $conf->config('cust_location-label_prefix') || '';
479   my $cust_or_prospect;
480   if ( $self->custnum ) {
481     $cust_or_prospect = FS::cust_main->by_key($self->custnum);
482   }
483   elsif ( $self->prospectnum ) {
484     $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
485   }
486
487   if ( $format eq 'CoStAg' ) {
488     my $agent = $conf->config('cust_main-custnum-display_prefix',
489                   $cust_or_prospect->agentnum)
490                 || $cust_or_prospect->agent->agent;
491     # else this location is invalid
492     $prefix = uc( join('',
493         $self->country,
494         ($self->state =~ /^(..)/),
495         ($agent =~ /^(..)/),
496         sprintf('%05d', $self->locationnum)
497     ) );
498   }
499   elsif ( $self->custnum and 
500           $self->locationnum == $cust_or_prospect->ship_locationnum ) {
501     $prefix = 'Default service location';
502   }
503   $prefix .= ($opt{join_string} ||  ': ') if $prefix;
504   $prefix . $self->SUPER::location_label(%opt);
505 }
506
507 =item county_state_county
508
509 Returns a string consisting of just the county, state and country.
510
511 =cut
512
513 sub county_state_country {
514   my $self = shift;
515   my $label = $self->country;
516   $label = $self->state.", $label" if $self->state;
517   $label = $self->county." County, $label" if $self->county;
518   $label;
519 }
520
521 =back
522
523 =head1 CLASS METHODS
524
525 =item in_county_sql OPTIONS
526
527 Returns an SQL expression to test membership in a cust_main_county 
528 geographic area.  By default, this requires district, city, county,
529 state, and country to match exactly.  Pass "ornull => 1" to allow 
530 partial matches where some fields are NULL in the cust_main_county 
531 record but not in the location.
532
533 Pass "param => 1" to receive a parameterized expression (rather than
534 one that requires a join to cust_main_county) and a list of parameter
535 names in order.
536
537 =cut
538
539 sub in_county_sql {
540   # replaces FS::cust_pkg::location_sql
541   my ($class, %opt) = @_;
542   my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
543   my $x = $ornull ? 3 : 2;
544   my @fields = (('district') x 3,
545                 ('city') x 3,
546                 ('county') x $x,
547                 ('state') x $x,
548                 'country');
549
550   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
551
552   my @where = (
553     "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
554     "cust_location.city     = ? OR ? = '' OR CAST(? AS $text) IS NULL",
555     "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
556     "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
557     "cust_location.country = ?"
558   );
559   my $sql = join(' AND ', map "($_)\n", @where);
560   if ( $opt{param} ) {
561     return $sql, @fields;
562   }
563   else {
564     # do the substitution here
565     foreach (@fields) {
566       $sql =~ s/\?/cust_main_county.$_/;
567       $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
568     }
569     return $sql;
570   }
571 }
572
573 =head1 BUGS
574
575 =head1 SEE ALSO
576
577 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
578 schema.html from the base documentation.
579
580 =cut
581
582 1;
583