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:" (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; } }