$data Service data. * @return Service * @throws DomainException When validation fails, or when every named service field is occupied. */ public function create( array $data ): Service { $this->assert_named_catalogue( $data, null ); $now = $this->clock->now(); $service = new Service( null, Uuid::generate(), isset( $data['category_id'] ) ? (int) $data['category_id'] : null, trim( (string) ( $data['name'] ?? '' ) ), isset( $data['slug'] ) ? trim( (string) $data['slug'] ) : $this->generate_slug( trim( (string) ( $data['name'] ?? '' ) ) ), isset( $data['description'] ) ? trim( (string) $data['description'] ) : null, isset( $data['short_description'] ) ? trim( (string) $data['short_description'] ) : null, isset( $data['image_id'] ) ? (int) $data['image_id'] : null, isset( $data['colour'] ) && 1 === preg_match( '/^#[0-9A-Fa-f]{6}$/', (string) $data['colour'] ) ? (string) $data['colour'] : null, isset( $data['status'] ) ? ServiceStatus::from( (string) $data['status'] ) : ServiceStatus::Active, (int) ( $data['capacity_min'] ?? 1 ), (int) ( $data['capacity_max'] ?? 1 ), (int) ( $data['buffer_before_min'] ?? 0 ), (int) ( $data['buffer_after_min'] ?? 0 ), isset( $data['lead_time_min'] ) ? (int) $data['lead_time_min'] : null, isset( $data['horizon_days'] ) ? (int) $data['horizon_days'] : null, isset( $data['slot_step_min'] ) ? (int) $data['slot_step_min'] : null, isset( $data['deposit_type'] ) ? DepositType::from( (string) $data['deposit_type'] ) : DepositType::None, (int) ( $data['deposit_value'] ?? 0 ), isset( $data['tax_class'] ) ? trim( (string) $data['tax_class'] ) : null, (bool) ( $data['requires_approval'] ?? false ), (int) ( $data['sort_order'] ?? 0 ), $this->catalogue_meta( $data, null ), $now, $now, ); $this->services->save( $service ); return $service; } /** * Update an existing service. * * @param int $id Service id. * @param array $data Service data. * @return Service * @throws DomainException When the service is missing. */ public function update( int $id, array $data ): Service { $service = $this->services->find_by_id( $id ); if ( null === $service ) { throw new DomainException( 'service.not_found', 'Service not found.' ); } $this->assert_named_catalogue( $data, $service ); if ( array_key_exists( 'name', $data ) ) { $name = trim( (string) $data['name'] ); } else { $name = $service->name(); } if ( array_key_exists( 'slug', $data ) ) { $slug = trim( (string) $data['slug'] ); } else { $slug = $service->slug(); } $now = $this->clock->now(); $updated = new Service( $service->id(), $service->uuid(), array_key_exists( 'category_id', $data ) ? ( null !== $data['category_id'] ? (int) $data['category_id'] : null ) : $service->category_id(), $name, $slug, array_key_exists( 'description', $data ) ? trim( (string) $data['description'] ) : $service->description(), array_key_exists( 'short_description', $data ) ? trim( (string) $data['short_description'] ) : $service->short_description(), array_key_exists( 'image_id', $data ) ? ( null !== $data['image_id'] ? (int) $data['image_id'] : null ) : $service->image_id(), array_key_exists( 'colour', $data ) ? ( null !== $data['colour'] && 1 === preg_match( '/^#[0-9A-Fa-f]{6}$/', (string) $data['colour'] ) ? (string) $data['colour'] : null ) : $service->colour(), array_key_exists( 'status', $data ) ? ServiceStatus::from( (string) $data['status'] ) : $service->status(), array_key_exists( 'capacity_min', $data ) ? (int) $data['capacity_min'] : $service->capacity_min(), array_key_exists( 'capacity_max', $data ) ? (int) $data['capacity_max'] : $service->capacity_max(), array_key_exists( 'buffer_before_min', $data ) ? (int) $data['buffer_before_min'] : $service->buffer_before_min(), array_key_exists( 'buffer_after_min', $data ) ? (int) $data['buffer_after_min'] : $service->buffer_after_min(), array_key_exists( 'lead_time_min', $data ) ? ( null !== $data['lead_time_min'] ? (int) $data['lead_time_min'] : null ) : $service->lead_time_min(), array_key_exists( 'horizon_days', $data ) ? ( null !== $data['horizon_days'] ? (int) $data['horizon_days'] : null ) : $service->horizon_days(), array_key_exists( 'slot_step_min', $data ) ? ( null !== $data['slot_step_min'] ? (int) $data['slot_step_min'] : null ) : $service->slot_step_min(), array_key_exists( 'deposit_type', $data ) ? DepositType::from( (string) $data['deposit_type'] ) : $service->deposit_type(), array_key_exists( 'deposit_value', $data ) ? (int) $data['deposit_value'] : $service->deposit_value(), array_key_exists( 'tax_class', $data ) ? trim( (string) $data['tax_class'] ) : $service->tax_class(), array_key_exists( 'requires_approval', $data ) ? (bool) $data['requires_approval'] : $service->requires_approval(), array_key_exists( 'sort_order', $data ) ? (int) $data['sort_order'] : $service->sort_order(), $this->catalogue_meta( $data, $service->meta() ), $service->created_at(), $now, ); $this->services->save( $updated ); return $updated; } /** * Delete a service (soft delete). * * @param int $id Service id. * * @throws DomainException When the service is missing. */ public function delete( int $id ): void { $service = $this->services->find_by_id( $id ); if ( null === $service ) { throw new DomainException( 'service.not_found', 'Service not found.' ); } $this->services->soft_delete( $service, $this->clock->now() ); } /** * Build a unique-enough slug from the service name. * * @param string $name Display name. */ private function generate_slug( string $name ): string { $slug = strtolower( (string) preg_replace( '/[^a-z0-9]+/', '-', trim( $name ) ) ); $slug = trim( $slug, '-' ); if ( '' === $slug ) { return 'service-' . substr( bin2hex( random_bytes( 4 ) ), 0, 8 ); } return $slug; } /** * Group bookings and deposits are named commercial features. * * @param array $data Incoming fields. * @param Service|null $existing Stored row on update. */ private function assert_named_catalogue( array $data, ?Service $existing ): void { $check_capacity = null === $existing || array_key_exists( 'capacity_min', $data ) || array_key_exists( 'capacity_max', $data ); if ( $check_capacity ) { if ( array_key_exists( 'capacity_min', $data ) ) { $min = (int) $data['capacity_min']; } elseif ( null !== $existing ) { $min = $existing->capacity_min(); } else { $min = 1; } if ( array_key_exists( 'capacity_max', $data ) ) { $max = (int) $data['capacity_max']; } elseif ( null !== $existing ) { $max = $existing->capacity_max(); } else { $max = 1; } if ( $min > 1 || $max > 1 ) { $this->named_features()->assert_allows( NamedFeature::GroupBookings ); } } $check_deposit = null === $existing || array_key_exists( 'deposit_type', $data ); if ( $check_deposit ) { if ( array_key_exists( 'deposit_type', $data ) ) { $deposit = DepositType::from( (string) $data['deposit_type'] ); } elseif ( null !== $existing ) { $deposit = $existing->deposit_type(); } else { $deposit = DepositType::None; } if ( DepositType::None !== $deposit ) { $this->named_features()->assert_allows( NamedFeature::Deposits ); } } if ( array_key_exists( 'meeting_provider', $data ) ) { $provider = $this->normalise_meeting_provider( $data['meeting_provider'] ); if ( 'none' !== $provider ) { $this->named_features()->assert_allows( NamedFeature::Meetings ); } } } /** * Merge meeting_provider into catalogue meta. * * @param array $data Incoming fields. * @param array|null $existing Stored meta. * @return array|null */ private function catalogue_meta( array $data, ?array $existing ): ?array { $meta = $existing ?? []; if ( array_key_exists( 'meeting_provider', $data ) ) { $provider = $this->normalise_meeting_provider( $data['meeting_provider'] ); if ( 'none' === $provider ) { unset( $meta['meeting_provider'] ); } else { $meta['meeting_provider'] = $provider; } } return [] === $meta ? null : $meta; } /** * Allow-listed meeting provider, or none. * * @param mixed $raw Incoming value. */ private function normalise_meeting_provider( mixed $raw ): string { $value = strtolower( trim( (string) $raw ) ); if ( in_array( $value, [ 'zoom', 'google_meet', 'microsoft_teams' ], true ) ) { return $value; } return 'none'; } /** * Plan map, or the open directory gate. */ private function named_features(): NamedFeatureGate { return $this->named ?? new OpenNamedFeatureGate(); } }