1 package FS::cust_location;
2 use base qw( FS::geocode_Mixin FS::Record );
5 use vars qw( $import );
7 use FS::UID qw( dbh driver_name );
8 use FS::Record qw( qsearch qsearchs );
10 use FS::prospect_main;
12 use FS::cust_main_county;
18 FS::cust_location - Object methods for cust_location records
22 use FS::cust_location;
24 $record = new FS::cust_location \%hash;
25 $record = new FS::cust_location { 'column' => 'value' };
27 $error = $record->insert;
29 $error = $new_record->replace($old_record);
31 $error = $record->delete;
33 $error = $record->check;
37 An FS::cust_location object represents a customer location. FS::cust_location
38 inherits from FS::Record. The following fields are currently supported:
52 Address line one (required)
56 Address line two (optional)
64 County (optional, see L<FS::cust_main_county>)
68 State (see L<FS::cust_main_county>)
76 Country (see L<FS::cust_main_county>)
84 Tax district code (optional)
88 Disabled flag; set to 'Y' to disable the location.
98 Creates a new location. To add the location to the database, see L<"insert">.
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.
105 sub table { 'cust_location'; }
109 Finds an existing location matching the customer and address values in this
110 location, if one exists, and sets the contents of this location equal to that
111 one (including its locationnum).
113 If an existing location is not found, this one I<will> be inserted. (This is a
114 change from the "new_or_existing" method that this replaces.)
116 The following fields are considered "essential" and I<must> match: custnum,
117 address1, address2, city, county, state, zip, country, location_number,
118 location_type, location_kind. Disabled locations will be found only if this
119 location is set to disabled.
121 If 'coord_auto' is null, and latitude and longitude are not null, then
122 latitude and longitude are also essential fields.
124 All other fields are considered "non-essential". If a non-essential field is
125 empty in this location, it will be ignored in determining whether an existing
128 If a non-essential field is non-empty in this location, existing locations
129 that contain a different non-empty value for that field will not match. An
130 existing location in which the field is I<empty> will match, but will be
131 updated in-place with the value of that field.
133 Returns an error string if inserting or updating a location failed.
135 It is unfortunately hard to determine if this created a new location or not.
142 my @essential = (qw(custnum address1 address2 city county state zip country
143 location_number location_type location_kind disabled));
145 if ( !$self->coord_auto and $self->latitude and $self->longitude ) {
146 push @essential, qw(latitude longitude);
147 # but NOT coord_auto; if the latitude and longitude match the geocoded
148 # values then that's good enough
151 # put nonempty, nonessential fields/values into this hash
152 my %nonempty = map { $_ => $self->get($_) }
153 grep {$self->get($_)} $self->fields;
154 delete @nonempty{@essential};
155 delete $nonempty{'locationnum'};
157 my %hash = map { $_ => $self->get($_) } @essential;
158 my @matches = qsearch('cust_location', \%hash);
160 # consider candidate locations
161 MATCH: foreach my $old (@matches) {
163 foreach my $field (keys %nonempty) {
164 my $old_value = $old->get($field);
165 if ( length($old_value) > 0 ) {
166 if ( $field eq 'latitude' or $field eq 'longitude' ) {
167 # special case, because these are decimals
168 if ( abs($old_value - $nonempty{$field}) > 0.000001 ) {
171 } elsif ( $old_value ne $nonempty{$field} ) {
175 # it's empty in $old, has a value in $self
176 $old->set($field, $nonempty{$field});
178 next MATCH if $reject;
181 if ( $old->modified ) {
182 my $error = $old->replace;
183 return $error if $error;
185 # set $self equal to $old
186 foreach ($self->fields) {
187 $self->set($_, $old->get($_));
192 # didn't find a match
193 return $self->insert;
198 Adds this record to the database. If there is an error, returns the error,
199 otherwise returns false.
205 my $conf = new FS::Conf;
207 if ( $self->censustract ) {
208 $self->set('censusyear' => $conf->config('census_year') || 2012);
211 my $error = $self->SUPER::insert(@_);
213 #false laziness with cust_main, will go away eventually
214 if ( !$import and !$error and $conf->config('tax_district_method') ) {
216 my $queue = new FS::queue {
217 'job' => 'FS::geocode_Mixin::process_district_update'
219 $error = $queue->insert( ref($self), $self->locationnum );
228 Delete this record from the database.
230 =item replace OLD_RECORD
232 Replaces the OLD_RECORD with this one in the database. If there is an error,
233 returns the error, otherwise returns false.
240 $old ||= $self->replace_old;
241 # the following fields are immutable
242 foreach (qw(address1 address2 city state zip country)) {
243 if ( $self->$_ ne $old->$_ ) {
244 return "can't change cust_location field $_";
248 $self->SUPER::replace($old);
254 Checks all fields to make sure this is a valid location. If there is
255 an error, returns the error, otherwise returns false. Called by the insert
260 #some false laziness w/cust_main, but since it should eventually lose these
264 my $conf = new FS::Conf;
267 $self->ut_numbern('locationnum')
268 || $self->ut_foreign_keyn('prospectnum', 'prospect_main', 'prospectnum')
269 || $self->ut_foreign_keyn('custnum', 'cust_main', 'custnum')
270 || $self->ut_text('address1')
271 || $self->ut_textn('address2')
272 || $self->ut_text('city')
273 || $self->ut_textn('county')
274 || $self->ut_textn('state')
275 || $self->ut_country('country')
276 || (!$import && $self->ut_zip('zip', $self->country))
277 || $self->ut_coordn('latitude')
278 || $self->ut_coordn('longitude')
279 || $self->ut_enum('coord_auto', [ '', 'Y' ])
280 || $self->ut_enum('addr_clean', [ '', 'Y' ])
281 || $self->ut_alphan('location_type')
282 || $self->ut_textn('location_number')
283 || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
284 || $self->ut_alphan('geocode')
285 || $self->ut_alphan('district')
286 || $self->ut_numbern('censusyear')
288 return $error if $error;
289 if ( $self->censustract ne '' ) {
290 $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
291 or return "Illegal census tract: ". $self->censustract;
293 $self->censustract("$1.$2");
296 if ( $conf->exists('cust_main-require_address2') and
297 !$self->ship_address2 =~ /\S/ ) {
298 return "Unit # is required";
301 # tricky...we have to allow for the customer to not be inserted yet
302 return "No prospect or customer!" unless $self->prospectnum
304 || $self->get('custnum_pending');
305 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
307 return 'Location kind is required'
308 if $self->prospectnum
309 && $conf->exists('prospect_main-alt_address_format')
310 && ! $self->location_kind;
312 unless ( $import or qsearch('cust_main_county', {
313 'country' => $self->country,
316 return "Unknown state/county/country: ".
317 $self->state. "/". $self->county. "/". $self->country
318 unless qsearch('cust_main_county',{
319 'state' => $self->state,
320 'county' => $self->county,
321 'country' => $self->country,
330 Returns this locations's full country name
336 code2country($self->country);
341 Synonym for location_label
347 $self->location_label;
350 =item has_ship_address
352 Returns false since cust_location objects do not have a separate shipping
357 sub has_ship_address {
363 Returns a list of key/value pairs, with the following keys: address1, address2,
364 city, county, state, zip, country, geocode, location_type, location_number,
369 =item disable_if_unused
371 Sets the "disabled" flag on the location if it is no longer in use as a
372 prospect location, package location, or a customer's billing or default
377 sub disable_if_unused {
380 my $locationnum = $self->locationnum;
381 return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
382 or FS::cust_main->count('ship_locationnum = '.$locationnum)
383 or FS::contact->count( 'locationnum = '.$locationnum)
384 or FS::cust_pkg->count('cancel IS NULL AND
385 locationnum = '.$locationnum)
387 $self->disabled('Y');
394 Takes a new L<FS::cust_location> object. Moves all packages that use the
395 existing location to the new one, then sets the "disabled" flag on the old
396 location. Returns nothing on success, an error message on error.
404 local $SIG{HUP} = 'IGNORE';
405 local $SIG{INT} = 'IGNORE';
406 local $SIG{QUIT} = 'IGNORE';
407 local $SIG{TERM} = 'IGNORE';
408 local $SIG{TSTP} = 'IGNORE';
409 local $SIG{PIPE} = 'IGNORE';
411 my $oldAutoCommit = $FS::UID::AutoCommit;
412 local $FS::UID::AutoCommit = 0;
416 # prevent this from failing because of pkg_svc quantity limits
417 local( $FS::cust_svc::ignore_quantity ) = 1;
419 if ( !$new->locationnum ) {
420 $error = $new->insert;
422 $dbh->rollback if $oldAutoCommit;
423 return "Error creating location: $error";
427 my @pkgs = qsearch('cust_pkg', {
428 'locationnum' => $old->locationnum,
431 foreach my $cust_pkg (@pkgs) {
432 $error = $cust_pkg->change(
433 'locationnum' => $new->locationnum,
436 if ( $error and not ref($error) ) {
437 $dbh->rollback if $oldAutoCommit;
438 return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
442 $error = $old->disable_if_unused;
444 $dbh->rollback if $oldAutoCommit;
445 return "Error disabling old location: $error";
448 $dbh->commit if $oldAutoCommit;
454 Attempts to parse data for location_type and location_number from address1
462 return '' if $self->get('location_type')
463 || $self->get('location_number');
466 if ( 1 ) { #ikano, switch on via config
467 { no warnings 'void';
468 eval { 'use FS::part_export::ikano;' };
471 %parse = FS::part_export::ikano->location_types_parse;
476 foreach my $from ('address1', 'address2') {
477 foreach my $parse ( keys %parse ) {
478 my $value = $self->get($from);
479 if ( $value =~ s/(^|\W+)$parse\W+(\w+)\W*$//i ) {
480 $self->set('location_type', $parse{$parse});
481 $self->set('location_number', $2);
482 $self->set($from, $value);
488 #nothing matched, no changes
489 $self->get('address2')
490 ? "Can't parse unit type and number from address2"
496 Moves data from location_type and location_number to the end of address1.
503 #false laziness w/geocode_Mixin.pm::line
504 my $lt = $self->get('location_type');
508 if ( 1 ) { #ikano, switch on via config
509 { no warnings 'void';
510 eval { 'use FS::part_export::ikano;' };
513 %location_type = FS::part_export::ikano->location_types;
515 %location_type = (); #?
518 $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
519 $self->location_type('');
522 if ( length($self->location_number) ) {
523 $self->address1( $self->address1. ' '. $self->location_number );
524 $self->location_number('');
532 Returns the label of the location object, with an optional site ID
533 string (based on the cust_location-label_prefix config option).
540 my $conf = new FS::Conf;
542 my $format = $conf->config('cust_location-label_prefix') || '';
543 my $cust_or_prospect;
544 if ( $self->custnum ) {
545 $cust_or_prospect = FS::cust_main->by_key($self->custnum);
547 elsif ( $self->prospectnum ) {
548 $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
551 if ( $format eq 'CoStAg' ) {
552 my $agent = $conf->config('cust_main-custnum-display_prefix',
553 $cust_or_prospect->agentnum)
554 || $cust_or_prospect->agent->agent;
555 # else this location is invalid
556 $prefix = uc( join('',
558 ($self->state =~ /^(..)/),
560 sprintf('%05d', $self->locationnum)
563 elsif ( $self->custnum and
564 $self->locationnum == $cust_or_prospect->ship_locationnum ) {
565 $prefix = 'Default service location';
567 $prefix .= ($opt{join_string} || ': ') if $prefix;
568 $prefix . $self->SUPER::location_label(%opt);
571 =item county_state_county
573 Returns a string consisting of just the county, state and country.
577 sub county_state_country {
579 my $label = $self->country;
580 $label = $self->state.", $label" if $self->state;
581 $label = $self->county." County, $label" if $self->county;
589 =item in_county_sql OPTIONS
591 Returns an SQL expression to test membership in a cust_main_county
592 geographic area. By default, this requires district, city, county,
593 state, and country to match exactly. Pass "ornull => 1" to allow
594 partial matches where some fields are NULL in the cust_main_county
595 record but not in the location.
597 Pass "param => 1" to receive a parameterized expression (rather than
598 one that requires a join to cust_main_county) and a list of parameter
604 # replaces FS::cust_pkg::location_sql
605 my ($class, %opt) = @_;
606 my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
607 my $x = $ornull ? 3 : 2;
608 my @fields = (('district') x 3,
614 my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
617 "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
618 "cust_location.city = ? OR ? = '' OR CAST(? AS $text) IS NULL",
619 "cust_location.county = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
620 "cust_location.state = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
621 "cust_location.country = ?"
623 my $sql = join(' AND ', map "($_)\n", @where);
625 return $sql, @fields;
628 # do the substitution here
630 $sql =~ s/\?/cust_main_county.$_/;
631 $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
641 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
642 schema.html from the base documentation.