[REF] Standardise validation of mapped fields in imports
authorEileen McNaughton <emcnaughton@wikimedia.org>
Thu, 1 Sep 2022 02:50:09 +0000 (14:50 +1200)
committerEileen McNaughton <emcnaughton@wikimedia.org>
Thu, 1 Sep 2022 22:02:21 +0000 (10:02 +1200)
CRM/Activity/Import/Parser/Activity.php
CRM/Contribute/Import/Form/MapField.php
CRM/Contribute/Import/Parser/Contribution.php
CRM/Custom/Import/Parser/Api.php
CRM/Event/Import/Parser/Participant.php
CRM/Import/Parser.php
tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php
tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php
tests/phpunit/CRMTraits/Import/ParserTrait.php

index e9508a59e084994880006482fe831f72f21f9c97..50fe0d93a23b5c10d9e9c4625853dbda77d083c7 100644 (file)
@@ -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']];
   }
 
   /**
index 4cd4aed6493af54c03a260099e20f31b0c9e7e99..60499bb907952fcba14a4bcff33fd6a209a59d64 100644 (file)
@@ -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]) . '<br />';
           }
-          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.') . '<br />';
-          }
-        }
-        else {
-          $errors['_qf_default'] .= ts('Missing required field: %1', [1 => $title]) . '<br />';
         }
       }
     }
@@ -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.') . '<br />';
-        }
+      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;
   }
 
   /**
index d3cb74c69b8ada6d0932ba65b2ab16371c75c55f..a67a94574ddd90a37c4946a325d79a92481fc2c2 100644 (file)
@@ -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.
    *
index 60a35a2f79b4632d411830436bf44b40a61e43e2..1c1ec51efb66c4786a246a1207bd336c139198eb 100644 (file)
@@ -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']];
   }
 
   /**
index 5d5b40edad7866992eea0ba4f404e4702eecef08..7d3e02d54b48c043331d078ba7da2f335700131b 100644 (file)
@@ -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']];
   }
 
 }
index 14e3661a1d1d51e1098c846f25f1bc3b2fc818c4..bf440797847b44183f1702867632e10cde907afa 100644 (file)
@@ -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('<br/>', $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];
   }
 
   /**
index 753717e4630796a853111a34d0aec7629bae6a21..ba35fc26fc6ffd4cf7c54c1dc50e145575f7bc0c 100644 (file)
@@ -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.
index 335f5bc86e6a840ed9f6fe238cef2428bab382b1..1d0a26c311ddd3b1d223629e0b265ad2dd77c55c 100644 (file)
@@ -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
index a21182dc0593f47bd4db94c15ef08452e23ebf31..c895bf26d7d4a38ecbf3fd6962e29ad976a3c108 100644 (file)
@@ -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;
+  }
+
 }