reverb.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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. `handmade` TINYINT(1) NOT NULL DEFAULT 0,
  15. `upc_does_not_apply` TINYINT(1) NOT NULL DEFAULT 1,
  16. `last_synced_at` DATETIME NULL DEFAULT NULL,
  17. PRIMARY KEY (`product_id`)
  18. ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
  19. ");
  20. $this->migrate();
  21. $this->db->query("
  22. CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_sync_log` (
  23. `log_id` INT(11) NOT NULL AUTO_INCREMENT,
  24. `product_id` INT(11) NOT NULL DEFAULT 0,
  25. `direction` ENUM('push','pull') NOT NULL DEFAULT 'push',
  26. `status` ENUM('success','error') NOT NULL DEFAULT 'success',
  27. `message` TEXT NOT NULL,
  28. `created_at` DATETIME NOT NULL,
  29. PRIMARY KEY (`log_id`),
  30. KEY `product_id` (`product_id`),
  31. KEY `created_at` (`created_at`)
  32. ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
  33. ");
  34. }
  35. public function uninstall() {
  36. $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_product_map`");
  37. $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_sync_log`");
  38. }
  39. public function migrate() {
  40. static $done = false;
  41. if ($done) return;
  42. $done = true;
  43. $this->addColumnIfMissing(DB_PREFIX . 'reverb_product_map', 'handmade', 'TINYINT(1) NOT NULL DEFAULT 0');
  44. $this->addColumnIfMissing(DB_PREFIX . 'reverb_product_map', 'upc_does_not_apply', 'TINYINT(1) NOT NULL DEFAULT 1');
  45. }
  46. private function addColumnIfMissing($table, $column, $definition) {
  47. $r = $this->db->query("
  48. SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
  49. WHERE TABLE_SCHEMA = DATABASE()
  50. AND TABLE_NAME = '" . $this->db->escape($table) . "'
  51. AND COLUMN_NAME = '" . $this->db->escape($column) . "'
  52. ");
  53. if (empty($r->row['cnt'])) {
  54. $this->db->query("ALTER TABLE `" . $table . "` ADD COLUMN `" . $column . "` " . $definition);
  55. }
  56. }
  57. // -------------------------------------------------------------------------
  58. // Product map CRUD
  59. // -------------------------------------------------------------------------
  60. public function getProductMap($product_id) {
  61. $this->migrate();
  62. $query = $this->db->query("
  63. SELECT * FROM `" . DB_PREFIX . "reverb_product_map`
  64. WHERE `product_id` = '" . (int)$product_id . "'
  65. ");
  66. return $query->num_rows ? $query->row : null;
  67. }
  68. public function saveProductMap($product_id, array $data) {
  69. $this->migrate();
  70. $existing = $this->getProductMap($product_id);
  71. $sync_enabled = isset($data['sync_enabled']) ? (int)(bool)$data['sync_enabled'] : 0;
  72. $condition_uuid = isset($data['condition_uuid']) ? $this->db->escape($data['condition_uuid']) : '';
  73. $reverb_category_uuid = isset($data['reverb_category_uuid']) ? $this->db->escape($data['reverb_category_uuid']) : '';
  74. $reverb_listing_id = isset($data['reverb_listing_id']) ? $this->db->escape($data['reverb_listing_id']) : '';
  75. $last_synced_at = isset($data['last_synced_at']) ? "'" . $this->db->escape($data['last_synced_at']) . "'" : 'NULL';
  76. $handmade = isset($data['handmade']) ? (int)(bool)$data['handmade'] : 0;
  77. $upc_does_not_apply = isset($data['upc_does_not_apply']) ? (int)(bool)$data['upc_does_not_apply'] : 1;
  78. if ($existing) {
  79. $this->db->query("
  80. UPDATE `" . DB_PREFIX . "reverb_product_map`
  81. SET `sync_enabled` = $sync_enabled,
  82. `condition_uuid` = '$condition_uuid',
  83. `reverb_category_uuid` = '$reverb_category_uuid',
  84. `handmade` = $handmade,
  85. `upc_does_not_apply` = $upc_does_not_apply"
  86. . (!empty($reverb_listing_id) ? ", `reverb_listing_id` = '$reverb_listing_id'" : '')
  87. . (isset($data['last_synced_at']) ? ", `last_synced_at` = $last_synced_at" : '')
  88. . " WHERE `product_id` = '" . (int)$product_id . "'"
  89. );
  90. } else {
  91. $this->db->query("
  92. INSERT INTO `" . DB_PREFIX . "reverb_product_map`
  93. (`product_id`, `sync_enabled`, `condition_uuid`, `reverb_category_uuid`,
  94. `reverb_listing_id`, `handmade`, `upc_does_not_apply`, `last_synced_at`)
  95. VALUES (
  96. '" . (int)$product_id . "',
  97. $sync_enabled,
  98. '$condition_uuid',
  99. '$reverb_category_uuid',
  100. '$reverb_listing_id',
  101. $handmade,
  102. $upc_does_not_apply,
  103. $last_synced_at
  104. )
  105. ");
  106. }
  107. }
  108. public function updateListingId($product_id, $listing_id) {
  109. $this->db->query("
  110. UPDATE `" . DB_PREFIX . "reverb_product_map`
  111. SET `reverb_listing_id` = '" . $this->db->escape($listing_id) . "',
  112. `last_synced_at` = NOW()
  113. WHERE `product_id` = '" . (int)$product_id . "'
  114. ");
  115. }
  116. /**
  117. * Return all products eligible for sync (sync_enabled = 1, in allowed categories).
  118. *
  119. * @param array $allowed_category_ids List of OC category IDs.
  120. * @return array
  121. */
  122. public function getSyncEnabledProducts(array $allowed_category_ids) {
  123. if (empty($allowed_category_ids)) {
  124. return [];
  125. }
  126. $ids = implode(',', array_map('intval', $allowed_category_ids));
  127. $query = $this->db->query("
  128. SELECT p.product_id, pd.name, pd.description, p.model, p.price, p.quantity, p.image,
  129. r.reverb_listing_id, r.condition_uuid, r.reverb_category_uuid,
  130. r.handmade, r.upc_does_not_apply,
  131. m.name AS manufacturer
  132. FROM `" . DB_PREFIX . "product` p
  133. INNER JOIN `" . DB_PREFIX . "product_description` pd
  134. ON pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  135. INNER JOIN `" . DB_PREFIX . "product_to_category` ptc
  136. ON ptc.product_id = p.product_id AND ptc.category_id IN ($ids)
  137. INNER JOIN `" . DB_PREFIX . "reverb_product_map` r
  138. ON r.product_id = p.product_id AND r.sync_enabled = 1
  139. LEFT JOIN `" . DB_PREFIX . "manufacturer` m
  140. ON m.manufacturer_id = p.manufacturer_id
  141. WHERE p.status = 1
  142. GROUP BY p.product_id
  143. ");
  144. return $query->rows;
  145. }
  146. // -------------------------------------------------------------------------
  147. // Product images
  148. // -------------------------------------------------------------------------
  149. public function getProductImages($product_id) {
  150. $images = [];
  151. $product_query = $this->db->query("
  152. SELECT image FROM `" . DB_PREFIX . "product`
  153. WHERE product_id = '" . (int)$product_id . "'
  154. ");
  155. if ($product_query->num_rows && !empty($product_query->row['image'])) {
  156. $images[] = $product_query->row['image'];
  157. }
  158. $gallery_query = $this->db->query("
  159. SELECT image FROM `" . DB_PREFIX . "product_image`
  160. WHERE product_id = '" . (int)$product_id . "'
  161. ORDER BY sort_order ASC
  162. ");
  163. foreach ($gallery_query->rows as $row) {
  164. $images[] = $row['image'];
  165. }
  166. return $images;
  167. }
  168. // -------------------------------------------------------------------------
  169. // Category mappings (OC category → Reverb category UUID)
  170. // -------------------------------------------------------------------------
  171. public function getCategoryMappings() {
  172. $raw = $this->config->get('module_reverb_category_mappings');
  173. return $raw ? json_decode($raw, true) : [];
  174. }
  175. public function saveCategoryMappings(array $mappings) {
  176. $this->load->model('setting/setting');
  177. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_category_mappings', json_encode($mappings));
  178. }
  179. // -------------------------------------------------------------------------
  180. // Reverb metadata cache (conditions + categories)
  181. // -------------------------------------------------------------------------
  182. public function getListingConditions() {
  183. $cached = $this->config->get('module_reverb_conditions_cache');
  184. $cached_at = (int)$this->config->get('module_reverb_conditions_cached_at');
  185. if ($cached && (time() - $cached_at) < 86400) {
  186. return json_decode($cached, true);
  187. }
  188. try {
  189. $api = $this->getApi();
  190. $resp = $api->getListingConditions();
  191. $conditions = isset($resp['conditions']) ? $resp['conditions'] : [];
  192. $this->load->model('setting/setting');
  193. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_conditions_cache', json_encode($conditions));
  194. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_conditions_cached_at', time());
  195. return $conditions;
  196. } catch (Exception $e) {
  197. return $cached ? json_decode($cached, true) : [];
  198. }
  199. }
  200. public function getReverbCategories() {
  201. $cached = $this->config->get('module_reverb_categories_cache');
  202. $cached_at = (int)$this->config->get('module_reverb_categories_cached_at');
  203. if ($cached && (time() - $cached_at) < 86400) {
  204. return json_decode($cached, true);
  205. }
  206. try {
  207. $api = $this->getApi();
  208. $resp = $api->getCategories();
  209. $categories = isset($resp['categories']) ? $resp['categories'] : [];
  210. $this->load->model('setting/setting');
  211. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_categories_cache', json_encode($categories));
  212. $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_categories_cached_at', time());
  213. return $categories;
  214. } catch (Exception $e) {
  215. return $cached ? json_decode($cached, true) : [];
  216. }
  217. }
  218. // -------------------------------------------------------------------------
  219. // Sync log
  220. // -------------------------------------------------------------------------
  221. public function log($product_id, $direction, $status, $message) {
  222. $this->db->query("
  223. INSERT INTO `" . DB_PREFIX . "reverb_sync_log`
  224. (`product_id`, `direction`, `status`, `message`, `created_at`)
  225. VALUES (
  226. '" . (int)$product_id . "',
  227. '" . $this->db->escape($direction) . "',
  228. '" . $this->db->escape($status) . "',
  229. '" . $this->db->escape(substr($message, 0, 65535)) . "',
  230. NOW()
  231. )
  232. ");
  233. }
  234. public function getSyncLog($limit = 100) {
  235. $query = $this->db->query("
  236. SELECT l.*, pd.name AS product_name
  237. FROM `" . DB_PREFIX . "reverb_sync_log` l
  238. LEFT JOIN `" . DB_PREFIX . "product_description` pd
  239. ON pd.product_id = l.product_id
  240. AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
  241. ORDER BY l.created_at DESC
  242. LIMIT " . (int)$limit
  243. );
  244. return $query->rows;
  245. }
  246. // -------------------------------------------------------------------------
  247. // Sync helpers
  248. // -------------------------------------------------------------------------
  249. /**
  250. * Push a single product to Reverb. Creates or updates the listing and uploads images.
  251. *
  252. * @param array $product Product row (product_id, name, model, price, quantity, image, ...).
  253. * @param array $reverb_data Row from reverb_product_map.
  254. * @param array $settings Global Reverb settings array.
  255. * @return string The Reverb listing ID.
  256. */
  257. public function syncProductToReverb(array $product, array $reverb_data, array $settings) {
  258. require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
  259. require_once(DIR_SYSTEM . 'library/reverb/ProductMapper.php');
  260. $api = $this->getApi();
  261. $payload = ProductMapper::toReverb($product, $reverb_data, $settings);
  262. // Photos are plain URL strings sent inside the listing payload (Reverb API v3)
  263. $store_url = $settings['store_url'] ?? '';
  264. $images = $this->getProductImages($product['product_id']);
  265. if (!empty($store_url) && !empty($images)) {
  266. $base = rtrim($store_url, '/') . '/image/';
  267. $photos = [];
  268. foreach ($images as $path) {
  269. if (!empty($path)) {
  270. $photos[] = $base . ltrim($path, '/');
  271. }
  272. }
  273. if (!empty($photos)) {
  274. $payload['photos'] = $photos;
  275. $payload['publish'] = true;
  276. $this->log($product['product_id'], 'push', 'success', 'Including ' . count($photos) . ' photo(s): ' . implode(', ', $photos));
  277. }
  278. } elseif (empty($store_url)) {
  279. $this->log($product['product_id'], 'push', 'error', 'Photo upload skipped: store URL not configured (check System > Settings > Store URL)');
  280. } else {
  281. $this->log($product['product_id'], 'push', 'error', 'No images found for this product in OpenCart.');
  282. }
  283. $listing_id = !empty($reverb_data['reverb_listing_id']) ? $reverb_data['reverb_listing_id'] : null;
  284. if ($listing_id) {
  285. $api->updateListing($listing_id, $payload);
  286. } else {
  287. $response = $api->createListing($payload);
  288. $listing_id = $response['id'] ?? ($response['listing']['id'] ?? null);
  289. if (!$listing_id) {
  290. throw new RuntimeException('Reverb did not return a listing ID after create.');
  291. }
  292. $this->updateListingId($product['product_id'], $listing_id);
  293. }
  294. return $listing_id;
  295. }
  296. // -------------------------------------------------------------------------
  297. // Utility
  298. // -------------------------------------------------------------------------
  299. private function getApi() {
  300. $token = $this->config->get('module_reverb_api_token');
  301. if (empty($token)) {
  302. throw new RuntimeException('Reverb API token is not configured.');
  303. }
  304. require_once(DIR_SYSTEM . 'library/reverb/ReverbApi.php');
  305. return new ReverbApi($token);
  306. }
  307. }