reverb.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. <?php
  2. class ModelExtensionModuleReverb extends Model {
  3. // -------------------------------------------------------------------------
  4. // Install / Uninstall
  5. // -------------------------------------------------------------------------
  6. public function install() {
  7. $this->db->query("
  8. CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_product_map` (
  9. `product_id` INT(11) NOT NULL,
  10. `reverb_listing_id` VARCHAR(64) NOT NULL DEFAULT '',
  11. `sync_enabled` TINYINT(1) NOT NULL DEFAULT 0,
  12. `condition_uuid` VARCHAR(64) NOT NULL DEFAULT '',
  13. `reverb_category_uuid` VARCHAR(64) NOT NULL DEFAULT '',
  14. `last_synced_at` DATETIME NULL DEFAULT NULL,
  15. PRIMARY KEY (`product_id`)
  16. ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
  17. ");
  18. $this->db->query("
  19. CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_sync_log` (
  20. `log_id` INT(11) NOT NULL AUTO_INCREMENT,
  21. `product_id` INT(11) NOT NULL DEFAULT 0,
  22. `direction` ENUM('push','pull') NOT NULL DEFAULT 'push',
  23. `status` ENUM('success','error') NOT NULL DEFAULT 'success',
  24. `message` TEXT NOT NULL,
  25. `created_at` DATETIME NOT NULL,
  26. PRIMARY KEY (`log_id`),
  27. KEY `product_id` (`product_id`),
  28. KEY `created_at` (`created_at`)
  29. ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
  30. ");
  31. }
  32. public function uninstall() {
  33. $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_product_map`");
  34. $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_sync_log`");
  35. }
  36. // -------------------------------------------------------------------------
  37. // Product map CRUD
  38. // -------------------------------------------------------------------------
  39. public function getProductMap($product_id) {
  40. $query = $this->db->query("
  41. SELECT * FROM `" . DB_PREFIX . "reverb_product_map`
  42. WHERE `product_id` = '" . (int)$product_id . "'
  43. ");
  44. return $query->num_rows ? $query->row : null;
  45. }
  46. public function saveProductMap($product_id, array $data) {
  47. $existing = $this->getProductMap($product_id);
  48. $sync_enabled = isset($data['sync_enabled']) ? (int)(bool)$data['sync_enabled'] : 0;
  49. $condition_uuid = isset($data['condition_uuid']) ? $this->db->escape($data['condition_uuid']) : '';
  50. $reverb_category_uuid = isset($data['reverb_category_uuid']) ? $this->db->escape($data['reverb_category_uuid']) : '';
  51. $reverb_listing_id = isset($data['reverb_listing_id']) ? $this->db->escape($data['reverb_listing_id']) : '';
  52. $last_synced_at = isset($data['last_synced_at']) ? "'" . $this->db->escape($data['last_synced_at']) . "'" : 'NULL';
  53. if ($existing) {
  54. $this->db->query("
  55. UPDATE `" . DB_PREFIX . "reverb_product_map`
  56. SET `sync_enabled` = $sync_enabled,
  57. `condition_uuid` = '$condition_uuid',
  58. `reverb_category_uuid` = '$reverb_category_uuid'"
  59. . (!empty($reverb_listing_id) ? ", `reverb_listing_id` = '$reverb_listing_id'" : '')
  60. . (isset($data['last_synced_at']) ? ", `last_synced_at` = $last_synced_at" : '')
  61. . " WHERE `product_id` = '" . (int)$product_id . "'"
  62. );
  63. } else {
  64. $this->db->query("
  65. INSERT INTO `" . DB_PREFIX . "reverb_product_map`
  66. (`product_id`, `sync_enabled`, `condition_uuid`, `reverb_category_uuid`, `reverb_listing_id`, `last_synced_at`)
  67. VALUES (
  68. '" . (int)$product_id . "',
  69. $sync_enabled,
  70. '$condition_uuid',
  71. '$reverb_category_uuid',
  72. '$reverb_listing_id',
  73. $last_synced_at
  74. )
  75. ");
  76. }
  77. }
  78. public function updateListingId($product_id, $listing_id) {
  79. $this->db->query("
  80. UPDATE `" . DB_PREFIX . "reverb_product_map`
  81. SET `reverb_listing_id` = '" . $this->db->escape($listing_id) . "',
  82. `last_synced_at` = NOW()
  83. WHERE `product_id` = '" . (int)$product_id . "'
  84. ");
  85. }
  86. /**
  87. * Return all products eligible for sync (sync_enabled = 1, in allowed categories).
  88. *
  89. * @param array $allowed_category_ids List of OC category IDs.
  90. * @return array
  91. */
  92. public function getSyncEnabledProducts(array $allowed_category_ids) {
  93. if (empty($allowed_category_ids)) {
  94. return [];
  95. }
  96. $ids = implode(',', array_map('intval', $allowed_category_ids));
  97. $query = $this->db->query("
  98. SELECT p.product_id, pd.name, p.model, p.price, p.quantity, p.image,
  99. r.reverb_listing_id, r.condition_uuid, r.reverb_category_uuid
  100. FROM `" . DB_PREFIX . "product` p
  101. INNER JOIN `" . DB_PREFIX . "product_description` pd
  102. ON pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  103. INNER JOIN `" . DB_PREFIX . "product_to_category` ptc
  104. ON ptc.product_id = p.product_id AND ptc.category_id IN ($ids)
  105. INNER JOIN `" . DB_PREFIX . "reverb_product_map` r
  106. ON r.product_id = p.product_id AND r.sync_enabled = 1
  107. WHERE p.status = 1
  108. GROUP BY p.product_id
  109. ");
  110. return $query->rows;
  111. }
  112. // -------------------------------------------------------------------------
  113. // Product images
  114. // -------------------------------------------------------------------------
  115. public function getProductImages($product_id) {
  116. $images = [];
  117. $product_query = $this->db->query("
  118. SELECT image FROM `" . DB_PREFIX . "product`
  119. WHERE product_id = '" . (int)$product_id . "'
  120. ");
  121. if ($product_query->num_rows && !empty($product_query->row['image'])) {
  122. $images[] = $product_query->row['image'];
  123. }
  124. $gallery_query = $this->db->query("
  125. SELECT image FROM `" . DB_PREFIX . "product_image`
  126. WHERE product_id = '" . (int)$product_id . "'
  127. ORDER BY sort_order ASC
  128. ");
  129. foreach ($gallery_query->rows as $row) {
  130. $images[] = $row['image'];
  131. }
  132. return $images;
  133. }
  134. // -------------------------------------------------------------------------
  135. // Category mappings (OC category → Reverb category UUID)
  136. // -------------------------------------------------------------------------
  137. public function getCategoryMappings() {
  138. $raw = $this->config->get('module_reverb_category_mappings');
  139. return $raw ? json_decode($raw, true) : [];
  140. }
  141. public function saveCategoryMappings(array $mappings) {
  142. $this->load->model('setting/setting');
  143. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_category_mappings', json_encode($mappings));
  144. }
  145. // -------------------------------------------------------------------------
  146. // Reverb metadata cache (conditions + categories)
  147. // -------------------------------------------------------------------------
  148. public function getListingConditions() {
  149. $cached = $this->config->get('module_reverb_conditions_cache');
  150. $cached_at = (int)$this->config->get('module_reverb_conditions_cached_at');
  151. if ($cached && (time() - $cached_at) < 86400) {
  152. return json_decode($cached, true);
  153. }
  154. try {
  155. $api = $this->getApi();
  156. $resp = $api->getListingConditions();
  157. $conditions = isset($resp['conditions']) ? $resp['conditions'] : [];
  158. $this->load->model('setting/setting');
  159. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_conditions_cache', json_encode($conditions));
  160. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_conditions_cached_at', time());
  161. return $conditions;
  162. } catch (Exception $e) {
  163. return $cached ? json_decode($cached, true) : [];
  164. }
  165. }
  166. public function getReverbCategories() {
  167. $cached = $this->config->get('module_reverb_categories_cache');
  168. $cached_at = (int)$this->config->get('module_reverb_categories_cached_at');
  169. if ($cached && (time() - $cached_at) < 86400) {
  170. return json_decode($cached, true);
  171. }
  172. try {
  173. $api = $this->getApi();
  174. $resp = $api->getCategories();
  175. $categories = isset($resp['categories']) ? $resp['categories'] : [];
  176. $this->load->model('setting/setting');
  177. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_categories_cache', json_encode($categories));
  178. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_categories_cached_at', time());
  179. return $categories;
  180. } catch (Exception $e) {
  181. return $cached ? json_decode($cached, true) : [];
  182. }
  183. }
  184. // -------------------------------------------------------------------------
  185. // Sync log
  186. // -------------------------------------------------------------------------
  187. public function log($product_id, $direction, $status, $message) {
  188. $this->db->query("
  189. INSERT INTO `" . DB_PREFIX . "reverb_sync_log`
  190. (`product_id`, `direction`, `status`, `message`, `created_at`)
  191. VALUES (
  192. '" . (int)$product_id . "',
  193. '" . $this->db->escape($direction) . "',
  194. '" . $this->db->escape($status) . "',
  195. '" . $this->db->escape(substr($message, 0, 65535)) . "',
  196. NOW()
  197. )
  198. ");
  199. }
  200. public function getSyncLog($limit = 100) {
  201. $query = $this->db->query("
  202. SELECT l.*, pd.name AS product_name
  203. FROM `" . DB_PREFIX . "reverb_sync_log` l
  204. LEFT JOIN `" . DB_PREFIX . "product_description` pd
  205. ON pd.product_id = l.product_id
  206. AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  207. ORDER BY l.created_at DESC
  208. LIMIT " . (int)$limit
  209. );
  210. return $query->rows;
  211. }
  212. // -------------------------------------------------------------------------
  213. // Sync helpers
  214. // -------------------------------------------------------------------------
  215. /**
  216. * Push a single product to Reverb. Creates or updates the listing and uploads images.
  217. *
  218. * @param array $product Product row (product_id, name, model, price, quantity, image, ...).
  219. * @param array $reverb_data Row from reverb_product_map.
  220. * @param array $settings Global Reverb settings array.
  221. * @return string The Reverb listing ID.
  222. */
  223. public function syncProductToReverb(array $product, array $reverb_data, array $settings) {
  224. require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
  225. require_once(DIR_SYSTEM . 'library/reverb/ProductMapper.php');
  226. $api = $this->getApi();
  227. $payload = ProductMapper::toReverb($product, $reverb_data, $settings);
  228. $listing_id = !empty($reverb_data['reverb_listing_id']) ? $reverb_data['reverb_listing_id'] : null;
  229. if ($listing_id) {
  230. $api->updateListing($listing_id, $payload);
  231. } else {
  232. $response = $api->createListing($payload);
  233. $listing_id = $response['id'] ?? ($response['listing']['id'] ?? null);
  234. if (!$listing_id) {
  235. throw new RuntimeException('Reverb did not return a listing ID after create.');
  236. }
  237. $this->updateListingId($product['product_id'], $listing_id);
  238. }
  239. // Upload images
  240. $images = $this->getProductImages($product['product_id']);
  241. $store_url = $settings['store_url'] ?? '';
  242. foreach (ProductMapper::buildPhotoPayloads($images, $store_url) as $photo) {
  243. try {
  244. $api->uploadPhoto($listing_id, $photo['image_url']);
  245. } catch (Exception $e) {
  246. // Non-fatal: log but continue
  247. $this->log($product['product_id'], 'push', 'error', 'Photo upload failed: ' . $e->getMessage());
  248. }
  249. }
  250. return $listing_id;
  251. }
  252. // -------------------------------------------------------------------------
  253. // Utility
  254. // -------------------------------------------------------------------------
  255. private function getApi() {
  256. $token = $this->config->get('module_reverb_api_token');
  257. if (empty($token)) {
  258. throw new RuntimeException('Reverb API token is not configured.');
  259. }
  260. require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
  261. return new ReverbApi($token);
  262. }
  263. }