CRM key`, and it is that way round deliberately. The reverse * reads more naturally in a settings screen, but it cannot express what actually happens: two source * fields legitimately map to one CRM field (a form with "First name" and "Given name" on different * pages), while one source field filling two CRM fields is a request nobody has ever made. Keying by * source makes the common case expressible and the nonsense case unrepresentable. It matches the * mapping JSON `ImportExport` already stores on the `imports` row. * * IT RESOLVES, IT DOES NOT WRITE. {@see apply()} hands back a value bag split into the entity's own * fields and its custom-field values, because those go to different places — * `plugixa_crm_record_writers` for the first and `CustomFieldValueService` for the second — and a * class that knew about both would be a class that had to be told which entities have which. What * to do with the split is the connector's business. */ final class FieldMap { /** * The prefix that marks a custom field, shared with filters, exports and the query builder. * * NOT A LOCAL CONSTANT WITH A COPY OF THE SAME STRING: `CustomFields\Module` composes the same * `cf_` prefix when it contributes filterable fields and export columns. If it ever changes, a * second literal spelled the same way is the one that gets missed. */ public const CUSTOM_PREFIX = 'cf_'; public function __construct( private readonly EntityRegistry $entities ) {} /** * Narrow a stored mapping to pairs that still mean something. * * A MAPPING OUTLIVES THE FIELDS IT NAMES. Somebody deletes a custom field, or a form gets a * question renamed, and the stored mapping still points at both. Resolving it fresh each time — * rather than trusting what was saved — is what stops a connector writing to a column that no * longer exists, and it is why this returns a filtered map instead of validating in place. * * @param string $entity_type CRM entity slug. * @param array $mapping Source key => CRM key. * * @return array The pairs whose CRM side is real. */ public function resolve( string $entity_type, array $mapping ): array { $known = $this->targets( $entity_type ); $out = array(); foreach ( $mapping as $source => $target ) { $source = (string) $source; $target = (string) $target; // An empty target is how the UI says "do not import this column". It is not an error and // must not be reported as one, or every mapping screen with a skipped column complains. if ( '' === $source || '' === $target ) { continue; } if ( isset( $known[ $target ] ) ) { $out[ $source ] = $target; } } return $out; } /** * Everything a row of this entity can be mapped onto. * * @param string $entity_type CRM entity slug. * * @return array CRM key => label. */ public function targets( string $entity_type ): array { $descriptor = $this->entities->get( $entity_type ); if ( null === $descriptor ) { return array(); } $targets = array(); // The DESCRIPTOR's fields, which already include custom fields under `cf_`: `CustomFields` // contributes them through `plugixa_crm_entity_fields`. Reading them from here rather than // asking the custom-field repository directly means a connector inherits the published and // filterable flags without knowing they exist. foreach ( (array) ( $descriptor['fields'] ?? array() ) as $name => $definition ) { $targets[ (string) $name ] = (string) ( $definition['label'] ?? $name ); } return $targets; } /** * Turn one source row into CRM values. * * @param string $entity_type CRM entity slug. * @param array $row The other system's record. * @param array $mapping Source key => CRM key. * * @return array{fields: array, custom: array} */ public function apply( string $entity_type, array $row, array $mapping ): array { $fields = array(); $custom = array(); foreach ( $this->resolve( $entity_type, $mapping ) as $source => $target ) { // A source field the row simply does not carry is skipped, not written as empty. Those are // different instructions: a partial payload — a webhook that sends only what changed — would // otherwise blank every field it did not mention. if ( ! array_key_exists( $source, $row ) ) { continue; } $value = $row[ $source ]; if ( str_starts_with( $target, self::CUSTOM_PREFIX ) ) { $custom[ substr( $target, strlen( self::CUSTOM_PREFIX ) ) ] = $value; continue; } $fields[ $target ] = $value; } return array( 'fields' => $fields, 'custom' => $custom, ); } /** * Whether a CRM key names a custom field. * * @param string $target CRM key. */ public static function is_custom( string $target ): bool { return str_starts_with( $target, self::CUSTOM_PREFIX ); } }