repository = $repository; $this->taxonomy = $metaRepository === null ? null : new ProductManualTaxonomy( $metaRepository ); $this->urlRepository = $urlRepository; } /** * Execute update. * * @param int $productId Product id. * @param array $fields Fields. * * @throws InvalidArgumentException When input is invalid. * @throws RuntimeException When product does not exist. */ public function execute( int $productId, array $fields ): ProductRecord { if ( $productId <= 0 ) { throw new InvalidArgumentException( 'Product id must be positive.' ); } $record = $this->repository->findById( $productId ); if ( $record === null ) { throw new RuntimeException( 'Product not found.' ); } $row = $record->toArray(); $row['title'] = self::requiredString( $fields, 'title' ); $row['image_primary_url'] = self::optionalHttpUrl( $fields, 'image_primary_url' ); $row['detail_url'] = self::requiredHttpUrl( $fields, 'detail_url' ); $row['affiliate_url'] = self::optionalHttpUrl( $fields, 'affiliate_url' ); $row['price_label'] = self::optionalString( $fields, 'price_label' ); $priceColumns = self::priceColumnsFromLabel( $row['price_label'], $record->priceCurrency() ); if ( $priceColumns !== null ) { $row['price_amount'] = $priceColumns['amount']; $row['price_currency'] = $priceColumns['currency']; } $row['lifecycle_state'] = self::lifecycleState( $fields['lifecycle_state'] ?? null ); $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); $row['updated_at'] = $now; $row['archived_at'] = $row['lifecycle_state'] === ProductListQuery::LIFECYCLE_STATE_ARCHIVED ? $row['updated_at'] : null; if ( array_key_exists( 'brand', $fields ) ) { $row['brand'] = self::optionalString( $fields, 'brand' ); } $updated = $this->repository->save( ProductRecord::fromArray( $row ) ); $updatedId = $updated->id(); if ( $updatedId !== null ) { $this->syncProviderUrls( $updated, $fields, $now ); } if ( $this->taxonomy !== null && $updatedId !== null ) { if ( array_key_exists( 'categories', $fields ) ) { $this->taxonomy->saveCategoriesForProduct( $updatedId, ProductManualTaxonomy::termsFromInput( $fields['categories'] ) ); } if ( array_key_exists( 'tags', $fields ) ) { $this->taxonomy->saveTagsForProduct( $updatedId, ProductManualTaxonomy::termsFromInput( $fields['tags'] ) ); } } return $updated; } /** * Parse only standalone monetary labels, never numbers in descriptive text. * * @param string|null $label Edited price label. * @param string|null $currency Existing currency. * * @return array{amount:string,currency:string}|null * * @throws InvalidArgumentException When currencies conflict or the amount exceeds decimal(12,2). */ private static function priceColumnsFromLabel( ?string $label, ?string $currency ): ?array { $pattern = '/\A(?:(?[A-Z]{3}|[\x{00A5}\x{FFE5}])\s*)?' . '(?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)\s*(?[A-Z]{3}|\x{5186})?\z/iu'; if ( $label === null || preg_match( $pattern, $label, $matches ) !== 1 ) { return null; } $parsedCurrency = null; foreach ( array( $matches['prefix'], $matches['suffix'] ?? '' ) as $token ) { if ( $token === '' ) { continue; } $tokenCurrency = preg_match( '/\A[A-Z]{3}\z/i', $token ) === 1 ? strtoupper( $token ) : 'JPY'; if ( $parsedCurrency !== null && $parsedCurrency !== $tokenCurrency ) { throw new InvalidArgumentException( 'Price label contains conflicting currencies.' ); } $parsedCurrency = $tokenCurrency; } $amount = str_replace( ',', '', $matches['amount'] ); $parts = explode( '.', $amount ); if ( strlen( ltrim( $parts[0], '0' ) ) > 10 || strlen( $parts[1] ?? '' ) > 2 ) { throw new InvalidArgumentException( 'Price label amount exceeds decimal(12,2).' ); } return array( 'amount' => $amount, 'currency' => $parsedCurrency ?? $currency ?? 'JPY', ); } /** * Synchronize provider URL rows from edit fields. * * @param ProductRecord $record Saved product. * @param array $fields Fields. * @param DateTimeImmutable $now Update timestamp. */ private function syncProviderUrls( ProductRecord $record, array $fields, DateTimeImmutable $now ): void { if ( $this->urlRepository === null || ! array_key_exists( 'provider_urls', $fields ) ) { return; } $productId = $record->id(); if ( $productId === null ) { return; } $providerUrls = $fields['provider_urls']; if ( ! is_array( $providerUrls ) ) { return; } foreach ( $providerUrls as $provider => $entry ) { if ( ! is_string( $provider ) || ! in_array( $provider, self::supportedProviders(), true ) ) { continue; } if ( ! is_array( $entry ) ) { continue; } $url = self::optionalHttpUrl( $entry, 'url' ); $this->urlRepository->deleteByProductAndProvider( $productId, $provider ); if ( $url === null ) { continue; } $providerItemId = self::optionalString( $entry, 'provider_item_id' ); if ( $provider === $record->provider() && $providerItemId === null ) { $providerItemId = $record->providerItemId(); } $this->urlRepository->save( ProductUrlRecord::createNew( $productId, $provider, $providerItemId, $url, $now, array( 'source' => 'user' ) ) ); } } /** * Supported provider keys. * * @return list */ private static function supportedProviders(): array { return array( 'amazon', 'rakuten', 'yahoo_shopping', 'mercari' ); } /** * Read required string. * * @param array $fields Fields. * @param string $key Key. * * @throws InvalidArgumentException When the value is absent. */ private static function requiredString( array $fields, string $key ): string { $value = self::optionalString( $fields, $key ); if ( $value === null ) { throw new InvalidArgumentException( 'Required product field is missing.' ); } return $value; } /** * Read optional string. * * @param array $fields Fields. * @param string $key Key. */ private static function optionalString( array $fields, string $key ): ?string { $value = $fields[ $key ] ?? null; if ( ! is_scalar( $value ) ) { return null; } $trimmed = trim( (string) $value ); return $trimmed === '' ? null : $trimmed; } /** * Read required HTTP(S) URL. * * @param array $fields Fields. * @param string $key Key. * * @throws InvalidArgumentException When the URL is absent or invalid. */ private static function requiredHttpUrl( array $fields, string $key ): string { $value = self::optionalHttpUrl( $fields, $key ); if ( $value === null ) { throw new InvalidArgumentException( 'Required product URL field must be an HTTP(S) URL.' ); } return $value; } /** * Read optional HTTP(S) URL. * * @param array $fields Fields. * @param string $key Key. * * @throws InvalidArgumentException When the URL is invalid. */ private static function optionalHttpUrl( array $fields, string $key ): ?string { $value = self::optionalString( $fields, $key ); if ( $value === null ) { return null; } if ( preg_match( '#\Ahttps?://#i', $value ) !== 1 ) { throw new InvalidArgumentException( 'Product URL field must be an HTTP(S) URL.' ); } return $value; } /** * Validate lifecycle state. * * @param mixed $value Value. * * @throws InvalidArgumentException When the state is invalid. */ private static function lifecycleState( mixed $value ): string { $state = is_scalar( $value ) ? trim( (string) $value ) : ProductListQuery::LIFECYCLE_STATE_ACTIVE; $valid = array( ProductListQuery::LIFECYCLE_STATE_ACTIVE, ProductListQuery::LIFECYCLE_STATE_ARCHIVED, ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING, ); if ( ! in_array( $state, $valid, true ) ) { throw new InvalidArgumentException( 'Invalid lifecycle state.' ); } return $state; } }