WooToolsHandler.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. <?php
  2. declare(strict_types=1);
  3. namespace EKSRelay\Handlers;
  4. use EKSRelay\Clients\WooCommerceClient;
  5. use EKSRelay\Core\Auth;
  6. use EKSRelay\Core\Env;
  7. use EKSRelay\Core\HttpClient;
  8. use EKSRelay\Core\HttpException;
  9. use EKSRelay\Core\Logger;
  10. use EKSRelay\Core\Router;
  11. use EKSRelay\Core\StoreLocales;
  12. /**
  13. * Handlers for all WooCommerce-backed tool endpoints.
  14. */
  15. final class WooToolsHandler
  16. {
  17. // =================================================================
  18. // POST /tools/get_order_data
  19. // =================================================================
  20. public static function getOrderData(): void
  21. {
  22. Auth::requireBearer();
  23. $body = Router::jsonBody();
  24. $orderNumber = $body['orderNumber'] ?? ($body['order_number'] ?? null);
  25. $email = $body['email'] ?? null;
  26. if ($orderNumber !== null) {
  27. $orderNumber = (string)$orderNumber;
  28. }
  29. if ($email === null || $email === '') {
  30. throw new HttpException(400, 'MISSING_PARAMS', 'email is required to verify order ownership.');
  31. }
  32. $language = $body['language'] ?? null;
  33. $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
  34. $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
  35. ? ['currency' => $currencyOverride, 'fallback' => false]
  36. : StoreLocales::resolve($language);
  37. $woo = new WooCommerceClient();
  38. if ($orderNumber !== null && $orderNumber !== '') {
  39. $order = $woo->getOrder(orderNumber: $orderNumber, currency: $locale['currency']);
  40. // Security: verify that the caller's email matches the billing email on the order.
  41. $billingEmail = strtolower(trim($order['billing']['email'] ?? ''));
  42. if ($billingEmail === '' || $billingEmail !== strtolower(trim($email))) {
  43. throw new HttpException(403, 'UNAUTHORIZED', 'Provided email does not match the order.');
  44. }
  45. } else {
  46. $order = $woo->getOrder(email: $email, currency: $locale['currency']);
  47. }
  48. $response = [
  49. 'ok' => true,
  50. 'data' => self::formatOrder($order),
  51. 'currency' => $locale['currency'],
  52. ];
  53. if ($locale['fallback']) {
  54. $response['currency_fallback'] = true;
  55. $response['currency_note'] = self::buildCurrencyNote($locale, $language);
  56. }
  57. // Return a curated subset useful for the LLM agent
  58. Router::sendJson(200, $response);
  59. }
  60. // =================================================================
  61. // POST /tools/get_product_data
  62. // =================================================================
  63. public static function getProductData(): void
  64. {
  65. Auth::requireBearer();
  66. $body = Router::jsonBody();
  67. $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
  68. $sku = $body['sku'] ?? null;
  69. $search = $body['search'] ?? null;
  70. $language = $body['language'] ?? null;
  71. $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
  72. $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
  73. ? ['currency' => $currencyOverride, 'fallback' => false]
  74. : StoreLocales::resolve($language);
  75. $woo = new WooCommerceClient();
  76. $currencyMeta = ['currency' => $locale['currency']];
  77. if ($locale['fallback']) {
  78. $currencyMeta['currency_fallback'] = true;
  79. $currencyMeta['currency_note'] = self::buildCurrencyNote($locale, $language);
  80. }
  81. $lang = $language ?? '';
  82. if ($productId !== null && $productId > 0) {
  83. $product = $woo->getProduct(productId: $productId, currency: $locale['currency'], lang: $lang);
  84. Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
  85. return;
  86. }
  87. if ($sku !== null && $sku !== '') {
  88. $product = $woo->getProduct(sku: $sku, currency: $locale['currency'], lang: $lang);
  89. Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
  90. return;
  91. }
  92. if ($search !== null && $search !== '') {
  93. $products = $woo->searchProducts($search, currency: $locale['currency'], lang: $lang);
  94. $formatted = array_map(fn($p) => self::formatProduct($p), $products);
  95. Router::sendJson(200, array_merge(['ok' => true, 'data' => $formatted], $currencyMeta));
  96. return;
  97. }
  98. throw new HttpException(400, 'MISSING_PARAMS', 'Provide productId, sku, or search.');
  99. }
  100. // =================================================================
  101. // POST /tools/get_shipping_data
  102. // =================================================================
  103. public static function getShippingData(): void
  104. {
  105. Auth::requireBearer();
  106. $body = Router::jsonBody();
  107. $language = $body['language'] ?? null;
  108. $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
  109. $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
  110. ? ['currency' => $currencyOverride, 'fallback' => false]
  111. : StoreLocales::resolve($language);
  112. $woo = new WooCommerceClient();
  113. // If zoneId provided — return methods for that specific zone
  114. $zoneId = $body['zoneId'] ?? ($body['zone_id'] ?? null);
  115. try {
  116. if ($zoneId !== null) {
  117. // Use custom WP endpoint to get per-currency shipping costs
  118. // (WC REST API only returns PLN costs; WCML costs are in wp_options)
  119. $wpBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
  120. $url = $wpBase . '/shipping-costs?' . http_build_query([
  121. 'zone_id' => (int)$zoneId,
  122. 'currency' => $locale['currency'],
  123. ]);
  124. $res = HttpClient::request('GET', $url);
  125. $response = ['ok' => true, 'data' => $res['json'] ?? [], 'currency' => $locale['currency']];
  126. if ($locale['fallback']) {
  127. $response['currency_fallback'] = true;
  128. $response['currency_note'] = self::buildCurrencyNote($locale, $language);
  129. }
  130. Router::sendJson(200, $response);
  131. return;
  132. }
  133. // Default: return list of zones with ISO country codes
  134. // Zone 0 (Rest of the World) has no locations — it's the fallback zone.
  135. $zones = $woo->getShippingZones();
  136. $formatted = [];
  137. foreach ($zones as $z) {
  138. $zid = (int)($z['id'] ?? 0);
  139. $locations = $woo->getShippingZoneLocations($zid);
  140. // Extract country-level ISO codes only (skip state/continent entries)
  141. $countries = array_values(array_filter(array_map(
  142. fn($l) => ($l['type'] ?? '') === 'country' ? strtoupper((string)($l['code'] ?? '')) : null,
  143. $locations
  144. )));
  145. $formatted[] = [
  146. 'id' => $zid,
  147. 'name' => $z['name'] ?? '',
  148. 'countries' => $countries, // ISO 3166-1 alpha-2 codes, e.g. ["PL"] or ["GB","IE"]
  149. ];
  150. }
  151. Router::sendJson(200, [
  152. 'ok' => true,
  153. 'data' => $formatted,
  154. 'hint' => 'Pass {"zoneId": <id>} to get shipping methods for a specific zone.',
  155. ]);
  156. } catch (HttpException $e) {
  157. if ($e->httpCode === 502) {
  158. Router::sendJson(200, [
  159. 'ok' => false,
  160. 'code' => 'NOT_IMPLEMENTED',
  161. 'message' => 'Shipping data is not available via WooCommerce REST API. '
  162. . 'Ensure shipping zones are configured and the consumer key has read access to shipping endpoints.',
  163. ]);
  164. return;
  165. }
  166. throw $e;
  167. }
  168. }
  169. // =================================================================
  170. // POST /tools/get_payment_methods
  171. // =================================================================
  172. public static function getPaymentMethods(): void
  173. {
  174. Auth::requireBearer();
  175. $woo = new WooCommerceClient();
  176. try {
  177. $gateways = $woo->getPaymentGateways();
  178. // Filter to enabled gateways only
  179. $enabled = array_values(array_filter($gateways, fn($g) => ($g['enabled'] ?? false) === true));
  180. $formatted = array_map(fn($g) => [
  181. 'id' => $g['id'] ?? '',
  182. 'title' => $g['title'] ?? '',
  183. 'description' => $g['description'] ?? '',
  184. 'enabled' => $g['enabled'] ?? false,
  185. ], $enabled);
  186. Router::sendJson(200, ['ok' => true, 'data' => $formatted]);
  187. } catch (HttpException $e) {
  188. if ($e->httpCode === 502) {
  189. Router::sendJson(200, [
  190. 'ok' => false,
  191. 'code' => 'NOT_IMPLEMENTED',
  192. 'message' => 'Payment gateways endpoint not accessible. '
  193. . 'Ensure the WooCommerce consumer key has admin-level (read/write) permissions '
  194. . 'to access GET /payment_gateways.',
  195. ]);
  196. return;
  197. }
  198. throw $e;
  199. }
  200. }
  201. // =================================================================
  202. // POST /tools/get_product_compatibility
  203. // =================================================================
  204. public static function getProductCompatibility(): void
  205. {
  206. Auth::requireBearer();
  207. $body = Router::jsonBody();
  208. $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
  209. $productName = $body['product_name'] ?? ($body['productName'] ?? null);
  210. $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
  211. $carBrand = $body['car_brand'] ?? ($body['carBrand'] ?? null);
  212. $carModel = $body['car_model'] ?? ($body['carModel'] ?? null);
  213. $carYear = $body['car_year'] ?? ($body['carYear'] ?? null);
  214. $carEngine = $body['car_engine'] ?? ($body['carEngine'] ?? null);
  215. $carEngineIndex = isset($body['car_engine_index']) ? (int)$body['car_engine_index'] : -1;
  216. $language = $body['language'] ?? null;
  217. $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
  218. $locale = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
  219. ? ['currency' => $currencyOverride, 'fallback' => false]
  220. : StoreLocales::resolve($language);
  221. // When a non-Polish product name is given, try to pre-resolve it to a product ID
  222. // via the WooCommerce REST API (which has a PL language fallback). This prevents
  223. // the WP endpoint from failing to find products with non-Polish search terms.
  224. if ($productName !== null && $productName !== '' && ($productId === null || $productId === 0)) {
  225. $woo = new WooCommerceClient();
  226. $found = $woo->searchProducts($productName, 5, '', $language ?? '');
  227. if (empty($found) && $language && $language !== 'pl') {
  228. // Fallback: search in PL (default) database without language filter
  229. $found = $woo->searchProducts($productName, 5, '');
  230. }
  231. if (count($found) === 1) {
  232. // Unambiguous match — use product ID so WP endpoint skips name-based search
  233. $productId = (int)$found[0]['id'];
  234. $productName = null;
  235. }
  236. // Multiple matches or no match: pass product_name to WP endpoint as usual
  237. }
  238. $payload = [];
  239. if ($productId !== null && $productId > 0) $payload['product_id'] = $productId;
  240. if ($productName !== null && $productName !== '') $payload['product_name'] = $productName;
  241. if ($carBrand !== null && $carBrand !== '') $payload['car_brand'] = $carBrand;
  242. if ($carModel !== null && $carModel !== '') $payload['car_model'] = $carModel;
  243. if ($carYear !== null && $carYear !== '') $payload['car_year'] = $carYear;
  244. if ($carEngine !== null && $carEngine !== '') $payload['car_engine'] = $carEngine;
  245. if ($carEngineIndex >= 0) $payload['car_engine_index'] = $carEngineIndex;
  246. if ($language !== null && $language !== '') $payload['lang'] = $language;
  247. $payload['currency'] = $locale['currency'];
  248. $res = HttpClient::request('POST', $restBase . '/product-compatibility', [], $payload);
  249. $data = $res['json'] ?? $res['body'];
  250. // Post-process product titles: the WP endpoint does not switch WPML for the
  251. // selected_product branch, so titles come back in Polish. Re-fetch from WC REST
  252. // API with the correct language to get the translated title.
  253. if (is_array($data) && $language !== null && $language !== '' && $language !== 'pl') {
  254. $woo = new WooCommerceClient();
  255. // Case A: specific product compatibility check — replace title in selected_product
  256. if (isset($data['selected_product']['id'])) {
  257. try {
  258. $translated = $woo->getProduct(
  259. productId: (int)$data['selected_product']['id'],
  260. lang: $language,
  261. currency: $locale['currency']
  262. );
  263. $data['selected_product']['title'] = $translated['name'] ?? $data['selected_product']['title'];
  264. } catch (\Throwable) { /* keep original title on failure */ }
  265. }
  266. // Case B: disambiguation options list for product_name — batch-translate titles
  267. if (
  268. ($data['error'] ?? '') === 'options'
  269. && ($data['field'] ?? '') === 'product_name'
  270. && !empty($data['options'])
  271. && is_array($data['options'])
  272. ) {
  273. $ids = array_values(array_filter(array_column($data['options'], 'id')));
  274. if (!empty($ids)) {
  275. try {
  276. $translated = $woo->getProductsByIds($ids, $language, $locale['currency']);
  277. $byId = [];
  278. foreach ($translated as $tp) {
  279. $byId[(int)($tp['id'] ?? 0)] = $tp['name'] ?? null;
  280. }
  281. foreach ($data['options'] as &$opt) {
  282. $pid = (int)($opt['id'] ?? 0);
  283. if ($pid && isset($byId[$pid])) {
  284. $opt['title'] = $byId[$pid];
  285. }
  286. }
  287. unset($opt);
  288. } catch (\Throwable) { /* keep original titles on failure */ }
  289. }
  290. }
  291. }
  292. $response = [
  293. 'ok' => true,
  294. 'data' => $data,
  295. 'currency' => $locale['currency'],
  296. ];
  297. if ($locale['fallback']) {
  298. $response['currency_fallback'] = true;
  299. $response['currency_note'] = self::buildCurrencyNote($locale, $language);
  300. }
  301. Router::sendJson(200, $response);
  302. }
  303. // =================================================================
  304. // POST /tools/get_car_data
  305. // =================================================================
  306. public static function getCarData(): void
  307. {
  308. Auth::requireBearer();
  309. $body = Router::jsonBody();
  310. $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
  311. $carBrand = $body['car_brand'] ?? ($body['make'] ?? null);
  312. $carModel = $body['car_model'] ?? ($body['model'] ?? null);
  313. $carYear = $body['car_year'] ?? ($body['year'] ?? null);
  314. $carEngine = $body['car_engine'] ?? ($body['engine'] ?? null);
  315. $payload = [];
  316. if ($carBrand !== null && $carBrand !== '') $payload['car_brand'] = $carBrand;
  317. if ($carModel !== null && $carModel !== '') $payload['car_model'] = $carModel;
  318. if ($carYear !== null && $carYear !== '') $payload['car_year'] = $carYear;
  319. if ($carEngine !== null && $carEngine !== '') $payload['car_engine'] = $carEngine;
  320. $res = HttpClient::request(
  321. 'POST',
  322. $restBase . '/car-data',
  323. [],
  324. $payload
  325. );
  326. Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
  327. }
  328. // =================================================================
  329. // Formatters (curate data for LLM consumption)
  330. // =================================================================
  331. private static function formatOrder(array $order): array
  332. {
  333. return [
  334. 'id' => $order['id'] ?? null,
  335. 'number' => $order['number'] ?? null,
  336. 'status' => $order['status'] ?? null,
  337. 'date_created' => $order['date_created'] ?? null,
  338. 'total' => $order['total'] ?? null,
  339. 'currency' => $order['currency'] ?? null,
  340. 'billing' => [
  341. 'first_name' => $order['billing']['first_name'] ?? '',
  342. 'last_name' => $order['billing']['last_name'] ?? '',
  343. 'email' => $order['billing']['email'] ?? '',
  344. 'phone' => $order['billing']['phone'] ?? '',
  345. 'city' => $order['billing']['city'] ?? '',
  346. 'country' => $order['billing']['country'] ?? '',
  347. ],
  348. 'shipping' => [
  349. 'first_name' => $order['shipping']['first_name'] ?? '',
  350. 'last_name' => $order['shipping']['last_name'] ?? '',
  351. 'city' => $order['shipping']['city'] ?? '',
  352. 'country' => $order['shipping']['country'] ?? '',
  353. 'address_1' => $order['shipping']['address_1'] ?? '',
  354. ],
  355. 'payment_method' => $order['payment_method_title'] ?? null,
  356. 'shipping_total' => $order['shipping_total'] ?? null,
  357. 'line_items' => array_map(fn($item) => [
  358. 'name' => $item['name'] ?? '',
  359. 'sku' => $item['sku'] ?? '',
  360. 'quantity' => $item['quantity'] ?? 0,
  361. 'total' => $item['total'] ?? '0',
  362. ], $order['line_items'] ?? []),
  363. 'shipping_lines' => array_map(fn($sl) => [
  364. 'method_title' => $sl['method_title'] ?? '',
  365. 'total' => $sl['total'] ?? '0',
  366. ], $order['shipping_lines'] ?? []),
  367. 'customer_note' => $order['customer_note'] ?? '',
  368. ];
  369. }
  370. private static function buildCurrencyNote(array $locale, ?string $language): string
  371. {
  372. if (($locale['reason'] ?? '') === 'unknown_language') {
  373. return 'Unknown language code ' . ($language ?? '?') . '. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
  374. }
  375. $natural = $locale['natural_currency'] ?? ($language ?? '?');
  376. return 'Currency for ' . $natural . ' is not supported in the store. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
  377. }
  378. private static function formatProduct(array $product): array
  379. {
  380. return [
  381. 'id' => $product['id'] ?? null,
  382. 'name' => $product['name'] ?? '',
  383. 'sku' => $product['sku'] ?? '',
  384. 'slug' => $product['slug'] ?? '',
  385. 'status' => $product['status'] ?? '',
  386. 'price' => $product['price'] ?? '',
  387. 'regular_price' => $product['regular_price'] ?? '',
  388. 'sale_price' => $product['sale_price'] ?? '',
  389. 'stock_status' => $product['stock_status'] ?? '',
  390. 'stock_quantity' => $product['stock_quantity'] ?? null,
  391. 'short_description' => strip_tags((string)($product['short_description'] ?? '')),
  392. 'categories' => array_map(fn($c) => $c['name'] ?? '', $product['categories'] ?? []),
  393. 'attributes' => array_map(fn($a) => [
  394. 'name' => $a['name'] ?? '',
  395. 'options' => $a['options'] ?? [],
  396. ], $product['attributes'] ?? []),
  397. 'permalink' => $product['permalink'] ?? '',
  398. ];
  399. }
  400. }