Fix Record duplicates multiple contacts contact import exception.
[civicrm-core.git] / CRM / Contact / Import / Parser / Contact.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
bc77d7c0 4 | Copyright CiviCRM LLC. All rights reserved. |
6a488035 5 | |
bc77d7c0
TO
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 |
6a488035 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
cdfa6649 11
12use Civi\Api4\Contact;
34f3f22a 13use Civi\Api4\RelationshipType;
018c9e26 14use Civi\Api4\StateProvince;
cdfa6649 15
10438107 16require_once 'api/v3/utils.php';
17
6a488035
TO
18/**
19 *
20 * @package CRM
ca5cec67 21 * @copyright CiviCRM LLC https://civicrm.org/licensing
6a488035
TO
22 */
23
6a488035
TO
24/**
25 * class to parse contact csv files
26 */
68f3bda5 27class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
91b4c63e 28
29 use CRM_Contact_Import_MetadataTrait;
30
44edf1fc 31 protected $_mapperKeys = [];
5ebaab5d 32 protected $_allExternalIdentifiers = [];
6a488035
TO
33
34 /**
ceb10dc7 35 * Array of successfully imported contact id's
6a488035 36 *
69078420 37 * @var array
6a488035 38 */
1a4443b0 39 protected $_newContacts = [];
6a488035
TO
40
41 /**
fe482240 42 * Line count id.
6a488035
TO
43 *
44 * @var int
45 */
46 protected $_lineCount;
47
68f3bda5
EM
48 protected $_tableName;
49
50 /**
51 * Total number of lines in file
52 *
53 * @var int
54 */
55 protected $_rowCount;
56
68f3bda5 57 protected $fieldMetadata = [];
34f3f22a
EM
58
59 /**
60 * Relationship labels.
61 *
62 * Temporary cache of labels to reduce queries in getRelationshipLabels.
63 *
64 * @var array
65 * e.g ['5a_b' => 'Employer', '5b_a' => 'Employee']
66 */
67 protected $relationshipLabels = [];
68
f363505a
EM
69 /**
70 * Addresses that failed to parse.
71 *
72 * @var array
73 */
74 private $_unparsedStreetAddressContacts = [];
75
6a488035 76 /**
186628c3 77 * The initializer code, called before processing.
6a488035 78 */
00be9182 79 public function init() {
af05126d
EM
80 // Force re-load of user job.
81 unset($this->userJob);
52bd01f5
EM
82 $this->setFieldMetadata();
83 foreach ($this->getImportableFieldsMetadata() as $name => $field) {
84 $this->addField($name, $field['title'], CRM_Utils_Array::value('type', $field), CRM_Utils_Array::value('headerPattern', $field), CRM_Utils_Array::value('dataPattern', $field), CRM_Utils_Array::value('hasLocationType', $field));
85 }
f363505a 86 }
6a488035 87
2a4de39f
EM
88 /**
89 * Get the fields to track the import.
90 *
91 * @return array
92 */
93 public function getTrackingFields(): array {
94 return [
95 'related_contact_created' => 'INT COMMENT "Number of related contacts created"',
96 'related_contact_matched' => 'INT COMMENT "Number of related contacts found (& potentially updated)"',
97 ];
98 }
99
f363505a
EM
100 /**
101 * Is street address parsing enabled for the site.
102 */
103 protected function isParseStreetAddress() : bool {
104 return (bool) (CRM_Core_BAO_Setting::valueOptions(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'address_options')['street_address_parsing'] ?? FALSE);
6a488035
TO
105 }
106
7e56b830
EM
107 /**
108 * Is this a case where the user has opted to update existing contacts.
109 *
110 * @return bool
111 *
112 * @throws \API_Exception
113 */
114 private function isUpdateExistingContacts(): bool {
115 return in_array((int) $this->getSubmittedValue('onDuplicate'), [
116 CRM_Import_Parser::DUPLICATE_UPDATE,
117 CRM_Import_Parser::DUPLICATE_FILL,
118 ], TRUE);
119 }
120
64623d6c
EM
121 /**
122 * Did the user specify duplicates checking should be skipped, resulting in possible duplicate contacts.
123 *
124 * Note we still need to check for external_identifier as it will hard-fail
125 * if we duplicate.
126 *
127 * @return bool
128 *
129 * @throws \API_Exception
130 */
131 private function isIgnoreDuplicates(): bool {
132 return ((int) $this->getSubmittedValue('onDuplicate')) === CRM_Import_Parser::DUPLICATE_NOCHECK;
133 }
134
6a488035 135 /**
fe482240 136 * Handle the values in preview mode.
6a488035 137 *
13575591
EM
138 * Function will be deprecated in favour of validateValues.
139 *
77c5b619
TO
140 * @param array $values
141 * The array of values belonging to this line.
6a488035 142 *
7c550ca0 143 * @return bool
a6c01b45 144 * the result of this processing
06ef1cdc 145 * CRM_Import_Parser::ERROR or CRM_Import_Parser::VALID
6a488035 146 */
00be9182 147 public function preview(&$values) {
6a488035
TO
148 return $this->summary($values);
149 }
150
151 /**
fe482240 152 * Handle the values in summary mode.
6a488035 153 *
13575591
EM
154 * Function will be deprecated in favour of validateValues.
155 *
77c5b619
TO
156 * @param array $values
157 * The array of values belonging to this line.
6a488035 158 *
b0f7df9b 159 * @return int
a6c01b45 160 * the result of this processing
06ef1cdc 161 * CRM_Import_Parser::ERROR or CRM_Import_Parser::VALID
6a488035 162 */
ca275c67 163 public function summary(&$values): int {
2940ddb3 164 $rowNumber = (int) ($values[array_key_last($values)]);
b0f7df9b 165 try {
13575591 166 $this->validateValues($values);
d4c8a770 167 }
b0f7df9b
EM
168 catch (CRM_Core_Exception $e) {
169 $this->setImportStatus($rowNumber, 'ERROR', $e->getMessage());
170 array_unshift($values, $e->getMessage());
a05662ef 171 return CRM_Import_Parser::ERROR;
6a488035 172 }
ca275c67 173 $this->setImportStatus($rowNumber, 'NEW', '');
6a488035 174
a05662ef 175 return CRM_Import_Parser::VALID;
6a488035
TO
176 }
177
178 /**
fe482240 179 * Handle the values in import mode.
6a488035 180 *
77c5b619
TO
181 * @param array $values
182 * The array of values belonging to this line.
6a488035 183 *
7c550ca0 184 * @return bool
a6c01b45 185 * the result of this processing
a0c6165f 186 *
a0c6165f 187 * @throws \CRM_Core_Exception
cdfa6649 188 * @throws \API_Exception
6a488035 189 */
b75fe839 190 public function import($values) {
2940ddb3 191 $rowNumber = (int) $values[array_key_last($values)];
578e4db3 192
be2fb01f 193 $this->_unparsedStreetAddressContacts = [];
dba9288a 194 if (!$this->getSubmittedValue('doGeocodeAddress')) {
6a488035 195 // CRM-5854, reset the geocode method to null to prevent geocoding
94d2b28e 196 CRM_Utils_GeocodeProvider::disableForSession();
6a488035
TO
197 }
198
bf94a235
EM
199 try {
200 $params = $this->getMappedRow($values);
bf94a235
EM
201 $formatted = [];
202 foreach ($params as $key => $value) {
203 if ($value !== '') {
204 $formatted[$key] = $value;
205 }
639e4f37 206 }
6a488035 207
6187f042 208 [$formatted, $params] = $this->processContact($params, $formatted, TRUE);
e0b8f9a9 209
101a203c
EM
210 //format common data, CRM-4062
211 $this->formatCommonData($params, $formatted);
6a488035 212
101a203c 213 $newContact = $this->createContact($formatted, $params['id'] ?? NULL);
2a4de39f 214 $contactID = $newContact->id;
6a488035 215
101a203c
EM
216 if ($contactID) {
217 // call import hook
218 $currentImportID = end($values);
101a203c
EM
219 $hookParams = [
220 'contactID' => $contactID,
221 'importID' => $currentImportID,
222 'importTempTable' => $this->_tableName,
223 'fieldHeaders' => $this->_mapperKeys,
224 'fields' => $this->_activeFields,
225 ];
101a203c
EM
226 CRM_Utils_Hook::import('Contact', 'process', $this, $hookParams);
227 }
6a488035 228
101a203c 229 $primaryContactId = $newContact->id;
6a488035 230
60110bfa
EM
231 //relationship contact insert
232 foreach ($this->getRelatedContactsParams($params) as $key => $field) {
233 $formatting = $field;
101a203c 234 [$formatting, $field] = $this->processContact($field, $formatting, FALSE);
6a488035 235
60110bfa 236 //format common data, CRM-4062
101a203c 237 $this->formatCommonData($field, $formatting);
6a488035 238
1149a00c 239 if (empty($formatting['id']) || $this->isUpdateExistingContacts()) {
101a203c 240 $relatedNewContact = $this->createContact($formatting, $formatting['id']);
2a4de39f
EM
241 $formatting['id'] = $relatedNewContact->id;
242 }
243 if (empty($relatedContacts[$formatting['id']])) {
244 $relatedContacts[$formatting['id']] = 'new';
60110bfa 245 }
2a4de39f
EM
246
247 $this->createRelationship($key, $formatting['id'], $primaryContactId);
6a488035
TO
248 }
249 }
101a203c
EM
250 catch (CRM_Core_Exception $e) {
251 $this->setImportStatus($rowNumber, $this->getStatus($e->getErrorCode()), $e->getMessage());
252 return FALSE;
253 }
2a4de39f
EM
254 // We can probably stop catching this once https://github.com/civicrm/civicrm-core/pull/23471
255 // is merged - testImportParserWithExternalIdForRelationship will confirm....
256 catch (CiviCRM_API3_Exception $e) {
257 $this->setImportStatus($rowNumber, $this->getStatus($e->getErrorCode()), $e->getMessage());
258 return FALSE;
259 }
260 $extraFields = ['related_contact_created' => 0, 'related_contact_matched' => 0];
261 foreach ($relatedContacts as $key => $outcome) {
262 if ($outcome === 'new') {
263 $extraFields['related_contact_created']++;
264 }
265 else {
266 $extraFields['related_contact_matched']++;
267 }
268 }
269 $this->setImportStatus($rowNumber, $this->getStatus(CRM_Import_Parser::VALID), $this->getSuccessMessage(), $contactID, $extraFields);
b2c28e7f 270 return CRM_Import_Parser::VALID;
6a488035
TO
271 }
272
3b9a6946
EM
273 /**
274 * Only called from import now... plus one place outside of core & tests.
275 *
276 * @todo - deprecate more aggressively - will involve copying to the import
277 * class, adding a deprecation notice here & removing from tests.
278 *
279 * Takes an associative array and creates a relationship object.
280 *
281 * @deprecated For single creates use the api instead (it's tested).
282 * For multiple a new variant of this function needs to be written and migrated to as this is a bit
283 * nasty
284 *
285 * @param array $params
286 * (reference ) an assoc array of name/value pairs.
287 * @param array $ids
288 * The array that holds all the db ids.
289 * per http://wiki.civicrm.org/confluence/display/CRM/Database+layer
290 * "we are moving away from the $ids param "
291 *
292 * @return array
293 * @throws \CRM_Core_Exception
294 */
295 private static function legacyCreateMultiple($params, $ids = []) {
296 // clarify that the only key ever pass in the ids array is 'contact'
297 // There is legacy handling for other keys but a universe search on
298 // calls to this function (not supported to be called from outside core)
299 // only returns 2 calls - one in CRM_Contact_Import_Parser_Contact
300 // and the other in jma grant applications (CRM_Grant_Form_Grant_Confirm)
301 // both only pass in contact as a key here.
302 $contactID = $ids['contact'];
303 unset($ids);
304 // There is only ever one value passed in from the 2 places above that call
305 // this - by clarifying here like this we can cleanup within this
306 // function without having to do more universe searches.
307 $relatedContactID = key($params['contact_check']);
308
309 // check if the relationship is valid between contacts.
310 // step 1: check if the relationship is valid if not valid skip and keep the count
311 // step 2: check the if two contacts already have a relationship if yes skip and keep the count
312 // step 3: if valid relationship then add the relation and keep the count
313
314 // step 1
315 [$contactFields['relationship_type_id'], $firstLetter, $secondLetter] = explode('_', $params['relationship_type_id']);
316 $contactFields['contact_id_' . $firstLetter] = $contactID;
317 $contactFields['contact_id_' . $secondLetter] = $relatedContactID;
318 if (!CRM_Contact_BAO_Relationship::checkRelationshipType($contactFields['contact_id_a'], $contactFields['contact_id_b'],
319 $contactFields['relationship_type_id'])) {
320 return [0, 0];
321 }
322
323 if (
324 CRM_Contact_BAO_Relationship::checkDuplicateRelationship(
325 $contactFields,
326 $contactID,
327 // step 2
328 $relatedContactID
329 )
330 ) {
331 return [0, 1];
332 }
333
334 $singleInstanceParams = array_merge($params, $contactFields);
335 CRM_Contact_BAO_Relationship::add($singleInstanceParams);
336 return [1, 0];
337 }
338
54847287 339 /**
7b033be4
EM
340 * Format common params data to the format that was required a very long time ago.
341 *
342 * I think the only useful things this function does now are
343 * 1) calls fillPrimary
344 * 2) possibly the street address parsing.
345 *
346 * The other hundred lines do stuff that is done elsewhere. Custom fields
347 * should already be formatted by getTransformedValue and we don't need to
348 * re-rewrite them to a BAO style array since we call the api which does that.
349 *
350 * The call to formatLocationBlock just does the address custom fields which,
351 * are already formatted by this point.
352 *
353 * @deprecated
54847287
EM
354 *
355 * @param array $params
356 * Contain record values.
357 * @param array $formatted
358 * Array of formatted data.
54847287 359 */
7b033be4
EM
360 private function formatCommonData($params, &$formatted) {
361 // @todo - remove just about everything in this function. See docblock.
e0b8f9a9 362 $customFields = CRM_Core_BAO_CustomField::getFields($formatted['contact_type'], FALSE, FALSE, $formatted['contact_sub_type'] ?? NULL);
54847287
EM
363
364 $addressCustomFields = CRM_Core_BAO_CustomField::getFields('Address');
365 $customFields = $customFields + $addressCustomFields;
366
54847287
EM
367 //format date first
368 $session = CRM_Core_Session::singleton();
369 $dateType = $session->get("dateTypes");
370 foreach ($params as $key => $val) {
371 $customFieldID = CRM_Core_BAO_CustomField::getKeyID($key);
372 if ($customFieldID &&
373 !array_key_exists($customFieldID, $addressCustomFields)
374 ) {
375 //we should not update Date to null, CRM-4062
376 if ($val && ($customFields[$customFieldID]['data_type'] == 'Date')) {
377 //CRM-21267
4b58c5c4 378 $this->formatCustomDate($params, $formatted, $dateType, $key);
54847287
EM
379 }
380 elseif ($customFields[$customFieldID]['data_type'] == 'Boolean') {
77c96d86 381 if (empty($val) && !is_numeric($val) && $this->isFillDuplicates()) {
54847287
EM
382 //retain earlier value when Import mode is `Fill`
383 unset($params[$key]);
384 }
385 else {
386 $params[$key] = CRM_Utils_String::strtoboolstr($val);
387 }
388 }
389 }
54847287 390 }
018c9e26 391 $metadataBlocks = ['phone', 'im', 'openid', 'email', 'address'];
639e4f37
EM
392 foreach ($metadataBlocks as $block) {
393 foreach ($formatted[$block] ?? [] as $blockKey => $blockValues) {
394 if ($blockValues['location_type_id'] === 'Primary') {
395 $this->fillPrimary($formatted[$block][$blockKey], $blockValues, $block, $formatted['id'] ?? NULL);
396 }
397 }
398 }
54847287
EM
399 //now format custom data.
400 foreach ($params as $key => $field) {
639e4f37
EM
401 if (in_array($key, $metadataBlocks, TRUE)) {
402 // This location block is already fully handled at this point.
403 continue;
404 }
54847287
EM
405 if (is_array($field)) {
406 $isAddressCustomField = FALSE;
639e4f37 407
54847287
EM
408 foreach ($field as $value) {
409 $break = FALSE;
410 if (is_array($value)) {
411 foreach ($value as $name => $testForEmpty) {
412 if ($addressCustomFieldID = CRM_Core_BAO_CustomField::getKeyID($name)) {
413 $isAddressCustomField = TRUE;
414 break;
415 }
639e4f37
EM
416
417 if (($testForEmpty === '' || $testForEmpty == NULL)) {
54847287
EM
418 $break = TRUE;
419 break;
420 }
421 }
422 }
423 else {
424 $break = TRUE;
425 }
426
427 if (!$break) {
428 if (!empty($value['location_type_id'])) {
429 $this->formatLocationBlock($value, $formatted);
430 }
54847287
EM
431 }
432 }
433 if (!$isAddressCustomField) {
434 continue;
435 }
436 }
437
438 $formatValues = [
439 $key => $field,
440 ];
441
54847287
EM
442 if ($key == 'id' && isset($field)) {
443 $formatted[$key] = $field;
444 }
445 $this->formatContactParameters($formatValues, $formatted);
446
447 //Handling Custom Data
448 // note: Address custom fields will be handled separately inside formatContactParameters
449 if (($customFieldID = CRM_Core_BAO_CustomField::getKeyID($key)) &&
450 array_key_exists($customFieldID, $customFields) &&
451 !array_key_exists($customFieldID, $addressCustomFields)
452 ) {
453
454 $extends = $customFields[$customFieldID]['extends'] ?? NULL;
455 $htmlType = $customFields[$customFieldID]['html_type'] ?? NULL;
456 $dataType = $customFields[$customFieldID]['data_type'] ?? NULL;
457 $serialized = CRM_Core_BAO_CustomField::isSerialized($customFields[$customFieldID]);
458
459 if (!$serialized && in_array($htmlType, ['Select', 'Radio', 'Autocomplete-Select']) && in_array($dataType, ['String', 'Int'])) {
460 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
461 foreach ($customOption as $customValue) {
462 $val = $customValue['value'] ?? NULL;
463 $label = strtolower($customValue['label'] ?? '');
464 $value = strtolower(trim($formatted[$key]));
465 if (($value == $label) || ($value == strtolower($val))) {
466 $params[$key] = $formatted[$key] = $val;
467 }
468 }
469 }
470 elseif ($serialized && !empty($formatted[$key]) && !empty($params[$key])) {
471 $mulValues = explode(',', $formatted[$key]);
472 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
473 $formatted[$key] = [];
474 $params[$key] = [];
475 foreach ($mulValues as $v1) {
476 foreach ($customOption as $v2) {
477 if ((strtolower($v2['label']) == strtolower(trim($v1))) ||
478 (strtolower($v2['value']) == strtolower(trim($v1)))
479 ) {
480 if ($htmlType == 'CheckBox') {
481 $params[$key][$v2['value']] = $formatted[$key][$v2['value']] = 1;
482 }
483 else {
484 $params[$key][] = $formatted[$key][] = $v2['value'];
485 }
486 }
487 }
488 }
489 }
490 }
491 }
492
493 if (!empty($key) && ($customFieldID = CRM_Core_BAO_CustomField::getKeyID($key)) && array_key_exists($customFieldID, $customFields) &&
494 !array_key_exists($customFieldID, $addressCustomFields)
495 ) {
496 // @todo calling api functions directly is not supported
497 _civicrm_api3_custom_format_params($params, $formatted, $extends);
498 }
499
54847287 500 // parse street address, CRM-5450
f363505a 501 if ($this->isParseStreetAddress()) {
54847287
EM
502 if (array_key_exists('address', $formatted) && is_array($formatted['address'])) {
503 foreach ($formatted['address'] as $instance => & $address) {
504 $streetAddress = $address['street_address'] ?? NULL;
505 if (empty($streetAddress)) {
506 continue;
507 }
508 // parse address field.
509 $parsedFields = CRM_Core_BAO_Address::parseStreetAddress($streetAddress);
510
511 //street address consider to be parsed properly,
512 //If we get street_name and street_number.
513 if (empty($parsedFields['street_name']) || empty($parsedFields['street_number'])) {
514 $parsedFields = array_fill_keys(array_keys($parsedFields), '');
515 }
516
517 // merge parse address w/ main address block.
518 $address = array_merge($address, $parsedFields);
519 }
520 }
521 }
522 }
523
6a488035 524 /**
100fef9d 525 * Build error-message containing error-fields
6a488035 526 *
341c643b 527 * Once upon a time there was a dev who hadn't heard of implode. That dev wrote this function.
528 *
529 * @todo just say no!
530 *
77c5b619
TO
531 * @param string $errorName
532 * A string containing error-field name.
533 * @param string $errorMessage
534 * A string containing all the error-fields, where the new errorName is concatenated.
6a488035 535 *
6a488035 536 */
00be9182 537 public static function addToErrorMsg($errorName, &$errorMessage) {
6a488035
TO
538 if ($errorMessage) {
539 $errorMessage .= "; $errorName";
540 }
541 else {
542 $errorMessage = $errorName;
543 }
544 }
545
bf94a235
EM
546 /**
547 * @param array $params
548 *
549 * @return string|null
550 * @throws \API_Exception
551 * @throws \CRM_Core_Exception
552 * @throws \Civi\API\Exception\NotImplementedException
553 */
554 protected function validateParams(array $params): ?string {
555 $contacts = array_merge(['0' => $params], $this->getRelatedContactsParams($params));
556 $errors = [];
557 foreach ($contacts as $value) {
558 // If we are referencing a related contact, or are in update mode then we
559 // don't need all the required fields if we have enough to find an existing contact.
560 $useExistingMatchFields = !empty($value['relationship_type_id']) || $this->isUpdateExistingContacts();
561 $prefixString = !empty($value['relationship_label']) ? '(' . $value['relationship_label'] . ') ' : '';
562 $this->validateRequiredContactFields($value['contact_type'], $value, $useExistingMatchFields, $prefixString);
563
564 $errors = array_merge($errors, $this->getInvalidValuesForContact($value, $prefixString));
565 if (!empty($value['contact_sub_type']) && !CRM_Contact_BAO_ContactType::isExtendsContactType($value['contact_sub_type'], $value['contact_type'])) {
566 $errors[] = ts('Mismatched or Invalid Contact Subtype.');
567 }
568 if (!empty($value['relationship_type_id'])) {
569 $requiredSubType = $this->getRelatedContactSubType($value['relationship_type_id'], $value['relationship_direction']);
570 if ($requiredSubType && $value['contact_sub_type'] && $requiredSubType !== $value['contact_sub_type']) {
571 throw new CRM_Core_Exception($prefixString . ts('Mismatched or Invalid contact subtype found for this related contact.'));
572 }
573 }
574 }
575
576 //check for duplicate external Identifier
577 $externalID = $params['external_identifier'] ?? NULL;
578 if ($externalID) {
579 /* If it's a dupe,external Identifier */
580
581 if ($externalDupe = CRM_Utils_Array::value($externalID, $this->_allExternalIdentifiers)) {
582 $errorMessage = ts('External ID conflicts with record %1', [1 => $externalDupe]);
583 throw new CRM_Core_Exception($errorMessage);
584 }
585 //otherwise, count it and move on
586 $this->_allExternalIdentifiers[$externalID] = $this->_lineCount;
587 }
588
589 //date-format part ends
590
591 $errorMessage = implode(', ', $errors);
592
593 //checking error in core data
bf94a235
EM
594 if ($errorMessage) {
595 $tempMsg = "Invalid value for field(s) : $errorMessage";
596 throw new CRM_Core_Exception($tempMsg);
597 }
598 return $errorMessage;
599 }
600
31fdf296
EM
601 /**
602 * @param $key
603 * @param $relContactId
604 * @param $primaryContactId
605 *
606 * @throws \CRM_Core_Exception
607 * @throws \CiviCRM_API3_Exception
608 */
609 protected function createRelationship($key, $relContactId, $primaryContactId): void {
610 //if more than one duplicate contact
611 //found, create relationship with first contact
612 // now create the relationship record
613 $relationParams = [
614 'relationship_type_id' => $key,
615 'contact_check' => [
616 $relContactId => 1,
617 ],
618 'is_active' => 1,
619 'skipRecentView' => TRUE,
620 ];
621
622 // we only handle related contact success, we ignore failures for now
623 // at some point wold be nice to have related counts as separate
624 $relationIds = [
625 'contact' => $primaryContactId,
626 ];
627
f363505a 628 [$valid, $duplicate] = self::legacyCreateMultiple($relationParams, $relationIds);
31fdf296
EM
629
630 if ($valid || $duplicate) {
631 $relationIds['contactTarget'] = $relContactId;
632 $action = ($duplicate) ? CRM_Core_Action::UPDATE : CRM_Core_Action::ADD;
633 CRM_Contact_BAO_Relationship::relatedMemberships($primaryContactId, $relationParams, $relationIds, $action);
634 }
635
636 //handle current employer, CRM-3532
637 if ($valid) {
638 $allRelationships = CRM_Core_PseudoConstant::relationshipType('name');
639 $relationshipTypeId = str_replace([
640 '_a_b',
641 '_b_a',
642 ], [
643 '',
644 '',
645 ], $key);
646 $relationshipType = str_replace($relationshipTypeId . '_', '', $key);
647 $orgId = $individualId = NULL;
648 if ($allRelationships[$relationshipTypeId]["name_{$relationshipType}"] == 'Employee of') {
649 $orgId = $relContactId;
650 $individualId = $primaryContactId;
651 }
652 elseif ($allRelationships[$relationshipTypeId]["name_{$relationshipType}"] == 'Employer of') {
653 $orgId = $primaryContactId;
654 $individualId = $relContactId;
655 }
656 if ($orgId && $individualId) {
657 $currentEmpParams[$individualId] = $orgId;
658 CRM_Contact_BAO_Contact_Utils::setCurrentEmployer($currentEmpParams);
659 }
660 }
661 }
662
6a488035 663 /**
fe482240 664 * Method for creating contact.
54957108 665 *
666 * @param array $formatted
54957108 667 * @param int $contactId
54957108 668 *
07b7795e 669 * @return \CRM_Contact_BAO_Contact
eaedbd91 670 * If a duplicate is found an array is returned, otherwise CRM_Contact_BAO_Contact
6a488035 671 */
93751158 672 public function createContact(&$formatted, $contactId = NULL) {
6a488035 673
9789bc5f 674 if ($contactId) {
77c96d86 675 $this->formatParams($formatted, (int) $contactId);
9789bc5f 676 }
6a488035 677
9789bc5f 678 // Resetting and rebuilding cache could be expensive.
679 CRM_Core_Config::setPermitCacheFlushMode(FALSE);
680
681 // If a user has logged in, or accessed via a checksum
682 // Then deliberately 'blanking' a value in the profile should remove it from their record
683 // @todo this should either be TRUE or FALSE in the context of import - once
684 // we figure out which we can remove all the rest.
685 // Also note the meaning of this parameter is less than it used to
686 // be following block cleanup.
687 $formatted['updateBlankLocInfo'] = TRUE;
688 if ((CRM_Core_Session::singleton()->get('authSrc') & (CRM_Core_Permission::AUTH_SRC_CHECKSUM + CRM_Core_Permission::AUTH_SRC_LOGIN)) == 0) {
689 $formatted['updateBlankLocInfo'] = FALSE;
690 }
29f811de 691
93751158 692 $contactFields = CRM_Contact_DAO_Contact::import();
897732d6 693 [$data, $contactDetails] = $this->formatProfileContactParams($formatted, $contactFields, $contactId, $formatted['contact_type']);
9789bc5f 694
695 // manage is_opt_out
696 if (array_key_exists('is_opt_out', $contactFields) && array_key_exists('is_opt_out', $formatted)) {
697 $wasOptOut = $contactDetails['is_opt_out'] ?? FALSE;
698 $isOptOut = $formatted['is_opt_out'];
699 $data['is_opt_out'] = $isOptOut;
700 // on change, create new civicrm_subscription_history entry
701 if (($wasOptOut != $isOptOut) && !empty($contactDetails['contact_id'])) {
702 $shParams = [
703 'contact_id' => $contactDetails['contact_id'],
704 'status' => $isOptOut ? 'Removed' : 'Added',
705 'method' => 'Web',
706 ];
707 CRM_Contact_BAO_SubscriptionHistory::create($shParams);
29f811de 708 }
9789bc5f 709 }
29f811de 710
cdfa6649 711 $contact = civicrm_api3('Contact', 'create', $data);
712 $cid = $contact['id'];
29f811de 713
9789bc5f 714 CRM_Core_Config::setPermitCacheFlushMode(TRUE);
6a488035 715
9789bc5f 716 $contact = [
717 'contact_id' => $cid,
718 ];
6a488035 719
9789bc5f 720 $defaults = [];
721 $newContact = CRM_Contact_BAO_Contact::retrieve($contact, $defaults);
6a488035
TO
722
723 //get the id of the contact whose street address is not parsable, CRM-5886
f363505a 724 if ($this->isParseStreetAddress() && property_exists($newContact, 'address') && $newContact->address) {
6a488035 725 foreach ($newContact->address as $address) {
8cc574cf 726 if (!empty($address['street_address']) && (empty($address['street_number']) || empty($address['street_name']))) {
be2fb01f 727 $this->_unparsedStreetAddressContacts[] = [
6a488035
TO
728 'id' => $newContact->id,
729 'streetAddress' => $address['street_address'],
be2fb01f 730 ];
6a488035
TO
731 }
732 }
733 }
734 return $newContact;
735 }
736
b248c095
EM
737 /**
738 * Legacy format profile contact parameters.
739 *
740 * This is a formerly shared function - most of the stuff in it probably does
741 * nothing but copied here to star unravelling that...
742 *
743 * @param array $params
744 * @param array $fields
745 * @param int|null $contactID
b248c095 746 * @param string|null $ctype
b248c095
EM
747 *
748 * @return array
749 */
750 private function formatProfileContactParams(
751 &$params,
752 $fields,
753 $contactID = NULL,
897732d6 754 $ctype = NULL
b248c095
EM
755 ) {
756
757 $data = $contactDetails = [];
758
759 // get the contact details (hier)
760 if ($contactID) {
761 $details = CRM_Contact_BAO_Contact::getHierContactDetails($contactID, $fields);
762
763 $contactDetails = $details[$contactID];
764 $data['contact_type'] = $contactDetails['contact_type'] ?? NULL;
765 $data['contact_sub_type'] = $contactDetails['contact_sub_type'] ?? NULL;
766 }
767 else {
768 //we should get contact type only if contact
897732d6 769 if ($ctype) {
b248c095
EM
770 $data['contact_type'] = $ctype;
771 }
772 else {
773 $data['contact_type'] = 'Individual';
774 }
775 }
776
777 //fix contact sub type CRM-5125
778 if (array_key_exists('contact_sub_type', $params) &&
779 !empty($params['contact_sub_type'])
780 ) {
781 $data['contact_sub_type'] = CRM_Utils_Array::implodePadded($params['contact_sub_type']);
782 }
783 elseif (array_key_exists('contact_sub_type_hidden', $params) &&
784 !empty($params['contact_sub_type_hidden'])
785 ) {
786 // if profile was used, and had any subtype, we obtain it from there
787 //CRM-13596 - add to existing contact types, rather than overwriting
788 if (empty($data['contact_sub_type'])) {
789 // If we don't have a contact ID the $data['contact_sub_type'] will not be defined...
790 $data['contact_sub_type'] = CRM_Utils_Array::implodePadded($params['contact_sub_type_hidden']);
791 }
792 else {
793 $data_contact_sub_type_arr = CRM_Utils_Array::explodePadded($data['contact_sub_type']);
794 if (!in_array($params['contact_sub_type_hidden'], $data_contact_sub_type_arr)) {
795 //CRM-20517 - make sure contact_sub_type gets the correct delimiters
796 $data['contact_sub_type'] = trim($data['contact_sub_type'], CRM_Core_DAO::VALUE_SEPARATOR);
797 $data['contact_sub_type'] = CRM_Core_DAO::VALUE_SEPARATOR . $data['contact_sub_type'] . CRM_Utils_Array::implodePadded($params['contact_sub_type_hidden']);
798 }
799 }
800 }
801
802 if ($ctype == 'Organization') {
803 $data['organization_name'] = $contactDetails['organization_name'] ?? NULL;
804 }
805 elseif ($ctype == 'Household') {
806 $data['household_name'] = $contactDetails['household_name'] ?? NULL;
807 }
808
809 $locationType = [];
810 $count = 1;
811
812 if ($contactID) {
813 //add contact id
814 $data['contact_id'] = $contactID;
815 $primaryLocationType = CRM_Contact_BAO_Contact::getPrimaryLocationType($contactID);
816 }
817 else {
818 $defaultLocation = CRM_Core_BAO_LocationType::getDefault();
819 $defaultLocationId = $defaultLocation->id;
820 }
821
822 $billingLocationTypeId = CRM_Core_BAO_LocationType::getBilling();
823
b248c095 824 $multiplFields = ['url'];
b248c095 825
b248c095
EM
826 $session = CRM_Core_Session::singleton();
827 foreach ($params as $key => $value) {
828 [$fieldName, $locTypeId, $typeId] = CRM_Utils_System::explode('-', $key, 3);
829
830 if ($locTypeId == 'Primary') {
831 if ($contactID) {
639e4f37 832 $locTypeId = CRM_Contact_BAO_Contact::getPrimaryLocationType($contactID, FALSE, 'address');
b248c095
EM
833 $primaryLocationType = $locTypeId;
834 }
835 else {
836 $locTypeId = $defaultLocationId;
837 }
838 }
839
840 if (is_numeric($locTypeId) &&
841 !in_array($fieldName, $multiplFields) &&
842 substr($fieldName, 0, 7) != 'custom_'
843 ) {
844 $index = $locTypeId;
845
846 if (is_numeric($typeId)) {
847 $index .= '-' . $typeId;
848 }
849 if (!in_array($index, $locationType)) {
850 $locationType[$count] = $index;
851 $count++;
852 }
853
854 $loc = CRM_Utils_Array::key($index, $locationType);
855
4586ae70 856 $blockName = strtolower($this->getFieldEntity($fieldName));
b248c095
EM
857
858 $data[$blockName][$loc]['location_type_id'] = $locTypeId;
859
860 //set is_billing true, for location type "Billing"
861 if ($locTypeId == $billingLocationTypeId) {
862 $data[$blockName][$loc]['is_billing'] = 1;
863 }
864
865 if ($contactID) {
866 //get the primary location type
867 if ($locTypeId == $primaryLocationType) {
868 $data[$blockName][$loc]['is_primary'] = 1;
869 }
870 }
871 elseif ($locTypeId == $defaultLocationId) {
872 $data[$blockName][$loc]['is_primary'] = 1;
873 }
874
639e4f37 875 if (0) {
b248c095
EM
876 }
877 else {
878 if ($fieldName === 'state_province') {
879 // CRM-3393
880 if (is_numeric($value) && ((int ) $value) >= 1000) {
881 $data['address'][$loc]['state_province_id'] = $value;
882 }
883 elseif (empty($value)) {
884 $data['address'][$loc]['state_province_id'] = '';
885 }
886 else {
887 $data['address'][$loc]['state_province'] = $value;
888 }
889 }
24948d41
EM
890 elseif ($fieldName === 'country_id') {
891 $data['address'][$loc]['country_id'] = $value;
b248c095
EM
892 }
893 elseif ($fieldName === 'county') {
894 $data['address'][$loc]['county_id'] = $value;
895 }
896 elseif ($fieldName == 'address_name') {
897 $data['address'][$loc]['name'] = $value;
898 }
899 elseif (substr($fieldName, 0, 14) === 'address_custom') {
900 $data['address'][$loc][substr($fieldName, 8)] = $value;
901 }
902 else {
903 $data[$blockName][$loc][$fieldName] = $value;
904 }
905 }
906 }
907 else {
80e9f1a2 908 if (($customFieldId = CRM_Core_BAO_CustomField::getKeyID($key))) {
b248c095
EM
909 // for autocomplete transfer hidden value instead of label
910 if ($params[$key] && isset($params[$key . '_id'])) {
911 $value = $params[$key . '_id'];
912 }
913
914 // we need to append time with date
915 if ($params[$key] && isset($params[$key . '_time'])) {
916 $value .= ' ' . $params[$key . '_time'];
917 }
918
919 // if auth source is not checksum / login && $value is blank, do not proceed - CRM-10128
920 if (($session->get('authSrc') & (CRM_Core_Permission::AUTH_SRC_CHECKSUM + CRM_Core_Permission::AUTH_SRC_LOGIN)) == 0 &&
921 ($value == '' || !isset($value))
922 ) {
923 continue;
924 }
925
926 $valueId = NULL;
b248c095
EM
927
928 //CRM-13596 - check for contact_sub_type_hidden first
929 if (array_key_exists('contact_sub_type_hidden', $params)) {
930 $type = $params['contact_sub_type_hidden'];
931 }
932 else {
933 $type = $data['contact_type'];
934 if (!empty($data['contact_sub_type'])) {
935 $type = CRM_Utils_Array::explodePadded($data['contact_sub_type']);
936 }
937 }
938
939 CRM_Core_BAO_CustomField::formatCustomField($customFieldId,
940 $data['custom'],
941 $value,
942 $type,
943 $valueId,
944 $contactID,
945 FALSE,
946 FALSE
947 );
948 }
949 elseif ($key === 'edit') {
950 continue;
951 }
952 else {
953 if ($key === 'location') {
954 foreach ($value as $locationTypeId => $field) {
955 foreach ($field as $block => $val) {
956 if ($block === 'address' && array_key_exists('address_name', $val)) {
957 $value[$locationTypeId][$block]['name'] = $value[$locationTypeId][$block]['address_name'];
958 }
959 }
960 }
961 }
639e4f37 962 if (in_array($key, ['nick_name', 'job_title', 'middle_name', 'birth_date', 'gender_id', 'current_employer', 'prefix_id', 'suffix_id'])
b248c095
EM
963 && ($value == '' || !isset($value)) &&
964 ($session->get('authSrc') & (CRM_Core_Permission::AUTH_SRC_CHECKSUM + CRM_Core_Permission::AUTH_SRC_LOGIN)) == 0 ||
965 ($key === 'current_employer' && empty($params['current_employer']))) {
966 // CRM-10128: if auth source is not checksum / login && $value is blank, do not fill $data with empty value
967 // to avoid update with empty values
968 continue;
969 }
970 else {
971 $data[$key] = $value;
972 }
973 }
974 }
975 }
976
977 if (!isset($data['contact_type'])) {
978 $data['contact_type'] = 'Individual';
979 }
980
981 //set the values for checkboxes (do_not_email, do_not_mail, do_not_trade, do_not_phone)
982 $privacy = CRM_Core_SelectValues::privacy();
983 foreach ($privacy as $key => $value) {
984 if (array_key_exists($key, $fields)) {
985 // do not reset values for existing contacts, if fields are added to a profile
986 if (array_key_exists($key, $params)) {
987 $data[$key] = $params[$key];
988 if (empty($params[$key])) {
989 $data[$key] = 0;
990 }
991 }
992 elseif (!$contactID) {
993 $data[$key] = 0;
994 }
995 }
996 }
997
998 return [$data, $contactDetails];
999 }
1000
6a488035 1001 /**
fe482240 1002 * Format params for update and fill mode.
6a488035 1003 *
5a4f6742
CW
1004 * @param array $params
1005 * reference to an array containing all the.
16b10e64 1006 * values for import
5a4f6742
CW
1007 * @param int $cid
1008 * contact id.
6a488035 1009 */
77c96d86
EM
1010 public function formatParams(&$params, $cid) {
1011 if ($this->isSkipDuplicates()) {
6a488035
TO
1012 return;
1013 }
1014
be2fb01f 1015 $contactParams = [
6a488035 1016 'contact_id' => $cid,
be2fb01f 1017 ];
6a488035 1018
be2fb01f 1019 $defaults = [];
6a488035
TO
1020 $contactObj = CRM_Contact_BAO_Contact::retrieve($contactParams, $defaults);
1021
77c96d86 1022 $modeFill = $this->isFillDuplicates();
6a488035 1023
0b330e6d 1024 $groupTree = CRM_Core_BAO_CustomGroup::getTree($params['contact_type'], NULL, $cid, 0, NULL);
6a488035
TO
1025 CRM_Core_BAO_CustomGroup::setDefaults($groupTree, $defaults, FALSE, FALSE);
1026
be2fb01f 1027 $locationFields = [
6a488035 1028 'address' => 'address',
be2fb01f 1029 ];
6a488035
TO
1030
1031 $contact = get_object_vars($contactObj);
1032
1033 foreach ($params as $key => $value) {
1034 if ($key == 'id' || $key == 'contact_type') {
1035 continue;
1036 }
1037
1038 if (array_key_exists($key, $locationFields)) {
1039 continue;
1040 }
f3f321c7 1041
60110bfa
EM
1042 if ($customFieldId = CRM_Core_BAO_CustomField::getKeyID($key)) {
1043 $custom_params = ['id' => $contact['id'], 'return' => $key];
1044 $getValue = civicrm_api3('Contact', 'getvalue', $custom_params);
1045 if (empty($getValue)) {
1046 unset($getValue);
6a488035 1047 }
60110bfa
EM
1048 }
1049 else {
1050 $getValue = CRM_Utils_Array::retrieveValueRecursive($contact, $key);
1051 }
1052 if ($key == 'contact_source') {
1053 $params['source'] = $params[$key];
1054 unset($params[$key]);
1055 }
6a488035 1056
60110bfa
EM
1057 if ($modeFill && isset($getValue)) {
1058 unset($params[$key]);
1059 if ($customFieldId) {
1060 // Extra values must be unset to ensure the values are not
1061 // imported.
1062 unset($params['custom'][$customFieldId]);
6a488035
TO
1063 }
1064 }
1065 }
1066
1067 foreach ($locationFields as $locKeys) {
e01bf597 1068 if (isset($params[$locKeys]) && is_array($params[$locKeys])) {
6a488035
TO
1069 foreach ($params[$locKeys] as $key => $value) {
1070 if ($modeFill) {
1071 $getValue = CRM_Utils_Array::retrieveValueRecursive($contact, $locKeys);
1072
1073 if (isset($getValue)) {
1074 foreach ($getValue as $cnt => $values) {
639e4f37
EM
1075 if ((!empty($getValue[$cnt]['location_type_id']) && !empty($params[$locKeys][$key]['location_type_id'])) && $getValue[$cnt]['location_type_id'] == $params[$locKeys][$key]['location_type_id']) {
1076 unset($params[$locKeys][$key]);
6a488035
TO
1077 }
1078 }
1079 }
1080 }
1081 }
1082 if (count($params[$locKeys]) == 0) {
1083 unset($params[$locKeys]);
1084 }
1085 }
1086 }
1087 }
1088
6a488035 1089 /**
b2c28e7f 1090 * Get the message for a successful import.
6a488035 1091 *
b2c28e7f 1092 * @return string
6a488035 1093 */
b2c28e7f
EM
1094 private function getSuccessMessage(): string {
1095 if (!empty($this->_unparsedStreetAddressContacts)) {
1096 $errorMessage = ts('Record imported successfully but unable to parse the street address: ');
6a488035
TO
1097 foreach ($this->_unparsedStreetAddressContacts as $contactInfo => $contactValue) {
1098 $contactUrl = CRM_Utils_System::url('civicrm/contact/add', 'reset=1&action=update&cid=' . $contactValue['id'], TRUE, NULL, FALSE);
b2c28e7f 1099 $errorMessage .= "\n Contact ID:" . $contactValue['id'] . " <a href=\"$contactUrl\"> " . $contactValue['streetAddress'] . '</a>';
6a488035 1100 }
b2c28e7f 1101 return $errorMessage;
6a488035 1102 }
b2c28e7f 1103 return '';
6a488035
TO
1104 }
1105
65070890 1106 /**
1107 * Get the possible contact matches.
1108 *
1109 * 1) the chosen dedupe rule falling back to
1110 * 2) a check for the external ID.
1111 *
0e480632 1112 * @see https://issues.civicrm.org/jira/browse/CRM-17275
65070890 1113 *
1114 * @param array $params
64623d6c
EM
1115 * @param int|null $extIDMatch
1116 * @param int|null $dedupeRuleID
65070890 1117 *
64623d6c
EM
1118 * @return int|null
1119 * IDs of a possible.
65070890 1120 *
1121 * @throws \CRM_Core_Exception
1122 * @throws \CiviCRM_API3_Exception
1123 */
64623d6c
EM
1124 protected function getPossibleContactMatch(array $params, ?int $extIDMatch, ?int $dedupeRuleID): ?int {
1125 $checkParams = ['check_permissions' => FALSE, 'match' => $params, 'dedupe_rule_id' => $dedupeRuleID];
65070890 1126 $possibleMatches = civicrm_api3('Contact', 'duplicatecheck', $checkParams);
1127 if (!$extIDMatch) {
6187f042
EM
1128 if (count($possibleMatches['values']) === 1) {
1129 return array_key_last($possibleMatches['values']);
1130 }
1131 if (count($possibleMatches['values']) > 1) {
7e29af11
DS
1132 throw new CRM_Core_Exception(ts('Record duplicates multiple contacts: ') . implode(',', array_keys($possibleMatches['values'])), CRM_Import_Parser::ERROR);
1133
6187f042
EM
1134 }
1135 return NULL;
65070890 1136 }
1137 if ($possibleMatches['count']) {
c3f7ab62 1138 if (array_key_exists($extIDMatch, $possibleMatches['values'])) {
64623d6c 1139 return $extIDMatch;
65070890 1140 }
07b7795e 1141 throw new CRM_Core_Exception(ts('Matching this contact based on the de-dupe rule would cause an external ID conflict'));
65070890 1142 }
64623d6c 1143 return $extIDMatch;
65070890 1144 }
1145
64cafaa3 1146 /**
1147 * Set field metadata.
1148 */
1149 protected function setFieldMetadata() {
91b4c63e 1150 $this->setImportableFieldsMetadata($this->getContactImportMetadata());
64cafaa3 1151 }
1152
68f3bda5
EM
1153 /**
1154 * @param string $name
1155 * @param $title
1156 * @param int $type
1157 * @param string $headerPattern
1158 * @param string $dataPattern
1159 * @param bool $hasLocationType
1160 */
1161 public function addField(
1162 $name, $title, $type = CRM_Utils_Type::T_INT,
1163 $headerPattern = '//', $dataPattern = '//',
1164 $hasLocationType = FALSE
1165 ) {
1166 $this->_fields[$name] = new CRM_Contact_Import_Field($name, $title, $type, $headerPattern, $dataPattern, $hasLocationType);
1167 if (empty($name)) {
1168 $this->_fields['doNotImport'] = new CRM_Contact_Import_Field($name, $title, $type, $headerPattern, $dataPattern, $hasLocationType);
1169 }
1170 }
1171
1172 /**
1173 * Store parser values.
1174 *
1175 * @param CRM_Core_Session $store
1176 *
1177 * @param int $mode
1178 */
1179 public function set($store, $mode = self::MODE_SUMMARY) {
68f3bda5
EM
1180 }
1181
1182 /**
1183 * Export data to a CSV file.
1184 *
1185 * @param string $fileName
1186 * @param array $header
1187 * @param array $data
1188 */
1189 public static function exportCSV($fileName, $header, $data) {
1190
1191 if (file_exists($fileName) && !is_writable($fileName)) {
1192 CRM_Core_Error::movedSiteError($fileName);
1193 }
1194 //hack to remove '_status', '_statusMsg' and '_id' from error file
1195 $errorValues = [];
1196 $dbRecordStatus = ['IMPORTED', 'ERROR', 'DUPLICATE', 'INVALID', 'NEW'];
1197 foreach ($data as $rowCount => $rowValues) {
1198 $count = 0;
1199 foreach ($rowValues as $key => $val) {
1200 if (in_array($val, $dbRecordStatus) && $count == (count($rowValues) - 3)) {
1201 break;
1202 }
1203 $errorValues[$rowCount][$key] = $val;
1204 $count++;
1205 }
1206 }
1207 $data = $errorValues;
1208
1209 $output = [];
1210 $fd = fopen($fileName, 'w');
1211
1212 foreach ($header as $key => $value) {
1213 $header[$key] = "\"$value\"";
1214 }
1215 $config = CRM_Core_Config::singleton();
1216 $output[] = implode($config->fieldSeparator, $header);
1217
1218 foreach ($data as $datum) {
1219 foreach ($datum as $key => $value) {
1220 $datum[$key] = "\"$value\"";
1221 }
1222 $output[] = implode($config->fieldSeparator, $datum);
1223 }
1224 fwrite($fd, implode("\n", $output));
1225 fclose($fd);
1226 }
1227
68f3bda5
EM
1228 /**
1229 * Format contact parameters.
1230 *
1231 * @todo this function needs re-writing & re-merging into the main function.
1232 *
1233 * Here be dragons.
1234 *
1235 * @param array $values
1236 * @param array $params
1237 *
1238 * @return bool
1239 */
1240 protected function formatContactParameters(&$values, &$params) {
1241 // Crawl through the possible classes:
1242 // Contact
1243 // Individual
1244 // Household
1245 // Organization
1246 // Location
1247 // Address
1248 // Email
68f3bda5
EM
1249 // IM
1250 // Note
1251 // Custom
1252
1253 // first add core contact values since for other Civi modules they are not added
1254 $contactFields = CRM_Contact_DAO_Contact::fields();
1255 _civicrm_api3_store_values($contactFields, $values, $params);
1256
1257 if (isset($values['contact_type'])) {
1258 // we're an individual/household/org property
1259
1260 $fields[$values['contact_type']] = CRM_Contact_DAO_Contact::fields();
1261
1262 _civicrm_api3_store_values($fields[$values['contact_type']], $values, $params);
1263 return TRUE;
1264 }
1265
1266 // Cache the various object fields
1267 // @todo - remove this after confirming this is just a compilation of other-wise-cached fields.
1268 static $fields = [];
1269
68f3bda5
EM
1270 if (isset($values['note'])) {
1271 // add a note field
1272 if (!isset($params['note'])) {
1273 $params['note'] = [];
1274 }
1275 $noteBlock = count($params['note']) + 1;
1276
1277 $params['note'][$noteBlock] = [];
1278 if (!isset($fields['Note'])) {
1279 $fields['Note'] = CRM_Core_DAO_Note::fields();
1280 }
1281
1282 // get the current logged in civicrm user
1283 $session = CRM_Core_Session::singleton();
1284 $userID = $session->get('userID');
1285
1286 if ($userID) {
1287 $values['contact_id'] = $userID;
1288 }
1289
1290 _civicrm_api3_store_values($fields['Note'], $values, $params['note'][$noteBlock]);
1291
1292 return TRUE;
1293 }
1294
1295 // Check for custom field values
1296 $customFields = CRM_Core_BAO_CustomField::getFields(CRM_Utils_Array::value('contact_type', $values),
1297 FALSE, FALSE, NULL, NULL, FALSE, FALSE, FALSE
1298 );
1299
1300 foreach ($values as $key => $value) {
1301 if ($customFieldID = CRM_Core_BAO_CustomField::getKeyID($key)) {
1302 // check if it's a valid custom field id
1303
1304 if (!array_key_exists($customFieldID, $customFields)) {
1305 return civicrm_api3_create_error('Invalid custom field ID');
1306 }
1307 else {
1308 $params[$key] = $value;
1309 }
1310 }
1311 }
1312 return TRUE;
1313 }
1314
1315 /**
1316 * Format location block ready for importing.
1317 *
7b033be4
EM
1318 * Note this formatting should all be by the time the code reaches this point
1319 *
639e4f37
EM
1320 * There is some test coverage for this in
1321 * CRM_Contact_Import_Parser_ContactTest e.g. testImportPrimaryAddress.
68f3bda5 1322 *
7b033be4
EM
1323 * @deprecated
1324 *
68f3bda5 1325 * @param array $values
68f3bda5
EM
1326 *
1327 * @return bool
639e4f37 1328 * @throws \CiviCRM_API3_Exception
68f3bda5 1329 */
7b033be4
EM
1330 protected function formatLocationBlock(&$values) {
1331 // @todo - remove this function.
1332 // Original explantion .....
68f3bda5
EM
1333 // Note: we doing multiple value formatting here for address custom fields, plus putting into right format.
1334 // The actual formatting (like date, country ..etc) for address custom fields is taken care of while saving
1335 // the address in CRM_Core_BAO_Address::create method
1336 if (!empty($values['location_type_id'])) {
1337 static $customFields = [];
1338 if (empty($customFields)) {
1339 $customFields = CRM_Core_BAO_CustomField::getFields('Address');
1340 }
1341 // make a copy of values, as we going to make changes
1342 $newValues = $values;
1343 foreach ($values as $key => $val) {
1344 $customFieldID = CRM_Core_BAO_CustomField::getKeyID($key);
1345 if ($customFieldID && array_key_exists($customFieldID, $customFields)) {
1346
1347 $htmlType = $customFields[$customFieldID]['html_type'] ?? NULL;
1348 if (CRM_Core_BAO_CustomField::isSerialized($customFields[$customFieldID]) && $val) {
1349 $mulValues = explode(',', $val);
1350 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
1351 $newValues[$key] = [];
1352 foreach ($mulValues as $v1) {
1353 foreach ($customOption as $v2) {
1354 if ((strtolower($v2['label']) == strtolower(trim($v1))) ||
1355 (strtolower($v2['value']) == strtolower(trim($v1)))
1356 ) {
1357 if ($htmlType == 'CheckBox') {
1358 $newValues[$key][$v2['value']] = 1;
1359 }
1360 else {
1361 $newValues[$key][] = $v2['value'];
1362 }
1363 }
1364 }
1365 }
1366 }
1367 }
1368 }
1369 // consider new values
1370 $values = $newValues;
1371 }
1372
68f3bda5
EM
1373 return TRUE;
1374 }
1375
1376 /**
1377 * Get the field metadata for the relevant entity.
1378 *
1379 * @param string $entity
1380 *
1381 * @return array
1382 */
1383 protected function getMetadataForEntity($entity) {
1384 if (!isset($this->fieldMetadata[$entity])) {
1385 $className = "CRM_Core_DAO_$entity";
1386 $this->fieldMetadata[$entity] = $className::fields();
1387 }
1388 return $this->fieldMetadata[$entity];
1389 }
1390
1391 /**
1392 * Fill in the primary location.
1393 *
1394 * If the contact has a primary address we update it. Otherwise
1395 * we add an address of the default location type.
1396 *
1397 * @param array $params
1398 * Address block parameters
1399 * @param array $values
1400 * Input values
1401 * @param string $entity
1402 * - address, email, phone
1403 * @param int|null $contactID
1404 *
1405 * @throws \CiviCRM_API3_Exception
1406 */
1407 protected function fillPrimary(&$params, $values, $entity, $contactID) {
1408 if ($values['location_type_id'] === 'Primary') {
1409 if ($contactID) {
1410 $primary = civicrm_api3($entity, 'get', [
1411 'return' => 'location_type_id',
1412 'contact_id' => $contactID,
1413 'is_primary' => 1,
1414 'sequential' => 1,
1415 ]);
1416 }
1417 $defaultLocationType = CRM_Core_BAO_LocationType::getDefault();
1418 $params['location_type_id'] = (int) (isset($primary) && $primary['count']) ? $primary['values'][0]['location_type_id'] : $defaultLocationType->id;
1419 $params['is_primary'] = 1;
1420 }
1421 }
1422
34f3f22a
EM
1423 /**
1424 * Get the civicrm_mapping_field appropriate layout for the mapper input.
1425 *
1426 * The input looks something like ['street_address', 1]
1427 * and would be mapped to ['name' => 'street_address', 'location_type_id' =>
1428 * 1]
1429 *
1430 * @param array $fieldMapping
b6df6c1b
EM
1431 * Field as submitted on the MapField form - this is a non-associative array,
1432 * the keys of which depend on the data/ field. Generally it will be one of
1433 * [$fieldName],
1434 * [$fieldName, $locationTypeID, $phoneTypeIDOrIMProviderIDIfRelevant],
1435 * [$fieldName, $websiteTypeID],
1436 * If the mapping is for a related contact it will be as above but the first
1437 * key will be the relationship key - eg. 5_a_b.
34f3f22a
EM
1438 * @param int $mappingID
1439 * @param int $columnNumber
1440 *
1441 * @return array
1442 * @throws \API_Exception
1443 */
1444 public function getMappingFieldFromMapperInput(array $fieldMapping, int $mappingID, int $columnNumber): array {
1445 $isRelationshipField = preg_match('/\d*_a_b|b_a$/', $fieldMapping[0]);
1446 $fieldName = $isRelationshipField ? $fieldMapping[1] : $fieldMapping[0];
1447 $locationTypeID = NULL;
1448 $possibleLocationField = $isRelationshipField ? 2 : 1;
639e4f37
EM
1449 $entity = strtolower($this->getFieldEntity($fieldName));
1450 if ($entity !== 'website' && is_numeric($fieldMapping[$possibleLocationField] ?? NULL)) {
34f3f22a
EM
1451 $locationTypeID = $fieldMapping[$possibleLocationField];
1452 }
639e4f37 1453
34f3f22a
EM
1454 return [
1455 'name' => $fieldName,
1456 'mapping_id' => $mappingID,
1457 'relationship_type_id' => $isRelationshipField ? substr($fieldMapping[0], 0, -4) : NULL,
1458 'relationship_direction' => $isRelationshipField ? substr($fieldMapping[0], -3) : NULL,
1459 'column_number' => $columnNumber,
1460 'contact_type' => $this->getContactType(),
639e4f37
EM
1461 'website_type_id' => $entity !== 'website' ? NULL : ($isRelationshipField ? $fieldMapping[2] : $fieldMapping[1]),
1462 'phone_type_id' => $entity !== 'phone' ? NULL : ($isRelationshipField ? $fieldMapping[3] : $fieldMapping[2]),
1463 'im_provider_id' => $entity !== 'im' ? NULL : ($isRelationshipField ? $fieldMapping[3] : $fieldMapping[2]),
34f3f22a
EM
1464 'location_type_id' => $locationTypeID,
1465 ];
1466 }
1467
1468 /**
1469 * @param array $mappedField
1470 * Field detail as would be saved in field_mapping table
1471 * or as returned from getMappingFieldFromMapperInput
1472 *
1473 * @return string
1474 * @throws \API_Exception
1475 */
1476 public function getMappedFieldLabel(array $mappedField): string {
1477 $this->setFieldMetadata();
1478 $title = [];
1479 if ($mappedField['relationship_type_id']) {
1480 $title[] = $this->getRelationshipLabel($mappedField['relationship_type_id'], $mappedField['relationship_direction']);
1481 }
e0b8f9a9 1482 $title[] = $this->getFieldMetadata($mappedField['name'])['title'];
34f3f22a
EM
1483 if ($mappedField['location_type_id']) {
1484 $title[] = CRM_Core_PseudoConstant::getLabel('CRM_Core_BAO_Address', 'location_type_id', $mappedField['location_type_id']);
1485 }
1486 if ($mappedField['website_type_id']) {
1487 $title[] = CRM_Core_PseudoConstant::getLabel('CRM_Core_BAO_Website', 'website_type_id', $mappedField['website_type_id']);
1488 }
1489 if ($mappedField['phone_type_id']) {
1490 $title[] = CRM_Core_PseudoConstant::getLabel('CRM_Core_BAO_Phone', 'phone_type_id', $mappedField['phone_type_id']);
1491 }
1492 if ($mappedField['im_provider_id']) {
0e38b3e4 1493 $title[] = CRM_Core_PseudoConstant::getLabel('CRM_Core_BAO_IM', 'provider_id', $mappedField['im_provider_id']);
34f3f22a
EM
1494 }
1495 return implode(' - ', $title);
1496 }
1497
1498 /**
1499 * Get the relevant label for the relationship.
1500 *
1501 * @param int $id
1502 * @param string $direction
1503 *
1504 * @return string
1505 * @throws \API_Exception
1506 */
1507 protected function getRelationshipLabel(int $id, string $direction): string {
1508 if (empty($this->relationshipLabels[$id . $direction])) {
1509 $this->relationshipLabels[$id . $direction] =
1510 $fieldName = 'label_' . $direction;
1511 $this->relationshipLabels[$id . $direction] = (string) RelationshipType::get(FALSE)
1512 ->addWhere('id', '=', $id)
1513 ->addSelect($fieldName)->execute()->first()[$fieldName];
1514 }
1515 return $this->relationshipLabels[$id . $direction];
1516 }
1517
4b3ef371 1518 /**
baeb2d67 1519 * Transform the input parameters into the form handled by the input routine.
4b3ef371
EM
1520 *
1521 * @param array $values
1522 * Input parameters as they come in from the datasource
1523 * eg. ['Bob', 'Smith', 'bob@example.org', '123-456']
1524 *
1525 * @return array
1526 * Parameters mapped to CiviCRM fields based on the mapping
1527 * and specified contact type. eg.
1528 * [
1529 * 'contact_type' => 'Individual',
1530 * 'first_name' => 'Bob',
1531 * 'last_name' => 'Smith',
1532 * 'phone' => ['phone' => '123', 'location_type_id' => 1, 'phone_type_id' => 1],
1533 * '5_a_b' => ['contact_type' => 'Organization', 'url' => ['url' => 'https://example.org', 'website_type_id' => 1]]
1534 * 'im' => ['im' => 'my-handle', 'location_type_id' => 1, 'provider_id' => 1],
baeb2d67
EM
1535 *
1536 * @throws \API_Exception
4b3ef371
EM
1537 */
1538 public function getMappedRow(array $values): array {
71deff40
EM
1539 $params = ['relationship' => []];
1540
1541 foreach ($this->getFieldMappings() as $i => $mappedField) {
1542 // The key is in the format 5_a_b where 5 is the relationship_type_id and a_b is the direction.
1543 $relatedContactKey = $mappedField['relationship_type_id'] ? ($mappedField['relationship_type_id'] . '_' . $mappedField['relationship_direction']) : NULL;
1544 $fieldName = $mappedField['name'];
1545 $importedValue = $values[$i];
1546 if ($fieldName === 'do_not_import' || $importedValue === NULL) {
1547 continue;
1548 }
1549
1550 $locationFields = ['location_type_id', 'phone_type_id', 'provider_id', 'website_type_id'];
1551 $locationValues = array_filter(array_intersect_key($mappedField, array_fill_keys($locationFields, 1)));
1552
1553 if ($relatedContactKey) {
1554 if (!isset($params['relationship'][$relatedContactKey])) {
1555 $params['relationship'][$relatedContactKey] = [
1556 // These will be over-written by any the importer has chosen but defaults are based on the relationship.
1557 'contact_type' => $this->getRelatedContactType($mappedField['relationship_type_id'], $mappedField['relationship_direction']),
1558 'contact_sub_type' => $this->getRelatedContactSubType($mappedField['relationship_type_id'], $mappedField['relationship_direction']),
1559 ];
1560 }
1561 $this->addFieldToParams($params['relationship'][$relatedContactKey], $locationValues, $fieldName, $importedValue);
1562 }
1563 else {
1564 $this->addFieldToParams($params, $locationValues, $fieldName, $importedValue);
1565 }
1566 }
1567
1568 $this->fillStateProvince($params);
1569
baeb2d67 1570 $params['contact_type'] = $this->getContactType();
e0b8f9a9
EM
1571 if ($this->getContactSubType()) {
1572 $params['contact_sub_type'] = $this->getContactSubType();
1573 }
4b3ef371
EM
1574 return $params;
1575 }
1576
13575591
EM
1577 /**
1578 * Validate the import values.
1579 *
1580 * The values array represents a row in the datasource.
1581 *
1582 * @param array $values
5ebaab5d
EM
1583 *
1584 * @throws \API_Exception
7e56b830 1585 * @throws \CRM_Core_Exception
13575591
EM
1586 */
1587 public function validateValues(array $values): void {
13575591 1588 $params = $this->getMappedRow($values);
bf94a235 1589 $this->validateParams($params);
13575591
EM
1590 }
1591
24948d41
EM
1592 /**
1593 * Get the invalid values in the params for the given contact.
1594 *
1595 * @param array|int|string $value
1596 * @param string $prefixString
1597 *
1598 * @return array
1599 * @throws \API_Exception
1600 * @throws \Civi\API\Exception\NotImplementedException
1601 */
1602 protected function getInvalidValuesForContact($value, string $prefixString): array {
1603 $errors = [];
1604 foreach ($value as $contactKey => $contactValue) {
741beb19 1605 if ($contactKey !== 'relationship') {
24948d41
EM
1606 $result = $this->getInvalidValues($contactValue, $contactKey, $prefixString);
1607 if (!empty($result)) {
1608 $errors = array_merge($errors, $result);
1609 }
1610 }
1611 }
1612 return $errors;
1613 }
1614
4473c68d
EM
1615 /**
1616 * Get the field mappings for the import.
1617 *
1618 * This is the same format as saved in civicrm_mapping_field except
1619 * that location_type_id = 'Primary' rather than empty where relevant.
f5d4a76c 1620 * Also 'im_provider_id' is mapped to the 'real' field name 'provider_id'
4473c68d
EM
1621 *
1622 * @return array
1623 * @throws \API_Exception
1624 */
1625 protected function getFieldMappings(): array {
1626 $mappedFields = [];
1627 foreach ($this->getSubmittedValue('mapper') as $i => $mapperRow) {
1628 $mappedField = $this->getMappingFieldFromMapperInput($mapperRow, 0, $i);
1629 if (!$mappedField['location_type_id'] && !empty($this->importableFieldsMetadata[$mappedField['name']]['hasLocationType'])) {
1630 $mappedField['location_type_id'] = 'Primary';
1631 }
f5d4a76c 1632 // Just for clarity since 0 is a pseudo-value
4473c68d 1633 unset($mappedField['mapping_id']);
f5d4a76c
EM
1634 // Annoyingly the civicrm_mapping_field name for this differs from civicrm_im.
1635 // Test cover in `CRM_Contact_Import_Parser_ContactTest::testMapFields`
1636 $mappedField['provider_id'] = $mappedField['im_provider_id'];
1637 unset($mappedField['im_provider_id']);
4473c68d
EM
1638 $mappedFields[] = $mappedField;
1639 }
1640 return $mappedFields;
1641 }
1642
40a48df0
EM
1643 /**
1644 * Get the related contact type.
1645 *
1646 * @param int|null $relationshipTypeID
1647 * @param int|string $relationshipDirection
1648 *
1649 * @return null|string
1650 *
1651 * @throws \API_Exception
1652 */
1653 protected function getRelatedContactType($relationshipTypeID, $relationshipDirection): ?string {
1654 if (!$relationshipTypeID) {
1655 return NULL;
1656 }
7d2012dc 1657 $relationshipField = 'contact_type_' . substr($relationshipDirection, -1);
02f1858b 1658 return $this->getRelationshipType($relationshipTypeID)[$relationshipField];
7d2012dc
EM
1659 }
1660
77b23b82
EM
1661 /**
1662 * Get the related contact sub type.
1663 *
1664 * @param int|null $relationshipTypeID
1665 * @param int|string $relationshipDirection
1666 *
1667 * @return null|string
1668 *
1669 * @throws \API_Exception
1670 */
1671 protected function getRelatedContactSubType(int $relationshipTypeID, $relationshipDirection): ?string {
1672 if (!$relationshipTypeID) {
1673 return NULL;
1674 }
1675 $relationshipField = 'contact_sub_type_' . substr($relationshipDirection, -1);
1676 return $this->getRelationshipType($relationshipTypeID)[$relationshipField];
1677 }
1678
7d2012dc
EM
1679 /**
1680 * Get the related contact type.
1681 *
1682 * @param int|null $relationshipTypeID
1683 * @param int|string $relationshipDirection
1684 *
1685 * @return null|string
1686 *
1687 * @throws \API_Exception
1688 */
1689 protected function getRelatedContactLabel($relationshipTypeID, $relationshipDirection): ?string {
1690 $relationshipField = 'label_' . $relationshipDirection;
02f1858b 1691 return $this->getRelationshipType($relationshipTypeID)[$relationshipField];
7d2012dc
EM
1692 }
1693
1694 /**
1695 * Get the relationship type.
1696 *
1697 * @param int $relationshipTypeID
1698 *
1699 * @return string[]
1700 * @throws \API_Exception
1701 */
1702 protected function getRelationshipType(int $relationshipTypeID): array {
1703 $cacheKey = 'relationship_type' . $relationshipTypeID;
40a48df0 1704 if (!isset(Civi::$statics[__CLASS__][$cacheKey])) {
40a48df0
EM
1705 Civi::$statics[__CLASS__][$cacheKey] = RelationshipType::get(FALSE)
1706 ->addWhere('id', '=', $relationshipTypeID)
7d2012dc 1707 ->addSelect('*')->execute()->first();
40a48df0
EM
1708 }
1709 return Civi::$statics[__CLASS__][$cacheKey];
1710 }
1711
19f33b09
EM
1712 /**
1713 * Add the given field to the contact array.
1714 *
1715 * @param array $contactArray
1716 * @param array $locationValues
1717 * @param string $fieldName
1718 * @param mixed $importedValue
1719 *
1720 * @return void
1721 * @throws \API_Exception
1722 */
1723 private function addFieldToParams(array &$contactArray, array $locationValues, string $fieldName, $importedValue): void {
1724 if (!empty($locationValues)) {
018c9e26 1725 $fieldMap = ['country' => 'country_id', 'state_province' => 'state_province_id', 'county' => 'county_id'];
24948d41
EM
1726 $realFieldName = empty($fieldMap[$fieldName]) ? $fieldName : $fieldMap[$fieldName];
1727 $entity = strtolower($this->getFieldEntity($fieldName));
639e4f37 1728
24948d41
EM
1729 // The entity key is either location_type_id for address, email - eg. 1, or
1730 // location_type_id + '_' + phone_type_id or im_provider_id
1731 // or the value for website(since websites are not historically one-per-type)
1732 $entityKey = $locationValues['location_type_id'] ?? $importedValue;
1733 if (!empty($locationValues['phone_type_id']) || !empty($locationValues['provider_id'])) {
1734 $entityKey .= '_' . ($locationValues['phone_type_id'] ?? '' . $locationValues['provider_id'] ?? '');
1735 }
1736 $fieldValue = $this->getTransformedFieldValue($realFieldName, $importedValue);
639e4f37 1737
24948d41
EM
1738 if (!isset($contactArray[$entity][$entityKey])) {
1739 $contactArray[$entity][$entityKey] = $locationValues;
1740 }
018c9e26
EM
1741 // So im has really non-standard handling...
1742 $reallyRealFieldName = $realFieldName === 'im' ? 'name' : $realFieldName;
639e4f37 1743 $contactArray[$entity][$entityKey][$reallyRealFieldName] = $fieldValue;
19f33b09
EM
1744 }
1745 else {
80e9f1a2 1746 $fieldName = array_search($fieldName, $this->getOddlyMappedMetadataFields(), TRUE) ?: $fieldName;
07b7795e
EM
1747 $importedValue = $this->getTransformedFieldValue($fieldName, $importedValue);
1748 if ($importedValue === '' && !empty($contactArray[$fieldName])) {
1749 // If we have already calculated contact type or subtype based on the relationship
1750 // do not overwrite it with an empty value.
1751 return;
1752 }
1753 $contactArray[$fieldName] = $importedValue;
19f33b09
EM
1754 }
1755 }
1756
02f1858b
EM
1757 /**
1758 * Get any related contacts designated for update.
1759 *
1760 * This extracts the parts that relate to separate related
1761 * contacts from the 'params' array.
1762 *
1763 * It is probably a bit silly not to nest them more clearly in
1764 * `getParams` in the first place & maybe in future we can do that.
1765 *
1766 * @param array $params
1767 *
1768 * @return array
1769 * e.g ['5_a_b' => ['contact_type' => 'Organization', 'organization_name' => 'The Firm']]
1770 * @throws \API_Exception
1771 */
1772 protected function getRelatedContactsParams(array $params): array {
1773 $relatedContacts = [];
07b7795e 1774 foreach ($params['relationship'] as $key => $value) {
02f1858b
EM
1775 // If the key is a relationship key - eg. 5_a_b or 10_b_a
1776 // then the value is an array that describes an existing contact.
1777 // We need to check the fields are present to identify or create this
1778 // contact.
1779 if (preg_match('/^\d+_[a|b]_[a|b]$/', $key)) {
1780 $value['relationship_type_id'] = substr($key, 0, -4);
1781 $value['relationship_direction'] = substr($key, -3);
1782 $value['relationship_label'] = $this->getRelationshipLabel($value['relationship_type_id'], $value['relationship_direction']);
1783 $relatedContacts[$key] = $value;
1784 }
1785 }
1786 return $relatedContacts;
1787 }
1788
64623d6c
EM
1789 /**
1790 * Look up for an existing contact with the given external_identifier.
1791 *
1792 * If the identifier is found on a deleted contact then it is not a match
1793 * but it must be removed from that contact to allow the new contact to
1794 * have that external_identifier.
1795 *
1796 * @param string|null $externalIdentifier
ed75aff2 1797 * @param string $contactType
64623d6c
EM
1798 *
1799 * @return int|null
1800 *
ed75aff2 1801 * @throws \CRM_Core_Exception
64623d6c
EM
1802 * @throws \CiviCRM_API3_Exception
1803 */
ed75aff2 1804 protected function lookupExternalIdentifier(?string $externalIdentifier, string $contactType): ?int {
64623d6c
EM
1805 if (!$externalIdentifier) {
1806 return NULL;
1807 }
1808 // Check for any match on external id, deleted or otherwise.
1809 $foundContact = civicrm_api3('Contact', 'get', [
1810 'external_identifier' => $externalIdentifier,
1811 'showAll' => 'all',
1812 'sequential' => TRUE,
ed75aff2 1813 'return' => ['id', 'contact_is_deleted', 'contact_type'],
64623d6c
EM
1814 ]);
1815 if (empty($foundContact['id'])) {
1816 return NULL;
1817 }
1818 if (!empty($foundContact['values'][0]['contact_is_deleted'])) {
1819 // If the contact is deleted, update external identifier to be blank
1820 // to avoid key error from MySQL.
1821 $params = ['id' => $foundContact['id'], 'external_identifier' => ''];
1822 civicrm_api3('Contact', 'create', $params);
1823 return NULL;
1824 }
ed75aff2
EM
1825 if ($foundContact['values'][0]['contact_type'] !== $contactType) {
1826 throw new CRM_Core_Exception('Mismatched contact Types', CRM_Import_Parser::NO_MATCH);
1827 }
64623d6c
EM
1828 return (int) $foundContact['id'];
1829 }
1830
1831 /**
1832 * Lookup the contact's contact ID.
1833 *
1834 * @param array $params
6187f042 1835 * @param bool $isMainContact
64623d6c
EM
1836 *
1837 * @return int|null
1838 *
1839 * @throws \API_Exception
1840 * @throws \CRM_Core_Exception
1841 * @throws \CiviCRM_API3_Exception
6187f042 1842 * @throws \Civi\API\Exception\UnauthorizedException
64623d6c 1843 */
6187f042 1844 protected function lookupContactID(array $params, bool $isMainContact): ?int {
ed75aff2 1845 $extIDMatch = $this->lookupExternalIdentifier($params['external_identifier'] ?? NULL, $params['contact_type']);
64623d6c
EM
1846 $contactID = !empty($params['id']) ? (int) $params['id'] : NULL;
1847 //check if external identifier exists in database
1848 if ($extIDMatch && $contactID && $extIDMatch !== $contactID) {
1849 throw new CRM_Core_Exception(ts('Existing external ID does not match the imported contact ID.'), CRM_Import_Parser::ERROR);
1850 }
6187f042 1851 if ($extIDMatch && $isMainContact && ($this->isSkipDuplicates() || $this->isIgnoreDuplicates())) {
64623d6c
EM
1852 throw new CRM_Core_Exception(ts('External ID already exists in Database.'), CRM_Import_Parser::DUPLICATE);
1853 }
1854 if ($contactID) {
ed75aff2
EM
1855 $existingContact = Contact::get(FALSE)
1856 ->addWhere('id', '=', $contactID)
1857 // Don't auto-filter deleted - people use import to undelete.
1858 ->addWhere('is_deleted', 'IN', [0, 1])
1859 ->addSelect('contact_type')->execute()->first();
1860 if (empty($existingContact['id'])) {
1861 throw new CRM_Core_Exception('No contact found for this contact ID:' . $params['id'], CRM_Import_Parser::NO_MATCH);
1862 }
1863 if ($existingContact['contact_type'] !== $params['contact_type']) {
1864 throw new CRM_Core_Exception('Mismatched contact Types', CRM_Import_Parser::NO_MATCH);
1865 }
64623d6c
EM
1866 return $contactID;
1867 }
1868 // Time to see if we can find an existing contact ID to make this an update
1869 // not a create.
07b7795e
EM
1870 if ($extIDMatch || !$this->isIgnoreDuplicates()) {
1871 if (isset($params['relationship'])) {
1872 unset($params['relationship']);
1873 }
1874 $id = $this->getPossibleContactMatch($params, $extIDMatch, $this->getSubmittedValue('dedupe_rule_id') ?: NULL);
1875 if ($id && $this->isSkipDuplicates()) {
1876 throw new CRM_Core_Exception(ts('Contact matched by dedupe rule already exists in the database.'), CRM_Import_Parser::DUPLICATE);
1877 }
1878 return $id;
64623d6c
EM
1879 }
1880 return NULL;
1881 }
1882
77b23b82
EM
1883 /**
1884 * @param array $params
1885 * @param array $formatted
6187f042
EM
1886 * @param bool $isMainContact
1887 *
77b23b82
EM
1888 * @return array[]
1889 * @throws \API_Exception
1890 * @throws \CRM_Core_Exception
1891 * @throws \CiviCRM_API3_Exception
77b23b82 1892 */
6187f042
EM
1893 protected function processContact(array $params, array $formatted, bool $isMainContact): array {
1894 $params['id'] = $formatted['id'] = $this->lookupContactID($params, $isMainContact);
2a4de39f 1895 if ($params['id'] && !empty($params['contact_sub_type'])) {
77b23b82
EM
1896 $contactSubType = Contact::get(FALSE)
1897 ->addWhere('id', '=', $params['id'])
1898 ->addSelect('contact_sub_type')
1899 ->execute()
1900 ->first()['contact_sub_type'];
1901 if (!empty($contactSubType) && $contactSubType[0] !== $params['contact_sub_type'] && !CRM_Contact_BAO_ContactType::isAllowEdit($params['id'], $contactSubType[0])) {
1902 throw new CRM_Core_Exception('Mismatched contact SubTypes :', CRM_Import_Parser::NO_MATCH);
1903 }
1904 }
1905 return array($formatted, $params);
1906 }
1907
018c9e26
EM
1908 /**
1909 * Try to get the correct state province using what country information we have.
1910 *
1911 * If the state matches more than one possibility then either the imported
1912 * country of the site country should help us....
1913 *
1914 * @param string $stateProvince
1915 * @param int|null|string $countryID
1916 *
1917 * @return int|string
1918 * @throws \API_Exception
1919 * @throws \Civi\API\Exception\UnauthorizedException
1920 */
1921 private function tryToResolveStateProvince(string $stateProvince, $countryID) {
1922 // Try to disambiguate since we likely have the country now.
1923 $possibleStates = $this->ambiguousOptions['state_province_id'][mb_strtolower($stateProvince)];
1924 if ($countryID) {
1925 return $this->checkStatesForCountry($countryID, $possibleStates) ?: 'invalid_import_value';
1926 }
1927 // Try the default country next.
1928 $defaultCountryMatch = $this->checkStatesForCountry($this->getSiteDefaultCountry(), $possibleStates);
1929 if ($defaultCountryMatch) {
1930 return $defaultCountryMatch;
1931 }
1932
1933 if ($this->getAvailableCountries()) {
1934 $countryMatches = [];
1935 foreach ($this->getAvailableCountries() as $availableCountryID) {
1936 $possible = $this->checkStatesForCountry($availableCountryID, $possibleStates);
1937 if ($possible) {
1938 $countryMatches[] = $possible;
1939 }
1940 }
1941 if (count($countryMatches) === 1) {
1942 return reset($countryMatches);
1943 }
1944
1945 }
1946 return $stateProvince;
1947 }
1948
1949 /**
1950 * @param array $params
1951 *
1952 * @return array
1953 * @throws \API_Exception
1954 */
1955 private function fillStateProvince(array &$params): array {
1956 foreach ($params as $key => $value) {
1957 if ($key === 'address') {
1958 foreach ($value as $index => $address) {
1959 $stateProvinceID = $address['state_province_id'] ?? NULL;
1960 if ($stateProvinceID) {
1961 if (!is_numeric($stateProvinceID)) {
1962 $params['address'][$index]['state_province_id'] = $this->tryToResolveStateProvince($stateProvinceID, $address['country_id'] ?? NULL);
1963 }
1964 elseif (!empty($address['country_id']) && is_numeric($address['country_id'])) {
1965 if (!$this->checkStatesForCountry((int) $address['country_id'], [$stateProvinceID])) {
1966 $params['address'][$index]['state_province_id'] = 'invalid_import_value';
1967 }
1968 }
1969 }
1970 }
1971 }
1972 elseif (is_array($value) && !in_array($key, ['email', 'phone', 'im', 'website', 'openid'], TRUE)) {
1973 $this->fillStateProvince($params[$key]);
1974 }
1975 }
1976 return $params;
1977 }
1978
1979 /**
1980 * Check is any of the given states correlate to the country.
1981 *
1982 * @param int $countryID
1983 * @param array $possibleStates
1984 *
1985 * @return int|null
1986 * @throws \API_Exception
1987 */
1988 private function checkStatesForCountry(int $countryID, array $possibleStates) {
1989 foreach ($possibleStates as $index => $state) {
1990 if (!empty($this->statesByCountry[$state])) {
1991 if ($this->statesByCountry[$state] === $countryID) {
1992 return $state;
1993 }
1994 unset($possibleStates[$index]);
1995 }
1996 }
1997 if (!empty($possibleStates)) {
1998 $states = StateProvince::get(FALSE)
1999 ->addSelect('country_id')
2000 ->addWhere('id', 'IN', $possibleStates)
2001 ->execute()
2002 ->indexBy('country_id');
2003 foreach ($states as $state) {
2004 $this->statesByCountry[$state['id']] = $state['country_id'];
2005 }
2006 foreach ($possibleStates as $state) {
2007 if ($this->statesByCountry[$state] === $countryID) {
2008 return $state;
2009 }
2010 }
2011 }
2012 return FALSE;
2013 }
2014
b2c28e7f
EM
2015 /**
2016 * @param $outcome
2017 *
2018 * @return string
2019 */
2020 protected function getStatus($outcome): string {
2021 if ($outcome === CRM_Import_Parser::VALID) {
f363505a 2022 return empty($this->_unparsedStreetAddressContacts) ? 'IMPORTED' : 'warning_unparsed_address';
b2c28e7f
EM
2023 }
2024 return [
2025 CRM_Import_Parser::DUPLICATE => 'DUPLICATE',
2026 CRM_Import_Parser::ERROR => 'ERROR',
2027 CRM_Import_Parser::NO_MATCH => 'invalid_no_match',
3592a5e4 2028 ][$outcome] ?? 'ERROR';
b2c28e7f
EM
2029 }
2030
6a488035 2031}