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'; }
107 =item new_or_existing HASHREF
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.
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
121 sub new_or_existing {
123 my %hash = ref($_[0]) ? %{$_[0]} : @_;
124 foreach ( qw(address1 address2 city county state zip country geocode
126 # empty fields match only empty fields
127 $hash{$_} = '' if !defined($hash{$_});
129 return qsearchs('cust_location', \%hash) || $class->new(\%hash);
134 Adds this record to the database. If there is an error, returns the error,
135 otherwise returns false.
141 my $conf = new FS::Conf;
143 if ( $self->censustract ) {
144 $self->set('censusyear' => $conf->config('census_year') || 2012);
147 my $error = $self->SUPER::insert(@_);
149 #false laziness with cust_main, will go away eventually
150 if ( !$import and !$error and $conf->config('tax_district_method') ) {
152 my $queue = new FS::queue {
153 'job' => 'FS::geocode_Mixin::process_district_update'
155 $error = $queue->insert( ref($self), $self->locationnum );
164 Delete this record from the database.
166 =item replace OLD_RECORD
168 Replaces the OLD_RECORD with this one in the database. If there is an error,
169 returns the error, otherwise returns false.
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 $_";
184 $self->SUPER::replace($old);
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
196 #some false laziness w/cust_main, but since it should eventually lose these
200 my $conf = new FS::Conf;
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')
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;
229 $self->censustract("$1.$2");
232 if ( $conf->exists('cust_main-require_address2') and
233 !$self->ship_address2 =~ /\S/ ) {
234 return "Unit # is required";
237 # tricky...we have to allow for the customer to not be inserted yet
238 return "No prospect or customer!" unless $self->prospectnum
240 || $self->get('custnum_pending');
241 return "Prospect and customer!" if $self->prospectnum && $self->custnum;
243 return 'Location kind is required'
244 if $self->prospectnum
245 && $conf->exists('prospect_main-alt_address_format')
246 && ! $self->location_kind;
248 unless ( $import or qsearch('cust_main_county', {
249 'country' => $self->country,
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,
266 Returns this locations's full country name
272 code2country($self->country);
277 Synonym for location_label
283 $self->location_label;
286 =item has_ship_address
288 Returns false since cust_location objects do not have a separate shipping
293 sub has_ship_address {
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,
305 =item disable_if_unused
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
313 sub disable_if_unused {
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)
323 $self->disabled('Y');
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.
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';
347 my $oldAutoCommit = $FS::UID::AutoCommit;
348 local $FS::UID::AutoCommit = 0;
352 # prevent this from failing because of pkg_svc quantity limits
353 local( $FS::cust_svc::ignore_quantity ) = 1;
355 if ( !$new->locationnum ) {
356 $error = $new->insert;
358 $dbh->rollback if $oldAutoCommit;
359 return "Error creating location: $error";
363 my @pkgs = qsearch('cust_pkg', {
364 'locationnum' => $old->locationnum,
367 foreach my $cust_pkg (@pkgs) {
368 $error = $cust_pkg->change(
369 'locationnum' => $new->locationnum,
372 if ( $error and not ref($error) ) {
373 $dbh->rollback if $oldAutoCommit;
374 return "Error moving pkgnum ".$cust_pkg->pkgnum.": $error";
378 $error = $old->disable_if_unused;
380 $dbh->rollback if $oldAutoCommit;
381 return "Error disabling old location: $error";
384 $dbh->commit if $oldAutoCommit;
390 Attempts to parse data for location_type and location_number from address1
398 return '' if $self->get('location_type')
399 || $self->get('location_number');
402 if ( 1 ) { #ikano, switch on via config
403 { no warnings 'void';
404 eval { 'use FS::part_export::ikano;' };
407 %parse = FS::part_export::ikano->location_types_parse;
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);
424 #nothing matched, no changes
425 $self->get('address2')
426 ? "Can't parse unit type and number from address2"
432 Moves data from location_type and location_number to the end of address1.
439 #false laziness w/geocode_Mixin.pm::line
440 my $lt = $self->get('location_type');
444 if ( 1 ) { #ikano, switch on via config
445 { no warnings 'void';
446 eval { 'use FS::part_export::ikano;' };
449 %location_type = FS::part_export::ikano->location_types;
451 %location_type = (); #?
454 $self->address1( $self->address1. ' '. $location_type{$lt} || $lt );
455 $self->location_type('');
458 if ( length($self->location_number) ) {
459 $self->address1( $self->address1. ' '. $self->location_number );
460 $self->location_number('');
468 Returns the label of the location object, with an optional site ID
469 string (based on the cust_location-label_prefix config option).
476 my $conf = new FS::Conf;
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);
483 elsif ( $self->prospectnum ) {
484 $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
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('',
494 ($self->state =~ /^(..)/),
496 sprintf('%05d', $self->locationnum)
499 elsif ( $self->custnum and
500 $self->locationnum == $cust_or_prospect->ship_locationnum ) {
501 $prefix = 'Default service location';
503 $prefix .= ($opt{join_string} || ': ') if $prefix;
504 $prefix . $self->SUPER::location_label(%opt);
507 =item county_state_county
509 Returns a string consisting of just the county, state and country.
513 sub county_state_country {
515 my $label = $self->country;
516 $label = $self->state.", $label" if $self->state;
517 $label = $self->county." County, $label" if $self->county;
525 =item in_county_sql OPTIONS
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.
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
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,
550 my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
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 = ?"
559 my $sql = join(' AND ', map "($_)\n", @where);
561 return $sql, @fields;
564 # do the substitution here
566 $sql =~ s/\?/cust_main_county.$_/;
567 $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
577 L<FS::cust_main_county>, L<FS::cust_pkg>, L<FS::Record>,
578 schema.html from the base documentation.