CrmModel.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. /**
  5. * Persistência dos dados recebidos via webhook de CRM.
  6. *
  7. * O company_id NUNCA vem do corpo do payload: é sempre resolvido a partir da
  8. * empresa identificada na URL e validada por HMAC no controller. Todos os
  9. * métodos de gravação aqui recebem o company_id já confiável como primeiro
  10. * parâmetro e o aplicam em cada query, garantindo o isolamento entre empresas.
  11. */
  12. class CrmModel
  13. {
  14. private \PDO $pdo;
  15. public function __construct()
  16. {
  17. $this->pdo = Database::pdo();
  18. }
  19. /**
  20. * Carrega a empresa ativa e seu segredo HMAC. Retorna null se não existir
  21. * ou estiver excluída logicamente.
  22. */
  23. public function findCompanyById(int $companyId): ?array
  24. {
  25. $stmt = $this->pdo->prepare(
  26. "SELECT company_id, company_hmac_secret
  27. FROM company
  28. WHERE company_id = :company_id
  29. AND company_deleted_at = 'infinity'
  30. LIMIT 1"
  31. );
  32. $stmt->execute(['company_id' => $companyId]);
  33. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  34. return $row === false ? null : $row;
  35. }
  36. /**
  37. * Insere ou atualiza um produto (SKU) da empresa.
  38. *
  39. * A tabela sku não possui chave única natural além do id, então a
  40. * identidade do produto é (company_id, sku_name): se já existir um SKU
  41. * ativo com o mesmo nome, ele é atualizado; caso contrário, é criado.
  42. *
  43. * Essencial (validado no controller): name.
  44. * Opcionais (aceitam "" ou ausência, com valor neutro):
  45. * - value -> 0.0 (qualquer valor não numérico vira 0)
  46. * - line -> ""
  47. * - sold -> 0
  48. *
  49. * @return array{sku_id:int, created:bool}
  50. */
  51. public function upsertSku(int $companyId, array $data): array
  52. {
  53. $name = trim((string) ($data['name'] ?? ''));
  54. $value = $this->toDecimal($data['value'] ?? null);
  55. $line = trim((string) ($data['line'] ?? ''));
  56. $sold = (int) ($data['sold'] ?? 0);
  57. $stmt = $this->pdo->prepare(
  58. "SELECT sku_id
  59. FROM sku
  60. WHERE company_id = :company_id
  61. AND sku_name = :name
  62. AND sku_deleted_at = 'infinity'
  63. LIMIT 1"
  64. );
  65. $stmt->execute(['company_id' => $companyId, 'name' => $name]);
  66. $existingId = $stmt->fetchColumn();
  67. if ($existingId !== false) {
  68. $update = $this->pdo->prepare(
  69. "UPDATE sku
  70. SET sku_value = :value,
  71. sku_line = :line,
  72. sku_sold = :sold
  73. WHERE sku_id = :sku_id
  74. AND company_id = :company_id"
  75. );
  76. $update->execute([
  77. 'value' => $value,
  78. 'line' => $line,
  79. 'sold' => $sold,
  80. 'sku_id' => (int) $existingId,
  81. 'company_id' => $companyId,
  82. ]);
  83. return ['sku_id' => (int) $existingId, 'created' => false];
  84. }
  85. $insert = $this->pdo->prepare(
  86. "INSERT INTO sku (company_id, sku_name, sku_value, sku_sold, sku_line)
  87. VALUES (:company_id, :name, :value, :sold, :line)
  88. RETURNING sku_id"
  89. );
  90. $insert->execute([
  91. 'company_id' => $companyId,
  92. 'name' => $name,
  93. 'value' => $value,
  94. 'sold' => $sold,
  95. 'line' => $line,
  96. ]);
  97. return ['sku_id' => (int) $insert->fetchColumn(), 'created' => true];
  98. }
  99. /**
  100. * Insere ou atualiza um cliente da empresa.
  101. *
  102. * A identidade é (company_id, client_phone) — chave única da tabela. O
  103. * client_provider_id é preenchido com o id externo do CRM quando informado
  104. * (ou derivado do telefone) para satisfazer o índice único parcial e
  105. * permitir rastrear a origem.
  106. *
  107. * Essencial (validado no controller): phone.
  108. * Opcionais (aceitam "" ou ausência, com valor neutro):
  109. * - name -> ""
  110. * - email -> ""
  111. * - segment -> ""
  112. * - provider_id -> "crm:<phone>" (derivado quando vazio)
  113. *
  114. * @return array{client_id:int}
  115. */
  116. public function upsertClient(int $companyId, array $data): array
  117. {
  118. $phone = trim((string) ($data['phone'] ?? ''));
  119. $name = trim((string) ($data['name'] ?? ''));
  120. $email = trim((string) ($data['email'] ?? ''));
  121. $segment = trim((string) ($data['segment'] ?? ''));
  122. $providerId = trim((string) ($data['provider_id'] ?? ''));
  123. if ($providerId === '') {
  124. $providerId = 'crm:' . $phone;
  125. }
  126. $stmt = $this->pdo->prepare(
  127. "INSERT INTO client
  128. (company_id, client_provider_id, client_phone, client_name,
  129. client_email, client_segment, client_is_registered)
  130. VALUES
  131. (:company_id, :provider_id, :phone, :name, :email, :segment, TRUE)
  132. ON CONFLICT (company_id, client_phone) DO UPDATE
  133. SET client_provider_id = EXCLUDED.client_provider_id,
  134. client_name = EXCLUDED.client_name,
  135. client_email = EXCLUDED.client_email,
  136. client_segment = EXCLUDED.client_segment,
  137. client_is_registered = TRUE
  138. RETURNING client_id"
  139. );
  140. $stmt->execute([
  141. 'company_id' => $companyId,
  142. 'provider_id' => $providerId,
  143. 'phone' => $phone,
  144. 'name' => $name,
  145. 'email' => $email,
  146. 'segment' => $segment,
  147. ]);
  148. return ['client_id' => (int) $stmt->fetchColumn()];
  149. }
  150. /**
  151. * Registra uma venda no histórico (tabela sale) e atualiza o total
  152. * acumulado do produto (sku.sku_sold).
  153. *
  154. * É idempotente: se o CRM reenviar a mesma venda (mesmo external_id), a
  155. * linha não é duplicada e o sku_sold NÃO é incrementado de novo. Por isso
  156. * o incremento do contador só acontece quando a venda é realmente nova.
  157. *
  158. * Essencial (validado no controller): external_id, amount, occurred_at.
  159. * Opcionais (aceitam "" ou ausência):
  160. * - product_name -> resolve sku_id; vazio/sem match => 0 (venda sem produto vinculado)
  161. * - quantity -> 1
  162. * - client_phone -> resolve client_id; vazio/sem match => 0
  163. * - operator_email -> resolve operator_id; vazio/sem match => 0
  164. *
  165. * @return array{sale_id:int, created:bool, sku_id:int}
  166. */
  167. public function upsertSale(int $companyId, array $data): array
  168. {
  169. $externalId = trim((string) ($data['external_id'] ?? ''));
  170. $amount = $this->toDecimal($data['amount'] ?? null);
  171. $quantity = (int) ($data['quantity'] ?? 1);
  172. if ($quantity < 1) {
  173. $quantity = 1;
  174. }
  175. $occurredAt = $this->toTimestamp($data['occurred_at'] ?? null);
  176. if ($occurredAt === null) {
  177. throw new \InvalidArgumentException('sale "occurred_at" must be a valid date/time');
  178. }
  179. $productName = trim((string) ($data['product_name'] ?? ''));
  180. $clientPhone = trim((string) ($data['client_phone'] ?? ''));
  181. $operatorEmail = trim((string) ($data['operator_email'] ?? ''));
  182. $skuId = $productName !== '' ? $this->findSkuIdByName($companyId, $productName) : 0;
  183. $clientId = $clientPhone !== '' ? $this->findClientIdByPhone($companyId, $clientPhone) : 0;
  184. $operatorId = $operatorEmail !== '' ? $this->findOperatorIdByEmail($companyId, $operatorEmail) : 0;
  185. $this->pdo->beginTransaction();
  186. try {
  187. $insert = $this->pdo->prepare(
  188. "INSERT INTO sale
  189. (company_id, sku_id, client_id, operator_id, sale_external_id,
  190. sale_amount, sale_quantity, sale_occurred_at)
  191. VALUES
  192. (:company_id, :sku_id, :client_id, :operator_id, :external_id,
  193. :amount, :quantity, :occurred_at)
  194. ON CONFLICT (company_id, sale_external_id) WHERE sale_deleted_at = 'infinity'
  195. DO NOTHING
  196. RETURNING sale_id"
  197. );
  198. $insert->execute([
  199. 'company_id' => $companyId,
  200. 'sku_id' => $skuId,
  201. 'client_id' => $clientId,
  202. 'operator_id' => $operatorId,
  203. 'external_id' => $externalId,
  204. 'amount' => $amount,
  205. 'quantity' => $quantity,
  206. 'occurred_at' => $occurredAt,
  207. ]);
  208. $saleId = $insert->fetchColumn();
  209. $created = $saleId !== false;
  210. // Só incrementa o contador quando a venda é nova e há produto vinculado.
  211. if ($created && $skuId > 0) {
  212. $update = $this->pdo->prepare(
  213. "UPDATE sku
  214. SET sku_sold = sku_sold + :quantity
  215. WHERE sku_id = :sku_id
  216. AND company_id = :company_id"
  217. );
  218. $update->execute([
  219. 'quantity' => $quantity,
  220. 'sku_id' => $skuId,
  221. 'company_id' => $companyId,
  222. ]);
  223. }
  224. $this->pdo->commit();
  225. } catch (\Throwable $e) {
  226. $this->pdo->rollBack();
  227. throw $e;
  228. }
  229. return [
  230. 'sale_id' => $created ? (int) $saleId : 0,
  231. 'created' => $created,
  232. 'sku_id' => $skuId,
  233. ];
  234. }
  235. private function findSkuIdByName(int $companyId, string $name): int
  236. {
  237. $stmt = $this->pdo->prepare(
  238. "SELECT sku_id
  239. FROM sku
  240. WHERE company_id = :company_id
  241. AND sku_name = :name
  242. AND sku_deleted_at = 'infinity'
  243. LIMIT 1"
  244. );
  245. $stmt->execute(['company_id' => $companyId, 'name' => $name]);
  246. $id = $stmt->fetchColumn();
  247. return $id === false ? 0 : (int) $id;
  248. }
  249. private function findClientIdByPhone(int $companyId, string $phone): int
  250. {
  251. $stmt = $this->pdo->prepare(
  252. "SELECT client_id
  253. FROM client
  254. WHERE company_id = :company_id
  255. AND client_phone = :phone
  256. AND client_deleted_at = 'infinity'
  257. LIMIT 1"
  258. );
  259. $stmt->execute(['company_id' => $companyId, 'phone' => $phone]);
  260. $id = $stmt->fetchColumn();
  261. return $id === false ? 0 : (int) $id;
  262. }
  263. private function findOperatorIdByEmail(int $companyId, string $email): int
  264. {
  265. $normalized = mb_strtolower(trim($email));
  266. if ($normalized === '') {
  267. return 0;
  268. }
  269. $stmt = $this->pdo->prepare(
  270. "SELECT operator_id
  271. FROM operator
  272. WHERE company_id = :company_id
  273. AND lower(operator_email) = :email
  274. AND operator_deleted_at = 'infinity'
  275. LIMIT 1"
  276. );
  277. $stmt->execute(['company_id' => $companyId, 'email' => $normalized]);
  278. $id = $stmt->fetchColumn();
  279. return $id === false ? 0 : (int) $id;
  280. }
  281. /**
  282. * Normaliza uma data/hora vinda do payload para 'Y-m-d H:i:s'.
  283. * Aceita qualquer formato que o DateTimeImmutable entenda (ex.: ISO 8601).
  284. * Retorna null quando ausente ou inválida.
  285. */
  286. private function toTimestamp($value): ?string
  287. {
  288. if (!is_string($value) || trim($value) === '') {
  289. return null;
  290. }
  291. try {
  292. return (new \DateTimeImmutable(trim($value)))->format('Y-m-d H:i:s');
  293. } catch (\Throwable $e) {
  294. return null;
  295. }
  296. }
  297. /**
  298. * Normaliza um valor monetário vindo do payload para DECIMAL.
  299. * Aceita número ou string; valores inválidos viram 0.
  300. */
  301. private function toDecimal($value): float
  302. {
  303. if (is_int($value) || is_float($value)) {
  304. return (float) $value;
  305. }
  306. if (is_string($value) && is_numeric(trim($value))) {
  307. return (float) trim($value);
  308. }
  309. return 0.0;
  310. }
  311. }