repository = $repository ?? new Repository(); $this->appointments = $appointments; } public static function permitted( string $kind ): bool { return $kind === 'services' ? current_user_can( 'abientot_manage_services' ) : ( $kind === 'customers' ? current_user_can( 'abientot_manage_customers' ) : ( $kind === 'appointments' && current_user_can( 'abientot_manage_customers' ) && current_user_can( 'abientot_manage_bookings' ) ) ); } public static function pro_enabled(): bool { return \Abientot\Edition::is_pro() && true === apply_filters( 'abientot_import_pro_enabled', false ); } /** Shared engine entry points enforce edition restrictions independently of the UI. */ public static function source_format( string $format ): array { $formats = Sources::formats(); $definition = $formats[ $format ] ?? null; if ( ! is_array( $definition ) || ( ! self::pro_enabled() && ! in_array( $definition['edition'] ?? '', array( 'free', 'shared' ), true ) ) ) { throw new \DomainException( 'import_permission' ); } return $definition; } public static function destination( string $type, int $id ): void { if ( self::pro_enabled() ) { return; } $record = $type === 'staff' ? ( new \Abientot\Repositories\StaffRepository() )->default_staff() : ( new \Abientot\Repositories\LocationsRepository() )->default_location(); if ( ! $record || (int) $record['id'] !== $id ) { throw new \DomainException( 'import_reference' ); } } private static function provenance( string $kind, array $raw, array $settings ): array { $format = $settings['source_format'] ?? ''; if ( $format === '' ) { if ( ! self::pro_enabled() ) { throw new \DomainException( 'import_permission' ); } return array(); } if ( ! is_string( $format ) || ! is_string( $settings['source_bytes'] ?? null ) || ! is_string( $settings['source_delimiter'] ?? null ) ) { throw new \DomainException( 'import_settings' ); } $definition = self::source_format( $format ); if ( ( $definition['kind'] ?? '' ) !== $kind ) { throw new \DomainException( 'import_kind' ); } $normalized = Sources::normalize( $format, $settings['source_bytes'], $settings['source_delimiter'] ); $parsed = Csv::parse( $normalized['csv'], ',' ); if ( Csv::map( $parsed, $kind, $parsed['headers'] ) !== $raw ) { throw new \DomainException( 'import_mapping' ); } return array( 'source_format' => $format, 'source_digest' => hash( 'sha256', $settings['source_bytes'] ) ); } public function supports_appointments(): bool { return $this->appointments !== null && is_callable( $this->appointments['validate'] ?? null ) && is_callable( $this->appointments['write'] ?? null ); } public static function integer( $value, int $min = 0, int $max = 2147483647 ): int { if ( ( ! is_string( $value ) && ! is_int( $value ) ) || ! preg_match( '/^(0|[1-9][0-9]*)$/D', (string) $value ) ) { throw new \DomainException( 'import_integer' ); } $out = filter_var( $value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => $min, 'max_range' => $max ) ) ); if ( $out === false ) { throw new \DomainException( 'import_integer' ); } return $out; } private static function text( $value, int $max = 190 ): string { if ( ! is_string( $value ) || strlen( $value ) > $max || ! preg_match( '//u', $value ) ) { throw new \DomainException( 'import_text' ); } $value = trim( $value ); if ( sanitize_text_field( $value ) !== $value ) { throw new \DomainException( 'import_text' ); } return $value; } private static function customer( array $raw ): array { $name = self::text( $raw['display_name'] ?? '' ); $email = strtolower( self::text( $raw['email'] ?? '' ) ); $phone = self::text( $raw['phone'] ?? '', 80 ); if ( $name === '' || ( $email === '' && $phone === '' ) || ( $email !== '' && ( ! is_email( $email ) || sanitize_email( $email ) !== $email ) ) || ( $phone !== '' && ! preg_match( '/^\+?[0-9 ().-]{3,80}$/D', $phone ) ) ) { throw new \DomainException( 'import_customer' ); } $phone = preg_replace( '/[ ().-]/', '', $phone ); return array( 'display_name' => $name, 'email' => $email, 'phone' => $phone, 'marketing_opt_in' => 0 ); } public static function normalize( string $kind, array $raw ): array { if ( array_diff( array_keys( $raw ), Csv::fields( $kind ) ) ) { throw new \DomainException( 'import_unknown_field' ); } $key = self::text( $raw['source_key'] ?? '' ); if ( $key === '' ) { throw new \DomainException( 'import_source_key' ); } $out = array( 'source_key' => $key ); if ( $kind === 'customers' || $kind === 'appointments' ) { $out['customer'] = self::customer( $raw ); } if ( $kind === 'services' ) { $name = self::text( $raw['name'] ?? '' ); $slug = self::text( $raw['slug'] ?? '' ); if ( $name === '' || ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug ) ) { throw new \DomainException( 'import_service' ); } $out['service'] = array( 'name' => $name, 'slug' => $slug, 'duration_minutes' => self::integer( $raw['duration_minutes'] ?? null, 1, 1440 ), 'price_cents' => self::integer( $raw['price_cents'] ?? null ), 'currency' => self::currency( $raw['currency'] ?? null ), 'buffer_before_minutes' => self::integer( ( $raw['buffer_before_minutes'] ?? '' ) === '' ? 0 : $raw['buffer_before_minutes'], 0, 1440 ), 'buffer_after_minutes' => self::integer( ( $raw['buffer_after_minutes'] ?? '' ) === '' ? 0 : $raw['buffer_after_minutes'], 0, 1440 ), 'status' => 'active', 'visibility' => 'public' ); } if ( $kind === 'appointments' ) { $start = self::text( $raw['start_utc'] ?? '', 19 ); $instant = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $start, new \DateTimeZone( 'UTC' ) ); if ( ! $instant || $instant->format( 'Y-m-d H:i:s' ) !== $start || $start < '1000-01-01 00:00:00' ) { throw new \DomainException( 'import_utc' ); } $duration = self::integer( $raw['duration_minutes'] ?? null, 1, 1440 ); $timezone = self::text( $raw['timezone'] ?? '', 100 ); if ( ( $timezone !== 'UTC' && strpos( $timezone, '/' ) === false ) || ( ! in_array( $timezone, \DateTimeZone::listIdentifiers(), true ) && $timezone !== 'UTC' ) ) { throw new \DomainException( 'import_timezone' ); } $end = $instant->modify( '+' . $duration . ' minutes' )->format( 'Y-m-d H:i:s' ); if ( strlen( $end ) !== 19 || $end > '9999-12-31 23:59:59' ) { throw new \DomainException( 'import_utc' ); } $out['appointment'] = array( 'start_utc' => $start, 'end_utc' => $end, 'timezone' => $timezone, 'duration_minutes' => $duration, 'price_cents' => self::integer( $raw['price_cents'] ?? null ), 'currency' => self::currency( $raw['currency'] ?? null ), 'source' => 'import', 'payment_status' => 'unpaid' ); foreach ( array( 'service_key', 'staff_key', 'location_key', 'status_slug' ) as $field ) { $out[ $field ] = self::text( $raw[ $field ] ?? '' ); if ( $out[ $field ] === '' ) { throw new \DomainException( 'import_reference' ); } } } if ( isset( $out['service'] ) ) { \Abientot\Support\Currency::validate( (int) $out['service']['price_cents'], $out['service']['currency'] ); } return $out; } private static function currency( $value ): string { if ( ! is_string( $value ) || ! preg_match( '/^[A-Z]{3}$/D', $value ) ) { throw new \DomainException( 'import_currency' ); } return $value; } private function customer_plan( array $customer, string $policy ): array { $matches = $this->repository->contacts( $customer['email'], $customer['phone'] ); if ( count( $matches ) > 1 ) { throw new \DomainException( 'import_ambiguous_contact' ); } if ( $matches && $policy === 'reject' ) { throw new \DomainException( 'import_duplicate' ); } return array( 'id' => (int) ( $matches[0]['id'] ?? 0 ), 'label' => $matches[0]['display_name'] ?? '', 'revision' => $matches ? Token::digest( $matches[0] ) : '' ); } /** Settings are explicit manager-selected canonical IDs, never numeric CSV lookups. */ private function settings( string $kind, array $settings ): array { if ( array_diff( array_keys( $settings ), array( 'duplicate_policy', 'source_namespace', 'staff_id', 'references', 'time_format', 'source_format', 'source_bytes', 'source_delimiter' ) ) ) { throw new \DomainException( 'import_settings' ); } $policy = $settings['duplicate_policy'] ?? ''; if ( ! in_array( $policy, array( 'reuse', 'reject' ), true ) ) { throw new \DomainException( 'import_policy' ); } $namespace = self::text( $settings['source_namespace'] ?? '' ); if ( $namespace === '' || strlen( $namespace ) > 100 ) { throw new \DomainException( 'import_namespace' ); } $out = array( 'duplicate_policy' => $policy, 'source_namespace' => $namespace ); if ( $kind === 'services' ) { $id = self::integer( $settings['staff_id'] ?? null, 1 ); $staff = $this->repository->find( 'staff', $id ); if ( ! $staff || $staff['status'] !== 'active' ) { throw new \DomainException( 'import_reference' ); } self::destination( 'staff', $id ); $out['staff_id'] = $id; $out['staff_revision'] = Token::digest( $staff ); } if ( $kind === 'appointments' ) { if ( ! $this->supports_appointments() ) { throw new \DomainException( 'import_appointment_adapter' ); } if ( ( $settings['time_format'] ?? '' ) !== 'utc_sql' ) { throw new \DomainException( 'import_utc' ); } $refs = $settings['references'] ?? null; if ( ! is_array( $refs ) || array_diff( array_keys( $refs ), array( 'services', 'staff', 'locations' ) ) ) { throw new \DomainException( 'import_reference' ); } $out['references'] = array(); $out['time_format'] = 'utc_sql'; foreach ( array( 'services', 'staff', 'locations' ) as $type ) { $mapping = $refs[ $type ] ?? null; if ( ! is_array( $mapping ) || count( $mapping ) > 100 ) { throw new \DomainException( 'import_reference' ); } $out['references'][ $type ] = array(); if ( ! self::pro_enabled() && $type !== 'services' && count( $mapping ) > 1 ) { throw new \DomainException( 'import_reference' ); } foreach ( $mapping as $key => $id ) { $key = self::text( (string) $key ); $id = self::integer( $id, 1 ); $record = $this->repository->find( $type, $id ); if ( ! $record || $record['status'] !== 'active' ) { throw new \DomainException( 'import_reference' ); } if ( $type !== 'services' ) { self::destination( $type, $id ); } $out['references'][ $type ][ $key ] = array( 'id' => $id, 'revision' => Token::digest( $record ) ); } } } return $out; } private function plan( string $kind, array $raw_rows, array $settings ): array { $provenance = self::provenance( $kind, $raw_rows, $settings ); $bound = array_merge( $this->settings( $kind, $settings ), $provenance ); $rows = array(); $errors = array(); $keys = array(); $contacts = array(); $plannedCustomers = array(); $existingCustomers = array(); $slugs = array(); $referenceRevisions = array(); foreach ( $raw_rows as $index => $raw ) { try { if ( ! is_array( $raw ) ) { throw new \DomainException( 'import_row' ); } $row = self::normalize( $kind, $raw ); $source = Token::digest( array( 'namespace' => $bound['source_namespace'], 'kind' => $kind, 'source_key' => $row['source_key'] ) ); if ( $this->repository->source( $source ) ) { throw new \DomainException( 'import_previous_source' ); } if ( isset( $keys[ $source ] ) ) { throw new \DomainException( 'import_duplicate_source' ); } $keys[ $source ] = true; $row['source_hash'] = $source; if ( $kind === 'customers' || $kind === 'appointments' ) { $customer = $this->customer_plan( $row['customer'], $bound['duplicate_policy'] ); $identifiers = array_filter( array( 'email' => $row['customer']['email'], 'phone' => $row['customer']['phone'] ) ); $same = null; foreach ( $identifiers as $type => $value ) { $key = $type . ':' . $value; if ( isset( $contacts[ $key ] ) ) { if ( $bound['duplicate_policy'] === 'reject' || ( $same !== null && $same !== $contacts[ $key ] ) ) { throw new \DomainException( 'import_ambiguous_contact' ); } $same = $contacts[ $key ]; } } if ( $customer['id'] && isset( $existingCustomers[ $customer['id'] ] ) ) { if ( $same !== null && $same !== $existingCustomers[ $customer['id'] ] ) { throw new \DomainException( 'import_ambiguous_contact' ); } $same = $existingCustomers[ $customer['id'] ]; } if ( $same !== null && ( $plannedCustomers[ $same ]['id'] ?? null ) !== $customer['id'] ) { throw new \DomainException( 'import_ambiguous_contact' ); } $root = $same ?? $index; $plannedCustomers[ $root ] = $customer; if ( $customer['id'] ) { $existingCustomers[ $customer['id'] ] = $root; } $row['customer_plan'] = $customer; $row['customer_row'] = $same; foreach ( $identifiers as $type => $value ) { $contacts[ $type . ':' . $value ] = $root; } } if ( $kind === 'services' ) { $matches = $this->repository->slug( $row['service']['slug'] ); if ( count( $matches ) > 1 || ( $matches && $bound['duplicate_policy'] === 'reject' ) || isset( $slugs[ $row['service']['slug'] ] ) ) { throw new \DomainException( 'import_duplicate' ); } $slugs[ $row['service']['slug'] ] = true; $row['service_plan'] = array( 'id' => (int) ( $matches[0]['id'] ?? 0 ), 'label' => $matches[0]['name'] ?? '', 'revision' => $matches ? Token::digest( $matches[0] ) : '' ); $row['staff_id'] = $bound['staff_id']; } if ( $kind === 'appointments' ) { $candidate = $row['appointment']; foreach ( array( 'services' => 'service', 'staff' => 'staff', 'locations' => 'location' ) as $type => $field ) { $key = $row[ $field . '_key' ]; $reference = $bound['references'][ $type ][ $key ] ?? null; if ( ! $reference ) { throw new \DomainException( 'import_reference' ); } $candidate[ $field . '_id' ] = $reference['id']; $referenceRevisions[ $type ][ $key ] = $reference['revision']; } global $wpdb; $statuses = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE slug=%s', $this->repository->table( 'booking_statuses' ), $row['status_slug'] ), ARRAY_A ), 'import_storage' ); if ( count( $statuses ) !== 1 ) { throw new \DomainException( 'import_status' ); } $candidate['status_id'] = (int) $statuses[0]['id']; $referenceRevisions['statuses'][ $row['status_slug'] ] = Token::digest( $statuses[0] ); $historical = $candidate['end_utc'] <= gmdate( 'Y-m-d H:i:s' ); if ( ! $historical && $candidate['start_utc'] < gmdate( 'Y-m-d H:i:s' ) ) { throw new \DomainException( 'import_ongoing' ); } $candidate['customer_id'] = $customer['id']; $validated = ( $this->appointments['validate'] )( $candidate, $historical ); if ( ! is_array( $validated ) ) { throw new \DomainException( 'import_appointment_validation' ); } foreach ( $candidate as $key => $value ) { if ( ! array_key_exists( $key, $validated ) || (string) $validated[ $key ] !== (string) $value ) { throw new \DomainException( 'import_appointment_validation' ); } } $row['candidate'] = $validated; $row['historical'] = $historical; } $row['normalized_hash'] = Token::digest( $row ); $rows[] = $row; } catch ( \DomainException $error ) { $errors[] = array( 'row' => $index + 2, 'code' => $error->getMessage() ); } } return array( 'rows' => $rows, 'errors' => $errors, 'revision' => Token::digest( array( 'rows' => $rows, 'settings' => $bound, 'references' => $referenceRevisions ) ) ); } public function preview( string $csv, string $delimiter, string $kind, array $mapping, array $settings ): array { if ( ! self::permitted( $kind ) ) { throw new \DomainException( 'import_permission' ); } $parsed = Csv::parse( $csv, $delimiter ); $raw = Csv::map( $parsed, $kind, $mapping ); $plan = $this->plan( $kind, $raw, $settings ); $payload = array( 'batch_uuid' => wp_generate_uuid4(), 'kind' => $kind, 'file_digest' => $parsed['file_digest'], 'mapping' => $mapping, 'settings' => $settings, 'raw_rows' => $raw, 'revision' => $plan['revision'] ); return array( 'kind' => $kind, 'rows' => $plan['rows'], 'errors' => $plan['errors'], 'count' => count( $raw ), 'batch_uuid' => $payload['batch_uuid'], 'token' => $plan['errors'] ? '' : Token::seal( 'preview', $payload, get_current_user_id() ) ); } public function commit( string $token ): array { $payload = Token::open( $token, 'preview', get_current_user_id() ); $kind = $payload['kind']; if ( ! self::permitted( $kind ) ) { throw new \DomainException( 'import_permission' ); } $hash = Token::digest( $payload ); return $this->repository->locked( function () use ( $payload, $kind, $hash ): array { self::provenance( $kind, $payload['raw_rows'], $payload['settings'] ); $batch = $this->repository->batch( $payload['batch_uuid'] ); if ( $batch ) { if ( (int) $batch['actor_id'] !== get_current_user_id() || ! hash_equals( $batch['command_hash'], $hash ) || $batch['state'] !== 'committed' ) { throw new \DomainException( 'import_replay' ); } return $this->result( $batch['batch_uuid'] ); } $plan = $this->plan( $kind, $payload['raw_rows'], $payload['settings'] ); if ( $plan['errors'] || ! hash_equals( $payload['revision'], $plan['revision'] ) ) { throw new \DomainException( 'import_stale' ); } return $this->repository->transaction( function () use ( $payload, $kind, $hash, $plan ): array { $stamp = gmdate( 'Y-m-d H:i:s' ); $this->repository->insert( 'import_batches', array( 'batch_uuid' => $payload['batch_uuid'], 'actor_id' => get_current_user_id(), 'command_hash' => $hash, 'file_digest' => $payload['file_digest'], 'mapping_json' => wp_json_encode( array( 'columns' => $payload['mapping'], 'duplicate_policy' => $payload['settings']['duplicate_policy'] ) ), 'revision_hash' => $plan['revision'], 'kind' => $kind, 'state' => 'committed', 'created_at' => $stamp, 'updated_at' => $stamp ), false ); $customer_ids = array(); foreach ( $plan['rows'] as $index => $row ) { $owned = array(); $result = array(); if ( isset( $row['customer'] ) ) { $reuse = $row['customer_plan']['id']; $from = $row['customer_row']; if ( ! $reuse && $from !== null ) { $reuse = $customer_ids[ $from ] ?? 0; } $customer = $reuse; if ( ! $customer ) { $customer = $this->repository->insert( 'customers', array_merge( $row['customer'], array( 'created_at' => $stamp, 'updated_at' => $stamp ) ) ); $owned[] = array( 'kind' => 'customers', 'id' => $customer, 'revision' => $this->repository->owned_revision( 'customers', $customer ) ); } $customer_ids[ $index ] = $customer; $result['customer_id'] = $customer; } if ( $kind === 'services' ) { $id = $row['service_plan']['id']; if ( ! $id ) { $id = $this->repository->insert( 'services', array_merge( $row['service'], array( 'created_at' => $stamp, 'updated_at' => $stamp ) ) ); $this->repository->insert( 'staff_services', array( 'staff_id' => $row['staff_id'], 'service_id' => $id, 'created_at' => $stamp ) ); $owned[] = array( 'kind' => 'services', 'id' => $id, 'revision' => $this->repository->owned_revision( 'services', $id ) ); } $result['service_id'] = $id; } if ( $kind === 'appointments' ) { $candidate = $row['candidate']; $candidate['customer_id'] = $customer; $id = ( $this->appointments['write'] )( $candidate, array( 'historical' => $row['historical'], 'notify' => false, 'source' => 'import' ), array( Repository::class, 'owned' ) ); $id = self::integer( $id, 1 ); $actual = $this->repository->find( 'appointments', $id ); if ( ! $actual ) { throw new \RuntimeException( 'import_appointment_write' ); } foreach ( $candidate as $key => $value ) { if ( array_key_exists( $key, $actual ) && (string) $actual[ $key ] !== (string) $value ) { throw new \RuntimeException( 'import_appointment_write' ); } } $owned[] = array( 'kind' => 'appointments', 'id' => $id, 'revision' => $this->repository->owned_revision( 'appointments', $id ) ); $result['appointment_id'] = $id; } $this->repository->insert( 'import_rows', array( 'batch_uuid' => $payload['batch_uuid'], 'ordinal' => $index, 'source_hash' => $row['source_hash'], 'normalized_hash' => $row['normalized_hash'], 'state' => 'committed', 'ownership_json' => wp_json_encode( array( 'created' => $owned, 'result' => $result ) ) ), false ); } return $this->result( $payload['batch_uuid'] ); } ); } ); } private function result( string $uuid ): array { $rows = $this->repository->batch_rows( $uuid ); if ( count( $rows ) > 100 ) { throw new \RuntimeException( 'import_rows' ); } return array( 'batch_uuid' => $uuid, 'rows' => array_map( static fn( $row )=>json_decode( $row['ownership_json'], true, 16, JSON_THROW_ON_ERROR )['result'], $rows ) ); } public function rollback_preview( string $uuid ): array { $batch = $this->repository->batch( $uuid ); if ( ! $batch || (int) $batch['actor_id'] !== get_current_user_id() || ! self::permitted( $batch['kind'] ) ) { throw new \DomainException( 'import_permission' ); } $assessment = $this->assess_rollback( $uuid ); return array( 'blocked' => $assessment['blocked'], 'count' => count( $assessment['created'] ), 'token' => $assessment['blocked'] ? '' : Token::seal( 'rollback', array( 'batch_uuid' => $uuid, 'revision' => $assessment['revision'] ), get_current_user_id() ) ); } private function assess_rollback( string $uuid ): array { $created = array(); $blocked = array(); $rows = $this->repository->batch_rows( $uuid ); foreach ( $rows as $row ) { $data = json_decode( $row['ownership_json'], true, 16, JSON_THROW_ON_ERROR ); foreach ( $data['created'] as $record ) { $created[ $record['kind'] . ':' . $record['id'] ] = $record; } } $ownedAppointments = array_values( array_map( static fn( $record )=>$record['id'], array_filter( $created, static fn( $record )=>$record['kind'] === 'appointments' ) ) ); foreach ( $created as $record ) { $safe = $record['kind'] === 'appointments' ? is_callable( $this->appointments['removable'] ?? null ) && true === ( $this->appointments['removable'] )( $record['id'], $record['revision'] ) : $this->repository->removable( $record['kind'], $record['id'], $record['revision'], $ownedAppointments ); if ( ! $safe ) { $blocked[] = array( 'kind' => $record['kind'], 'id' => $record['id'] ); } } return array( 'created' => $created, 'blocked' => $blocked, 'revision' => Token::digest( array( 'rows' => $rows, 'blocked' => $blocked ) ) ); } public function rollback( string $token ): array { $payload = Token::open( $token, 'rollback', get_current_user_id() ); return $this->repository->locked( function () use ( $payload ): array { $uuid = $payload['batch_uuid']; $batch = $this->repository->batch( $uuid ); if ( ! $batch || (int) $batch['actor_id'] !== get_current_user_id() || ! self::permitted( $batch['kind'] ) ) { throw new \DomainException( 'import_permission' ); } if ( $batch['state'] === 'rolled_back' ) { return array( 'batch_uuid' => $uuid, 'rolled_back' => true ); } if ( $batch['state'] !== 'committed' ) { throw new \DomainException( 'import_replay' ); } $assessment = $this->assess_rollback( $uuid ); if ( $assessment['blocked'] || ! hash_equals( $payload['revision'], $assessment['revision'] ) ) { throw new \DomainException( 'import_rollback_unsafe' ); } return $this->repository->transaction( function () use ( $uuid, $assessment ): array { $records = array_values( $assessment['created'] ); usort( $records, static fn( $a, $b )=>( $a['kind'] === 'appointments' ? 0 : 1 ) <=> ( $b['kind'] === 'appointments' ? 0 : 1 ) ); foreach ( $records as $record ) { if ( $record['kind'] === 'appointments' ) { if ( ! is_callable( $this->appointments['remove'] ?? null ) ) { throw new \DomainException( 'import_rollback_unsafe' ); }( $this->appointments['remove'] )( $record['id'], array( Repository::class, 'owned' ) ); } else { $this->repository->remove( $record['kind'], $record['id'] ); } } $this->repository->update( 'import_batches', array( 'state' => 'rolled_back', 'updated_at' => gmdate( 'Y-m-d H:i:s' ) ), array( 'batch_uuid' => $uuid ) ); $this->repository->update( 'import_rows', array( 'state' => 'rolled_back' ), array( 'batch_uuid' => $uuid ) ); return array( 'batch_uuid' => $uuid, 'rolled_back' => true ); } ); } ); } }