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