|
|
@@ -0,0 +1,347 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Models;
|
|
|
+
|
|
|
+use Libs\Database;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Persistência dos dados recebidos via webhook de CRM.
|
|
|
+ *
|
|
|
+ * O company_id NUNCA vem do corpo do payload: é sempre resolvido a partir da
|
|
|
+ * empresa identificada na URL e validada por HMAC no controller. Todos os
|
|
|
+ * métodos de gravação aqui recebem o company_id já confiável como primeiro
|
|
|
+ * parâmetro e o aplicam em cada query, garantindo o isolamento entre empresas.
|
|
|
+ */
|
|
|
+class CrmModel
|
|
|
+{
|
|
|
+ private \PDO $pdo;
|
|
|
+
|
|
|
+ public function __construct()
|
|
|
+ {
|
|
|
+ $this->pdo = Database::pdo();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Carrega a empresa ativa e seu segredo HMAC. Retorna null se não existir
|
|
|
+ * ou estiver excluída logicamente.
|
|
|
+ */
|
|
|
+ public function findCompanyById(int $companyId): ?array
|
|
|
+ {
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ "SELECT company_id, company_hmac_secret
|
|
|
+ FROM company
|
|
|
+ WHERE company_id = :company_id
|
|
|
+ AND company_deleted_at = 'infinity'
|
|
|
+ LIMIT 1"
|
|
|
+ );
|
|
|
+ $stmt->execute(['company_id' => $companyId]);
|
|
|
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
|
+
|
|
|
+ return $row === false ? null : $row;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Insere ou atualiza um produto (SKU) da empresa.
|
|
|
+ *
|
|
|
+ * A tabela sku não possui chave única natural além do id, então a
|
|
|
+ * identidade do produto é (company_id, sku_name): se já existir um SKU
|
|
|
+ * ativo com o mesmo nome, ele é atualizado; caso contrário, é criado.
|
|
|
+ *
|
|
|
+ * Essencial (validado no controller): name.
|
|
|
+ * Opcionais (aceitam "" ou ausência, com valor neutro):
|
|
|
+ * - value -> 0.0 (qualquer valor não numérico vira 0)
|
|
|
+ * - line -> ""
|
|
|
+ * - sold -> 0
|
|
|
+ *
|
|
|
+ * @return array{sku_id:int, created:bool}
|
|
|
+ */
|
|
|
+ public function upsertSku(int $companyId, array $data): array
|
|
|
+ {
|
|
|
+ $name = trim((string) ($data['name'] ?? ''));
|
|
|
+ $value = $this->toDecimal($data['value'] ?? null);
|
|
|
+ $line = trim((string) ($data['line'] ?? ''));
|
|
|
+ $sold = (int) ($data['sold'] ?? 0);
|
|
|
+
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ "SELECT sku_id
|
|
|
+ FROM sku
|
|
|
+ WHERE company_id = :company_id
|
|
|
+ AND sku_name = :name
|
|
|
+ AND sku_deleted_at = 'infinity'
|
|
|
+ LIMIT 1"
|
|
|
+ );
|
|
|
+ $stmt->execute(['company_id' => $companyId, 'name' => $name]);
|
|
|
+ $existingId = $stmt->fetchColumn();
|
|
|
+
|
|
|
+ if ($existingId !== false) {
|
|
|
+ $update = $this->pdo->prepare(
|
|
|
+ "UPDATE sku
|
|
|
+ SET sku_value = :value,
|
|
|
+ sku_line = :line,
|
|
|
+ sku_sold = :sold
|
|
|
+ WHERE sku_id = :sku_id
|
|
|
+ AND company_id = :company_id"
|
|
|
+ );
|
|
|
+ $update->execute([
|
|
|
+ 'value' => $value,
|
|
|
+ 'line' => $line,
|
|
|
+ 'sold' => $sold,
|
|
|
+ 'sku_id' => (int) $existingId,
|
|
|
+ 'company_id' => $companyId,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return ['sku_id' => (int) $existingId, 'created' => false];
|
|
|
+ }
|
|
|
+
|
|
|
+ $insert = $this->pdo->prepare(
|
|
|
+ "INSERT INTO sku (company_id, sku_name, sku_value, sku_sold, sku_line)
|
|
|
+ VALUES (:company_id, :name, :value, :sold, :line)
|
|
|
+ RETURNING sku_id"
|
|
|
+ );
|
|
|
+ $insert->execute([
|
|
|
+ 'company_id' => $companyId,
|
|
|
+ 'name' => $name,
|
|
|
+ 'value' => $value,
|
|
|
+ 'sold' => $sold,
|
|
|
+ 'line' => $line,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return ['sku_id' => (int) $insert->fetchColumn(), 'created' => true];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Insere ou atualiza um cliente da empresa.
|
|
|
+ *
|
|
|
+ * A identidade é (company_id, client_phone) — chave única da tabela. O
|
|
|
+ * client_provider_id é preenchido com o id externo do CRM quando informado
|
|
|
+ * (ou derivado do telefone) para satisfazer o índice único parcial e
|
|
|
+ * permitir rastrear a origem.
|
|
|
+ *
|
|
|
+ * Essencial (validado no controller): phone.
|
|
|
+ * Opcionais (aceitam "" ou ausência, com valor neutro):
|
|
|
+ * - name -> ""
|
|
|
+ * - email -> ""
|
|
|
+ * - segment -> ""
|
|
|
+ * - provider_id -> "crm:<phone>" (derivado quando vazio)
|
|
|
+ *
|
|
|
+ * @return array{client_id:int}
|
|
|
+ */
|
|
|
+ public function upsertClient(int $companyId, array $data): array
|
|
|
+ {
|
|
|
+ $phone = trim((string) ($data['phone'] ?? ''));
|
|
|
+ $name = trim((string) ($data['name'] ?? ''));
|
|
|
+ $email = trim((string) ($data['email'] ?? ''));
|
|
|
+ $segment = trim((string) ($data['segment'] ?? ''));
|
|
|
+ $providerId = trim((string) ($data['provider_id'] ?? ''));
|
|
|
+ if ($providerId === '') {
|
|
|
+ $providerId = 'crm:' . $phone;
|
|
|
+ }
|
|
|
+
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ "INSERT INTO client
|
|
|
+ (company_id, client_provider_id, client_phone, client_name,
|
|
|
+ client_email, client_segment, client_is_registered)
|
|
|
+ VALUES
|
|
|
+ (:company_id, :provider_id, :phone, :name, :email, :segment, TRUE)
|
|
|
+ ON CONFLICT (company_id, client_phone) DO UPDATE
|
|
|
+ SET client_provider_id = EXCLUDED.client_provider_id,
|
|
|
+ client_name = EXCLUDED.client_name,
|
|
|
+ client_email = EXCLUDED.client_email,
|
|
|
+ client_segment = EXCLUDED.client_segment,
|
|
|
+ client_is_registered = TRUE
|
|
|
+ RETURNING client_id"
|
|
|
+ );
|
|
|
+ $stmt->execute([
|
|
|
+ 'company_id' => $companyId,
|
|
|
+ 'provider_id' => $providerId,
|
|
|
+ 'phone' => $phone,
|
|
|
+ 'name' => $name,
|
|
|
+ 'email' => $email,
|
|
|
+ 'segment' => $segment,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return ['client_id' => (int) $stmt->fetchColumn()];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Registra uma venda no histórico (tabela sale) e atualiza o total
|
|
|
+ * acumulado do produto (sku.sku_sold).
|
|
|
+ *
|
|
|
+ * É idempotente: se o CRM reenviar a mesma venda (mesmo external_id), a
|
|
|
+ * linha não é duplicada e o sku_sold NÃO é incrementado de novo. Por isso
|
|
|
+ * o incremento do contador só acontece quando a venda é realmente nova.
|
|
|
+ *
|
|
|
+ * Essencial (validado no controller): external_id, amount, occurred_at.
|
|
|
+ * Opcionais (aceitam "" ou ausência):
|
|
|
+ * - product_name -> resolve sku_id; vazio/sem match => 0 (venda sem produto vinculado)
|
|
|
+ * - quantity -> 1
|
|
|
+ * - client_phone -> resolve client_id; vazio/sem match => 0
|
|
|
+ * - operator_email -> resolve operator_id; vazio/sem match => 0
|
|
|
+ *
|
|
|
+ * @return array{sale_id:int, created:bool, sku_id:int}
|
|
|
+ */
|
|
|
+ public function upsertSale(int $companyId, array $data): array
|
|
|
+ {
|
|
|
+ $externalId = trim((string) ($data['external_id'] ?? ''));
|
|
|
+ $amount = $this->toDecimal($data['amount'] ?? null);
|
|
|
+ $quantity = (int) ($data['quantity'] ?? 1);
|
|
|
+ if ($quantity < 1) {
|
|
|
+ $quantity = 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ $occurredAt = $this->toTimestamp($data['occurred_at'] ?? null);
|
|
|
+ if ($occurredAt === null) {
|
|
|
+ throw new \InvalidArgumentException('sale "occurred_at" must be a valid date/time');
|
|
|
+ }
|
|
|
+
|
|
|
+ $productName = trim((string) ($data['product_name'] ?? ''));
|
|
|
+ $clientPhone = trim((string) ($data['client_phone'] ?? ''));
|
|
|
+ $operatorEmail = trim((string) ($data['operator_email'] ?? ''));
|
|
|
+
|
|
|
+ $skuId = $productName !== '' ? $this->findSkuIdByName($companyId, $productName) : 0;
|
|
|
+ $clientId = $clientPhone !== '' ? $this->findClientIdByPhone($companyId, $clientPhone) : 0;
|
|
|
+ $operatorId = $operatorEmail !== '' ? $this->findOperatorIdByEmail($companyId, $operatorEmail) : 0;
|
|
|
+
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
+ try {
|
|
|
+ $insert = $this->pdo->prepare(
|
|
|
+ "INSERT INTO sale
|
|
|
+ (company_id, sku_id, client_id, operator_id, sale_external_id,
|
|
|
+ sale_amount, sale_quantity, sale_occurred_at)
|
|
|
+ VALUES
|
|
|
+ (:company_id, :sku_id, :client_id, :operator_id, :external_id,
|
|
|
+ :amount, :quantity, :occurred_at)
|
|
|
+ ON CONFLICT (company_id, sale_external_id) WHERE sale_deleted_at = 'infinity'
|
|
|
+ DO NOTHING
|
|
|
+ RETURNING sale_id"
|
|
|
+ );
|
|
|
+ $insert->execute([
|
|
|
+ 'company_id' => $companyId,
|
|
|
+ 'sku_id' => $skuId,
|
|
|
+ 'client_id' => $clientId,
|
|
|
+ 'operator_id' => $operatorId,
|
|
|
+ 'external_id' => $externalId,
|
|
|
+ 'amount' => $amount,
|
|
|
+ 'quantity' => $quantity,
|
|
|
+ 'occurred_at' => $occurredAt,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $saleId = $insert->fetchColumn();
|
|
|
+ $created = $saleId !== false;
|
|
|
+
|
|
|
+ // Só incrementa o contador quando a venda é nova e há produto vinculado.
|
|
|
+ if ($created && $skuId > 0) {
|
|
|
+ $update = $this->pdo->prepare(
|
|
|
+ "UPDATE sku
|
|
|
+ SET sku_sold = sku_sold + :quantity
|
|
|
+ WHERE sku_id = :sku_id
|
|
|
+ AND company_id = :company_id"
|
|
|
+ );
|
|
|
+ $update->execute([
|
|
|
+ 'quantity' => $quantity,
|
|
|
+ 'sku_id' => $skuId,
|
|
|
+ 'company_id' => $companyId,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->pdo->commit();
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ $this->pdo->rollBack();
|
|
|
+ throw $e;
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'sale_id' => $created ? (int) $saleId : 0,
|
|
|
+ 'created' => $created,
|
|
|
+ 'sku_id' => $skuId,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function findSkuIdByName(int $companyId, string $name): int
|
|
|
+ {
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ "SELECT sku_id
|
|
|
+ FROM sku
|
|
|
+ WHERE company_id = :company_id
|
|
|
+ AND sku_name = :name
|
|
|
+ AND sku_deleted_at = 'infinity'
|
|
|
+ LIMIT 1"
|
|
|
+ );
|
|
|
+ $stmt->execute(['company_id' => $companyId, 'name' => $name]);
|
|
|
+ $id = $stmt->fetchColumn();
|
|
|
+
|
|
|
+ return $id === false ? 0 : (int) $id;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function findClientIdByPhone(int $companyId, string $phone): int
|
|
|
+ {
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ "SELECT client_id
|
|
|
+ FROM client
|
|
|
+ WHERE company_id = :company_id
|
|
|
+ AND client_phone = :phone
|
|
|
+ AND client_deleted_at = 'infinity'
|
|
|
+ LIMIT 1"
|
|
|
+ );
|
|
|
+ $stmt->execute(['company_id' => $companyId, 'phone' => $phone]);
|
|
|
+ $id = $stmt->fetchColumn();
|
|
|
+
|
|
|
+ return $id === false ? 0 : (int) $id;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function findOperatorIdByEmail(int $companyId, string $email): int
|
|
|
+ {
|
|
|
+ $normalized = mb_strtolower(trim($email));
|
|
|
+ if ($normalized === '') {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ "SELECT operator_id
|
|
|
+ FROM operator
|
|
|
+ WHERE company_id = :company_id
|
|
|
+ AND lower(operator_email) = :email
|
|
|
+ AND operator_deleted_at = 'infinity'
|
|
|
+ LIMIT 1"
|
|
|
+ );
|
|
|
+ $stmt->execute(['company_id' => $companyId, 'email' => $normalized]);
|
|
|
+ $id = $stmt->fetchColumn();
|
|
|
+
|
|
|
+ return $id === false ? 0 : (int) $id;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Normaliza uma data/hora vinda do payload para 'Y-m-d H:i:s'.
|
|
|
+ * Aceita qualquer formato que o DateTimeImmutable entenda (ex.: ISO 8601).
|
|
|
+ * Retorna null quando ausente ou inválida.
|
|
|
+ */
|
|
|
+ private function toTimestamp($value): ?string
|
|
|
+ {
|
|
|
+ if (!is_string($value) || trim($value) === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ return (new \DateTimeImmutable(trim($value)))->format('Y-m-d H:i:s');
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Normaliza um valor monetário vindo do payload para DECIMAL.
|
|
|
+ * Aceita número ou string; valores inválidos viram 0.
|
|
|
+ */
|
|
|
+ private function toDecimal($value): float
|
|
|
+ {
|
|
|
+ if (is_int($value) || is_float($value)) {
|
|
|
+ return (float) $value;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_string($value) && is_numeric(trim($value))) {
|
|
|
+ return (float) trim($value);
|
|
|
+ }
|
|
|
+
|
|
|
+ return 0.0;
|
|
|
+ }
|
|
|
+}
|