| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455 |
- <?php
- declare(strict_types=1);
- namespace EKSRelay\Handlers;
- use EKSRelay\Clients\WooCommerceClient;
- use EKSRelay\Core\Auth;
- use EKSRelay\Core\Env;
- use EKSRelay\Core\HttpClient;
- use EKSRelay\Core\HttpException;
- use EKSRelay\Core\Logger;
- use EKSRelay\Core\Router;
- use EKSRelay\Core\StoreLocales;
- /**
- * Handlers for all WooCommerce-backed tool endpoints.
- */
- final class WooToolsHandler
- {
- // =================================================================
- // POST /tools/get_order_data
- // =================================================================
- public static function getOrderData(): void
- {
- Auth::requireBearer();
- $body = Router::jsonBody();
- $orderNumber = $body['orderNumber'] ?? ($body['order_number'] ?? null);
- $email = $body['email'] ?? null;
- if ($orderNumber !== null) {
- $orderNumber = (string)$orderNumber;
- }
- if ($email === null || $email === '') {
- throw new HttpException(400, 'MISSING_PARAMS', 'email is required to verify order ownership.');
- }
- $language = $body['language'] ?? null;
- $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
- $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
- ? ['currency' => $currencyOverride, 'fallback' => false]
- : StoreLocales::resolve($language);
- $woo = new WooCommerceClient();
- if ($orderNumber !== null && $orderNumber !== '') {
- $order = $woo->getOrder(orderNumber: $orderNumber, currency: $locale['currency']);
- // Security: verify that the caller's email matches the billing email on the order.
- $billingEmail = strtolower(trim($order['billing']['email'] ?? ''));
- if ($billingEmail === '' || $billingEmail !== strtolower(trim($email))) {
- throw new HttpException(403, 'UNAUTHORIZED', 'Provided email does not match the order.');
- }
- } else {
- $order = $woo->getOrder(email: $email, currency: $locale['currency']);
- }
- $response = [
- 'ok' => true,
- 'data' => self::formatOrder($order),
- 'currency' => $locale['currency'],
- ];
- if ($locale['fallback']) {
- $response['currency_fallback'] = true;
- $response['currency_note'] = self::buildCurrencyNote($locale, $language);
- }
- // Return a curated subset useful for the LLM agent
- Router::sendJson(200, $response);
- }
- // =================================================================
- // POST /tools/get_product_data
- // =================================================================
- public static function getProductData(): void
- {
- Auth::requireBearer();
- $body = Router::jsonBody();
- $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
- $sku = $body['sku'] ?? null;
- $search = $body['search'] ?? null;
- $language = $body['language'] ?? null;
- $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
- $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
- ? ['currency' => $currencyOverride, 'fallback' => false]
- : StoreLocales::resolve($language);
- $woo = new WooCommerceClient();
- $currencyMeta = ['currency' => $locale['currency']];
- if ($locale['fallback']) {
- $currencyMeta['currency_fallback'] = true;
- $currencyMeta['currency_note'] = self::buildCurrencyNote($locale, $language);
- }
- $lang = $language ?? '';
- if ($productId !== null && $productId > 0) {
- $product = $woo->getProduct(productId: $productId, currency: $locale['currency'], lang: $lang);
- Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
- return;
- }
- if ($sku !== null && $sku !== '') {
- $product = $woo->getProduct(sku: $sku, currency: $locale['currency'], lang: $lang);
- Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
- return;
- }
- if ($search !== null && $search !== '') {
- $products = $woo->searchProducts($search, currency: $locale['currency'], lang: $lang);
- $formatted = array_map(fn($p) => self::formatProduct($p), $products);
- Router::sendJson(200, array_merge(['ok' => true, 'data' => $formatted], $currencyMeta));
- return;
- }
- throw new HttpException(400, 'MISSING_PARAMS', 'Provide productId, sku, or search.');
- }
- // =================================================================
- // POST /tools/get_shipping_data
- // =================================================================
- public static function getShippingData(): void
- {
- Auth::requireBearer();
- $body = Router::jsonBody();
- $language = $body['language'] ?? null;
- $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
- $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
- ? ['currency' => $currencyOverride, 'fallback' => false]
- : StoreLocales::resolve($language);
- $woo = new WooCommerceClient();
- // If zoneId provided — return methods for that specific zone
- $zoneId = $body['zoneId'] ?? ($body['zone_id'] ?? null);
- try {
- if ($zoneId !== null) {
- // Use custom WP endpoint to get per-currency shipping costs
- // (WC REST API only returns PLN costs; WCML costs are in wp_options)
- $wpBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
- $url = $wpBase . '/shipping-costs?' . http_build_query([
- 'zone_id' => (int)$zoneId,
- 'currency' => $locale['currency'],
- ]);
- $res = HttpClient::request('GET', $url);
- $response = ['ok' => true, 'data' => $res['json'] ?? [], 'currency' => $locale['currency']];
- if ($locale['fallback']) {
- $response['currency_fallback'] = true;
- $response['currency_note'] = self::buildCurrencyNote($locale, $language);
- }
- Router::sendJson(200, $response);
- return;
- }
- // Default: return list of zones with ISO country codes
- // Zone 0 (Rest of the World) has no locations — it's the fallback zone.
- $zones = $woo->getShippingZones();
- $formatted = [];
- foreach ($zones as $z) {
- $zid = (int)($z['id'] ?? 0);
- $locations = $woo->getShippingZoneLocations($zid);
- // Extract country-level ISO codes only (skip state/continent entries)
- $countries = array_values(array_filter(array_map(
- fn($l) => ($l['type'] ?? '') === 'country' ? strtoupper((string)($l['code'] ?? '')) : null,
- $locations
- )));
- $formatted[] = [
- 'id' => $zid,
- 'name' => $z['name'] ?? '',
- 'countries' => $countries, // ISO 3166-1 alpha-2 codes, e.g. ["PL"] or ["GB","IE"]
- ];
- }
- Router::sendJson(200, [
- 'ok' => true,
- 'data' => $formatted,
- 'hint' => 'Pass {"zoneId": <id>} to get shipping methods for a specific zone.',
- ]);
- } catch (HttpException $e) {
- if ($e->httpCode === 502) {
- Router::sendJson(200, [
- 'ok' => false,
- 'code' => 'NOT_IMPLEMENTED',
- 'message' => 'Shipping data is not available via WooCommerce REST API. '
- . 'Ensure shipping zones are configured and the consumer key has read access to shipping endpoints.',
- ]);
- return;
- }
- throw $e;
- }
- }
- // =================================================================
- // POST /tools/get_payment_methods
- // =================================================================
- public static function getPaymentMethods(): void
- {
- Auth::requireBearer();
- $woo = new WooCommerceClient();
- try {
- $gateways = $woo->getPaymentGateways();
- // Filter to enabled gateways only
- $enabled = array_values(array_filter($gateways, fn($g) => ($g['enabled'] ?? false) === true));
- $formatted = array_map(fn($g) => [
- 'id' => $g['id'] ?? '',
- 'title' => $g['title'] ?? '',
- 'description' => $g['description'] ?? '',
- 'enabled' => $g['enabled'] ?? false,
- ], $enabled);
- Router::sendJson(200, ['ok' => true, 'data' => $formatted]);
- } catch (HttpException $e) {
- if ($e->httpCode === 502) {
- Router::sendJson(200, [
- 'ok' => false,
- 'code' => 'NOT_IMPLEMENTED',
- 'message' => 'Payment gateways endpoint not accessible. '
- . 'Ensure the WooCommerce consumer key has admin-level (read/write) permissions '
- . 'to access GET /payment_gateways.',
- ]);
- return;
- }
- throw $e;
- }
- }
- // =================================================================
- // POST /tools/get_product_compatibility
- // =================================================================
- public static function getProductCompatibility(): void
- {
- Auth::requireBearer();
- $body = Router::jsonBody();
- $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
- $productName = $body['product_name'] ?? ($body['productName'] ?? null);
- $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
- $carBrand = $body['car_brand'] ?? ($body['carBrand'] ?? null);
- $carModel = $body['car_model'] ?? ($body['carModel'] ?? null);
- $carYear = $body['car_year'] ?? ($body['carYear'] ?? null);
- $carEngine = $body['car_engine'] ?? ($body['carEngine'] ?? null);
- $carEngineIndex = isset($body['car_engine_index']) ? (int)$body['car_engine_index'] : -1;
- $language = $body['language'] ?? null;
- $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
- $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
- ? ['currency' => $currencyOverride, 'fallback' => false]
- : StoreLocales::resolve($language);
- // When a non-Polish product name is given, try to pre-resolve it to a product ID
- // via the WooCommerce REST API (which has a PL language fallback). This prevents
- // the WP endpoint from failing to find products with non-Polish search terms.
- if ($productName !== null && $productName !== '' && ($productId === null || $productId === 0)) {
- $woo = new WooCommerceClient();
- $found = $woo->searchProducts($productName, 5, '', $language ?? '');
- if (empty($found) && $language && $language !== 'pl') {
- // Fallback: search in PL (default) database without language filter
- $found = $woo->searchProducts($productName, 5, '');
- }
- if (count($found) === 1) {
- // Unambiguous match — use product ID so WP endpoint skips name-based search
- $productId = (int)$found[0]['id'];
- $productName = null;
- }
- // Multiple matches or no match: pass product_name to WP endpoint as usual
- }
- $payload = [];
- if ($productId !== null && $productId > 0) $payload['product_id'] = $productId;
- if ($productName !== null && $productName !== '') $payload['product_name'] = $productName;
- if ($carBrand !== null && $carBrand !== '') $payload['car_brand'] = $carBrand;
- if ($carModel !== null && $carModel !== '') $payload['car_model'] = $carModel;
- if ($carYear !== null && $carYear !== '') $payload['car_year'] = $carYear;
- if ($carEngine !== null && $carEngine !== '') $payload['car_engine'] = $carEngine;
- if ($carEngineIndex >= 0) $payload['car_engine_index'] = $carEngineIndex;
- if ($language !== null && $language !== '') $payload['lang'] = $language;
- $payload['currency'] = $locale['currency'];
- $res = HttpClient::request('POST', $restBase . '/product-compatibility', [], $payload);
- $data = $res['json'] ?? $res['body'];
- // Post-process product titles: the WP endpoint does not switch WPML for the
- // selected_product branch, so titles come back in Polish. Re-fetch from WC REST
- // API with the correct language to get the translated title.
- if (is_array($data) && $language !== null && $language !== '' && $language !== 'pl') {
- $woo = new WooCommerceClient();
- // Case A: specific product compatibility check — replace title in selected_product
- if (isset($data['selected_product']['id'])) {
- try {
- $translated = $woo->getProduct(
- productId: (int)$data['selected_product']['id'],
- lang: $language,
- currency: $locale['currency']
- );
- $data['selected_product']['title'] = $translated['name'] ?? $data['selected_product']['title'];
- } catch (\Throwable) { /* keep original title on failure */ }
- }
- // Case B: disambiguation options list for product_name — batch-translate titles
- if (
- ($data['error'] ?? '') === 'options'
- && ($data['field'] ?? '') === 'product_name'
- && !empty($data['options'])
- && is_array($data['options'])
- ) {
- $ids = array_values(array_filter(array_column($data['options'], 'id')));
- if (!empty($ids)) {
- try {
- $translated = $woo->getProductsByIds($ids, $language, $locale['currency']);
- $byId = [];
- foreach ($translated as $tp) {
- $byId[(int)($tp['id'] ?? 0)] = $tp['name'] ?? null;
- }
- foreach ($data['options'] as &$opt) {
- $pid = (int)($opt['id'] ?? 0);
- if ($pid && isset($byId[$pid])) {
- $opt['title'] = $byId[$pid];
- }
- }
- unset($opt);
- } catch (\Throwable) { /* keep original titles on failure */ }
- }
- }
- }
- $response = [
- 'ok' => true,
- 'data' => $data,
- 'currency' => $locale['currency'],
- ];
- if ($locale['fallback']) {
- $response['currency_fallback'] = true;
- $response['currency_note'] = self::buildCurrencyNote($locale, $language);
- }
- Router::sendJson(200, $response);
- }
- // =================================================================
- // POST /tools/get_car_data
- // =================================================================
- public static function getCarData(): void
- {
- Auth::requireBearer();
- $body = Router::jsonBody();
- $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
- $carBrand = $body['car_brand'] ?? ($body['make'] ?? null);
- $carModel = $body['car_model'] ?? ($body['model'] ?? null);
- $carYear = $body['car_year'] ?? ($body['year'] ?? null);
- $carEngine = $body['car_engine'] ?? ($body['engine'] ?? null);
- $payload = [];
- if ($carBrand !== null && $carBrand !== '') $payload['car_brand'] = $carBrand;
- if ($carModel !== null && $carModel !== '') $payload['car_model'] = $carModel;
- if ($carYear !== null && $carYear !== '') $payload['car_year'] = $carYear;
- if ($carEngine !== null && $carEngine !== '') $payload['car_engine'] = $carEngine;
- $res = HttpClient::request(
- 'POST',
- $restBase . '/car-data',
- [],
- $payload
- );
- Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
- }
- // =================================================================
- // Formatters (curate data for LLM consumption)
- // =================================================================
- private static function formatOrder(array $order): array
- {
- return [
- 'id' => $order['id'] ?? null,
- 'number' => $order['number'] ?? null,
- 'status' => $order['status'] ?? null,
- 'date_created' => $order['date_created'] ?? null,
- 'total' => $order['total'] ?? null,
- 'currency' => $order['currency'] ?? null,
- 'billing' => [
- 'first_name' => $order['billing']['first_name'] ?? '',
- 'last_name' => $order['billing']['last_name'] ?? '',
- 'email' => $order['billing']['email'] ?? '',
- 'phone' => $order['billing']['phone'] ?? '',
- 'city' => $order['billing']['city'] ?? '',
- 'country' => $order['billing']['country'] ?? '',
- ],
- 'shipping' => [
- 'first_name' => $order['shipping']['first_name'] ?? '',
- 'last_name' => $order['shipping']['last_name'] ?? '',
- 'city' => $order['shipping']['city'] ?? '',
- 'country' => $order['shipping']['country'] ?? '',
- 'address_1' => $order['shipping']['address_1'] ?? '',
- ],
- 'payment_method' => $order['payment_method_title'] ?? null,
- 'shipping_total' => $order['shipping_total'] ?? null,
- 'line_items' => array_map(fn($item) => [
- 'name' => $item['name'] ?? '',
- 'sku' => $item['sku'] ?? '',
- 'quantity' => $item['quantity'] ?? 0,
- 'total' => $item['total'] ?? '0',
- ], $order['line_items'] ?? []),
- 'shipping_lines' => array_map(fn($sl) => [
- 'method_title' => $sl['method_title'] ?? '',
- 'total' => $sl['total'] ?? '0',
- ], $order['shipping_lines'] ?? []),
- 'customer_note' => $order['customer_note'] ?? '',
- ];
- }
- private static function buildCurrencyNote(array $locale, ?string $language): string
- {
- if (($locale['reason'] ?? '') === 'unknown_language') {
- return 'Unknown language code ' . ($language ?? '?') . '. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
- }
- $natural = $locale['natural_currency'] ?? ($language ?? '?');
- return 'Currency for ' . $natural . ' is not supported in the store. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
- }
- private static function formatProduct(array $product): array
- {
- return [
- 'id' => $product['id'] ?? null,
- 'name' => $product['name'] ?? '',
- 'sku' => $product['sku'] ?? '',
- 'slug' => $product['slug'] ?? '',
- 'status' => $product['status'] ?? '',
- 'price' => $product['price'] ?? '',
- 'regular_price' => $product['regular_price'] ?? '',
- 'sale_price' => $product['sale_price'] ?? '',
- 'stock_status' => $product['stock_status'] ?? '',
- 'stock_quantity' => $product['stock_quantity'] ?? null,
- 'short_description' => strip_tags((string)($product['short_description'] ?? '')),
- 'categories' => array_map(fn($c) => $c['name'] ?? '', $product['categories'] ?? []),
- 'attributes' => array_map(fn($a) => [
- 'name' => $a['name'] ?? '',
- 'options' => $a['options'] ?? [],
- ], $product['attributes'] ?? []),
- 'permalink' => $product['permalink'] ?? '',
- ];
- }
- }
|