From 1b58b4503081b86c64a05324eb988fb28124177e Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Thu, 1 Sep 2022 14:50:09 +1200 Subject: [PATCH] [REF] Standardise validation of mapped fields in imports --- CRM/Activity/Import/Parser/Activity.php | 2 +- CRM/Contribute/Import/Form/MapField.php | 60 +---- CRM/Contribute/Import/Parser/Contribution.php | 81 ++++++- CRM/Custom/Import/Parser/Api.php | 2 +- CRM/Event/Import/Parser/Participant.php | 2 +- CRM/Import/Parser.php | 227 ++++++++++++++---- .../CRM/Contact/Import/Parser/ContactTest.php | 6 +- .../Import/Parser/ContributionTest.php | 70 +++++- .../phpunit/CRMTraits/Import/ParserTrait.php | 40 ++- 9 files changed, 375 insertions(+), 115 deletions(-) diff --git a/CRM/Activity/Import/Parser/Activity.php b/CRM/Activity/Import/Parser/Activity.php index e9508a59e0..50fe0d93a2 100644 --- a/CRM/Activity/Import/Parser/Activity.php +++ b/CRM/Activity/Import/Parser/Activity.php @@ -179,7 +179,7 @@ class CRM_Activity_Import_Parser_Activity extends CRM_Import_Parser { * @return array */ protected function getRequiredFields(): array { - return [['activity_type_id' => ts('Activity Type'), 'activity_date_time' => ts('Activity Date')]]; + return [['activity_type_id', 'activity_date_time']]; } /** diff --git a/CRM/Contribute/Import/Form/MapField.php b/CRM/Contribute/Import/Form/MapField.php index 4cd4aed649..60499bb907 100644 --- a/CRM/Contribute/Import/Form/MapField.php +++ b/CRM/Contribute/Import/Form/MapField.php @@ -36,32 +36,16 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField { protected static function checkRequiredFields($self, string $contactORContributionId, array $importKeys, array $errors, int $weightSum, $threshold, string $fieldMessage): array { // FIXME: should use the schema titles, not redeclare them $requiredFields = [ - $contactORContributionId == 'contribution_id' ? 'contribution_id' : 'contribution_contact_id' => $contactORContributionId == 'contribution_id' ? ts('Contribution ID') : ts('Contact ID'), - 'total_amount' => ts('Total Amount'), - 'financial_type_id' => ts('Financial Type'), + 'contribution_contact_id' => ts('Contact ID'), ]; foreach ($requiredFields as $field => $title) { if (!in_array($field, $importKeys)) { - if (empty($errors['_qf_default'])) { - $errors['_qf_default'] = ''; - } - if ($field == $contactORContributionId) { - if (!($weightSum >= $threshold || in_array('external_identifier', $importKeys)) && - !$self->isUpdateExisting() + if ($field == 'contribution_contact_id') { + if (!($weightSum >= $threshold || in_array('external_identifier', $importKeys)) ) { $errors['_qf_default'] .= ts('Missing required contact matching fields.') . " $fieldMessage " . ts('(Sum of all weights should be greater than or equal to threshold: %1).', [1 => $threshold]) . '
'; } - elseif ($self->isUpdateExisting() && - !(in_array('invoice_id', $importKeys) || in_array('trxn_id', $importKeys) || - in_array('contribution_id', $importKeys) - ) - ) { - $errors['_qf_default'] .= ts('Invoice ID or Transaction ID or Contribution ID are required to match to the existing contribution records in Update mode.') . '
'; - } - } - else { - $errors['_qf_default'] .= ts('Missing required field: %1', [1 => $title]) . '
'; } } } @@ -210,38 +194,18 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField { foreach ($ruleFields as $field => $weight) { $fieldMessage .= ' ' . $field . '(weight ' . $weight . ')'; } - $errors = self::checkRequiredFields($self, $contactORContributionId, $importKeys, $errors, $weightSum, $threshold, $fieldMessage); - - //at least one field should be mapped during update. - if ($self->isUpdateExisting()) { - $atleastOne = FALSE; - foreach ($self->_mapperFields as $key => $field) { - if (in_array($key, $importKeys) && - !in_array($key, [ - 'doNotImport', - 'contribution_id', - 'invoice_id', - 'trxn_id', - ]) - ) { - $atleastOne = TRUE; - break; - } - } - if (!$atleastOne) { - $errors['_qf_default'] .= ts('At least one contribution field needs to be mapped for update during update mode.') . '
'; - } + try { + $parser = $self->getParser(); + $parser->validateMapping($fields['mapper']); } - } - - if (!empty($errors)) { - if (!empty($errors['_qf_default'])) { - CRM_Core_Session::setStatus($errors['_qf_default'], ts("Error"), "error"); - return $errors; + catch (CRM_Core_Exception $e) { + $errors['_qf_default'] = $e->getMessage(); + } + if (!$self->isUpdateExisting()) { + $errors = self::checkRequiredFields($self, $contactORContributionId, $importKeys, $errors, $weightSum, $threshold, $fieldMessage); } } - - return TRUE; + return !empty($errors) ? $errors : TRUE; } /** diff --git a/CRM/Contribute/Import/Parser/Contribution.php b/CRM/Contribute/Import/Parser/Contribution.php index d3cb74c69b..a67a94574d 100644 --- a/CRM/Contribute/Import/Parser/Contribution.php +++ b/CRM/Contribute/Import/Parser/Contribution.php @@ -17,6 +17,7 @@ use Civi\Api4\Contact; use Civi\Api4\Contribution; +use Civi\Api4\ContributionSoft; use Civi\Api4\Email; use Civi\Api4\Note; @@ -32,6 +33,8 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser { */ protected $_newContributions; + protected $baseEntity = 'Contribution'; + /** * Get information about the provided job. * - name @@ -129,7 +132,6 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser { * Also 'im_provider_id' is mapped to the 'real' field name 'provider_id' * * @return array - * @throws \API_Exception */ protected function getFieldMappings(): array { $mappedFields = []; @@ -148,7 +150,25 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser { * @return array */ public function getRequiredFields(): array { - return ['id' => ts('Contribution ID'), ['financial_type_id' => ts('Financial Type'), 'total_amount' => ts('Total Amount')]]; + return [[$this->getRequiredFieldsForMatch(), $this->getRequiredFieldsForCreate()]]; + } + + /** + * Get required fields to create a contribution. + * + * @return array + */ + public function getRequiredFieldsForCreate(): array { + return ['financial_type_id', 'total_amount']; + } + + /** + * Get required fields to match a contribution. + * + * @return array + */ + public function getRequiredFieldsForMatch(): array { + return [['id'], ['invoice_id'], ['trxn_id']]; } /** @@ -273,6 +293,63 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser { } } + /** + * Get a list of entities this import supports. + * + * @return array + * @throws \API_Exception + */ + public function getImportEntities() : array { + $softCreditTypes = ContributionSoft::getFields() + ->setLoadOptions(TRUE) + ->addWhere('name', '=', 'soft_credit_type_id') + ->selectRowCount() + ->addSelect('options')->execute(); + return [ + 'Contribution' => [ + 'text' => ts('Contribution Fields'), + 'required_fields_update' => $this->getRequiredFieldsForMatch(), + 'required_fields_create' => $this->getRequiredFieldsForCreate(), + 'is_base_entity' => TRUE, + // For now we stick with the action selected on the DataSource page. + 'actions' => $this->isUpdateExisting() ? + [['id' => 'update', 'text' => ts('Update existing'), 'description' => ts('Skip if no match found')]] : + [['id' => 'create', 'text' => ts('Create'), 'description' => ts('Skip if already exists')]], + 'default_action' => $this->isUpdateExisting() ? 'update' : 'create', + 'entity_name' => 'Contribution', + 'entity_title' => ts('Contribution'), + ], + 'Contact' => [ + 'text' => ts('Contact Fields'), + 'unique_fields' => ['external_identifier', 'id'], + 'is_contact' => TRUE, + 'actions' => [ + ['id' => 'select', 'text' => ts('Match existing')], + ['id' => 'update', 'text' => ts('Update existing'), ts('Skip if not found')], + ['id' => 'update_or_create', 'text' => ts('Update or Create')], + ], + 'default_action' => 'select', + 'entity_name' => 'Contact', + 'entity_title' => ts('Contribution Contact'), + ], + 'SoftCreditContact' => [ + 'text' => ts('Soft Credit Contact Fields'), + 'maximum' => count($softCreditTypes), + 'unique_fields' => ['external_identifier', 'id'], + 'is_contact' => TRUE, + 'actions' => [ + ['id' => 'select', 'text' => ts('Match existing')], + ['id' => 'update', 'text' => ts('Update existing'), 'description' => ts('Skip if not found')], + ['id' => 'update_or_create', 'text' => ts('Update or Create')], + ], + 'default_action' => 'select', + 'entity_name' => 'SoftCreditContact', + 'entity_title' => ts('Soft Credit Contact'), + 'entity_data' => ['soft_credit_type_id' => ['required' => TRUE, 'options' => $softCreditTypes]], + ], + ]; + } + /** * Combine all the importable fields from the lower levels object. * diff --git a/CRM/Custom/Import/Parser/Api.php b/CRM/Custom/Import/Parser/Api.php index 60a35a2f79..1c1ec51efb 100644 --- a/CRM/Custom/Import/Parser/Api.php +++ b/CRM/Custom/Import/Parser/Api.php @@ -93,7 +93,7 @@ class CRM_Custom_Import_Parser_Api extends CRM_Import_Parser { * @return array */ public function getRequiredFields(): array { - return ['contact_id' => ts('Contact ID'), 'external_identifier' => ts('External Identifier')]; + return [['contact_id'], ['external_identifier']]; } /** diff --git a/CRM/Event/Import/Parser/Participant.php b/CRM/Event/Import/Parser/Participant.php index 5d5b40edad..7d3e02d54b 100644 --- a/CRM/Event/Import/Parser/Participant.php +++ b/CRM/Event/Import/Parser/Participant.php @@ -518,7 +518,7 @@ class CRM_Event_Import_Parser_Participant extends CRM_Import_Parser { * @return array */ protected function getRequiredFields(): array { - return [['event_id' => ts('Event'), 'status_id' => ts('Status')]]; + return [['event_id', 'status_id']]; } } diff --git a/CRM/Import/Parser.php b/CRM/Import/Parser.php index 14e3661a1d..bf44079784 100644 --- a/CRM/Import/Parser.php +++ b/CRM/Import/Parser.php @@ -125,6 +125,15 @@ abstract class CRM_Import_Parser implements UserJobInterface { return []; } + /** + * An array of Custom field mappings for api formatting + * + * e.g ['custom_7' => 'IndividualData.Marriage_date'] + * + * @var array + */ + protected $customFieldNameMap = []; + /** * Get User Job. * @@ -625,25 +634,44 @@ abstract class CRM_Import_Parser implements UserJobInterface { if (!empty($params['id'])) { return; } - $requiredFields = [ - 'Individual' => [ - 'first_name_last_name' => ['first_name' => ts('First Name'), 'last_name' => ts('Last Name')], - 'email' => ts('Email Address'), - ], - 'Organization' => ['organization_name' => ts('Organization Name')], - 'Household' => ['household_name' => ts('Household Name')], - ][$contactType]; + $requiredFields = $this->getRequiredFieldsContactCreate()[$contactType]; if ($isPermitExistingMatchFields) { - $requiredFields['external_identifier'] = ts('External Identifier'); // Historically just an email has been accepted as it is 'usually good enough' // for a dedupe rule look up - but really this is a stand in for // whatever is needed to find an existing matching contact using the // specified dedupe rule (or the default Unsupervised if not specified). - $requiredFields['email'] = ts('Email Address'); + $requiredFields = $contactType === 'Individual' ? [[$requiredFields, 'external_identifier']] : [[$requiredFields, 'email', 'external_identifier']]; } $this->validateRequiredFields($requiredFields, $params, $prefixString); } + /** + * Get the fields required for contact create. + * + * @return array + */ + protected function getRequiredFieldsContactMatch(): array { + return [['id', 'external_identifier']]; + } + + /** + * Get the fields required for contact create. + * + * @return array + */ + protected function getRequiredFieldsContactCreate(): array { + return [ + 'Individual' => [ + [ + ['first_name', 'last_name'], + 'email', + ], + ], + 'Organization' => ['organization_name'], + 'Household' => ['household_name'], + ]; + } + protected function doPostImportActions() { $userJob = $this->getUserJob(); $summaryInfo = $userJob['metadata']['summary_info'] ?? []; @@ -1342,8 +1370,8 @@ abstract class CRM_Import_Parser implements UserJobInterface { * - note this follows the and / or array nesting we see in permission checks * eg. * [ - * 'email' => ts('Email'), - * ['first_name' => ts('First Name'), 'last_name' => ts('Last Name')] + * 'email', + * ['first_name', 'last_name'] * ] * Means 'email' OR 'first_name AND 'last_name'. * @param string $prefixString @@ -1351,41 +1379,149 @@ abstract class CRM_Import_Parser implements UserJobInterface { * @throws \CRM_Core_Exception Exception thrown if field requirements are not met. */ protected function validateRequiredFields(array $requiredFields, array $params, $prefixString = ''): void { - if (empty($requiredFields)) { + $missingFields = $this->getMissingFields($requiredFields, $params); + if (empty($missingFields)) { return; } - $missingFields = []; - foreach ($requiredFields as $key => $required) { - if (!is_array($required)) { - $importParameter = $params[$key] ?? []; - if (!is_array($importParameter)) { - if (!empty($importParameter)) { - return; - } - } - else { - foreach ($importParameter as $locationValues) { - if (!empty($locationValues[$key])) { - return; - } - } - } + throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields)); + } - $missingFields[$key] = $required; + /** + * Validate that the mapping has the required fields. + * + * @throws \CRM_Core_Exception + */ + public function validateMapping($mapping): void { + $mappedFields = []; + foreach ($mapping as $mappingField) { + $mappedFields[$mappingField[0]] = $mappingField[0]; + } + $entity = $this->baseEntity; + $missingFields = $this->getMissingFields($this->getRequiredFieldsForEntity($entity, $this->getActionForEntity($entity)), $mappedFields); + if (!empty($missingFields)) { + $error = []; + foreach ($missingFields as $missingField) { + $error[] = ts('Missing required field: %1', [1 => $missingField]); } - else { - foreach ($required as $field => $label) { - if (empty($params[$field])) { - $missing[$field] = $label; - } + throw new CRM_Core_Exception(implode('
', $error)); + } + } + + /** + * Get the import action for the given entity. + * + * @param string $entity + * + * @return string + * @throws \API_Exception + */ + private function getActionForEntity(string $entity): string { + return $this->getUserJob()['metadata']['entity_metadata'][$entity]['action'] ?? $this->getImportEntities()[$entity]['default_action']; + } + + /** + * @param string $entity + * @param string $action + * + * @return array + */ + private function getRequiredFieldsForEntity(string $entity, string $action): array { + $entityMetadata = $this->getImportEntities()[$entity]; + if ($action === 'select') { + // Select uses the same lookup as update. + $action = 'update'; + } + if (isset($entityMetadata['required_fields_' . $action])) { + return $entityMetadata['required_fields_' . $action]; + } + return []; + } + + /** + * Get the field requirements that are missing from the params array. + * + * Eg Must have 'total_amount' and 'financial_type_id' + * [ + * 'total_amount', + * 'financial_type_id' + * ] + * + * Eg Must have 'invoice_id' or 'trxn_id' or 'id' + * + * [ + * ['invoice_id'], + * ['trxn_id'], + * ['id'] + * ], + * + * Eg Must have 'invoice_id' or 'trxn_id' or 'id' OR (total_amount AND financial_type_id) + * [ + * [['invoice_id'], ['trxn_id'], ['id']]], + * ['total_amount', 'financial_type_id] + * ], + * + * Eg Must have 'invoice_id' or 'trxn_id' or 'id' AND (total_amount AND financial_type_id) + * [ + * [['invoice_id'], ['trxn_id'], ['id']], + * ['total_amount', 'financial_type_id] + * ] + * + * @param array $requiredFields + * @param array $params + * + * @return array + */ + protected function getMissingFields(array $requiredFields, array $params): array { + if (empty($requiredFields)) { + return []; + } + return $this->checkRequirement($requiredFields, $params); + } + + /** + * Check an individual required fields criteria. + * + * @see getMissingFields + * + * @param string|array $requirement + * @param array $params + * + * @return array + */ + private function checkRequirement($requirement, array $params): array { + $missing = []; + if (!is_array($requirement)) { + // In this case we need to match the field.... + // if we do, then return empty, otherwise return + if (!empty($params[$requirement])) { + if (!is_array($params[$requirement])) { + return []; } - if (empty($missing)) { - return; + // Recurse the array looking for the key - eg. look for email + // in a location values array + foreach ($params[$requirement] as $locationValues) { + if (!empty($locationValues[$requirement])) { + return []; + } } - $missingFields[$key] = implode(' ' . ts('and') . ' ', $missing); } + return [$requirement => $this->getFieldMetadata($requirement)['title']]; } - throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields)); + + foreach ($requirement as $required) { + $isOrOperator = isset($requirement[0]) && is_array($requirement[0]); + $check = $this->checkRequirement($required, $params); + // A nested array is an 'OR' If we find any one then return. + if ($isOrOperator && empty($check)) { + return []; + } + $missing = array_merge($missing, $check); + } + if (!empty($missing)) { + $separator = ' ' . ($isOrOperator ? ts('OR') : ts('and')) . ' '; + return [implode($separator, $missing)]; + } + return []; } /** @@ -2107,13 +2243,18 @@ abstract class CRM_Import_Parser implements UserJobInterface { * * @param string $key * + * @return string + * * @throws \CRM_Core_Exception */ protected function getApi4Name(string $key): string { - return Contact::getFields(FALSE) - ->addWhere('custom_field_id', '=', $this->getFieldMetadata($key)['custom_field_id']) - ->addSelect('name') - ->execute()->first()['name']; + if (!isset($this->customFieldNameMap[$key])) { + $this->customFieldNameMap[$key] = Contact::getFields(FALSE) + ->addWhere('custom_field_id', '=', str_replace('custom_', '', $key)) + ->addSelect('name') + ->execute()->first()['name']; + } + return $this->customFieldNameMap[$key]; } /** diff --git a/tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php b/tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php index 753717e463..ba35fc26fc 100644 --- a/tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php +++ b/tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php @@ -1070,7 +1070,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase { 'individual_required' => [ 'csv' => 'individual_invalid_missing_name.csv', 'mapper' => [['last_name']], - 'expected_error' => 'Missing required fields: First Name OR Email Address', + 'expected_error' => 'Missing required fields: First Name OR Email', ], 'individual_related_required_met' => [ 'csv' => 'individual_valid_with_related_email.csv', @@ -1080,7 +1080,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase { 'individual_related_required_not_met' => [ 'csv' => 'individual_invalid_with_related_phone.csv', 'mapper' => [['first_name'], ['last_name'], ['1_a_b', 'phone', 1, 2]], - 'expected_error' => '(Child of) Missing required fields: First Name and Last Name OR Email Address OR External Identifier', + 'expected_error' => '(Child of) Missing required fields: First Name and Last Name OR Email OR External Identifier', ], 'individual_bad_email' => [ 'csv' => 'individual_invalid_email.csv', @@ -1096,7 +1096,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase { // External identifier is only enough in upgrade mode. 'csv' => 'individual_invalid_external_identifier_only.csv', 'mapper' => [['external_identifier'], ['gender_id']], - 'expected_error' => 'Missing required fields: First Name and Last Name OR Email Address', + 'expected_error' => 'Missing required fields: First Name and Last Name OR Email', ], 'individual_invalid_external_identifier_only_update_mode' => [ // External identifier only enough in upgrade mode, so no error here. diff --git a/tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php b/tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php index 335f5bc86e..1d0a26c311 100644 --- a/tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php +++ b/tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php @@ -125,7 +125,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase { $contactID = $this->individualCreate(); $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', 'payment_instrument_id' => 'Check', 'contribution_status_id' => 'Pending']; // Note that the expected result should logically be CRM_Import_Parser::valid but writing test to reflect not fix here - $this->runImport($values, CRM_Import_Parser::DUPLICATE_SKIP, NULL); + $this->runImport($values, CRM_Import_Parser::DUPLICATE_SKIP); $contribution = $this->callAPISuccessGetSingle('Contribution', ['contact_id' => $contactID]); $this->assertEquals('Pending Label**', $contribution['contribution_status']); @@ -172,13 +172,13 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase { $contactID = $this->individualCreate(); $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', 'payment_instrument_id' => 'Check', 'contribution_status_id' => 'Pending']; // Note that the expected result should logically be CRM_Import_Parser::valid but writing test to reflect not fix here - $this->runImport($values, CRM_Import_Parser::DUPLICATE_SKIP, NULL); + $this->runImport($values, CRM_Import_Parser::DUPLICATE_SKIP); $contribution = $this->callAPISuccess('Contribution', 'getsingle', ['contact_id' => $contactID]); $this->createCustomGroupWithFieldOfType([], 'radio'); $values['contribution_id'] = $contribution['id']; $values[$this->getCustomFieldName('radio')] = 'Red Testing'; unset(Civi::$statics['CRM_Core_BAO_OptionGroup']); - $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, NULL); + $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE); $contribution = $this->callAPISuccess('Contribution', 'get', ['contact_id' => $contactID, $this->getCustomFieldName('radio') => 'Red Testing']); $this->assertEquals(5, $contribution['values'][$contribution['id']]['custom_' . $this->ids['CustomField']['radio']]); $this->callAPISuccess('CustomField', 'delete', ['id' => $this->ids['CustomField']['radio']]); @@ -260,8 +260,8 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase { $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', $customField => 'L,V']; $this->runImport($values, CRM_Import_Parser::DUPLICATE_SKIP, NULL); $initialContribution = $this->callAPISuccessGetSingle('Contribution', ['contact_id' => $contactID]); - $this->assertContains('L', $initialContribution[$customField], "Contribution Duplicate Skip Import contains L"); - $this->assertContains('V', $initialContribution[$customField], "Contribution Duplicate Skip Import contains V"); + $this->assertContains('L', $initialContribution[$customField], 'Contribution Duplicate Skip Import contains L'); + $this->assertContains('V', $initialContribution[$customField], 'Contribution Duplicate Skip Import contains V'); // Now update. $values['contribution_id'] = $initialContribution['id']; @@ -332,6 +332,64 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase { $this->assertEquals($anthony, $contribution['contact_id']); } + /** + * Test that a trxn_id is enough in update mode to void the total_amount requirement. + * + * @throws \CRM_Core_Exception + */ + public function testImportFieldsNotRequiredWithTrxnID(): void { + $this->individualCreate(['email' => 'mum@example.com']); + $fieldMappings = [ + ['name' => 'first_name'], + ['name' => ''], + ['name' => 'receive_date'], + ['name' => 'financial_type_id'], + ['name' => 'email'], + ['name' => ''], + ['name' => ''], + ['name' => 'trxn_id'], + ]; + // First we try to create without total_amount mapped. + // It will fail in create mode as total_amount is required for create. + $this->submitDataSourceForm('contributions.csv', $fieldMappings, ['onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP]); + $form = $this->getMapFieldForm([ + 'onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP, + 'mapper' => $this->getMapperFromFieldMappings($fieldMappings), + 'contactType' => CRM_Import_Parser::CONTACT_INDIVIDUAL, + ]); + $form->setUserJobID($this->userJobID); + $form->buildForm(); + $this->assertFalse($form->validate()); + $this->assertEquals(['_qf_default' => 'Missing required field: Total Amount'], $form->_errors); + + // Now we add in total amount - it works in create mode. + $fieldMappings[1]['name'] = 'total_amount'; + $this->importCSV('contributions.csv', $fieldMappings, ['onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP]); + + $row = $this->getDataSource()->getRows()[0]; + $this->assertEquals('IMPORTED', $row[9]); + $contribution = Contribution::get()->addSelect('source', 'id')->execute()->first(); + $this->assertEmpty($contribution['source']); + + // Now we re-import as an update, only setting the 'source' field. + $fieldMappings = [ + ['name' => ''], + ['name' => ''], + ['name' => ''], + ['name' => ''], + ['name' => ''], + ['name' => ''], + ['name' => 'contribution_source'], + ['name' => 'trxn_id'], + ]; + $this->importCSV('contributions.csv', $fieldMappings, ['onDuplicate' => CRM_Import_Parser::DUPLICATE_UPDATE]); + + $row = $this->getDataSource()->getRows()[0]; + $this->assertEquals('IMPORTED', $row[9]); + $contribution = Contribution::get()->addSelect('source', 'id')->execute()->first(); + $this->assertEquals('Call him back', $contribution['source']); + } + /** * @throws \CRM_Core_Exception */ @@ -416,7 +474,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase { /** * @param array $submittedValues * - * @return array + * @return int * * @throws \API_Exception * @throws \CRM_Core_Exception diff --git a/tests/phpunit/CRMTraits/Import/ParserTrait.php b/tests/phpunit/CRMTraits/Import/ParserTrait.php index a21182dc05..c895bf26d7 100644 --- a/tests/phpunit/CRMTraits/Import/ParserTrait.php +++ b/tests/phpunit/CRMTraits/Import/ParserTrait.php @@ -31,10 +31,7 @@ trait CRMTraits_Import_ParserTrait { * @param array $submittedValues */ protected function importCSV(string $csv, array $fieldMappings, array $submittedValues = []): void { - $reflector = new ReflectionClass(get_class($this)); - $directory = dirname($reflector->getFileName()); $submittedValues = array_merge([ - 'uploadFile' => ['name' => $directory . '/data/' . $csv], 'skipColumnHeader' => TRUE, 'fieldSeparator' => ',', 'contactType' => CRM_Import_Parser::CONTACT_INDIVIDUAL, @@ -45,13 +42,7 @@ trait CRMTraits_Import_ParserTrait { 'onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP, 'groups' => [], ], $submittedValues); - $form = $this->getDataSourceForm($submittedValues); - $values = $_SESSION['_' . $form->controller->_name . '_container']['values']; - $form->buildForm(); - $form->postProcess(); - $this->userJobID = $form->getUserJobID(); - // This gets reset in DataSource so re-do.... - $_SESSION['_' . $form->controller->_name . '_container']['values'] = $values; + $this->submitDataSourceForm($csv, $submittedValues); $form = $this->getMapFieldForm($submittedValues); $form->setUserJobID($this->userJobID); @@ -103,4 +94,33 @@ trait CRMTraits_Import_ParserTrait { return new CRM_Import_DataSource_CSV($this->userJobID); } + /** + * Submit the data source form. + * + * @param string $csv + * @param array $submittedValues + */ + protected function submitDataSourceForm(string $csv, $submittedValues): void { + $reflector = new ReflectionClass(get_class($this)); + $directory = dirname($reflector->getFileName()); + $submittedValues = array_merge([ + 'uploadFile' => ['name' => $directory . '/data/' . $csv], + 'skipColumnHeader' => TRUE, + 'fieldSeparator' => ',', + 'contactType' => CRM_Import_Parser::CONTACT_INDIVIDUAL, + 'dataSource' => 'CRM_Import_DataSource_CSV', + 'file' => ['name' => $csv], + 'dateFormats' => CRM_Core_Form_Date::DATE_yyyy_mm_dd, + 'onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP, + 'groups' => [], + ], $submittedValues); + $form = $this->getDataSourceForm($submittedValues); + $values = $_SESSION['_' . $form->controller->_name . '_container']['values']; + $form->buildForm(); + $form->postProcess(); + $this->userJobID = $form->getUserJobID(); + // This gets reset in DataSource so re-do.... + $_SESSION['_' . $form->controller->_name . '_container']['values'] = $values; + } + } -- 2.25.1