| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- <?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;
- }
- }
|