From bfabad9ba67bc42e42831c28fd5088ae3fad01b4 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 | 226 ++++++++++++++---- .../CRM/Contact/Import/Parser/ContactTest.php | 6 +- .../Import/Parser/ContributionTest.php | 70 +++++- .../phpunit/CRMTraits/Import/ParserTrait.php | 40 +++- 9 files changed, 377 insertions(+), 112 deletions(-) diff --git a/CRM/Activity/Import/Parser/Activity.php b/CRM/Activity/Import/Parser/Activity.php index e9508a59e084..50fe0d93a23b 100644 --- a/CRM/Activity/Import/Parser/Activity.php +++ b/CRM/Activity/Import/Parser/Activity.php @@ -179,7 +179,7 @@ public function getMappedRow(array $values): array { * @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 4cd4aed6493a..60499bb90795 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 @@ public static function formRule($fields, $files, $self) { 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 d3cb74c69b8a..8052366d312f 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 @@ public static function getUserJobInfo(): array { * 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 @@ protected function getFieldMappings(): array { * @return array */ public function getRequiredFields(): array { - return ['id' => ts('Contribution ID'), ['financial_type_id' => ts('Financial Type'), 'total_amount' => ts('Total Amount')]]; + return array_merge([$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 @@ protected function setFieldMetadata(): void { } } + /** + * 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 60a35a2f79b4..9e8a91724982 100644 --- a/CRM/Custom/Import/Parser/Api.php +++ b/CRM/Custom/Import/Parser/Api.php @@ -93,7 +93,7 @@ public function setFieldMetadata(): void { * @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 5d5b40edad78..7d3e02d54b48 100644 --- a/CRM/Event/Import/Parser/Participant.php +++ b/CRM/Event/Import/Parser/Participant.php @@ -518,7 +518,7 @@ protected function setFieldMetadata(): void { * @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 14e3661a1d1d..cdfc883c192a 100644 --- a/CRM/Import/Parser.php +++ b/CRM/Import/Parser.php @@ -125,6 +125,15 @@ public function getTrackingFields(): array { 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 @@ protected function validateRequiredContactFields(string $contactType, array $par 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'] ?? []; @@ -1351,41 +1379,154 @@ public static function unserializeCustomValue($customFieldID, $value, $fieldType * @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; } + throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields)); + } + + /** + * 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]); + } + 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' + * ]m + * + * 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 { $missingFields = []; - foreach ($requiredFields as $key => $required) { - if (!is_array($required)) { - $importParameter = $params[$key] ?? []; - if (!is_array($importParameter)) { - if (!empty($importParameter)) { - return; - } + if (empty($requiredFields)) { + return []; + } + foreach ($requiredFields as $required) { + $missingFields = array_merge($this->checkRequirement($required, $params), $missingFields); + } + return $missingFields; + } + + /** + * 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 []; } - else { - foreach ($importParameter as $locationValues) { - if (!empty($locationValues[$key])) { - 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] = $required; } - else { - foreach ($required as $field => $label) { - if (empty($params[$field])) { - $missing[$field] = $label; - } - } - if (empty($missing)) { - return; - } - $missingFields[$key] = implode(' ' . ts('and') . ' ', $missing); + return [$requirement => $this->getFieldMetadata($requirement)['title']]; + } + + 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); } - throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields)); + if (!empty($missing)) { + $separator = ' ' . ($isOrOperator ? ts('OR') : ts('and')) . ' '; + return [implode($separator, $missing)]; + } + return []; } /** @@ -2107,13 +2248,18 @@ protected function getPossibleMatchesByDedupeRule(array $params, $dedupeRuleID = * * @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 753717e46307..ba35fc26fc6f 100644 --- a/tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php +++ b/tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php @@ -1070,7 +1070,7 @@ public function validateDataProvider(): array { '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 @@ public function validateDataProvider(): array { '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 @@ public function validateDataProvider(): array { // 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 335f5bc86e6a..1d0a26c311dd 100644 --- a/tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php +++ b/tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php @@ -125,7 +125,7 @@ public function testContributionStatusLabel(): void { $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 @@ public function testParsedCustomOption(): void { $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 @@ public function testCustomSerializedCheckBox(): void { $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 @@ public function testImportMatchNonPrimary(): void { $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 @@ protected function runImport(array $originalValues, int $onDuplicateAction, ?int /** * @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 a21182dc0593..c895bf26d7d4 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 @@ protected function importCSV(string $csv, array $fieldMappings, array $submitted '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 @@ protected function getDataSource(): CRM_Import_DataSource { 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; + } + }