Merge pull request #23044 from braders/https-maps-links
[civicrm-core.git] / CRM / Core / BAO / Address.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
18 /**
19 * This is class to handle address related functions.
20 */
21 class CRM_Core_BAO_Address extends CRM_Core_DAO_Address {
22 use CRM_Contact_AccessTrait;
23
24 /**
25 * Takes an associative array and creates a address.
26 *
27 * @param array $params
28 * (reference ) an assoc array of name/value pairs.
29 * @param bool $fixAddress
30 * True if you need to fix (format) address values.
31 * before inserting in db
32 *
33 * @return array|NULL|self
34 * array of created address
35 */
36 public static function create(&$params, $fixAddress = TRUE) {
37 if (!isset($params['address']) || !is_array($params['address'])) {
38 return self::add($params, $fixAddress);
39 }
40 CRM_Core_Error::deprecatedFunctionWarning('Use legacyCreate if not doing a single crud action');
41 return self::legacyCreate($params, $fixAddress);
42 }
43
44 /**
45 * Takes an associative array and adds address.
46 *
47 * @param array $params
48 * (reference ) an assoc array of name/value pairs.
49 * @param bool $fixAddress
50 * True if you need to fix (format) address values.
51 * before inserting in db
52 *
53 * @return CRM_Core_BAO_Address|null
54 */
55 public static function add(&$params, $fixAddress = FALSE) {
56
57 $address = new CRM_Core_DAO_Address();
58 $checkPermissions = $params['check_permissions'] ?? TRUE;
59
60 // fixAddress mode to be done
61 if ($fixAddress) {
62 CRM_Core_BAO_Address::fixAddress($params);
63 }
64
65 $hook = empty($params['id']) ? 'create' : 'edit';
66 CRM_Utils_Hook::pre($hook, 'Address', CRM_Utils_Array::value('id', $params), $params);
67
68 CRM_Core_BAO_Block::handlePrimary($params, get_class());
69
70 // (prevent chaining 1 and 3) CRM-21214
71 if (isset($params['master_id']) && !CRM_Utils_System::isNull($params['master_id'])) {
72 self::fixSharedAddress($params);
73 }
74
75 $address->copyValues($params);
76 $address->save();
77
78 if ($address->id) {
79 // first get custom field from master address if any
80 if (isset($params['master_id']) && !CRM_Utils_System::isNull($params['master_id'])) {
81 $address->copyCustomFields($params['master_id'], $address->id, $hook);
82 }
83
84 if (isset($params['custom'])) {
85 $addressCustom = $params['custom'];
86 }
87 else {
88 $customFields = CRM_Core_BAO_CustomField::getFields('Address', FALSE, TRUE, NULL, NULL,
89 FALSE, FALSE, $checkPermissions ? CRM_Core_Permission::EDIT : FALSE);
90
91 if (!empty($customFields)) {
92 $addressCustom = CRM_Core_BAO_CustomField::postProcess($params,
93 $address->id,
94 'Address',
95 FALSE,
96 $checkPermissions
97 );
98 }
99 }
100 if (!empty($addressCustom)) {
101 CRM_Core_BAO_CustomValueTable::store($addressCustom, 'civicrm_address', $address->id, $hook);
102 }
103
104 // call the function to sync shared address and create relationships
105 // if address is already shared, share master_id with all children and update relationships accordingly
106 // (prevent chaining 2) CRM-21214
107 self::processSharedAddress($address->id, $params, $hook);
108
109 // lets call the post hook only after we've done all the follow on processing
110 CRM_Utils_Hook::post($hook, 'Address', $address->id, $address);
111 }
112
113 return $address;
114 }
115
116 /**
117 * Format the address params to have reasonable values.
118 *
119 * @param array $params
120 * (reference ) an assoc array of name/value pairs.
121 */
122 public static function fixAddress(&$params) {
123 if (!empty($params['billing_street_address'])) {
124 //Check address is coming from online contribution / registration page
125 //Fixed :CRM-5076
126 $billing = [
127 'street_address' => 'billing_street_address',
128 'city' => 'billing_city',
129 'postal_code' => 'billing_postal_code',
130 'state_province' => 'billing_state_province',
131 'state_province_id' => 'billing_state_province_id',
132 'country' => 'billing_country',
133 'country_id' => 'billing_country_id',
134 ];
135
136 foreach ($billing as $key => $val) {
137 if ($value = CRM_Utils_Array::value($val, $params)) {
138 if (!empty($params[$key])) {
139 unset($params[$val]);
140 }
141 else {
142 //add new key and removed old
143 $params[$key] = $value;
144 unset($params[$val]);
145 }
146 }
147 }
148 }
149
150 /* Split the zip and +4, if it's in US format */
151 if (!empty($params['postal_code']) &&
152 preg_match('/^(\d{4,5})[+-](\d{4})$/',
153 $params['postal_code'],
154 $match
155 )
156 ) {
157 $params['postal_code'] = $match[1];
158 $params['postal_code_suffix'] = $match[2];
159 }
160
161 // add country id if not set
162 if ((!isset($params['country_id']) || !is_numeric($params['country_id'])) &&
163 isset($params['country'])
164 ) {
165 $country = new CRM_Core_DAO_Country();
166 $country->name = $params['country'];
167 if (!$country->find(TRUE)) {
168 $country->name = NULL;
169 $country->iso_code = $params['country'];
170 $country->find(TRUE);
171 }
172 $params['country_id'] = $country->id;
173 }
174
175 // add state_id if state is set
176 if ((!isset($params['state_province_id']) || !is_numeric($params['state_province_id']))
177 && isset($params['state_province'])
178 ) {
179 if (!empty($params['state_province'])) {
180 $state_province = new CRM_Core_DAO_StateProvince();
181 $state_province->name = $params['state_province'];
182
183 // add country id if present
184 if (!empty($params['country_id'])) {
185 $state_province->country_id = $params['country_id'];
186 }
187
188 if (!$state_province->find(TRUE)) {
189 unset($state_province->name);
190 $state_province->abbreviation = $params['state_province'];
191 $state_province->find(TRUE);
192 }
193 $params['state_province_id'] = $state_province->id;
194 if (empty($params['country_id'])) {
195 // set this here since we have it
196 $params['country_id'] = $state_province->country_id;
197 }
198 }
199 else {
200 $params['state_province_id'] = 'null';
201 }
202 }
203
204 // add county id if county is set
205 // CRM-7837
206 if ((!isset($params['county_id']) || !is_numeric($params['county_id']))
207 && isset($params['county']) && !empty($params['county'])
208 ) {
209 $county = new CRM_Core_DAO_County();
210 $county->name = $params['county'];
211
212 if (isset($params['state_province_id'])) {
213 $county->state_province_id = $params['state_province_id'];
214 }
215
216 if ($county->find(TRUE)) {
217 $params['county_id'] = $county->id;
218 }
219 }
220
221 // currently copy values populates empty fields with the string "null"
222 // and hence need to check for the string null
223 if (isset($params['state_province_id']) &&
224 is_numeric($params['state_province_id']) &&
225 (!isset($params['country_id']) || empty($params['country_id']))
226 ) {
227 // since state id present and country id not present, hence lets populate it
228 // jira issue http://issues.civicrm.org/jira/browse/CRM-56
229 $params['country_id'] = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_StateProvince',
230 $params['state_province_id'],
231 'country_id'
232 );
233 }
234
235 //special check to ignore non numeric values if they are not
236 //detected by formRule(sometimes happens due to internet latency), also allow user to unselect state/country
237 if (isset($params['state_province_id'])) {
238 if (empty($params['state_province_id'])) {
239 $params['state_province_id'] = 'null';
240 }
241 elseif (!is_numeric($params['state_province_id']) ||
242 ((int ) $params['state_province_id'] < 1000)
243 ) {
244 // CRM-3393 ( the hacky 1000 check)
245 $params['state_province_id'] = 'null';
246 }
247 }
248
249 if (isset($params['country_id'])) {
250 if (empty($params['country_id'])) {
251 $params['country_id'] = 'null';
252 }
253 elseif (!is_numeric($params['country_id']) ||
254 ((int ) $params['country_id'] < 1000)
255 ) {
256 // CRM-3393 ( the hacky 1000 check)
257 $params['country_id'] = 'null';
258 }
259 }
260
261 // add state and country names from the ids
262 if (isset($params['state_province_id']) && is_numeric($params['state_province_id'])) {
263 $params['state_province'] = CRM_Core_PseudoConstant::stateProvinceAbbreviation($params['state_province_id']);
264 }
265
266 if (isset($params['country_id']) && is_numeric($params['country_id'])) {
267 $params['country'] = CRM_Core_PseudoConstant::country($params['country_id']);
268 }
269
270 $asp = Civi::settings()->get('address_standardization_provider');
271 // clean up the address via USPS web services if enabled
272 if ($asp === 'USPS' &&
273 $params['country_id'] == 1228
274 ) {
275 CRM_Utils_Address_USPS::checkAddress($params);
276 }
277 // do street parsing again if enabled, since street address might have changed
278 $parseStreetAddress = CRM_Utils_Array::value(
279 'street_address_parsing',
280 CRM_Core_BAO_Setting::valueOptions(
281 CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME,
282 'address_options'
283 ),
284 FALSE
285 );
286
287 if ($parseStreetAddress && !empty($params['street_address'])) {
288 foreach (['street_number', 'street_name', 'street_unit', 'street_number_suffix'] as $fld) {
289 unset($params[$fld]);
290 }
291 // main parse string.
292 $parseString = $params['street_address'] ?? NULL;
293 $parsedFields = CRM_Core_BAO_Address::parseStreetAddress($parseString);
294
295 // merge parse address in to main address block.
296 $params = array_merge($params, $parsedFields);
297 }
298
299 // skip_geocode is an optional parameter through the api.
300 // manual_geo_code is on the contact edit form. They do the same thing....
301 if (empty($params['skip_geocode']) && empty($params['manual_geo_code'])) {
302 self::addGeocoderData($params);
303 }
304
305 }
306
307 /**
308 * Check if there is data to create the object.
309 *
310 * @param array $params
311 * (reference ) an assoc array of name/value pairs.
312 *
313 * @return bool
314 */
315 public static function dataExists(&$params) {
316 //check if location type is set if not return false
317 if (!isset($params['location_type_id'])) {
318 return FALSE;
319 }
320
321 $config = CRM_Core_Config::singleton();
322 foreach ($params as $name => $value) {
323 if (in_array($name, [
324 'is_primary',
325 'location_type_id',
326 'id',
327 'contact_id',
328 'is_billing',
329 'display',
330 'master_id',
331 ])) {
332 continue;
333 }
334 elseif (!CRM_Utils_System::isNull($value)) {
335 // name could be country or country id
336 if (substr($name, 0, 7) == 'country') {
337 // make sure its different from the default country
338 // iso code
339 $defaultCountry = CRM_Core_BAO_Country::defaultContactCountry();
340 // full name
341 $defaultCountryName = CRM_Core_BAO_Country::defaultContactCountryName();
342
343 if ($defaultCountry) {
344 if ($value == $defaultCountry ||
345 $value == $defaultCountryName ||
346 $value == $config->defaultContactCountry
347 ) {
348 // do nothing
349 }
350 else {
351 return TRUE;
352 }
353 }
354 else {
355 // return if null default
356 return TRUE;
357 }
358 }
359 else {
360 return TRUE;
361 }
362 }
363 }
364
365 return FALSE;
366 }
367
368 /**
369 * Given the list of params in the params array, fetch the object
370 * and store the values in the values array
371 *
372 * @param array $entityBlock
373 * Associated array of fields.
374 * @param bool $microformat
375 * If microformat output is required.
376 * @param int|string $fieldName conditional field name
377 *
378 * @return array
379 * array with address fields
380 */
381 public static function &getValues($entityBlock, $microformat = FALSE, $fieldName = 'contact_id') {
382 if (empty($entityBlock)) {
383 return NULL;
384 }
385 $addresses = [];
386 $address = new CRM_Core_BAO_Address();
387
388 if (empty($entityBlock['entity_table'])) {
389 $address->$fieldName = $entityBlock[$fieldName] ?? NULL;
390 }
391 else {
392 $addressIds = [];
393 $addressIds = self::allEntityAddress($entityBlock);
394
395 if (!empty($addressIds[1])) {
396 $address->id = $addressIds[1];
397 }
398 else {
399 return $addresses;
400 }
401 }
402 if (isset($entityBlock['is_billing']) && $entityBlock['is_billing'] == 1) {
403 $address->orderBy('is_billing desc, id');
404 }
405 else {
406 //get primary address as a first block.
407 $address->orderBy('is_primary desc, id');
408 }
409
410 $address->find();
411
412 $locationTypes = CRM_Core_PseudoConstant::get('CRM_Core_DAO_Address', 'location_type_id');
413 $count = 1;
414 while ($address->fetch()) {
415 // deprecate reference.
416 if ($count > 1) {
417 foreach (['state', 'state_name', 'country', 'world_region'] as $fld) {
418 if (isset($address->$fld)) {
419 unset($address->$fld);
420 }
421 }
422 }
423 $stree = $address->street_address;
424 $values = [];
425 CRM_Core_DAO::storeValues($address, $values);
426
427 // add state and country information: CRM-369
428 if (!empty($address->location_type_id)) {
429 $values['location_type'] = $locationTypes[$address->location_type_id] ?? NULL;
430 }
431 if (!empty($address->state_province_id)) {
432 $address->state = CRM_Core_PseudoConstant::stateProvinceAbbreviation($address->state_province_id, FALSE);
433 $address->state_name = CRM_Core_PseudoConstant::stateProvince($address->state_province_id, FALSE);
434 $values['state_province_abbreviation'] = $address->state;
435 $values['state_province'] = $address->state_name;
436 }
437
438 if (!empty($address->country_id)) {
439 $address->country = CRM_Core_PseudoConstant::country($address->country_id);
440 $values['country'] = $address->country;
441
442 //get world region
443 $regionId = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_Country', $address->country_id, 'region_id');
444 $values['world_region'] = CRM_Core_PseudoConstant::worldregion($regionId);
445 }
446
447 $address->addDisplay($microformat);
448
449 $values['display'] = $address->display;
450 $values['display_text'] = $address->display_text;
451
452 if (isset($address->master_id) && !CRM_Utils_System::isNull($address->master_id)) {
453 $values['use_shared_address'] = 1;
454 }
455
456 $addresses[$count] = $values;
457
458 //There should never be more than one primary blocks, hence set is_primary = 0 other than first
459 // Calling functions expect the key is_primary to be set, so do not unset it here!
460 if ($count > 1) {
461 $addresses[$count]['is_primary'] = 0;
462 }
463
464 $count++;
465 }
466
467 return $addresses;
468 }
469
470 /**
471 * Add the formatted address to $this-> display.
472 *
473 * @param bool $microformat
474 * Unexplained parameter that I've always wondered about.
475 */
476 public function addDisplay($microformat = FALSE) {
477 $fields = [
478 // added this for CRM 1200
479 'address_id' => $this->id,
480 // CRM-4003
481 'address_name' => str_replace('\ 1', ' ', $this->name),
482 'street_address' => $this->street_address,
483 'supplemental_address_1' => $this->supplemental_address_1,
484 'supplemental_address_2' => $this->supplemental_address_2,
485 'supplemental_address_3' => $this->supplemental_address_3,
486 'city' => $this->city,
487 'state_province_name' => $this->state_name ?? "",
488 'state_province' => $this->state ?? "",
489 'postal_code' => $this->postal_code ?? "",
490 'postal_code_suffix' => $this->postal_code_suffix ?? "",
491 'country' => $this->country ?? "",
492 'world_region' => $this->world_region ?? "",
493 ];
494
495 if (isset($this->county_id) && $this->county_id) {
496 $fields['county'] = CRM_Core_PseudoConstant::county($this->county_id);
497 }
498 else {
499 $fields['county'] = NULL;
500 }
501
502 $this->display = CRM_Utils_Address::format($fields, NULL, $microformat);
503 $this->display_text = CRM_Utils_Address::format($fields);
504 }
505
506 /**
507 * Get all the addresses for a specified contact_id, with the primary address being first
508 *
509 * @param int $id
510 * The contact id.
511 *
512 * @param bool $updateBlankLocInfo
513 *
514 * @return array
515 * the array of adrress data
516 */
517 public static function allAddress($id, $updateBlankLocInfo = FALSE) {
518 if (!$id) {
519 return NULL;
520 }
521
522 $query = "
523 SELECT civicrm_address.id as address_id, civicrm_address.location_type_id as location_type_id
524 FROM civicrm_contact, civicrm_address
525 WHERE civicrm_address.contact_id = civicrm_contact.id AND civicrm_contact.id = %1
526 ORDER BY civicrm_address.is_primary DESC, address_id ASC";
527 $params = [1 => [$id, 'Integer']];
528
529 $addresses = [];
530 $dao = CRM_Core_DAO::executeQuery($query, $params);
531 $count = 1;
532 while ($dao->fetch()) {
533 if ($updateBlankLocInfo) {
534 $addresses[$count++] = $dao->address_id;
535 }
536 else {
537 $addresses[$dao->location_type_id] = $dao->address_id;
538 }
539 }
540 return $addresses;
541 }
542
543 /**
544 * Get all the addresses for a specified location_block id, with the primary address being first
545 *
546 * @param array $entityElements
547 * The array containing entity_id and.
548 * entity_table name
549 *
550 * @return array
551 * the array of adrress data
552 */
553 public static function allEntityAddress(&$entityElements) {
554 $addresses = [];
555 if (empty($entityElements)) {
556 return $addresses;
557 }
558
559 $entityId = $entityElements['entity_id'];
560 $entityTable = $entityElements['entity_table'];
561
562 $sql = "
563 SELECT civicrm_address.id as address_id
564 FROM civicrm_loc_block loc, civicrm_location_type ltype, civicrm_address, {$entityTable} ev
565 WHERE ev.id = %1
566 AND loc.id = ev.loc_block_id
567 AND civicrm_address.id IN (loc.address_id, loc.address_2_id)
568 AND ltype.id = civicrm_address.location_type_id
569 ORDER BY civicrm_address.is_primary DESC, civicrm_address.location_type_id DESC, address_id ASC ";
570
571 $params = [1 => [$entityId, 'Integer']];
572 $dao = CRM_Core_DAO::executeQuery($sql, $params);
573 $locationCount = 1;
574 while ($dao->fetch()) {
575 $addresses[$locationCount] = $dao->address_id;
576 $locationCount++;
577 }
578 return $addresses;
579 }
580
581 /**
582 * Get address sequence.
583 *
584 * @return array
585 * Array of address sequence.
586 */
587 public static function addressSequence() {
588 $addressSequence = CRM_Utils_Address::sequence(\Civi::settings()->get('address_format'));
589
590 $countryState = $cityPostal = FALSE;
591 foreach ($addressSequence as $key => $field) {
592 if (
593 in_array($field, ['country', 'state_province']) &&
594 !$countryState
595 ) {
596 $countryState = TRUE;
597 $addressSequence[$key] = 'country_state_province';
598 }
599 elseif (
600 in_array($field, ['city', 'postal_code']) &&
601 !$cityPostal
602 ) {
603 $cityPostal = TRUE;
604 $addressSequence[$key] = 'city_postal_code';
605 }
606 elseif (
607 in_array($field, ['country', 'state_province', 'city', 'postal_code'])
608 ) {
609 unset($addressSequence[$key]);
610 }
611 }
612
613 return $addressSequence;
614 }
615
616 /**
617 * Parse given street address string in to street_name,
618 * street_unit, street_number and street_number_suffix
619 * eg "54A Excelsior Ave. Apt 1C", or "917 1/2 Elm Street"
620 *
621 * NB: civic street formats for en_CA and fr_CA used by default if those locales are active
622 * otherwise en_US format is default action
623 *
624 * @param string $streetAddress
625 * Street address including number and apt.
626 * @param string $locale
627 * Locale used to parse address.
628 *
629 * @return array
630 * parsed fields values.
631 */
632 public static function parseStreetAddress($streetAddress, $locale = NULL) {
633 // use 'en_US' for address parsing if the requested locale is not supported.
634 if (!self::isSupportedParsingLocale($locale)) {
635 $locale = 'en_US';
636 }
637
638 $emptyParseFields = $parseFields = [
639 'street_name' => '',
640 'street_unit' => '',
641 'street_number' => '',
642 'street_number_suffix' => '',
643 ];
644
645 if (empty($streetAddress)) {
646 return $parseFields;
647 }
648
649 $streetAddress = trim($streetAddress);
650
651 $matches = [];
652 if (in_array($locale, ['en_CA', 'fr_CA'])
653 && preg_match('/^([A-Za-z0-9]+)[ ]*\-[ ]*/', $streetAddress, $matches)
654 ) {
655 $parseFields['street_unit'] = $matches[1];
656 // unset from rest of street address
657 $streetAddress = preg_replace('/^([A-Za-z0-9]+)[ ]*\-[ ]*/', '', $streetAddress);
658 }
659
660 // get street number and suffix.
661 $matches = [];
662 //alter street number/suffix handling so that we accept -digit
663 if (preg_match('/^[A-Za-z0-9]+([\S]+)/', $streetAddress, $matches)) {
664 // check that $matches[0] is numeric, else assume no street number
665 if (preg_match('/^(\d+)/', $matches[0])) {
666 $streetNumAndSuffix = $matches[0];
667
668 // get street number.
669 $matches = [];
670 if (preg_match('/^(\d+)/', $streetNumAndSuffix, $matches)) {
671 $parseFields['street_number'] = $matches[0];
672 $suffix = preg_replace('/^(\d+)/', '', $streetNumAndSuffix);
673 $parseFields['street_number_suffix'] = trim($suffix);
674 }
675
676 // unset from main street address.
677 $streetAddress = preg_replace('/^[A-Za-z0-9]+([\S]+)/', '', $streetAddress);
678 $streetAddress = trim($streetAddress);
679 }
680 }
681 elseif (preg_match('/^(\d+)/', $streetAddress, $matches)) {
682 $parseFields['street_number'] = $matches[0];
683 // unset from main street address.
684 $streetAddress = preg_replace('/^(\d+)/', '', $streetAddress);
685 $streetAddress = trim($streetAddress);
686 }
687
688 // If street number is too large, we cannot store it.
689 if ($parseFields['street_number'] > CRM_Utils_Type::INT_MAX) {
690 return $emptyParseFields;
691 }
692
693 // suffix might be like 1/2
694 $matches = [];
695 if (preg_match('/^\d\/\d/', $streetAddress, $matches)) {
696 $parseFields['street_number_suffix'] .= $matches[0];
697
698 // unset from main street address.
699 $streetAddress = preg_replace('/^\d+\/\d+/', '', $streetAddress);
700 $streetAddress = trim($streetAddress);
701 }
702
703 // now get the street unit.
704 // supportable street unit formats.
705 $streetUnitFormats = [
706 'APT',
707 'APARTMENT',
708 'BSMT',
709 'BASEMENT',
710 'BLDG',
711 'BUILDING',
712 'DEPT',
713 'DEPARTMENT',
714 'FL',
715 'FLOOR',
716 'FRNT',
717 'FRONT',
718 'HNGR',
719 'HANGER',
720 'LBBY',
721 'LOBBY',
722 'LOWR',
723 'LOWER',
724 'OFC',
725 'OFFICE',
726 'PH',
727 'PENTHOUSE',
728 'TRLR',
729 'TRAILER',
730 'UPPR',
731 'RM',
732 'ROOM',
733 'SIDE',
734 'SLIP',
735 'KEY',
736 'LOT',
737 'PIER',
738 'REAR',
739 'SPC',
740 'SPACE',
741 'STOP',
742 'STE',
743 'SUITE',
744 'UNIT',
745 '#',
746 ];
747
748 // overwriting $streetUnitFormats for 'en_CA' and 'fr_CA' locale
749 if (in_array($locale, [
750 'en_CA',
751 'fr_CA',
752 ])) {
753 $streetUnitFormats = ['APT', 'APP', 'SUITE', 'BUREAU', 'UNIT'];
754 }
755 //@todo per CRM-14459 this regex picks up words with the string in them - e.g APT picks up
756 //Captain - presuming fixing regex (& adding test) to ensure a-z does not preced string will fix
757 $streetUnitPreg = '/(' . implode('|\s', $streetUnitFormats) . ')(.+)?/i';
758 $matches = [];
759 if (preg_match($streetUnitPreg, $streetAddress, $matches)) {
760 $parseFields['street_unit'] = trim($matches[0]);
761 $streetAddress = str_replace($matches[0], '', $streetAddress);
762 $streetAddress = trim($streetAddress);
763 }
764
765 // consider remaining string as street name.
766 $parseFields['street_name'] = $streetAddress;
767
768 //run parsed fields through stripSpaces to clean
769 foreach ($parseFields as $parseField => $value) {
770 $parseFields[$parseField] = CRM_Utils_String::stripSpaces($value);
771 }
772 //CRM-14459 if the field is too long we should assume it didn't get it right & skip rather than allow
773 // the DB to fatal
774 $fields = CRM_Core_BAO_Address::fields();
775 foreach ($fields as $fieldname => $field) {
776 if (!empty($field['maxlength']) && strlen(CRM_Utils_Array::value($fieldname, $parseFields)) > $field['maxlength']) {
777 return $emptyParseFields;
778 }
779 }
780
781 return $parseFields;
782 }
783
784 /**
785 * Determines if the specified locale is
786 * supported by address parsing.
787 * If no locale is specified then it
788 * will check the default configured locale.
789 *
790 * locales supported include:
791 * en_US - http://pe.usps.com/cpim/ftp/pubs/pub28/pub28.pdf
792 * en_CA - http://www.canadapost.ca/tools/pg/manual/PGaddress-e.asp
793 * fr_CA - http://www.canadapost.ca/tools/pg/manual/PGaddress-f.asp
794 * NB: common use of comma after street number also supported
795 *
796 * @param string $locale
797 * The locale to be checked
798 *
799 * @return bool
800 */
801 public static function isSupportedParsingLocale($locale = NULL) {
802 if (!$locale) {
803 $config = CRM_Core_Config::singleton();
804 $locale = $config->lcMessages;
805 }
806
807 $parsingSupportedLocales = ['en_US', 'en_CA', 'fr_CA'];
808
809 if (in_array($locale, $parsingSupportedLocales)) {
810 return TRUE;
811 }
812
813 return FALSE;
814 }
815
816 /**
817 * Validate the address fields based on the address options enabled.
818 * in the Address Settings
819 *
820 * @param array $fields
821 * An array of importable/exportable contact fields.
822 *
823 * @return array
824 * an array of contact fields and only the enabled address options
825 */
826 public static function validateAddressOptions($fields) {
827 static $addressOptions = NULL;
828 if (!$addressOptions) {
829 $addressOptions = CRM_Core_BAO_Setting::valueOptions(
830 CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME,
831 'address_options'
832 );
833 }
834
835 if (is_array($fields) && !empty($fields)) {
836 foreach ($addressOptions as $key => $value) {
837 if (!$value && isset($fields[$key])) {
838 unset($fields[$key]);
839 }
840 }
841 }
842 return $fields;
843 }
844
845 /**
846 * Check if current address is used by any other contacts.
847 *
848 * @param int $addressId
849 * Address id.
850 *
851 * @return int
852 * count of contacts that use this shared address
853 */
854 public static function checkContactSharedAddress($addressId) {
855 $query = 'SELECT count(id) FROM civicrm_address WHERE master_id = %1';
856 return CRM_Core_DAO::singleValueQuery($query, [1 => [$addressId, 'Integer']]);
857 }
858
859 /**
860 * Check if current address fields are shared with any other address.
861 *
862 * @param array $fields
863 * Address fields in profile.
864 * @param int $contactId
865 * Contact id.
866 *
867 */
868 public static function checkContactSharedAddressFields(&$fields, $contactId) {
869 if (!$contactId || !is_array($fields) || empty($fields)) {
870 return;
871 }
872
873 $sharedLocations = [];
874
875 $query = "
876 SELECT is_primary,
877 location_type_id
878 FROM civicrm_address
879 WHERE contact_id = %1
880 AND master_id IS NOT NULL";
881
882 $dao = CRM_Core_DAO::executeQuery($query, [1 => [$contactId, 'Positive']]);
883 while ($dao->fetch()) {
884 $sharedLocations[$dao->location_type_id] = $dao->location_type_id;
885 if ($dao->is_primary) {
886 $sharedLocations['Primary'] = 'Primary';
887 }
888 }
889
890 //no need to process further.
891 if (empty($sharedLocations)) {
892 return;
893 }
894
895 $addressFields = [
896 'city',
897 'county',
898 'country',
899 'geo_code_1',
900 'geo_code_2',
901 'postal_code',
902 'address_name',
903 'state_province',
904 'street_address',
905 'postal_code_suffix',
906 'supplemental_address_1',
907 'supplemental_address_2',
908 'supplemental_address_3',
909 ];
910
911 foreach ($fields as $name => & $values) {
912 if (!is_array($values) || empty($values)) {
913 continue;
914 }
915
916 $nameVal = explode('-', $values['name']);
917 $fldName = $nameVal[0] ?? NULL;
918 $locType = $nameVal[1] ?? NULL;
919 if (!empty($values['location_type_id'])) {
920 $locType = $values['location_type_id'];
921 }
922
923 if (in_array($fldName, $addressFields) &&
924 in_array($locType, $sharedLocations)
925 ) {
926 $values['is_shared'] = TRUE;
927 }
928 }
929 }
930
931 /**
932 * Fix the shared address if address is already shared
933 * or if address will be shared with itself.
934 *
935 * @param array $params
936 * Associated array of address params.
937 */
938 public static function fixSharedAddress(&$params) {
939 // if address master address is shared, use its master (prevent chaining 1) CRM-21214
940 $masterMasterId = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_Address', $params['master_id'], 'master_id');
941 if ($masterMasterId > 0) {
942 $params['master_id'] = $masterMasterId;
943 }
944
945 // prevent an endless chain between two shared addresses (prevent chaining 3) CRM-21214
946 if (CRM_Utils_Array::value('id', $params) == $params['master_id']) {
947 $params['master_id'] = NULL;
948 CRM_Core_Session::setStatus(ts("You can't connect an address to itself"), '', 'warning');
949 }
950 }
951
952 /**
953 * Update the shared addresses if master address is modified.
954 *
955 * @param int $addressId
956 * Address id.
957 * @param array $params
958 * Associated array of address params.
959 * @param string $parentOperation Operation being taken on the parent entity.
960 */
961 public static function processSharedAddress($addressId, $params, $parentOperation = NULL) {
962 $query = 'SELECT id, contact_id FROM civicrm_address WHERE master_id = %1';
963 $dao = CRM_Core_DAO::executeQuery($query, [1 => [$addressId, 'Integer']]);
964
965 // Default to TRUE if not set to maintain api backward compatibility.
966 $createRelationship = $params['add_relationship'] ?? TRUE;
967
968 // unset contact id
969 $skipFields = ['is_primary', 'location_type_id', 'is_billing', 'contact_id'];
970 if (isset($params['master_id']) && !CRM_Utils_System::isNull($params['master_id'])) {
971 if ($createRelationship) {
972 // call the function to create a relationship for the new shared address
973 self::processSharedAddressRelationship($params['master_id'], $params['contact_id']);
974 }
975 }
976 else {
977 // else no new shares will be created, only update shared addresses
978 $skipFields[] = 'master_id';
979 }
980 foreach ($skipFields as $value) {
981 unset($params[$value]);
982 }
983
984 $addressDAO = new CRM_Core_DAO_Address();
985 while ($dao->fetch()) {
986 // call the function to update the relationship
987 if ($createRelationship && isset($params['master_id']) && !CRM_Utils_System::isNull($params['master_id'])) {
988 self::processSharedAddressRelationship($params['master_id'], $dao->contact_id);
989 }
990 $addressDAO->copyValues($params);
991 $addressDAO->id = $dao->id;
992 $addressDAO->save();
993 $addressDAO->copyCustomFields($addressId, $addressDAO->id, $parentOperation);
994 }
995 }
996
997 /**
998 * Merge contacts with the Same address to get one shared label.
999 * @param array $rows
1000 * Array[contact_id][contactDetails].
1001 */
1002 public static function mergeSameAddress(&$rows) {
1003 $uniqueAddress = [];
1004 foreach (array_keys($rows) as $rowID) {
1005 // load complete address as array key
1006 $address = trim($rows[$rowID]['street_address'])
1007 . trim($rows[$rowID]['city'])
1008 . trim($rows[$rowID]['state_province'])
1009 . trim($rows[$rowID]['postal_code'])
1010 . trim($rows[$rowID]['country']);
1011 if (isset($rows[$rowID]['last_name'])) {
1012 $name = $rows[$rowID]['last_name'];
1013 }
1014 else {
1015 $name = $rows[$rowID]['display_name'];
1016 }
1017
1018 // CRM-15120
1019 $formatted = [
1020 'first_name' => $rows[$rowID]['first_name'],
1021 'individual_prefix' => $rows[$rowID]['individual_prefix'],
1022 ];
1023 $format = Civi::settings()->get('display_name_format');
1024 $firstNameWithPrefix = CRM_Utils_Address::format($formatted, $format, FALSE, FALSE);
1025 $firstNameWithPrefix = trim($firstNameWithPrefix);
1026
1027 // fill uniqueAddress array with last/first name tree
1028 if (isset($uniqueAddress[$address])) {
1029 $uniqueAddress[$address]['names'][$name][$firstNameWithPrefix]['first_name'] = $rows[$rowID]['first_name'];
1030 $uniqueAddress[$address]['names'][$name][$firstNameWithPrefix]['addressee_display'] = $rows[$rowID]['addressee_display'];
1031 // drop unnecessary rows
1032 unset($rows[$rowID]);
1033 // this is the first listing at this address
1034 }
1035 else {
1036 $uniqueAddress[$address]['ID'] = $rowID;
1037 $uniqueAddress[$address]['names'][$name][$firstNameWithPrefix]['first_name'] = $rows[$rowID]['first_name'];
1038 $uniqueAddress[$address]['names'][$name][$firstNameWithPrefix]['addressee_display'] = $rows[$rowID]['addressee_display'];
1039 }
1040 }
1041 foreach ($uniqueAddress as $address => $data) {
1042 // copy data back to $rows
1043 $count = 0;
1044 // one last name list per row
1045 foreach ($data['names'] as $last_name => $first_names) {
1046 // too many to list
1047 if ($count > 2) {
1048 break;
1049 }
1050 if (count($first_names) == 1) {
1051 $family = $first_names[current(array_keys($first_names))]['addressee_display'];
1052 }
1053 else {
1054 // collapse the tree to summarize
1055 $family = trim(implode(" & ", array_keys($first_names)) . " " . $last_name);
1056 }
1057 if ($count) {
1058 $processedNames .= "\n" . $family;
1059 }
1060 else {
1061 // build display_name string
1062 $processedNames = $family;
1063 }
1064 $count++;
1065 }
1066 $rows[$data['ID']]['addressee'] = $rows[$data['ID']]['addressee_display'] = $rows[$data['ID']]['display_name'] = $processedNames;
1067 }
1068 }
1069
1070 /**
1071 * Create relationship between contacts who share an address.
1072 *
1073 * Note that currently we create relationship between
1074 * Individual + Household and Individual + Organization
1075 *
1076 * @param int $masterAddressId
1077 * Master address id.
1078 * @param int $currentContactId
1079 * Current contact id.
1080 */
1081 public static function processSharedAddressRelationship($masterAddressId, $currentContactId) {
1082 // get the contact type of contact being edited / created
1083 $currentContactType = CRM_Contact_BAO_Contact::getContactType($currentContactId);
1084
1085 // if current contact is not of type individual return
1086 if ($currentContactType != 'Individual') {
1087 return;
1088 }
1089
1090 // get the contact id and contact type of shared contact
1091 // check the contact type of shared contact, return if it is of type Individual
1092 $query = 'SELECT cc.id, cc.contact_type
1093 FROM civicrm_contact cc INNER JOIN civicrm_address ca ON cc.id = ca.contact_id
1094 WHERE ca.id = %1';
1095
1096 $dao = CRM_Core_DAO::executeQuery($query, [1 => [$masterAddressId, 'Integer']]);
1097 $dao->fetch();
1098
1099 // master address contact needs to be Household or Organization, otherwise return
1100 if ($dao->contact_type == 'Individual') {
1101 return;
1102 }
1103 $sharedContactType = $dao->contact_type;
1104 $sharedContactId = $dao->id;
1105
1106 // create relationship between ontacts who share an address
1107 if ($sharedContactType == 'Organization') {
1108 return CRM_Contact_BAO_Contact_Utils::createCurrentEmployerRelationship($currentContactId, $sharedContactId);
1109 }
1110
1111 // get the relationship type id of "Household Member of"
1112 $relTypeId = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_RelationshipType', 'Household Member of', 'id', 'name_a_b');
1113
1114 if (!$relTypeId) {
1115 throw new CRM_Core_Exception(ts("You seem to have deleted the relationship type 'Household Member of'"));
1116 }
1117
1118 $relParam = [
1119 'is_active' => TRUE,
1120 'relationship_type_id' => $relTypeId,
1121 'contact_id_a' => $currentContactId,
1122 'contact_id_b' => $sharedContactId,
1123 ];
1124
1125 // If already there is a relationship record of $relParam criteria, avoid creating relationship again or else
1126 // it will casue CRM-16588 as the Duplicate Relationship Exception will revert other contact field values on update
1127 if (CRM_Contact_BAO_Relationship::checkDuplicateRelationship($relParam, $currentContactId, $sharedContactId)) {
1128 return;
1129 }
1130
1131 try {
1132 // create relationship
1133 civicrm_api3('relationship', 'create', $relParam);
1134 }
1135 catch (CiviCRM_API3_Exception $e) {
1136 // We catch and ignore here because this has historically been a best-effort relationship create call.
1137 // presumably it could refuse due to duplication or similar and we would ignore that.
1138 }
1139 }
1140
1141 /**
1142 * Check and set the status for shared address delete.
1143 *
1144 * @param int $addressId
1145 * Address id.
1146 * @param int $contactId
1147 * Contact id.
1148 * @param bool $returnStatus
1149 * By default false.
1150 *
1151 * @return string
1152 */
1153 public static function setSharedAddressDeleteStatus($addressId = NULL, $contactId = NULL, $returnStatus = FALSE) {
1154 // check if address that is being deleted has any shared
1155 if ($addressId) {
1156 $entityId = $addressId;
1157 $query = 'SELECT cc.id, cc.display_name
1158 FROM civicrm_contact cc INNER JOIN civicrm_address ca ON cc.id = ca.contact_id
1159 WHERE ca.master_id = %1';
1160 }
1161 else {
1162 $entityId = $contactId;
1163 $query = 'SELECT cc.id, cc.display_name
1164 FROM civicrm_address ca1
1165 INNER JOIN civicrm_address ca2 ON ca1.id = ca2.master_id
1166 INNER JOIN civicrm_contact cc ON ca2.contact_id = cc.id
1167 WHERE ca1.contact_id = %1';
1168 }
1169
1170 $dao = CRM_Core_DAO::executeQuery($query, [1 => [$entityId, 'Integer']]);
1171
1172 $deleteStatus = [];
1173 $sharedContactList = [];
1174 $statusMessage = NULL;
1175 $addressCount = 0;
1176 while ($dao->fetch()) {
1177 if (empty($deleteStatus)) {
1178 $deleteStatus[] = ts('The following contact(s) have address records which were shared with the address you removed from this contact. These address records are no longer shared - but they have not been removed or altered.');
1179 }
1180
1181 $contactViewUrl = CRM_Utils_System::url('civicrm/contact/view', "reset=1&cid={$dao->id}");
1182 $sharedContactList[] = "<a href='{$contactViewUrl}'>{$dao->display_name}</a>";
1183 $deleteStatus[] = "<a href='{$contactViewUrl}'>{$dao->display_name}</a>";
1184
1185 $addressCount++;
1186 }
1187
1188 if (!empty($deleteStatus)) {
1189 $statusMessage = implode('<br/>', $deleteStatus) . '<br/>';
1190 }
1191
1192 if (!$returnStatus) {
1193 CRM_Core_Session::setStatus($statusMessage, '', 'info');
1194 }
1195 else {
1196 return [
1197 'contactList' => $sharedContactList,
1198 'count' => $addressCount,
1199 ];
1200 }
1201 }
1202
1203 /**
1204 * Call common delete function.
1205 *
1206 * @see \CRM_Contact_BAO_Contact::on_hook_civicrm_post
1207 *
1208 * @param int $id
1209 * @deprecated
1210 * @return bool
1211 */
1212 public static function del($id) {
1213 return (bool) self::deleteRecord(['id' => $id]);
1214 }
1215
1216 /**
1217 * Get options for a given address field.
1218 * @see CRM_Core_DAO::buildOptions
1219 *
1220 * TODO: Should we always assume chainselect? What fn should be responsible for controlling that flow?
1221 * TODO: In context of chainselect, what to return if e.g. a country has no states?
1222 *
1223 * @param string $fieldName
1224 * @param string $context
1225 * @see CRM_Core_DAO::buildOptionsContext
1226 * @param array $props
1227 * whatever is known about this dao object.
1228 *
1229 * @return array|bool
1230 */
1231 public static function buildOptions($fieldName, $context = NULL, $props = []) {
1232 $params = [];
1233 // Special logic for fields whose options depend on context or properties
1234 switch ($fieldName) {
1235 // Filter state_province list based on chosen country or site defaults
1236 case 'state_province_id':
1237 case 'state_province_name':
1238 case 'state_province':
1239 // change $fieldName to DB specific names.
1240 $fieldName = 'state_province_id';
1241 if (empty($props['country_id']) && $context !== 'validate') {
1242 $config = CRM_Core_Config::singleton();
1243 if (!empty($config->provinceLimit)) {
1244 $props['country_id'] = $config->provinceLimit;
1245 }
1246 else {
1247 $props['country_id'] = $config->defaultContactCountry;
1248 }
1249 }
1250 if (!empty($props['country_id'])) {
1251 if (!CRM_Utils_Rule::commaSeparatedIntegers(implode(',', (array) $props['country_id']))) {
1252 throw new CRM_Core_Exception(ts('Province limit or default country setting is incorrect'));
1253 }
1254 $params['condition'] = 'country_id IN (' . implode(',', (array) $props['country_id']) . ')';
1255 }
1256 break;
1257
1258 // Filter country list based on site defaults
1259 case 'country_id':
1260 case 'country':
1261 // change $fieldName to DB specific names.
1262 $fieldName = 'country_id';
1263 if ($context != 'get' && $context != 'validate') {
1264 $config = CRM_Core_Config::singleton();
1265 if (!empty($config->countryLimit) && is_array($config->countryLimit)) {
1266 if (!CRM_Utils_Rule::commaSeparatedIntegers(implode(',', $config->countryLimit))) {
1267 throw new CRM_Core_Exception(ts('Available Country setting is incorrect'));
1268 }
1269 $params['condition'] = 'id IN (' . implode(',', $config->countryLimit) . ')';
1270 }
1271 }
1272 break;
1273
1274 // Filter county list based on chosen state
1275 case 'county_id':
1276 if (!empty($props['state_province_id'])) {
1277 if (!CRM_Utils_Rule::commaSeparatedIntegers(implode(',', (array) $props['state_province_id']))) {
1278 throw new CRM_Core_Exception(ts('Can only accept Integers for state_province_id filtering'));
1279 }
1280 $params['condition'] = 'state_province_id IN (' . implode(',', (array) $props['state_province_id']) . ')';
1281 }
1282 break;
1283
1284 // Not a real field in this entity
1285 case 'world_region':
1286 case 'worldregion':
1287 case 'worldregion_id':
1288 return CRM_Core_BAO_Country::buildOptions('region_id', $context, $props);
1289 }
1290 return CRM_Core_PseudoConstant::get(__CLASS__, $fieldName, $params, $context);
1291 }
1292
1293 /**
1294 * Add data from the configured geocoding provider.
1295 *
1296 * Generally this means latitude & longitude data.
1297 *
1298 * @param array $params
1299 * @return bool
1300 * TRUE if params could be passed to a provider, else FALSE.
1301 */
1302 public static function addGeocoderData(&$params) {
1303 try {
1304 $provider = CRM_Utils_GeocodeProvider::getConfiguredProvider();
1305 $providerExists = TRUE;
1306 }
1307 catch (CRM_Core_Exception $e) {
1308 $providerExists = FALSE;
1309 }
1310 if ($providerExists) {
1311 $provider::format($params);
1312 }
1313 // core#2379 - Limit geocode length to 14 characters to avoid validation error on save in UI.
1314 foreach (['geo_code_1', 'geo_code_2'] as $geocode) {
1315 if ($params[$geocode] ?? FALSE) {
1316 // ensure that if the geocoding provider (Google, OSM etc) has returned the string 'null' because they can't geocode, ensure that contacts are not placed on null island 0,0
1317 if ($params[$geocode] !== 'null') {
1318 $params[$geocode] = (float) substr($params[$geocode], 0, 14);
1319 }
1320 }
1321 }
1322 return $providerExists;
1323 }
1324
1325 /**
1326 * Create multiple addresses using legacy methodology.
1327 *
1328 * @param array $params
1329 * @param bool $fixAddress
1330 *
1331 * @return array|null
1332 */
1333 public static function legacyCreate(array $params, bool $fixAddress) {
1334 if (!isset($params['address']) || !is_array($params['address'])) {
1335 return NULL;
1336 }
1337 CRM_Core_BAO_Block::sortPrimaryFirst($params['address']);
1338 $contactId = NULL;
1339
1340 $updateBlankLocInfo = CRM_Utils_Array::value('updateBlankLocInfo', $params, FALSE);
1341 $contactId = $params['contact_id'];
1342 //get all the addresses for this contact
1343 $addresses = self::allAddress($contactId);
1344
1345 $isPrimary = $isBilling = TRUE;
1346 $blocks = [];
1347 foreach ($params['address'] as $key => $value) {
1348 if (!is_array($value)) {
1349 continue;
1350 }
1351
1352 $addressExists = self::dataExists($value);
1353 if (empty($value['id'])) {
1354 if (!empty($addresses) && !empty($value['location_type_id']) && array_key_exists($value['location_type_id'], $addresses)) {
1355 $value['id'] = $addresses[$value['location_type_id']];
1356 }
1357 }
1358
1359 // Note there could be cases when address info already exist ($value[id] is set) for a contact/entity
1360 // BUT info is not present at this time, and therefore we should be really careful when deleting the block.
1361 // $updateBlankLocInfo will help take appropriate decision. CRM-5969
1362 if (isset($value['id']) && !$addressExists && $updateBlankLocInfo) {
1363 //delete the existing record
1364 CRM_Core_BAO_Block::blockDelete('Address', ['id' => $value['id']]);
1365 continue;
1366 }
1367 elseif (!$addressExists) {
1368 continue;
1369 }
1370
1371 if ($isPrimary && !empty($value['is_primary'])) {
1372 $isPrimary = FALSE;
1373 }
1374 else {
1375 $value['is_primary'] = 0;
1376 }
1377
1378 if ($isBilling && !empty($value['is_billing'])) {
1379 $isBilling = FALSE;
1380 }
1381 else {
1382 $value['is_billing'] = 0;
1383 }
1384
1385 if (empty($value['manual_geo_code'])) {
1386 $value['manual_geo_code'] = 0;
1387 }
1388 $value['contact_id'] = $contactId;
1389 $blocks[] = self::add($value, $fixAddress);
1390 }
1391 return $blocks;
1392 }
1393
1394 }