Merge pull request #10 from jonaspachecoometas/Servidor

Servidor
This commit is contained in:
engenharia-creator 2026-03-27 10:16:37 -03:00 committed by GitHub
commit 5aa401683c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4723 changed files with 631308 additions and 71 deletions

View File

@ -0,0 +1,38 @@
uploads = []
generated = []
[[outputs]]
id = "28NDwt4UT3HPglsSw-IQk"
uri = "file://MAPA_MICROSERVICOS_COOLIFY.md"
type = "text"
title = "Mapa de Microserviços - Arcádia Suite para Coolify/Docker"
[[outputs]]
id = "YTVCn39myPk5kzF8smbQz"
uri = "file://MAPA_SOE_ARCADIA.md"
type = "text"
title = "Mapa SOE — Sistema Operacional Empresarial (Deploy)"
[[outputs]]
id = "pQjx1X6m9hp2j-sZa0_Rg"
uri = "file://MAPA_BI_ARCADIA.md"
type = "text"
title = "Mapa BI - Business Intelligence da Arcádia Suite (com Metabase)"
[[outputs]]
id = "gpyYTqB0ytdU1zAJij6KK"
uri = "file://MAPA_CRM_ARCADIA.md"
type = "text"
title = "Mapa CRM - Sistema de Relacionamento da Arcádia Suite"
[[outputs]]
id = "zal1KfcZdP7OqKEXu67Pp"
uri = "file://MAPA_TENANTS_ARCADIA.md"
type = "text"
title = "Mapa Multi-Tenant - Gestão de Tenants da Arcádia Suite"
[[outputs]]
id = "GCMgcBAHQ1aIDjHxoXniQ"
uri = "file://MAPA_AUTOMACOES_ARCADIA.md"
type = "text"
title = "Mapa Automações - Motor de Automação da Arcádia Suite"

25
.deployignore Normal file
View File

@ -0,0 +1,25 @@
metabase/
attached_assets/
.git/
.cache/
.upm/
.config/
.local/
.pythonlibs/
uploads/
plus/
whatsapp-sessions/
migrations/
docs/
scripts/
server/python/
python-service/
*.tar.gz
*.zip
*.sql
*.log
*.docx
plus_backup_*/
RELATORIO_TECNICO_RETAIL.md
MAPA_GERAL_RETAIL.md
PLANO_EVOLUCAO_ARCADIA.docx

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
dist
.git
*.zip
*.log
.env
.replit
replit.nix
.cache
.config
.upm

20
.env Normal file
View File

@ -0,0 +1,20 @@
PGPASSWORD=afefb610090de1646944b537f3054715ad5755665c90036c2bcd258c9ab52dd8
SESSION_SECRET=092a26dcbebd6f7f5e7b9e020bdc4d4e966286c9780ef72e3426f437eb8d608b
SSO_SECRET=fbb87d1c5a14a6623d4e3dcc2b679bd68c46e59afb48dd6abd42b95cac945856
LITELLM_API_KEY=e396b827cbeb5e77d85cce4eb9da0521114f57a8e799f7aa8febad08d776c07e
WEBUI_SECRET_KEY=76c7fc044f9e8d6e5eb60b67c82832b67e84739da4827ce1fa7c101ae1a055df
DOMAIN=suite.onboardbi.com.br
OLLAMA_BASE_URL=http://ollama-ia1upsekrad96at5hq97e4qa:11434
LLMFIT_BASE_URL=http://llmfit:8000
LLMFIT_API_KEY=key-arcadia
# ── Superset BI ───────────────────────────────────────────────
SUPERSET_SECRET_KEY=421274b5ba360778a5398d528116a45ea962d1197e3dfc99f36a372ff1025a63
SUPERSET_ADMIN_USERNAME=admin
SUPERSET_ADMIN_PASSWORD=arcadia1337
SUPERSET_ADMIN_EMAIL=engenharia@ometas.com.br
COMPOSE_PROFILES=bi
SUPERSET_HOST=superset
SUPERSET_PORT=8088

3
.gitignore vendored
View File

@ -1,9 +1,6 @@
node_modules/
dist/
.DS_Store
.env
.env.local
.env.production
# Replit specific
.cache/

6
.gitmodules vendored Normal file
View File

@ -0,0 +1,6 @@
[submodule "server/modules/miroflow"]
path = server/modules/miroflow
url = https://github.com/MiroMindAI/MiroFlow.git
[submodule "server/modules/openclaw"]
path = server/modules/openclaw
url = https://github.com/miaoxworld/OpenClawInstaller.git

View File

@ -0,0 +1,475 @@
# Fase 4: OpenClaw Embutido — Plano de Implementação
**Fase:** 4
**Data Início:** 2026-03-26
**Prazo:** 2026-04-08 (Semanas 7-8)
**Status:** 🔄 Sprint 3 Pendente (Sprint 1 e 2 concluídos)
---
## 1. OBJETIVO DA FASE
Implementar **detecção proativa de padrões** e **sugestão emergente de skills**, transformando comportamentos repetitivos do usuário em automações reutilizáveis através de um widget conversacional flutuante.
### Entregável Final
✅ Skills emergentes funcionando end-to-end:
- Usuário executa ação repetida → PatternDetector detecta → OpenClawWidget sugere → Blackboard gera DRAFT → Dev Center aprova → Skill disponível
---
## 2. ARQUITETURA
```
FLUXO OPENCCLAW:
1. USER INTERACTION
└─ Usuário usa Arcádia
└─ Learning API registra evento (já existe)
└─ Neo4j armazena interação
2. PATTERN DETECTION (NOVO)
└─ PatternDetector analisa Learning API
└─ Identifica: 3+ ocorrências em 30 dias
└─ Calcula confiança (threshold 0.8+)
└─ Dispara evento no Socket.IO
3. WIDGET SUGGESTION (NOVO)
└─ OpenClawWidget fica "ouvindo"
└─ Recebe evento de padrão detectado
└─ Exibe notificação flutuante
└─ Modal SkillSuggestion mostra preview
4. SKILL GENERATION (NOVO)
└─ Usuário confirma
└─ OpenClawEngine chama Blackboard
└─ Blackboard gera código (DRAFT)
└─ Armazena em database
5. APPROVAL FLOW (EXISTENTE - Dev Center)
└─ Skill DRAFT aparece em Dev Center
└─ Admin/Lead revisa e aprova
└─ Publica no tenant
└─ Skill fica disponível para reutilização
```
### Componentes para Implementar
```
BACKEND:
server/modules/openclaw/
├── PatternDetector.ts ← [NOVO] Detecta padrões
├── SkillEmergence.ts ← [NOVO] Coordena criação
├── OpenClawEngine.ts ← [NOVO] Lógica central
├── config/
│ └── arcadia.config.yaml ← [NOVO] Configuração
FRONTEND:
client/src/components/openclaw/
├── OpenClawWidget.tsx ← [NOVO] Widget flutuante
├── PatternDetector.ts ← [NOVO] Hook de detecção
├── SkillSuggestion.tsx ← [NOVO] Modal de sugestão
└── useAgentEmergence.ts ← [NOVO] Lógica de emergência
```
---
## 3. DEPENDÊNCIAS VERIFICADAS
| Componente | Status | Localização |
|-----------|--------|-----------|
| **Skills Engine** | ✅ Pronto | `server/skills/engine.ts` |
| **Blackboard** | ✅ Pronto | `server/blackboard/service.ts` |
| **Dev Center** | ✅ Pronto | `client/src/pages/DevCenter.tsx` |
| **Learning API** | ✅ Existe | `/api/learning/*` |
| **Neo4j (KG)** | ✅ Running | Docker |
| **Socket.IO** | ✅ Configurado | `server/socket-io.ts` |
| **PostgreSQL** | ✅ Rodando | Docker |
| **Scientist (MiroFlow)** | ✅ Existe | `/api/manus/scientist/*` |
**Conclusão:** Todas as dependências estão prontas ✅
---
## 4. TASKS DETALHADAS
### SPRINT 1 (26-27/03): PatternDetector Backend — ✅ COMPLETO
#### Task 1.1: Criar PatternDetector.ts ✅
**Arquivo:** `server/modules/openclaw/PatternDetector.ts`
**Objetivo:** Analisar eventos de usuário e detectar padrões
```typescript
// Pseudocódigo da estrutura
export class PatternDetector {
// Config
min_occurrences: 3;
time_window_days: 30;
confidence_threshold: 0.8;
// Métodos
detectPattern(userId: string, action: string): Pattern | null
calculateConfidence(interactions: Interaction[]): number
storePattern(pattern: Pattern): Promise<void>
emitEvent(pattern: Pattern): void
}
```
**Entrada:** Eventos do Learning API (`/api/learning/interactions`)
**Saída:** Pattern objects armazenados em Neo4j + Socket.IO event
**Dependência:** Learning API funcionando ✅
#### Task 1.2: Criar OpenClawEngine.ts ✅
**Arquivo:** `server/modules/openclaw/OpenClawEngine.ts`
**Objetivo:** Orquestração central de OpenClaw
```typescript
export class OpenClawEngine {
patternDetector: PatternDetector;
skillEmergence: SkillEmergence;
// Métodos
async suggestSkill(pattern: Pattern): Promise<SkillSuggestion>
async onUserConfirmation(suggestion: SkillSuggestion): Promise<Skill>
async checkPatternsPeriodicly(): Promise<void>
}
```
**Entrada:** Padrões detectados
**Saída:** Sugestões + Skills gerados
**Evento:** Socket.IO broadcast `/openclaw/pattern-detected`
#### Task 1.3: Criar SkillEmergence.ts ✅
**Arquivo:** `server/modules/openclaw/SkillEmergence.ts`
**Objetivo:** Integração com Blackboard para codegen
```typescript
export class SkillEmergence {
async generateSkillDraft(pattern: Pattern): Promise<SkillDraft> {
// 1. Montar prompt baseado no padrão
// 2. Chamar POST /api/blackboard/generate-skill
// 3. Receber código gerado
// 4. Salvar como DRAFT no database
// 5. Notificar Dev Center
// 6. Retornar skill DRAFT
}
}
```
**Integração:** POST `/api/blackboard/generate-skill`
**Database:** skills table (com status: 'draft')
#### Task 1.4: Criar rotas OpenClaw ✅
**Arquivo:** `server/modules/openclaw/routes.ts`
**Rotas:**
```
POST /api/openclaw/detect-patterns ← Trigger manual de análise
GET /api/openclaw/patterns ← Listar padrões detectados
POST /api/openclaw/confirm-skill ← Usuário confirma sugestão
GET /api/openclaw/suggestions ← Histórico de sugestões
```
#### Task 1.5: Integrar em server/index.ts ✅
**Arquivo:** `server/index.ts`
**Ação:** Importar rotas OpenClaw e carregá-las
```typescript
import openclawRoutes from "./modules/openclaw/routes";
app.use("/api/openclaw", openclawRoutes);
```
### SPRINT 2 (28-29/03): Frontend Widgets — ✅ COMPLETO (commit 5231166)
#### Task 2.1: Criar OpenClawWidget.tsx ✅
**Arquivo:** `client/src/components/openclaw/OpenClawWidget.tsx`
**Objetivo:** Widget flutuante que recebe notificações de padrões
```typescript
export function OpenClawWidget() {
// Escutar Socket.IO event: /openclaw/pattern-detected
// Exibir notificação flutuante (canto inferior direito)
// Mostrar padrão detectado
// Botão "Ver Sugestão" abre modal
// Botão "Ignorar" descarta
return (
<div className="fixed bottom-4 right-4">
{/* Widget flutuante */}
</div>
);
}
```
**Socket.IO:** Escuta evento `/openclaw/pattern-detected`
**Trigger:** Modal SkillSuggestion
#### Task 2.2: Criar SkillSuggestion.tsx ✅
**Arquivo:** `client/src/components/openclaw/SkillSuggestion.tsx`
**Objetivo:** Modal com preview da skill sugerida
```typescript
export function SkillSuggestion({ pattern, suggestion }) {
// Modal mostra:
// 1. "Detectamos um padrão na sua utilização:"
// 2. Preview do padrão (ações repetidas)
// 3. Preview da automação gerada
// 4. Campo de nome (auto-preenchido)
// 5. Descrição (auto-gerada)
// 6. Botão "Criar Skill" ou "Cancelar"
const handleCreate = async () => {
// POST /api/openclaw/confirm-skill
// Esperar resposta (skill DRAFT)
// Toast: "Skill criada! Vá para Dev Center para publicar"
};
}
```
**Integração:** Chama `POST /api/openclaw/confirm-skill`
**Feedback:** Toast notification
#### Task 2.3: Hook useAgentEmergence.ts ✅
**Arquivo:** `client/src/hooks/useAgentEmergence.ts`
**Objetivo:** Lógica de emergência de agentes
```typescript
export function useAgentEmergence(userId: string) {
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
// Socket.IO listener
socket.on("openclaw:pattern-detected", (pattern) => {
// Filtrar por tenant, user prefs
// Atualizar suggestions state
setSuggestions([...suggestions, pattern]);
});
}, []);
return { suggestions, dismiss, confirm };
}
```
#### Task 2.4: Integrar Widget em layout principal ✅
**Arquivo:** `client/src/App.tsx` ou `client/src/layouts/MainLayout.tsx`
**Ação:** Adicionar OpenClawWidget ao layout persistente
```typescript
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
export default function App() {
return (
<>
{/* Conteúdo existente */}
<OpenClawWidget /> {/* ← Adicionar aqui */}
</>
);
}
```
### SPRINT 3 (30-31/03): Configuração e Integração — ⏳ PRÓXIMO
#### Task 3.1: Criar arcadia.config.yaml
**Arquivo:** `server/modules/openclaw/config/arcadia.config.yaml`
```yaml
openclaw:
enabled: true
# Endpoints
arcadia_api_url: http://localhost:3000/api
arcadia_kg_url: http://localhost:7474
blackboard_url: http://localhost:3000/api/blackboard
# Tenant awareness
tenant_aware: true
# Detecção de padrões
pattern_detection:
enabled: true
min_occurrences: 3
time_window_days: 30
confidence_threshold: 0.8
scheduled_check_interval_minutes: 60
# Sugestões
suggestions:
enabled: true
auto_suggest: false # Sempre pedir confirmação do usuário
channels: ['widget', 'chat']
priority_threshold: 0.85
# Skills emergentes
emergence:
create_as_draft: true # Sempre requer aprovação
auto_publish: false
notify_admins: true
skill_prefix: "emergent_" # Prefixo para skills auto-geradas
# Logging
logging:
enabled: true
store_patterns: true
store_in_kg: true
audit_trail: true
```
#### Task 3.2: Integrar Socket.IO events
**Arquivo:** `server/socket-io.ts`
**Ação:** Adicionar listeners e emitters para OpenClaw
```typescript
socket.on("openclaw:confirm-skill", async (data) => {
// Receber confirmação do usuário
// Chamar SkillEmergence.generateSkillDraft()
// Emitir "openclaw:skill-created"
});
// Emitter no PatternDetector
io.emit("openclaw:pattern-detected", pattern);
```
#### Task 3.3: Database schema para OpenClaw
**Arquivo:** `shared/schema.ts` (Drizzle ORM)
**Adicionar tabelas:**
```typescript
// Padrões detectados
export const detectedPatterns = pgTable("detected_patterns", {
id: serial().primaryKey(),
tenant_id: uuid().notNull(),
user_id: uuid().notNull(),
action_type: varchar(255).notNull(),
occurrences: integer().notNull(),
confidence: numeric(3, 2).notNull(),
time_window_days: integer().notNull(),
created_at: timestamp().defaultNow(),
metadata: jsonb(),
});
// Sugestões geradas
export const skillSuggestions = pgTable("skill_suggestions", {
id: serial().primaryKey(),
pattern_id: integer().references(() => detectedPatterns.id),
skill_draft_id: uuid().references(() => skills.id),
status: varchar(50).notNull(), // 'suggested', 'accepted', 'rejected'
created_at: timestamp().defaultNow(),
});
```
#### Task 3.4: Migration SQL
**Arquivo:** `migrations/0004_openclaw_tables.sql`
```sql
CREATE TABLE detected_patterns (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
action_type VARCHAR(255) NOT NULL,
occurrences INTEGER NOT NULL,
confidence NUMERIC(3,2) NOT NULL,
time_window_days INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata JSONB
);
CREATE TABLE skill_suggestions (
id SERIAL PRIMARY KEY,
pattern_id INTEGER REFERENCES detected_patterns(id),
skill_draft_id UUID REFERENCES skills(id),
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_detected_patterns_user_tenant
ON detected_patterns(user_id, tenant_id);
```
#### Task 3.5: Testes unitários
**Arquivo:** `tests/openclaw.test.ts`
- PatternDetector detecta padrão corretamente
- OpenClawEngine orquestra fluxo correto
- SkillEmergence integra com Blackboard
- Socket.IO emite eventos corretamente
#### Task 3.6: Documentação
**Arquivo:** `DOCUMENTATION.md` (adicionar seção)
- Como OpenClaw funciona
- Configuração por tenant
- Troubleshooting
---
## 5. TIMELINE
| Data | Sprint | Tasks | Status |
|------|--------|-------|--------|
| 26-27/03 | 1 | PatternDetector backend | ✅ Completo |
| 26/03 | 2 | OpenClawWidget frontend | ✅ Completo (commit 5231166) |
| 26/03 | 3 | Migration DB + testes | ⏳ Próximo |
| 01-08/04 | Buffer | Refinamento, testes E2E | ⏳ Próximo |
**Nota:** Trabalho em `/opt/arcadia_merged/` (diretório canônico do projeto).
---
## 6. CRITÉRIOS DE SUCESSO
✅ **PatternDetector funciona:**
- Detecta padrão após 3 ocorrências em 30 dias
- Confiança calculada corretamente
- Eventos emitidos para Socket.IO
✅ **OpenClawWidget funciona:**
- Widget aparece quando padrão é detectado
- Modal mostra sugestão corretamente
- Usuário pode confirmar ou rejeitar
✅ **SkillEmergence funciona:**
- Integra com Blackboard
- Gera skill como DRAFT
- Aparece em Dev Center para aprovação
✅ **E2E funciona:**
- User faz ação 3x
- Widget sugere
- Approva
- Skill publicada
- Skill disponível para reutilização
---
## 7. RISCOS E MITIGAÇÕES
| Risco | Probabilidade | Mitigação |
|-------|--------------|-----------|
| Blackboard não retorna código correto | Média | Testar integração antes, ter fallback |
| Padrões detectados incorretamente | Média | Validar threshold, testes no KG |
| Socket.IO não emite em tempo real | Baixa | Já funciona em outras partes |
| Performance com muitos eventos | Média | Usar índices em PG, cache em Redis |
---
## 8. BRANCH E COMMITS
**Branch:** `Servidor` (deploy branch, conforme feedback anterior)
**Commit pattern:**
```
feat(openclaw): PatternDetector backend
feat(openclaw): OpenClawWidget frontend
feat(openclaw): Integração Blackboard + esquema DB
docs(openclaw): Documentação e testes
```
---
## 9. PRÓXIMAS AÇÕES
1. ✅ **26/03:** Sprint 1 concluído (PatternDetector, OpenClawEngine, SkillEmergence, routes, config)
2. ✅ **26/03:** Sprint 2 concluído (OpenClawWidget, SkillSuggestion, useAgentEmergence, App.tsx — commit 5231166)
3. ⏳ **Agora:** Sprint 3 — migration SQL + vitest + testes unitários em `/opt/arcadia_merged/`
4. ⏳ **01-08/04:** Refinamento e testes E2E
---
*Fase 4: OpenClaw Embutido — Plano Executável*
*Data: 2026-03-26*
*Pronto para implementação ✅*

116
.planning/ROADMAP.md Normal file
View File

@ -0,0 +1,116 @@
# Roadmap: Arcádia Agentic Suite
## Overview
Evolução do Arcádia Suite de ERP tradicional para Sistema Agêntico Orientado a Objetos. Skills são objetos reutilizáveis (POO), Agentes instanciam Skills, Automações são composições orquestradas, Dev Center é a fábrica de agentes.
## Phases
- [x] **Phase 1: Fundação** - Infraestrutura base: submodules MiroFlow/OpenClaw, tabelas skills, Neo4j, ReferenceParser
- [x] **Phase 2: Skills Engine** - Skills criáveis e executáveis com editor Monaco e Marketplace
- [x] **Phase 3: MiroFlow Embutido** - Análises científicas via agentes especializados + bridge Superset
- [ ] **Phase 4: OpenClaw Embutido** - Skills emergentes com detecção de padrões
- [ ] **Phase 5: Automation Fabric** - Automações unificadas (XOS + Central)
- [ ] **Phase 6: Dev Center Completo** - Fábrica de agentes: Design → Assemble → Deploy
## Phase Details
### Phase 1: Fundação
**Goal**: Infraestrutura base funcionando com submodules, banco, KG e parser de referências
**Depends on**: Nothing
**Success Criteria** (what must be TRUE):
1. Submodules MiroFlow e OpenClaw clonados e inicializados
2. Tabelas arcadia_skills e skill_executions existem no banco
3. Neo4j rodando via docker-compose
4. ReferenceParser parseia referências /skill/, /kg/, /file/ etc.
Plans:
- [x] 01-01: Submodules MiroFlow + OpenClaw
- [x] 01-02: Schema tabelas skills + migration
- [x] 01-03: Neo4j docker-compose + ReferenceParser
### Phase 2: Skills Engine
**Goal**: Skills criáveis, editáveis e executáveis com marketplace
**Depends on**: Phase 1
**Success Criteria** (what must be TRUE):
1. SkillEngine.ts suporta herança, composição e polimorfismo
2. API REST /skills CRUD funcionando
3. Editor Monaco com autocomplete de referências (/)
4. Skill Marketplace lista e filtra skills disponíveis
5. Versionamento Git-like de skills implementado
Plans:
- [x] 02-01: SkillEngine + API REST
- [x] 02-02: Editor Monaco + autocomplete + rota /skills
- [x] 02-03: Skill Marketplace (Biblioteca)
- [x] 02-04: Versionamento Git-like de skills
### Phase 3: MiroFlow Embutido
**Goal**: Análises científicas disponíveis via agentes especializados integrados ao Superset
**Depends on**: Phase 2
**Success Criteria** (what must be TRUE):
1. MiroFlow configurado para Ollama local com modelos até 14B
2. Agente Statistician analisa dados SQL com deepseek-r1:14b
3. Agente Fiscal Auditor valida NFe/SPED com deepseek-r1:14b
4. Agente Researcher consulta KG com llama3.1:8b
5. Endpoint POST /api/miroflow/analyze retorna análise estruturada
6. MiroFlowControl.tsx toggle "Modo Científico" aparece no Superset
7. Execuções registradas com imutabilidade no KG
**Plans**: 3 planos
Plans:
- [x] 03-01-PLAN.md — Setup (ollama pull llama3.1:8b) + miroflow_service.py FastAPI porta 8006 com 3 agentes
- [x] 03-02-PLAN.md — Node bridge (engine-proxy.ts + routes.ts) + KG logging SHA-256
- [x] 03-03-PLAN.md — Frontend MiroFlowControl.tsx + tab "Científico" em BiWorkspace.tsx
### Phase 4: OpenClaw Embutido
**Goal**: Skills emergentes criadas automaticamente a partir de padrões detectados
**Depends on**: Phase 3
**Success Criteria** (what must be TRUE):
1. PatternDetector detecta padrões (min 3 ocorrências, 30 dias, 80% confiança)
2. Skills emergentes criadas como DRAFT aguardando aprovação
3. Widget flutuante notifica usuário de sugestões
4. Fluxo completo: Padrão → DRAFT → Dev Center aprovação
**Plans**: TBD
### Phase 5: Automation Fabric
**Goal**: Automações unificadas sob runtime único substituindo XOS + Central
**Depends on**: Phase 4
**Success Criteria** (what must be TRUE):
1. 5 runtimes funcionando: WorkflowEngine, RuleEngine, AgentExecutor, ScheduleEngine, EventEngine
2. Automações existentes do XOS migradas sem perda de dados
3. Automações existentes do /automations Central migradas
4. AutomationCenter.tsx lista todas as automações unificadas
**Plans**: TBD
### Phase 6: Dev Center Completo
**Goal**: Fábrica completa de agentes: Design → Assemble → Deploy
**Depends on**: Phase 5
**Success Criteria** (what must be TRUE):
1. DesignStudio com modos UML, Visual Flow, Markdown Spec, Code Editor
2. AssembleLine gera código via Blackboard (GeneratorAgent)
3. OrchestrateCenter faz deploy, versionamento e monitoramento
4. Galeria de agentes por tenant funcional
**Plans**: TBD
## Progress
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Fundação | 3/3 | Complete | 2026-03-25 |
| 2. Skills Engine | 4/4 | Complete | 2026-03-26 |
| 3. MiroFlow Embutido | 3/3 | Complete | 2026-03-26 |
| 4. OpenClaw Embutido | 0/TBD | Not started | - |
| 5. Automation Fabric | 0/TBD | Not started | - |
| 6. Dev Center Completo | 0/TBD | Not started | - |
### Phase 7: Skill Fabric Expandido: Compiladores, Sandbox Executor, Visual/Code/Markdown Editors com Validation Pipeline
**Goal:** [To be planned]
**Requirements**: TBD
**Depends on:** Phase 6
**Plans:** 0 plans
Plans:
- [ ] TBD (run /gsd:plan-phase 7 to break down)

21
.planning/STATE.md Normal file
View File

@ -0,0 +1,21 @@
# State
## Current Phase: 5 — Automation Fabric
## Completed
- Phase 1: submodules, tabelas, Neo4j, ReferenceParser
- Phase 2: SkillEngine, API REST, Monaco Editor, /skills, autocomplete, Marketplace, versionamento Git-like
- Phase 3: miroflow_service.py, bridge TS + KG logging, MiroFlowControl.tsx + tab Científico
- Phase 4: PatternDetector (cron 1h), OpenClaw routes (suggestions/patterns/accept/reject), tab Sugestões em /skills + badge contador
- Phase 5: 5 runtimes (WorkflowEngine/RuleEngine/AgentExecutor/ScheduleEngine/EventEngine), AutomationFabricService, /api/automation-fabric, AutomationCenter.tsx em /automations-center
## In Progress
- (nenhum — Phase 5 concluída, iniciar Phase 6)
## Roadmap Evolution
- Phase 7 added: Skill Fabric Expandido (compiladores, sandbox, 3 editor modes, validation pipeline)
## Notes
- Superset em produção com RLS configurado — não alterar sem confirmação
- Branch de deploy: `Servidor`
- Modelos Ollama precisam ser baixados: deepseek-r1:14b, llama3.1:8b (máximo 14B)

View File

@ -0,0 +1,48 @@
---
plan: "03-01"
phase: "03-miroflow-embutido"
status: complete
completed: 2026-03-25
---
# Summary: 03-01 — Python MiroFlow Microservice
## What was built
Microserviço FastAPI porta 8006 com 3 agentes científicos MiroFlow configurados para Ollama local, mais testes pytest cobrindo REQ-3.1 a REQ-3.4.
## Files created/modified
### Created
- `server/python/miroflow_service.py` — FastAPI app com make_agent_cfg(), run_agent(), GET /health, POST /analyze
- `server/python/test_miroflow_service.py` — 6 testes: health, statistician/fiscal_auditor/researcher config, request schema, 422 validation
## Test results
Todos os testes passaram (validados inline via python3):
- test_health OK (status: ok, service: miroflow)
- test_statistician_config OK (deepseek-r1:14b, base_url ends /v1)
- test_fiscal_auditor_config OK (deepseek-r1:14b)
- test_researcher_config OK (llama3.1:8b ou llama3.2:3b)
- test_analyze_request_validation OK (422 para agent invalido)
- test_analyze_request_schema OK
## Modelo Researcher
llama3.1:8b nao disponivel no Ollama local — fallback automatico para llama3.2:3b (ja instalado). Configuravel via MIROFLOW_RESEARCHER_MODEL no .env.
## Deps instaladas
omegaconf 2.3.0, hydra-core 1.3.2, mcp 1.26.0 instalados via pip3 --break-system-packages.
## Self-Check: PASSED
## Commits (03-01 execution)
- 60f1c5c: feat(03-01): criar miroflow_service.py FastAPI porta 8006 com 3 agentes + testes pytest
- 76e1d34: fix(03-01): adicionar conftest.py para resolver imports em pytest do root
## Deviations
- [Rule 2] conftest.py adicionado: pytest da raiz falhava sem sys.path fix
- [Documentado] llama3.2:3b usado como fallback para researcher (llama3.1:8b nao instalado)

View File

@ -0,0 +1,40 @@
---
plan: "03-02"
phase: "03-miroflow-embutido"
status: complete
completed: 2026-03-25
---
# Summary: 03-02 — Node.js MiroFlow Bridge
## What was built
Bridge Node.js → Python para o MiroFlow: proxy TypeScript com timeout 300s, rotas Express autenticadas e registro imutável de execuções no KG via auditHash SHA-256.
## Files created/modified
### Created
- `server/miroflow/engine-proxy.ts` — proxy para :8006, MIROFLOW_TIMEOUT=300_000ms, registerExecutionInKG com SHA-256
- `server/miroflow/routes.ts` — re-export de registerMiroFlowRoutes
### Modified
- `server/routes.ts` — import + chamada registerMiroFlowRoutes(app) após registerBiEngineRoutes
## Key decisions
- Timeout de 300_000ms (5 min) para POST /analyze — adequado para deepseek-r1:14b
- Health check usa timeout curto (5_000ms)
- KG failure não bloqueia resposta ao cliente (try/catch com console.error)
- auditHash cobre: execution_id + agent + model + input + output
## TypeScript compilation
Erros nos novos arquivos: **0**
(Erros pré-existentes em App.tsx e server/modules/miroflow/ não relacionados)
## Commits
- `d53be76` — feat(03-02): criar MiroFlow proxy TypeScript com timeout 300s e KG audit logging
- `1a70e87` — feat(03-02): registrar MiroFlow routes em server/routes.ts
## Self-Check: PASSED

View File

@ -0,0 +1,97 @@
# Phase 4 Context — OpenClaw Embutido
> Generated in --auto mode on 2026-03-26. All decisions auto-selected with recommended defaults.
## Phase Goal
Skills emergentes criadas automaticamente a partir de padrões detectados em `skill_executions`.
## Prior Context Applied
- **Stack de IA:** Ollama local via LiteLLM — usar `llama3.1:8b` para geração de drafts (leve, rápido)
- **Backend-first:** API definida antes do frontend
- **Auditoria imutável:** todas as execuções registradas com SHA-256
- **Schema já existe:** `detected_patterns` e `skill_suggestions` em `shared/schema.ts` — usar sem alterar
---
## Decisions
### 1. O que constitui um "padrão"?
**[auto] Mesma skill executada ≥3 vezes em 30 dias com confiança ≥80%**
- Fonte de dados: tabela `skill_executions`
- Agrupamento: `skillId + userId` (por usuário, não global)
- Confiança calculada como: `(frequency / max_frequency_in_window) * 100`
- Janela: `lastSeenAt - firstSeenAt ≤ 30 dias`
- Thresholds alinhados com o roadmap: min 3 ocorrências, 30 dias, 80% confiança
### 2. Trigger do PatternDetector
**[auto] Cron job a cada hora (setInterval no servidor Node.js)**
- Sem dependência de infraestrutura externa (sem Redis, sem Bull)
- Ao iniciar, PatternDetector registra no startup do servidor
- Execuções recentes analisadas em lote; padrões gravados em `detected_patterns`
- Se padrão já existe (mesmo skillId + userId): atualiza frequency + lastSeenAt
### 3. Geração do body da skill emergente
**[auto] AI via llama3.1:8b (LiteLLM) gera o body do DRAFT**
- Ao atingir threshold: PatternDetector cria entrada em `skill_suggestions` (status: `pending`)
- Skill DRAFT gerada automaticamente em `arcadia_skills` (status: `draft`, source: `openclaw`)
- Body gerado via prompt para llama3.1:8b descrevendo o padrão detectado
- DRAFT aguarda aprovação — não é executável até ser `published`
### 4. Widget de notificação
**[auto] Badge no header global + painel slide-in**
- Badge no ícone de notificações existente no header (sem criar novo componente de header)
- Ao clicar: slide-in panel lateral mostrando lista de sugestões pendentes
- Cada sugestão mostra: nome sugerido, padrão detectado, frequência, confiança, body preview
- Ações inline: "Aceitar" → promove para skill publicada | "Rejeitar" → status `rejected`
### 5. Aprovação — onde?
**[auto] Tab "Sugestões" em `/skills` (página existente) — Dev Center completo na Phase 6**
- Sem criar nova rota — adicionar tab na página `/skills` já existente
- Tab lista `skill_suggestions` com status `pending`
- Fluxo: aceitar → cria skill publicada a partir do DRAFT | rejeitar → arquiva sugestão
- Dev Center completo (Phase 6) herdará esse fluxo
---
## Reusable Assets Identified
- `shared/schema.ts``detectedPatterns`, `skillSuggestions` já definidos (Phase 1)
- `server/skills/engine.ts` — SkillEngine para criar DRAFT skills programaticamente
- `server/skills/routes.ts` — rota `/api/skills` existente, adicionar sub-rotas de sugestões
- `server/skills/versioning.ts` — SHA-256 audit logging (reusar padrão)
- `client/src/pages/BiWorkspace.tsx` — referência de como adicionar tabs a uma página existente
- `docker/litellm-config.yaml` — llama3.1:8b já configurado como Tier 2
---
## Scope Boundary
**Incluído nesta fase:**
- PatternDetector backend (cron + análise de skill_executions)
- Geração de DRAFT via AI
- Widget de notificação + slide-in
- Tab "Sugestões" em /skills com aprovação/rejeição
**Fora de escopo (phases futuras):**
- Dev Center completo (Phase 6)
- Detecção de padrões em outras fontes além de skill_executions
- Pattern sharing entre tenants
---
## Plan Breakdown Suggested
- **04-01:** PatternDetector service (backend) — cron, análise, gravação em detected_patterns + skill_suggestions + DRAFT creation via AI
- **04-02:** API routes + frontend widget (badge + slide-in) + tab "Sugestões" em /skills

View File

@ -0,0 +1,97 @@
# Phase 5 Context — Automation Fabric
> Generated in --auto mode on 2026-03-26. All decisions auto-selected with recommended defaults.
## Phase Goal
Automações unificadas sob runtime único — 5 runtimes tipados substituindo a fragmentação entre XOS (`xos_automations`) e Central (`automations`), com `AutomationCenter.tsx` como visão unificada.
## Prior Context Applied
- **Stack Node.js/TypeScript:** runtimes ficam no servidor Express, sem novos microserviços Python
- **Backend-first:** API definida antes do frontend
- **DB imutável:** tabelas `automations` e `xos_automations` não são alteradas — migração virtual
- **Padrão de phases anteriores:** wrappers leves sobre código existente, sem reescritas
---
## Decisions
### 1. Estratégia de migração
**[auto] Unificação virtual — zero migração de dados**
- Tabelas `automations` e `xos_automations` permanecem intactas
- Nova camada `server/automation-fabric/` agrega os dois via API layer
- `AutomationCenter.tsx` consome endpoint unificado `/api/automation-fabric/list`
- Endpoint retorna rows de ambas as tabelas com campo `source: "central" | "xos"`
- Dados existentes: sem risco de perda, sem downtime, sem migration script
### 2. Os 5 runtimes
**[auto] Classes TypeScript leves — roteamento para serviços existentes**
Implementados em `server/automation-fabric/runtimes/`:
| Runtime | Responsabilidade | Delega para |
|---|---|---|
| `WorkflowEngine` | Executa ações sequenciais com condições | `AutomationService` existente |
| `RuleEngine` | Avalia condições JSONB e dispara ações | Lógica inline, sem dependência |
| `AgentExecutor` | Executa automações via agente Manus | `ManusService` |
| `ScheduleEngine` | Gerencia cron/interval | `scheduledTasks` + setInterval existente |
| `EventEngine` | Webhook/event triggers | Registra listeners no Express |
- `AutomationFabricService` orquestra: dado tipo de trigger → escolhe runtime correto
- Trigger routing: `schedule` → ScheduleEngine, `event`/`webhook` → EventEngine, `agent` → AgentExecutor, default → WorkflowEngine
### 3. Rota do AutomationCenter
**[auto] Nova rota `/automations-center` — existentes intocadas**
- `/automations` (Central) e `/xos/automations` (XOS) permanecem sem alteração
- Nova página `client/src/pages/AutomationCenter.tsx` em `/automations-center`
- Adicionada ao nav/router sem remover rotas existentes
### 4. UI do AutomationCenter
**[auto] Lista unificada com badge de fonte + filtros inline**
- Lista flat com cards: nome, descrição, trigger type, última execução, status ativo/inativo
- Badge colorido: `"Central"` (azul) | `"XOS"` (roxo)
- Filtros: por fonte, por status (ativo/inativo), por trigger type
- Toggle inline enable/disable via PATCH ao respectivo endpoint original
- Execução manual via botão "Executar" → POST `/api/automation-fabric/{id}/run`
- Empty state informativo
---
## Reusable Assets Identified
- `server/automations/service.ts``AutomationService.runAutomation()` (reusar no WorkflowEngine)
- `server/automations/routes.ts` — padrão de rota com auth check
- `server/manus/service.ts``manusService` para AgentExecutor
- `shared/schema.ts``automations`, `xos_automations`, `automationLogs` já existentes
- `client/src/pages/Automations.tsx` — referência de UX/layout para a nova página
- `client/src/pages/XosAutomations.tsx` — referência de cards e filtros
---
## Scope Boundary
**Incluído nesta fase:**
- `server/automation-fabric/` — 5 runtimes + `AutomationFabricService` + rotas unificadas
- `GET /api/automation-fabric/list` — lista unificada (central + xos)
- `POST /api/automation-fabric/:id/run` — execução via fabric
- `AutomationCenter.tsx` — página `/automations-center`
**Fora de escopo (phases futuras):**
- Editor visual de automações (Phase 6 / Dev Center)
- Migração física de dados para tabela unificada
- Novos tipos de trigger não existentes
---
## Plan Breakdown Suggested
- **05-01:** `server/automation-fabric/` — 5 runtimes + `AutomationFabricService` + routes (`/api/automation-fabric`)
- **05-02:** `AutomationCenter.tsx` — página unificada `/automations-center` com lista, filtros e execução inline

View File

@ -0,0 +1,71 @@
# Quick Task 260326-kmz: Implementar Skill Fabric
**Description:** Implementar Skill Fabric — gerador e gestor de skills da Arcádia Suite
**Date:** 2026-03-26
**Mode:** quick
---
## Task 1: Backend — Skill Fabric Module
**Files:**
- `server/modules/skill-fabric/src/types/index.ts`
- `server/modules/skill-fabric/src/core/skill/Skill.entity.ts`
- `server/modules/skill-fabric/src/core/compiler/VisualCompiler.ts`
- `server/modules/skill-fabric/src/core/compiler/CodeCompiler.ts`
- `server/modules/skill-fabric/src/core/compiler/MarkdownCompiler.ts`
- `server/modules/skill-fabric/src/core/validator/ValidationPipeline.ts`
- `server/modules/skill-fabric/src/core/executor/SkillExecutor.ts`
- `server/modules/skill-fabric/src/core/executor/Sandbox.ts`
- `server/modules/skill-fabric/src/core/lifecycle/LifecycleManager.ts`
- `server/modules/skill-fabric/src/api/routes/skills.routes.ts`
- `server/modules/skill-fabric/src/api/controllers/skills.controller.ts`
- `server/modules/skill-fabric/index.ts`
**Action:** Create the full backend Skill Fabric module with types, entity, compilers, validator pipeline, executor/sandbox, lifecycle manager, and API routes. The project uses TypeScript + Express + Drizzle ORM. Reuse existing `db` and `skills` schema from `../../shared/schema`. No new DB migration needed — extend existing skills table logic.
**Context:**
- Existing `server/skills/routes.ts` has basic CRUD. The new module adds compilation, validation, execution, versioning, lifecycle.
- DB import: `import { db } from "@/db"` or `import { db } from "../../../db"` — check what works
- Existing schema: `skills`, `skillExecutions` from `../../shared/schema`
- Do NOT use vm2 (needs install) — use Node.js built-in `vm` module for sandbox
- Do NOT use ReactFlow/Monaco yet (frontend task) — backend only here
**Done:** Files created, module compiles without errors
---
## Task 2: Frontend — Skill Fabric UI
**Files:**
- `client/src/modules/skill-fabric/index.ts`
- `client/src/modules/skill-fabric/types.ts`
- `client/src/modules/skill-fabric/api.ts`
- `client/src/modules/skill-fabric/code-ide/components/SkillCodeEditor.tsx`
- `client/src/modules/skill-fabric/markdown-studio/components/MarkdownEditor.tsx`
- `client/src/modules/skill-fabric/shared/components/SkillToolbar.tsx`
- `client/src/modules/skill-fabric/shared/components/VersionSelector.tsx`
- `client/src/modules/skill-fabric/shared/components/ValidationPanel.tsx`
- `client/src/pages/SkillFabricPage.tsx`
**Action:** Create frontend Skill Fabric module. Use textarea-based code editor (Monaco not installed — don't add it). Use Tailwind CSS + shadcn/ui components matching the existing project style. Create a SkillFabricPage that lists skills, allows creating/editing (code + markdown modes), compiling, validating, and executing. Wire to the backend API routes from Task 1.
**Context:**
- Check `client/src/components/` for existing UI patterns (shadcn components available)
- Check `client/src/App.tsx` to see how routes are registered — add the new page there
- The visual canvas (ReactFlow) is a stretch goal — skip it, implement code + markdown modes only
- API base: `/api/skills` (existing) + `/api/skill-fabric` (new from Task 1)
**Done:** SkillFabricPage renders, skills can be listed and created
---
## Task 3: Register Routes & Integration
**Files:**
- `server/modules/loader.ts`
- `client/src/App.tsx` (or router file)
**Action:** Register the new skill-fabric backend routes in `server/modules/loader.ts`. Add the SkillFabricPage route in the frontend router. Verify existing skills route still works.
**Done:** `/api/skill-fabric/*` routes registered, frontend page accessible

Binary file not shown.

Binary file not shown.

BIN
DOCUMENTATION.docx Normal file

Binary file not shown.

View File

@ -4,7 +4,7 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
RUN npm ci
COPY . .
RUN npm run build
@ -18,16 +18,19 @@ ENV NODE_ENV=production
# Deps de produção apenas
COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts
RUN npm ci --omit=dev
# Artefatos do build
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/shared ./shared
COPY --from=builder /app/migrations ./migrations
# Arquivos de runtime necessários (caminhos quebrados pelo bundler)
RUN cp /app/node_modules/connect-pg-simple/table.sql /app/dist/table.sql
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:5000/api/health || exit 1
CMD wget -qO- http://127.0.0.1:5000/ || exit 1
CMD ["node", "dist/index.cjs"]

24
Dockerfile.miroflow Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Instalar MiroFlow e suas dependências direto do submodule
COPY server/modules/miroflow/ ./server/modules/miroflow/
RUN pip install --no-cache-dir -e ./server/modules/miroflow/
# Copiar o service script
COPY server/python/miroflow_service.py ./server/python/miroflow_service.py
ENV MIROFLOW_PORT=8006
ENV OLLAMA_BASE_URL=http://host.docker.internal:11434
EXPOSE 8006
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8006/health')" || exit 1
CMD ["python", "server/python/miroflow_service.py"]

BIN
PLANO_EVOLUCAO_ARCADIA.docx Normal file

Binary file not shown.

167
SESSAO_2026-03-18.md Normal file
View File

@ -0,0 +1,167 @@
# Sessão de Desenvolvimento — 2026-03-18
**Branch:** `claude/analyze-project-0mXjP`
**Commits desta sessão:** `8d14fc0`, `20a0237`
**Contexto:** Continuação do ciclo iniciado em 2026-03-13 (auditoria A-Z + desenvolvimento das fases XOS)
---
## 1. O que foi feito nesta sessão
### 1.1 Correção de erros TypeScript críticos no `App.tsx`
Três classes de erros impediam o build limpo do frontend:
| Erro | Arquivo | Correção |
|---|---|---|
| `Cannot find module '@/contexts/SoeMotorContext'` | `App.tsx:8` | Criado `client/src/contexts/SoeMotorContext.tsx` |
| `Cannot find module '@/pages/SOE'` | `App.tsx:39` | Criado `client/src/pages/SOE.tsx` |
| `LazyExoticComponent` não atribuível a `() => Element` (20x) | `protected-route.tsx` | Tipo alterado para `React.ComponentType<any>` |
**Arquivos criados/modificados:**
- `client/src/contexts/SoeMotorContext.tsx` — context global com `SoeMotorProvider` e hook `useSoeMotor`
- `client/src/pages/SOE.tsx` — página SOE (Sistema de Operações Empresariais), re-export do módulo Plus/ERP
- `client/src/lib/protected-route.tsx` — tipo do prop `component` corrigido
**Resultado:** `npx tsc --noEmit` zerou todos os erros do `App.tsx`. Erros remanescentes são pré-existentes em outros arquivos e não relacionados às correções.
---
### 1.2 Guia de deploy no Coolify (`DEPLOY_COOLIFY.md`)
Documento completo de 359 linhas cobrindo:
- **Arquitetura de serviços** — mapa visual de todos os containers e redes
- **Variáveis de ambiente** — obrigatórias e opcionais por módulo (Core, IA, Superset, Plus, ERPNext)
- **Profiles Docker** — como habilitar `ai`, `bi`, `plus`, `erpnext` via `COMPOSE_PROFILES`
- **Sequência de inicialização** — ordem garantida pelos `depends_on` e healthchecks
- **Traefik + HTTPS** — labels prontas para Let's Encrypt via Coolify
- **Volumes e persistência** — tabela de criticidade + comandos de backup PostgreSQL
- **Stack de IA** — 3 tiers LiteLLM (LLMFit → Ollama → externos), como baixar modelos, como habilitar LLMFit
- **Pós-deploy** — migrations, verificação de saúde
- **Checklist final** — antes, durante e após o deploy
- **Comandos de referência rápida**
---
### 1.3 Relatório de comparação Auditoria vs Desenvolvimento
Análise completa cruzando o `ARCADIA_AUDIT_EXECUCAO.md` (auditoria de 13/Mar) com todos os 18 commits do branch:
**Resultado geral: 44% concluído, 17% parcial, 38% pendente**
| Sprint | ✅ | ⚠️ | ❌ |
|---|---|---|---|
| S — Segurança (10 itens) | 7 | 3 | 0 |
| 0 — Deploy (6 itens) | 2 | 1 | 3 |
| 1 — Inteligência (6 itens) | 2 | 2 | 2 |
| 2 — App Store (4 itens) | 3 | 0 | 1 |
| 3 — Compass IA (4 itens) | 1 | 1 | 2 |
| 4 — ERP Real (6 itens) | 0 | 0 | 6 |
| 5 — Soberania IA (4 itens) | 3 | 1 | 0 |
| 6 — Qualidade (7 itens) | 3 | 0 | 4 |
| 7 — ERPNext (5 itens) | 2 | 1 | 2 |
---
## 2. Estado do Superset BI (análise técnica)
Levantamento completo da integração Apache Superset:
**Funcionando:**
- Gateway Node.js com 4 endpoints (`/guest-token`, `/dashboards`, `/health`, proxy `/superset/*`)
- Guest Token JWT via service account com cache de 50 min
- Componente React `<SupersetDashboard>` reutilizável com fallback iframe
- Docker: init automático, banco `arcadia` registrado somente leitura, admin criado
- Feature flags: embed, drill-to-detail, cross-filters, alerts, templates Jinja
**Pendente / stub:**
- RLS por tenant — `rls: []` vazio, todos veem todos os dados
- Nenhum dashboard pré-criado — criação manual pelo usuário
- Cache Redis — usando `SimpleCache` em memória
- SSO/OAuth — auth local apenas
---
## 3. Contexto acumulado do ciclo completo (desde 13/Mar)
### O que foi construído no branch (18 commits, 71 arquivos, +9.399 linhas)
| Área | Principais entregas |
|---|---|
| **Segurança** | Auth em todas as rotas XOS, CORS Python corrigido, credentials removidas, timeout+retry, approval guard Manus |
| **XOS CRM** | Fases 14 completas: automações, SLA, horário comercial, campanhas, Socket.IO tempo real |
| **Novas páginas React** | `XosSupervisor`, `XosReports`, `XosProtocols` |
| **SOE Rule Engine** | Motor de regras contábil/fiscal + importador ERPNext |
| **Superset BI** | Integração completa embed + proxy + guest token |
| **Infra Docker** | `docker-compose.prod.yml` com profiles: `ai`, `bi`, `plus`, `erpnext` |
| **IA Soberania** | LiteLLM 3 tiers + Ollama + slot LLMFit pronto |
| **Qualidade** | Rate limiting, logging estruturado (`server/logger.ts`), paginação |
| **App Store** | `GET /api/marketplace/my-apps` + AppCenter por subscription |
| **Docs** | `DEPLOY_COOLIFY.md`, `INTEGRACAO_IA.md`, `PLANO_BI_SUPERSET.md`, `PLANO_SOE_CENTRAL_v2.md` |
---
## 4. Demandas prioritárias pendentes
### Críticas (bloqueia produção estável)
1. **LMS e Quality** — auth ainda incompleta (SEC-02, SEC-03)
- `server/lms/routes.ts` — 8 rotas ainda públicas
- `server/quality/routes.ts` — ~50 rotas revisão insuficiente
2. **WhatsApp auto-reply** — configuração ainda em `Map` na memória
- Reiniciar servidor = perda total da configuração sem aviso
- Persistir em tabela `whatsapp_auto_reply_config` (migration já existe)
3. **`pg_dump` do Replit** — exportar banco antes de encerrar o plano
- Risco de perda de dados de produção atual
### Altas (produto incompleto)
4. **Ciclo de aprendizado Manus** — embeddings não são populados automaticamente
- Busca semântica retorna vazio, sistema não aprende das interações
5. **Páginas placeholder**`Agent.tsx`, `CentralApis.tsx`, `ApiHub.tsx`
- Três das páginas mais estratégicas ainda sem implementação
6. **Sprint 4 inteiro — Integrações ERP reais**
- Adaptadores Omie e TOTVS: zero implementado
- Todos os conectores da Central de API ainda mockados
- Estrutura fragmentada em 4 lugares (`server/erpnext/`, `server/crm/frappe-service.ts`, `server/api-central/`, `server/migration/`)
### Médias (qualidade de engenharia)
7. **Error boundaries globais no React** — sem captura de erros em cascata
8. **Testes automatizados** — zero no projeto inteiro
9. **Connection pooling Python** — nova conexão DB por request nos 6 microserviços
10. **Consolidar sistemas de comunicação** — legacy (`whatsapp_*`, `chat_*`) e moderno (`comm_*`, `xosConversations`) duplicados
11. **36 tabelas sem insert schema** — risco de runtime errors no Drizzle
12. **40+ tabelas sem `createdAt`/`updatedAt`** — auditoria e ordenação impossíveis
### Decisões arquiteturais abertas
| # | Decisão |
|---|---|
| D1 | BI padrão: Metabase (existente) vs Superset (integrado)? |
| D2 | LMS: tabelas dinâmicas vs schema.ts fixo? |
| D3 | Sistema de comunicação canônico: legacy vs moderno vs XOS? |
| D4 | Marketplace público ou autenticado (hoje público — intencional)? |
---
## 5. Próxima sessão recomendada
**Sequência sugerida por impacto/risco:**
```
1. Corrigir auth LMS + Quality (23h) → fecha todas as vulnerabilidades SEC
2. Persistir WhatsApp auto-reply no banco (1h) → estabilidade em produção
3. Fechar ciclo embeddings Manus (2h) → IA começa a aprender de verdade
4. Implementar Agent.tsx com UI real (34h) → página mais estratégica
5. Adaptador Omie — primeira integração ERP real (46h)
```
---
*Sessão registrada em 2026-03-18 | Branch: `claude/analyze-project-0mXjP`*

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -9,6 +9,7 @@ import { SoeMotorProvider } from "@/contexts/SoeMotorContext";
import { ProtectedRoute } from "@/lib/protected-route";
import { CommandPalette } from "@/components/CommandPalette";
import { KnowledgeCollectorInit } from "@/components/KnowledgeCollectorInit";
import { OpenClawWidget } from "@/components/openclaw/OpenClawWidget";
import NotFound from "@/pages/not-found";
import AuthPage from "@/pages/auth-page";
@ -164,6 +165,7 @@ function App() {
<KnowledgeCollectorInit />
<Toaster />
<CommandPalette />
<OpenClawWidget />
<Router />
</TooltipProvider>
</SoeMotorProvider>

View File

@ -0,0 +1,217 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Brain, Loader2 } from "lucide-react";
interface AnalyzeRequest {
agent: "statistician" | "fiscal_auditor" | "researcher";
task: string;
context?: Record<string, unknown>;
}
interface AnalyzeResponse {
agent: string;
model: string;
result: string;
execution_id: string;
duration_ms: number;
}
const AGENTS = [
{
value: "statistician",
label: "Statistician",
model: "deepseek-r1:14b",
placeholder:
"Ex: Analise a distribuição de vendas por região no último trimestre",
},
{
value: "fiscal_auditor",
label: "Fiscal Auditor",
model: "deepseek-r1:14b",
placeholder:
"Ex: Verifique inconsistências nos registros NFe do CNPJ 12.345.678/0001-90",
},
{
value: "researcher",
label: "Researcher",
model: "llama3.1:8b",
placeholder:
"Ex: Quais fornecedores têm maior correlação com atrasos de entrega?",
},
] as const;
interface MiroFlowControlProps {
currentDashboardData?: Record<string, unknown>;
}
export function MiroFlowControl({
currentDashboardData,
}: MiroFlowControlProps) {
const [enabled, setEnabled] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<
"statistician" | "fiscal_auditor" | "researcher"
>("statistician");
const [task, setTask] = useState("");
const mutation = useMutation({
mutationFn: async (request: AnalyzeRequest) => {
const response = await apiRequest("POST", "/api/miroflow/analyze", request);
return response.json() as Promise<AnalyzeResponse>;
},
});
const handleAnalyze = () => {
if (!task.trim()) {
return;
}
mutation.mutate({
agent: selectedAgent,
task,
context: currentDashboardData,
});
};
const currentAgentConfig = AGENTS.find((a) => a.value === selectedAgent);
return (
<div className="space-y-6">
<Card className="bg-[#1f334d] border border-[#c89b3c]/20">
<CardHeader className="border-b border-[#c89b3c]/20 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Brain className="w-6 h-6 text-[#c89b3c]" />
<CardTitle className="text-white">Modo Científico</CardTitle>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-colors ${
enabled ? "bg-[#c89b3c]" : "bg-gray-600"
}`}
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-7" : "translate-x-1"
}`}
/>
</button>
</div>
</CardHeader>
{enabled && (
<CardContent className="space-y-4 pt-6">
{/* Agent Selection */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Selecione o Agente
</label>
<Select value={selectedAgent} onValueChange={(value: any) => setSelectedAgent(value)}>
<SelectTrigger className="bg-[#2a4466] border border-[#c89b3c]/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#2a4466] border border-[#c89b3c]/20">
{AGENTS.map((agent) => (
<SelectItem key={agent.value} value={agent.value} className="text-white">
<div>
<div>{agent.label}</div>
<div className="text-xs text-gray-400">{agent.model}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Task Input */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Digite a Tarefa
</label>
<Textarea
placeholder={currentAgentConfig?.placeholder || "Digite sua tarefa..."}
value={task}
onChange={(e) => setTask(e.target.value)}
disabled={mutation.isPending}
className="bg-[#2a4466] border border-[#c89b3c]/20 text-white placeholder:text-gray-500 min-h-24"
/>
</div>
{/* Analyze Button */}
<Button
onClick={handleAnalyze}
disabled={!task.trim() || mutation.isPending}
className="w-full bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d] font-semibold"
>
{mutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Analisando...
</>
) : (
"Analisar"
)}
</Button>
{/* Error Display */}
{mutation.isError && (
<div className="rounded-lg p-4 bg-red-900/20 border border-red-500/50">
<Badge className="mb-2 bg-red-600 text-white">Erro</Badge>
<p className="text-sm text-red-100">
{mutation.error instanceof Error
? mutation.error.message
: "Erro ao processar a análise"}
</p>
</div>
)}
{/* Result Display */}
{mutation.isSuccess && (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<Badge className="bg-green-600 text-white">Sucesso</Badge>
</div>
<div className="rounded-lg p-4 bg-[#2a4466] border border-[#c89b3c]/20">
<ScrollArea className="h-64 w-full pr-4">
<p className="text-white text-sm whitespace-pre-wrap">
{mutation.data?.result}
</p>
</ScrollArea>
</div>
{/* Metadata */}
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400">
<div>
<span className="font-semibold text-[#c89b3c]">Modelo:</span>{" "}
{mutation.data?.model}
</div>
<div>
<span className="font-semibold text-[#c89b3c]">Tempo:</span>{" "}
{mutation.data?.duration_ms}ms
</div>
<div>
<span className="font-semibold text-[#c89b3c]">ID:</span>{" "}
{mutation.data?.execution_id?.slice(0, 8)}...
</div>
</div>
</div>
)}
</CardContent>
)}
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,245 @@
/**
* OpenClawPanel Phase 4
*
* Painel de visualização de Skills Emergentes detectadas pelo PatternDetector.
* Exibido como aba na sidebar do /development.
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Sparkles, CheckCircle, XCircle, ChevronDown, ChevronRight,
Loader2, Activity, Clock, BarChart2
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
interface Suggestion {
id: string;
suggestedSkillName: string;
suggestedDescription: string | null;
estimatedAutomation: string | null;
confidence: string;
draftBody: string | null;
createdAt: string;
}
interface Pattern {
id: string;
actionType: string;
description: string | null;
frequency: number;
confidence: string;
firstSeenAt: string;
lastSeenAt: string;
status: string;
}
export default function OpenClawPanel() {
const [tab, setTab] = useState<"suggestions" | "patterns">("suggestions");
const [expanded, setExpanded] = useState<string | null>(null);
const { toast } = useToast();
const qc = useQueryClient();
const { data: sugData, isLoading: sugLoading } = useQuery<{ suggestions: Suggestion[]; total: number }>({
queryKey: ["/api/openclaw/suggestions"],
refetchInterval: 2 * 60 * 1000,
});
const { data: patData, isLoading: patLoading } = useQuery<{ patterns: Pattern[]; total: number }>({
queryKey: ["/api/openclaw/patterns"],
enabled: tab === "patterns",
});
const accept = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/openclaw/suggestions/${id}/accept`, { method: "POST" });
if (!res.ok) throw new Error();
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/openclaw/suggestions"] });
qc.invalidateQueries({ queryKey: ["/api/skills"] });
toast({ title: "Skill publicada!" });
},
onError: () => toast({ title: "Erro ao aceitar", variant: "destructive" }),
});
const reject = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/openclaw/suggestions/${id}/reject`, { method: "POST" });
if (!res.ok) throw new Error();
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/openclaw/suggestions"] });
toast({ title: "Sugestão rejeitada" });
},
onError: () => toast({ title: "Erro ao rejeitar", variant: "destructive" }),
});
const suggestions = sugData?.suggestions ?? [];
const patterns = patData?.patterns ?? [];
return (
<div className="h-full flex flex-col bg-gray-900 text-white">
{/* Header */}
<div className="px-5 py-4 border-b border-white/10 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-yellow-400" />
<div>
<h2 className="text-sm font-semibold">Skills Emergentes</h2>
<p className="text-[11px] text-white/40">OpenClaw detecção automática de padrões</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-white/10 text-xs">
<button
onClick={() => setTab("suggestions")}
className={`px-4 py-2.5 flex items-center gap-1.5 transition-colors border-b-2 ${tab === "suggestions" ? "border-yellow-400 text-yellow-400" : "border-transparent text-white/50 hover:text-white"}`}
>
<Sparkles className="w-3 h-3" />
Sugestões
{suggestions.length > 0 && (
<span className="ml-1 rounded-full bg-yellow-400 text-black text-[9px] font-bold w-4 h-4 flex items-center justify-center">
{suggestions.length}
</span>
)}
</button>
<button
onClick={() => setTab("patterns")}
className={`px-4 py-2.5 flex items-center gap-1.5 transition-colors border-b-2 ${tab === "patterns" ? "border-yellow-400 text-yellow-400" : "border-transparent text-white/50 hover:text-white"}`}
>
<Activity className="w-3 h-3" />
Padrões
</button>
</div>
<ScrollArea className="flex-1">
{/* Sugestões */}
{tab === "suggestions" && (
<div>
{sugLoading && (
<div className="flex justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-white/30" />
</div>
)}
{!sugLoading && suggestions.length === 0 && (
<div className="px-5 py-12 text-center">
<Sparkles className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">Nenhuma sugestão pendente</p>
<p className="text-xs text-white/20 mt-1">O detector roda a cada hora</p>
</div>
)}
<div className="divide-y divide-white/5">
{suggestions.map((s) => {
const conf = Math.round(Number(s.confidence) * 100);
const isExp = expanded === s.id;
const isAccepting = accept.isPending && accept.variables === s.id;
const isRejecting = reject.isPending && reject.variables === s.id;
return (
<div key={s.id} className="px-4 py-4 space-y-2.5">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{s.suggestedSkillName}</p>
{s.suggestedDescription && (
<p className="text-xs text-white/50 mt-0.5 line-clamp-2">{s.suggestedDescription}</p>
)}
</div>
<Badge className="shrink-0 bg-yellow-400/10 text-yellow-400 border-yellow-400/20 text-[10px]">
{conf}%
</Badge>
</div>
{s.draftBody && (
<div>
<button
onClick={() => setExpanded(isExp ? null : s.id)}
className="flex items-center gap-1 text-[11px] text-white/40 hover:text-white/60 transition-colors"
>
{isExp ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
{isExp ? "Ocultar body" : "Ver body gerado"}
</button>
{isExp && (
<pre className="mt-2 rounded bg-black/40 p-3 text-[11px] text-white/60 overflow-x-auto whitespace-pre-wrap font-mono max-h-40 border border-white/5">
{s.draftBody}
</pre>
)}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
className="h-7 text-xs bg-emerald-900/40 text-emerald-400 border border-emerald-700/40 hover:bg-emerald-900/60"
onClick={() => accept.mutate(s.id)}
disabled={isAccepting || isRejecting}
>
{isAccepting ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
<span className="ml-1">Aceitar</span>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => reject.mutate(s.id)}
disabled={isAccepting || isRejecting}
>
{isRejecting ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
<span className="ml-1">Rejeitar</span>
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Padrões */}
{tab === "patterns" && (
<div>
{patLoading && (
<div className="flex justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-white/30" />
</div>
)}
{!patLoading && patterns.length === 0 && (
<div className="px-5 py-12 text-center">
<BarChart2 className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">Nenhum padrão detectado</p>
<p className="text-xs text-white/20 mt-1">Execute skills manualmente para gerar dados</p>
</div>
)}
<div className="divide-y divide-white/5">
{patterns.map((p) => {
const conf = Math.round(Number(p.confidence) * 100);
const last = new Date(p.lastSeenAt).toLocaleDateString("pt-BR");
return (
<div key={p.id} className="px-4 py-3 space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-mono text-white/70 truncate">{p.actionType}</p>
<Badge className="shrink-0 bg-white/5 text-white/50 border-white/10 text-[10px]">
{conf}%
</Badge>
</div>
{p.description && (
<p className="text-[11px] text-white/40">{p.description}</p>
)}
<div className="flex items-center gap-3 text-[10px] text-white/30">
<span className="flex items-center gap-1"><Activity className="w-3 h-3" />{p.frequency}x</span>
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{last}</span>
</div>
</div>
);
})}
</div>
</div>
)}
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,353 @@
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import {
RefreshCw, CheckCircle, XCircle, Loader2, Users, Package,
ShoppingCart, FileText, ExternalLink, Building2, Link2
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Empresa {
id: number;
tenantId: number;
razaoSocial: string;
nomeFantasia: string | null;
cnpj: string;
tipo: string;
plusEmpresaId: number | null;
isActive: boolean;
}
interface SyncResult {
success: boolean;
created?: number;
updated?: number;
failed?: number;
errors?: string[];
message?: string;
}
interface PlusStatus {
connected: boolean;
url?: string;
message?: string;
}
export default function PlusSyncPanel({ tenantId }: { tenantId?: number }) {
const { toast } = useToast();
const [empresas, setEmpresas] = useState<Empresa[]>([]);
const [selectedEmpresa, setSelectedEmpresa] = useState<string>("");
const [plusStatus, setPlusStatus] = useState<PlusStatus | null>(null);
const [loading, setLoading] = useState(false);
const [syncingCustomers, setSyncingCustomers] = useState(false);
const [syncingProducts, setSyncingProducts] = useState(false);
const [syncingSales, setSyncingSales] = useState(false);
const [importingCustomers, setImportingCustomers] = useState(false);
const [importingProducts, setImportingProducts] = useState(false);
const [bindPlusId, setBindPlusId] = useState("");
const [lastResults, setLastResults] = useState<Record<string, SyncResult>>({});
const currentTenant = tenantId || 1;
const loadEmpresas = useCallback(async () => {
try {
const res = await fetch(`/api/retail/plus/empresas?tenantId=${currentTenant}`);
if (res.ok) {
const data = await res.json();
setEmpresas(data);
if (data.length > 0 && !selectedEmpresa) {
setSelectedEmpresa(data[0].id.toString());
}
}
} catch (error) {
console.error("Error loading empresas:", error);
}
}, [currentTenant, selectedEmpresa]);
const checkStatus = useCallback(async () => {
setLoading(true);
try {
const empresaId = selectedEmpresa ? `?empresaId=${selectedEmpresa}` : "";
const res = await fetch(`/api/retail/plus/status${empresaId}`);
if (res.ok) {
const data = await res.json();
setPlusStatus(data);
}
} catch (error) {
setPlusStatus({ connected: false, message: "Erro ao verificar conexão" });
} finally {
setLoading(false);
}
}, [selectedEmpresa]);
useEffect(() => {
loadEmpresas();
}, [loadEmpresas]);
useEffect(() => {
if (selectedEmpresa) checkStatus();
}, [selectedEmpresa, checkStatus]);
const handleSync = async (action: string, setLoading: (v: boolean) => void) => {
setLoading(true);
try {
const body: any = { tenantId: currentTenant };
if (selectedEmpresa) body.empresaId = parseInt(selectedEmpresa);
const res = await fetch(`/api/retail/plus/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
setLastResults(prev => ({ ...prev, [action]: data }));
if (data.success) {
toast({ title: "Sincronização concluída", description: `Criados: ${data.created || 0}, Atualizados: ${data.updated || 0}` });
} else {
toast({ title: "Erro na sincronização", description: data.message || "Falha na operação", variant: "destructive" });
}
} catch (error) {
toast({ title: "Erro", description: "Falha na comunicação com o servidor", variant: "destructive" });
} finally {
setLoading(false);
}
};
const handleBindEmpresa = async () => {
if (!selectedEmpresa || !bindPlusId) return;
try {
const res = await fetch(`/api/retail/plus/empresas/${selectedEmpresa}/bind`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plusEmpresaId: parseInt(bindPlusId) }),
});
if (res.ok) {
toast({ title: "Empresa vinculada", description: "Empresa vinculada ao Plus com sucesso" });
loadEmpresas();
setBindPlusId("");
}
} catch (error) {
toast({ title: "Erro", description: "Falha ao vincular empresa", variant: "destructive" });
}
};
const currentEmpresa = empresas.find(e => e.id.toString() === selectedEmpresa);
const SyncResultBadge = ({ result }: { result?: SyncResult }) => {
if (!result) return null;
return (
<div className="flex items-center gap-2 text-xs mt-1">
{result.success ? (
<Badge variant="outline" className="text-green-600 border-green-300">
<CheckCircle className="h-3 w-3 mr-1" />
OK: {result.created || 0} criados, {result.updated || 0} atualizados
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<XCircle className="h-3 w-3 mr-1" />
{result.message || "Falha"}
</Badge>
)}
</div>
);
};
return (
<div className="space-y-6" data-testid="plus-sync-panel">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Integração Plus ERP</h3>
<p className="text-sm text-muted-foreground">Sincronização bidirecional com o Arcádia Plus (Laravel ERP)</p>
</div>
<div className="flex items-center gap-2">
{plusStatus && (
<Badge variant={plusStatus.connected ? "default" : "destructive"} data-testid="plus-connection-status">
{plusStatus.connected ? (
<><CheckCircle className="h-3 w-3 mr-1" /> Conectado</>
) : (
<><XCircle className="h-3 w-3 mr-1" /> Desconectado</>
)}
</Badge>
)}
<Button variant="outline" size="sm" onClick={checkStatus} disabled={loading} data-testid="btn-check-plus-status">
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? "animate-spin" : ""}`} />
Testar Conexão
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
Empresa Ativa
</CardTitle>
<CardDescription>Selecione a empresa para operações de sincronização</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end gap-3">
<div className="flex-1">
<Label>Empresa</Label>
<Select value={selectedEmpresa} onValueChange={setSelectedEmpresa}>
<SelectTrigger data-testid="select-empresa-plus">
<SelectValue placeholder="Selecione uma empresa" />
</SelectTrigger>
<SelectContent>
{empresas.map(emp => (
<SelectItem key={emp.id} value={emp.id.toString()}>
{emp.nomeFantasia || emp.razaoSocial} ({emp.cnpj}) {emp.tipo === "matriz" ? "📍" : "🔗"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{currentEmpresa && (
<div className="rounded-lg border p-3 bg-muted/50 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{currentEmpresa.razaoSocial}</span>
<Badge variant="outline">{currentEmpresa.tipo === "matriz" ? "Matriz" : "Filial"}</Badge>
</div>
<div className="text-xs text-muted-foreground">CNPJ: {currentEmpresa.cnpj}</div>
<div className="flex items-center gap-2">
<Link2 className="h-3 w-3" />
<span className="text-xs">Plus ID: {currentEmpresa.plusEmpresaId || "Não vinculada"}</span>
{!currentEmpresa.plusEmpresaId && (
<div className="flex items-center gap-1 ml-2">
<Input
type="number"
placeholder="ID da empresa no Plus"
value={bindPlusId}
onChange={e => setBindPlusId(e.target.value)}
className="h-7 w-36 text-xs"
data-testid="input-bind-plus-id"
/>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleBindEmpresa} data-testid="btn-bind-empresa">
Vincular
</Button>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Clientes / Fornecedores
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={syncingCustomers}
onClick={() => handleSync("sync/customers", setSyncingCustomers)}
data-testid="btn-sync-customers-to-plus"
>
{syncingCustomers ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <ExternalLink className="h-4 w-4 mr-1" />}
Enviar Plus
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={importingCustomers}
onClick={() => handleSync("import/customers", setImportingCustomers)}
data-testid="btn-import-customers-from-plus"
>
{importingCustomers ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <ExternalLink className="h-4 w-4 mr-1 rotate-180" />}
Plus Importar
</Button>
</div>
<SyncResultBadge result={lastResults["sync/customers"] || lastResults["import/customers"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
Produtos / Estoque
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
disabled={importingProducts}
onClick={() => handleSync("import/products", setImportingProducts)}
data-testid="btn-import-products-from-plus"
>
{importingProducts ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Package className="h-4 w-4 mr-1" />}
Plus Importar Produtos
</Button>
</div>
<SyncResultBadge result={lastResults["import/products"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
Vendas PDV
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
As vendas são sincronizadas automaticamente ao finalizar no PDV.
Use o botão abaixo para reenviar vendas pendentes.
</p>
<Button
variant="outline"
size="sm"
disabled={syncingSales}
onClick={() => {
toast({ title: "Em breve", description: "Sincronização em lote será implementada" });
}}
data-testid="btn-sync-pending-sales"
>
{syncingSales ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-1" />}
Reenviar Pendentes
</Button>
<SyncResultBadge result={lastResults["sync/sales"]} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4" />
Documentos Fiscais
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Emissão de NF-e e NFC-e via Plus ERP. Selecione a venda na aba PDV
e use a opção "Emitir NF-e" para gerar o documento fiscal.
</p>
<Badge variant="outline" className="text-xs">
<FileText className="h-3 w-3 mr-1" />
Integrado via Plus API
</Badge>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
/**
* OpenClawWidget.tsx
*
* Widget flutuante do OpenClaw aparece quando sugestões pendentes.
* Posicionado no canto inferior direito, fora das rotas.
* Abre o modal SkillSuggestion para confirmação.
*
* Fase 4: OpenClaw Sprint 2
*/
import { useState } from "react";
import { Sparkles, X, ChevronUp, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAgentEmergence } from "@/hooks/useAgentEmergence";
import { SkillSuggestion } from "./SkillSuggestion";
import { useAuth } from "@/hooks/use-auth";
export function OpenClawWidget() {
const { user } = useAuth();
const {
suggestions,
activeSuggestion,
pendingCount,
confirmSkill,
rejectSkill,
openSuggestion,
closeSuggestion,
} = useAgentEmergence();
const [collapsed, setCollapsed] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Não renderiza se não há usuário, não há sugestões, ou foi dispensado
if (!user || pendingCount === 0 || dismissed) return null;
return (
<>
{/* Widget flutuante */}
<div
className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2"
role="complementary"
aria-label="OpenClaw - Sugestões de Skills"
>
{/* Painel expandido */}
{!collapsed && (
<div className="w-72 rounded-xl border bg-background shadow-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 border-b bg-violet-50 dark:bg-violet-900/20">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
<span className="text-sm font-semibold text-violet-700 dark:text-violet-300">
OpenClaw
</span>
<Badge className="bg-violet-600 text-white text-xs px-1.5 py-0">
{pendingCount}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setCollapsed(true)}
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
aria-label="Minimizar"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setDismissed(true)}
className="p-0.5 rounded hover:bg-violet-100 dark:hover:bg-violet-900/40 text-muted-foreground"
aria-label="Fechar"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="p-3 space-y-2 max-h-64 overflow-y-auto">
<p className="text-xs text-muted-foreground">
Detectei padrões de uso que podem virar skills automáticas:
</p>
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => openSuggestion(s)}
className="w-full text-left rounded-lg border p-2.5 hover:bg-muted/50 transition-colors space-y-1"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium leading-tight line-clamp-1">
{s.suggested_skill_name}
</span>
<Badge variant="outline" className="text-xs shrink-0">
{Math.round(s.confidence * 100)}%
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{s.suggested_description}
</p>
</button>
))}
</div>
</div>
)}
{/* Botão minimizado */}
{collapsed && (
<button
onClick={() => setCollapsed(false)}
className="flex items-center gap-2 rounded-full bg-violet-600 hover:bg-violet-700 text-white px-4 py-2.5 shadow-lg transition-colors"
aria-label="Expandir sugestões de skills"
>
<Sparkles className="h-4 w-4" />
<span className="text-sm font-medium">Skills sugeridas</span>
<Badge className="bg-white text-violet-700 text-xs px-1.5 py-0 ml-0.5">
{pendingCount}
</Badge>
<ChevronUp className="h-3.5 w-3.5 ml-0.5" />
</button>
)}
</div>
{/* Modal de detalhes */}
{activeSuggestion && (
<SkillSuggestion
suggestion={activeSuggestion}
onConfirm={confirmSkill}
onReject={rejectSkill}
onClose={closeSuggestion}
/>
)}
</>
);
}

View File

@ -0,0 +1,101 @@
/**
* SkillSuggestion.tsx
*
* Modal com preview da skill sugerida pelo OpenClaw.
* Apresenta padrão detectado, nome, descrição e preview de automação.
* Usuário pode confirmar ou rejeitar.
*
* Fase 4: OpenClaw Sprint 2
*/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Sparkles, X, Check, Zap, BarChart2 } from "lucide-react";
import type { SkillSuggestionData } from "@/hooks/useAgentEmergence";
interface SkillSuggestionProps {
suggestion: SkillSuggestionData;
onConfirm: (id: string) => void;
onReject: (id: string) => void;
onClose: () => void;
}
export function SkillSuggestion({ suggestion, onConfirm, onReject, onClose }: SkillSuggestionProps) {
const confidencePct = Math.round(suggestion.confidence * 100);
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-violet-100 dark:bg-violet-900/30">
<Sparkles className="h-4 w-4 text-violet-600 dark:text-violet-400" />
</div>
<DialogTitle className="text-base">Nova skill detectada</DialogTitle>
</div>
</DialogHeader>
{/* Padrão detectado */}
<div className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Padrão detectado</p>
<p className="font-medium">{suggestion.pattern.description || suggestion.pattern.action_type}</p>
<div className="flex items-center gap-3 text-muted-foreground text-xs mt-1">
<span className="flex items-center gap-1">
<BarChart2 className="h-3 w-3" />
{suggestion.pattern.frequency}x detectado
</span>
<span className="flex items-center gap-1">
<Zap className="h-3 w-3" />
Confiança: {confidencePct}%
</span>
</div>
</div>
{/* Skill sugerida */}
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Nome da skill</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs">
{suggestion.suggested_skill_name}
</Badge>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Descrição</p>
<p className="text-sm">{suggestion.suggested_description}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">O que será automatizado</p>
<div className="rounded-md border bg-muted/20 p-2.5 text-sm text-muted-foreground">
{suggestion.estimated_automation}
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
size="sm"
onClick={() => onReject(suggestion.id)}
className="gap-1.5"
>
<X className="h-3.5 w-3.5" />
Rejeitar
</Button>
<Button
size="sm"
onClick={() => onConfirm(suggestion.id)}
className="gap-1.5 bg-violet-600 hover:bg-violet-700"
>
<Check className="h-3.5 w-3.5" />
Criar skill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,59 @@
import { useQuery } from "@tanstack/react-query";
export type TenantFeatures = {
ide: boolean;
ideMode: 'none' | 'no-code' | 'low-code' | 'pro-code';
whatsapp: boolean;
whatsappSessions: number;
crm: boolean;
erp: boolean;
bi: boolean;
manus: boolean;
manusTools: string[];
centralApis: boolean;
centralApisManage: boolean;
comunidades: boolean;
maxChannels: number;
biblioteca: boolean;
bibliotecaPublish: boolean;
suporteN3: boolean;
retail: boolean;
plus: boolean;
fisco: boolean;
cockpit: boolean;
compass: boolean;
production: boolean;
support: boolean;
xosCrm: boolean;
};
const defaultFeatures: TenantFeatures = {
ide: true, ideMode: 'pro-code', whatsapp: false, whatsappSessions: 0,
crm: true, erp: true, bi: false, manus: true, manusTools: [],
centralApis: false, centralApisManage: false, comunidades: false,
maxChannels: 5, biblioteca: false, bibliotecaPublish: false,
suporteN3: false, retail: false, plus: false, fisco: false,
cockpit: false, compass: true, production: false, support: false, xosCrm: false
};
export function useTenantFeatures() {
const { data, isLoading } = useQuery({
queryKey: ["tenant-modules"],
queryFn: async () => {
const res = await fetch("/api/soe/tenant/modules", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch tenant modules");
return res.json();
},
staleTime: 60000,
retry: 1
});
const features: TenantFeatures = { ...defaultFeatures, ...(data?.features || {}) };
return {
features,
isLoading,
plan: data?.plan as string | undefined,
isEnabled: (key: keyof TenantFeatures) => !!features[key],
};
}

View File

@ -0,0 +1,143 @@
/**
* useAgentEmergence.ts
*
* Hook para gerenciar sugestões de skills do OpenClaw.
* Escuta eventos Socket.IO e coordena confirmações/rejeições.
*
* Fase 4: OpenClaw Sprint 2
*/
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "./use-auth";
export interface SkillSuggestionData {
id: string;
pattern: {
id: string;
action_type: string;
frequency: number;
confidence: number;
description: string;
};
suggested_skill_name: string;
suggested_description: string;
estimated_automation: string;
confidence: number;
created_at: string;
status: "pending" | "accepted" | "rejected";
}
interface UseAgentEmergenceReturn {
suggestions: SkillSuggestionData[];
activeSuggestion: SkillSuggestionData | null;
isOpen: boolean;
pendingCount: number;
confirmSkill: (suggestionId: string) => Promise<void>;
rejectSkill: (suggestionId: string) => Promise<void>;
openSuggestion: (suggestion: SkillSuggestionData) => void;
closeSuggestion: () => void;
dismissWidget: () => void;
}
export function useAgentEmergence(): UseAgentEmergenceReturn {
const { user } = useAuth();
const [suggestions, setSuggestions] = useState<SkillSuggestionData[]>([]);
const [activeSuggestion, setActiveSuggestion] = useState<SkillSuggestionData | null>(null);
const [isOpen, setIsOpen] = useState(false);
// Carrega sugestões pendentes do servidor
const fetchPendingSuggestions = useCallback(async () => {
if (!user) return;
try {
const res = await fetch("/api/openclaw/suggestions");
if (res.ok) {
const data = await res.json();
setSuggestions(data.suggestions || []);
}
} catch {
// silencioso — não bloqueia o usuário
}
}, [user]);
// Escuta eventos Socket.IO via window (padrão da suite)
useEffect(() => {
if (!user) return;
fetchPendingSuggestions();
const handleOpenClawEvent = (event: CustomEvent) => {
const suggestion: SkillSuggestionData = event.detail;
setSuggestions((prev) => {
const exists = prev.find((s) => s.id === suggestion.id);
if (exists) return prev;
return [suggestion, ...prev];
});
};
window.addEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
return () => {
window.removeEventListener("openclaw:suggestion" as any, handleOpenClawEvent);
};
}, [user, fetchPendingSuggestions]);
const confirmSkill = useCallback(async (suggestionId: string) => {
try {
const res = await fetch("/api/openclaw/confirm-skill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestion_id: suggestionId, action: "confirm" }),
});
if (res.ok) {
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
setActiveSuggestion(null);
setIsOpen(false);
}
} catch {
// erro tratado na UI via toast externo
}
}, []);
const rejectSkill = useCallback(async (suggestionId: string) => {
try {
const res = await fetch("/api/openclaw/confirm-skill", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suggestion_id: suggestionId, action: "reject" }),
});
if (res.ok) {
setSuggestions((prev) => prev.filter((s) => s.id !== suggestionId));
setActiveSuggestion(null);
setIsOpen(false);
}
} catch {
// silencioso
}
}, []);
const openSuggestion = useCallback((suggestion: SkillSuggestionData) => {
setActiveSuggestion(suggestion);
setIsOpen(true);
}, []);
const closeSuggestion = useCallback(() => {
setActiveSuggestion(null);
setIsOpen(false);
}, []);
const dismissWidget = useCallback(() => {
setIsOpen(false);
setActiveSuggestion(null);
}, []);
return {
suggestions,
activeSuggestion,
isOpen,
pendingCount: suggestions.length,
confirmSkill,
rejectSkill,
openSuggestion,
closeSuggestion,
dismissWidget,
};
}

View File

@ -0,0 +1,267 @@
/**
* AutomationCenter Phase 5: Automation Fabric
*
* Visão unificada de todas as automações: Central + XOS.
* Rota: /automations-center
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Zap, Search, Play, Loader2, CheckCircle, XCircle,
Clock, Webhook, Bot, Calendar, Activity, Filter
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
interface UnifiedAutomation {
id: string;
sourceId: number;
source: "central" | "xos";
name: string;
description: string | null;
triggerType: string;
isActive: boolean;
executionCount: number;
lastExecutedAt: string | null;
createdAt: string;
}
const TRIGGER_ICONS: Record<string, React.ReactNode> = {
schedule: <Clock className="w-3.5 h-3.5" />,
webhook: <Webhook className="w-3.5 h-3.5" />,
agent: <Bot className="w-3.5 h-3.5" />,
event: <Activity className="w-3.5 h-3.5" />,
contact_created: <Zap className="w-3.5 h-3.5" />,
deal_stage_changed: <Zap className="w-3.5 h-3.5" />,
form_submitted: <Zap className="w-3.5 h-3.5" />,
};
const TRIGGER_LABEL: Record<string, string> = {
schedule: "Agendada",
webhook: "Webhook",
manual: "Manual",
event: "Evento",
agent: "Agente",
contact_created: "Novo Contato",
deal_stage_changed: "Mudança de Etapa",
form_submitted: "Formulário",
};
export default function AutomationCenter() {
const [search, setSearch] = useState("");
const [filterSource, setFilterSource] = useState<"all" | "central" | "xos">("all");
const [filterStatus, setFilterStatus] = useState<"all" | "active" | "inactive">("all");
const { toast } = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ automations: UnifiedAutomation[]; total: number }>({
queryKey: ["/api/automation-fabric/list"],
refetchInterval: 30_000,
});
const runMutation = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/automation-fabric/${encodeURIComponent(id)}/run`, { method: "POST" });
if (!res.ok) throw new Error();
return res.json();
},
onSuccess: (_, id) => {
toast({ title: "Automação executada" });
qc.invalidateQueries({ queryKey: ["/api/automation-fabric/list"] });
},
onError: () => toast({ title: "Erro ao executar", variant: "destructive" }),
});
const toggleMutation = useMutation({
mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => {
const res = await fetch(`/api/automation-fabric/${encodeURIComponent(id)}/toggle`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive }),
});
if (!res.ok) throw new Error();
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["/api/automation-fabric/list"] }),
onError: () => toast({ title: "Erro ao atualizar status", variant: "destructive" }),
});
const automations = data?.automations ?? [];
const filtered = automations.filter((a) => {
if (filterSource !== "all" && a.source !== filterSource) return false;
if (filterStatus === "active" && !a.isActive) return false;
if (filterStatus === "inactive" && a.isActive) return false;
if (search) {
const q = search.toLowerCase();
return (
a.name.toLowerCase().includes(q) ||
(a.description ?? "").toLowerCase().includes(q) ||
a.triggerType.toLowerCase().includes(q)
);
}
return true;
});
const activeCount = automations.filter((a) => a.isActive).length;
const centralCount = automations.filter((a) => a.source === "central").length;
const xosCount = automations.filter((a) => a.source === "xos").length;
return (
<BrowserFrame>
<div className="flex flex-col h-full bg-[#0a0f1a] text-white">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<h1 className="text-lg font-semibold flex items-center gap-2">
<Zap className="w-5 h-5 text-[#c89b3c]" />
Automation Center
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{automations.length} automações · {activeCount} ativas · Central ({centralCount}) + XOS ({xosCount})
</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-2 p-3 border-b border-white/10 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-2.5 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Buscar automações..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8 text-sm bg-white/5 border-white/10"
/>
</div>
{/* Source filter */}
<div className="flex rounded-md border border-white/10 overflow-hidden">
{(["all", "central", "xos"] as const).map((s) => (
<button
key={s}
onClick={() => setFilterSource(s)}
className={`px-2.5 py-1.5 text-xs transition-colors ${
filterSource === s ? "bg-[#c89b3c] text-black font-medium" : "text-white/50 hover:text-white hover:bg-white/5"
}`}
>
{s === "all" ? "Todas" : s === "central" ? "Central" : "XOS"}
</button>
))}
</div>
{/* Status filter */}
<div className="flex rounded-md border border-white/10 overflow-hidden">
{(["all", "active", "inactive"] as const).map((s) => (
<button
key={s}
onClick={() => setFilterStatus(s)}
className={`px-2.5 py-1.5 text-xs transition-colors ${
filterStatus === s ? "bg-[#c89b3c] text-black font-medium" : "text-white/50 hover:text-white hover:bg-white/5"
}`}
>
{s === "all" ? "Todas" : s === "active" ? "Ativas" : "Inativas"}
</button>
))}
</div>
</div>
{/* List */}
<ScrollArea className="flex-1">
{isLoading && (
<div className="flex justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-white/30" />
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-white/30 gap-3">
<Filter className="w-10 h-10 opacity-30" />
<p className="text-sm">Nenhuma automação encontrada</p>
{search && <p className="text-xs">Tente outro filtro ou busca</p>}
</div>
)}
<div className="divide-y divide-white/5">
{filtered.map((a) => {
const isRunning = runMutation.isPending && runMutation.variables === a.id;
const isToggling = toggleMutation.isPending && toggleMutation.variables?.id === a.id;
const triggerIcon = TRIGGER_ICONS[a.triggerType] ?? <Zap className="w-3.5 h-3.5" />;
const triggerLabel = TRIGGER_LABEL[a.triggerType] ?? a.triggerType;
const lastRun = a.lastExecutedAt
? new Date(a.lastExecutedAt).toLocaleDateString("pt-BR")
: null;
return (
<div key={a.id} className="px-4 py-3 flex items-center gap-3 hover:bg-white/3 transition-colors">
{/* Icon */}
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${a.isActive ? "bg-[#c89b3c]/15 text-[#c89b3c]" : "bg-white/5 text-white/30"}`}>
{triggerIcon}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{a.name}</span>
<Badge
className={`text-[9px] px-1.5 py-0 shrink-0 ${
a.source === "central"
? "bg-blue-500/15 text-blue-400 border-blue-500/20"
: "bg-purple-500/15 text-purple-400 border-purple-500/20"
}`}
>
{a.source === "central" ? "Central" : "XOS"}
</Badge>
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-[11px] text-white/40 flex items-center gap-1">
{triggerIcon} {triggerLabel}
</span>
{a.executionCount > 0 && (
<span className="text-[11px] text-white/30">{a.executionCount}x</span>
)}
{lastRun && (
<span className="text-[11px] text-white/30 flex items-center gap-1">
<Calendar className="w-3 h-3" /> {lastRun}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-white/40 hover:text-white hover:bg-white/10"
onClick={() => runMutation.mutate(a.id)}
disabled={isRunning || !a.isActive}
title="Executar agora"
>
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
</Button>
<Switch
checked={a.isActive}
disabled={isToggling}
onCheckedChange={(v) => toggleMutation.mutate({ id: a.id, isActive: v })}
className="scale-75"
/>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</BrowserFrame>
);
}

View File

@ -2985,6 +2985,7 @@ export default function BiWorkspace() {
</TabsContent>
<TabsContent value="advanced" className="mt-0">
<SupersetAdvancedTab />
</TabsContent>
</Tabs>
</div>

View File

@ -0,0 +1,78 @@
import React, { useState } from "react";
import { TradeInFiltersWidget } from "../components/retail-reports/TradeInFiltersWidget";
import { TradeInDetailsModal } from "../components/retail-reports/TradeInDetailsModal";
import { TradeInPrintButton } from "../components/retail-reports/TradeInPrintButton";
import { useQuery } from "@tanstack/react-query";
const fetchTradeIns = async (filters) => {
const query = new URLSearchParams(filters).toString();
const response = await fetch(`/api/modules/retail-reports/trade-ins?${query}`);
if (!response.ok) throw new Error("Failed to fetch trade-ins.");
return response.json();
};
export const RetailReportsPage: React.FC = () => {
const [filters, setFilters] = useState({});
const [selectedTradeIn, setSelectedTradeIn] = useState(null);
const { data: tradeIns, isLoading, error } = useQuery(["tradeIns", filters], () => fetchTradeIns(filters));
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Retail Trade-In Reports</h1>
<TradeInFiltersWidget onApplyFilters={setFilters} data-testid="filters-widget" />
{isLoading && <p>Loading trade-ins...</p>}
{error && <p className="text-red-500">Failed to load trade-ins.</p>}
<table className="table-auto w-full border-collapse border border-gray-300 mt-4">
<thead>
<tr>
<th className="border border-gray-300 px-4 py-2">ID</th>
<th className="border border-gray-300 px-4 py-2">Seller</th>
<th className="border border-gray-300 px-4 py-2">Company</th>
<th className="border border-gray-300 px-4 py-2">Client</th>
<th className="border border-gray-300 px-4 py-2">Date</th>
<th className="border border-gray-300 px-4 py-2">Value</th>
<th className="border border-gray-300 px-4 py-2">Status</th>
<th className="border border-gray-300 px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
{tradeIns?.map((tradeIn) => (
<tr key={tradeIn.id}>
<td className="border border-gray-300 px-4 py-2">{tradeIn.id}</td>
<td className="border border-gray-300 px-4 py-2">{tradeIn.sellerId}</td>
<td className="border border-gray-300 px-4 py-2">{tradeIn.companyId}</td>
<td className="border border-gray-300 px-4 py-2">{tradeIn.clientId}</td>
<td className="border border-gray-300 px-4 py-2">{new Date(tradeIn.tradeInDate).toLocaleDateString()}</td>
<td className="border border-gray-300 px-4 py-2">{tradeIn.tradeInValue}</td>
<td className="border border-gray-300 px-4 py-2">{tradeIn.status}</td>
<td className="border border-gray-300 px-4 py-2">
<button
className="text-blue-500 hover:underline"
onClick={() => setSelectedTradeIn(tradeIn)}
data-testid="view-details-button"
>
View Details
</button>
</td>
</tr>
))}
</tbody>
</table>
<TradeInPrintButton tradeIns={tradeIns} data-testid="print-button" />
{selectedTradeIn && (
<TradeInDetailsModal
tradeIn={selectedTradeIn}
onClose={() => setSelectedTradeIn(null)}
data-testid="details-modal"
/>
)}
</div>
);
};
export default RetailReportsPage;

View File

@ -1,2 +1,2 @@
// SOE — Sistema de Operações Empresariais (alias para o módulo Plus/ERP)
export { default } from "./Plus";
// SOE — Sistema de Operações Empresariais
export { default } from "./ERP";

1002
client/src/pages/Skills.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "wouter";
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import {
@ -14,6 +14,9 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface XosStats {
total_contacts: number;
@ -67,6 +70,48 @@ interface Activity {
export default function XosCentral() {
const [activeTab, setActiveTab] = useState("dashboard");
const [isNewContactOpen, setIsNewContactOpen] = useState(false);
const [isNewActivityOpen, setIsNewActivityOpen] = useState(false);
const [newContact, setNewContact] = useState({ name: "", email: "", phone: "", company: "", position: "" });
const [newActivity, setNewActivity] = useState({ type: "task", title: "", due_at: "", priority: "medium" });
const queryClient = useQueryClient();
const createContactMutation = useMutation({
mutationFn: async (data: typeof newContact) => {
const res = await fetch("/api/xos/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar contato");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/contacts"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewContactOpen(false);
setNewContact({ name: "", email: "", phone: "", company: "", position: "" });
},
});
const createActivityMutation = useMutation({
mutationFn: async (data: typeof newActivity) => {
const res = await fetch("/api/xos/activities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar atividade");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/activities"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewActivityOpen(false);
setNewActivity({ type: "task", title: "", due_at: "", priority: "medium" });
},
});
const { data: stats } = useQuery<XosStats>({
queryKey: ["/api/xos/stats"],
@ -181,9 +226,9 @@ export default function XosCentral() {
</span>
)}
</Button>
<Button data-testid="button-new-contact">
<Button data-testid="button-new-contact" onClick={() => setIsNewContactOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Novo
Novo Contato
</Button>
</div>
</div>
@ -410,7 +455,7 @@ export default function XosCentral() {
<Filter className="h-4 w-4 mr-2" />
Filtros
</Button>
<Button size="sm">
<Button size="sm" onClick={() => setIsNewContactOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Novo Contato
</Button>
@ -482,7 +527,7 @@ export default function XosCentral() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Atividades</CardTitle>
<Button size="sm">
<Button size="sm" onClick={() => setIsNewActivityOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Nova Atividade
</Button>
@ -524,6 +569,129 @@ export default function XosCentral() {
</Tabs>
</main>
</div>
{/* Modal: Novo Contato */}
<Dialog open={isNewContactOpen} onOpenChange={setIsNewContactOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo Contato</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input
value={newContact.name}
onChange={(e) => setNewContact({ ...newContact, name: e.target.value })}
placeholder="Nome do contato"
/>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={newContact.email}
onChange={(e) => setNewContact({ ...newContact, email: e.target.value })}
placeholder="email@exemplo.com"
/>
</div>
<div className="space-y-2">
<Label>Telefone</Label>
<Input
value={newContact.phone}
onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })}
placeholder="(00) 00000-0000"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Empresa</Label>
<Input
value={newContact.company}
onChange={(e) => setNewContact({ ...newContact, company: e.target.value })}
placeholder="Empresa"
/>
</div>
<div className="space-y-2">
<Label>Cargo</Label>
<Input
value={newContact.position}
onChange={(e) => setNewContact({ ...newContact, position: e.target.value })}
placeholder="Cargo"
/>
</div>
</div>
<Button
className="w-full"
onClick={() => createContactMutation.mutate(newContact)}
disabled={!newContact.name || createContactMutation.isPending}
>
{createContactMutation.isPending ? "Salvando..." : "Criar Contato"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Modal: Nova Atividade */}
<Dialog open={isNewActivityOpen} onOpenChange={setIsNewActivityOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nova Atividade</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Título *</Label>
<Input
value={newActivity.title}
onChange={(e) => setNewActivity({ ...newActivity, title: e.target.value })}
placeholder="Descrição da atividade"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Tipo</Label>
<Select value={newActivity.type} onValueChange={(v) => setNewActivity({ ...newActivity, type: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Tarefa</SelectItem>
<SelectItem value="call">Ligação</SelectItem>
<SelectItem value="email">E-mail</SelectItem>
<SelectItem value="meeting">Reunião</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Prioridade</Label>
<Select value={newActivity.priority} onValueChange={(v) => setNewActivity({ ...newActivity, priority: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Baixa</SelectItem>
<SelectItem value="medium">Média</SelectItem>
<SelectItem value="high">Alta</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Data prevista</Label>
<Input
type="datetime-local"
value={newActivity.due_at}
onChange={(e) => setNewActivity({ ...newActivity, due_at: e.target.value })}
/>
</div>
<Button
className="w-full"
onClick={() => createActivityMutation.mutate(newActivity)}
disabled={!newActivity.title || createActivityMutation.isPending}
>
{createActivityMutation.isPending ? "Salvando..." : "Criar Atividade"}
</Button>
</div>
</DialogContent>
</Dialog>
</BrowserFrame>
);
}

View File

@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import browserIcon from "@assets/arcadia_branding/arcadia_suite_icon.png";
import browserIcon from "/favicon.png";
import { Loader2 } from "lucide-react";
export default function AuthPage() {

72
deploy.sh Executable file
View File

@ -0,0 +1,72 @@
#!/bin/bash
# ==============================================
# Arcádia Suite - Script de Deploy para VPS
# ==============================================
set -e
echo "=========================================="
echo " Arcádia Suite - Deploy VPS"
echo "=========================================="
if ! command -v docker &> /dev/null; then
echo "Docker não encontrado. Instalando..."
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
echo "Docker instalado! Faça logout e login novamente para aplicar as permissões."
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo "Docker Compose não encontrado. Instalando..."
sudo apt-get update && sudo apt-get install -y docker-compose-plugin
fi
if [ ! -f .env ]; then
echo "Arquivo .env não encontrado. Criando a partir do .env.example..."
cp .env.example .env
echo ""
echo "IMPORTANTE: Edite o arquivo .env com suas configurações antes de continuar!"
echo " nano .env"
echo ""
echo "Depois execute este script novamente."
exit 1
fi
echo ""
echo "1/4 - Parando containers existentes..."
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
echo ""
echo "2/5 - Construindo a aplicação..."
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
echo ""
echo "3/5 - Iniciando o banco de dados..."
docker compose up -d db 2>/dev/null || docker-compose up -d db
echo "Aguardando banco ficar pronto..."
sleep 5
echo ""
echo "4/5 - Aplicando migrations do banco de dados..."
docker compose run --rm app npx drizzle-kit push 2>/dev/null || docker-compose run --rm app npx drizzle-kit push
echo ""
echo "5/5 - Iniciando a aplicação..."
docker compose up -d 2>/dev/null || docker-compose up -d
sleep 10
echo ""
echo "=========================================="
echo " Deploy concluído!"
echo "=========================================="
echo ""
echo " App: http://$(hostname -I | awk '{print $1}'):5000"
echo " DB: PostgreSQL na porta 5432"
echo ""
echo " Comandos úteis:"
echo " Ver logs: docker compose logs -f app"
echo " Parar tudo: docker compose down"
echo " Reiniciar: docker compose restart app"
echo " Status: docker compose ps"
echo ""

View File

@ -86,12 +86,145 @@ services:
condition: service_started
networks:
- arcadia-internal
- arcadia-public
- coolify
labels:
- "traefik.enable=true"
- "traefik.docker.network=coolify"
- "traefik.http.routers.arcadia.entrypoints=https"
- "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.arcadia.tls=true"
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
- "traefik.http.services.arcadia.loadbalancer.server.port=5000"
# ── Arcádia Plus (Laravel + MySQL) ──────────────────────────────────────────
# Perfil `plus` — suba com: docker compose --profile plus up
plus-db:
image: mysql:8.0
restart: always
profiles: [plus]
environment:
MYSQL_ROOT_PASSWORD: ${PLUS_DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${PLUS_DB_DATABASE:-arcadia_plus}
MYSQL_USER: ${PLUS_DB_USER:-plus}
MYSQL_PASSWORD: ${PLUS_DB_PASSWORD}
volumes:
- plus_db:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${PLUS_DB_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- arcadia-internal
plus:
image: ${PLUS_IMAGE:-php:8.3-apache}
restart: always
profiles: [plus]
environment:
APP_ENV: production
APP_KEY: ${PLUS_APP_KEY}
APP_URL: https://${DOMAIN}/plus
DB_HOST: plus-db
DB_DATABASE: ${PLUS_DB_DATABASE:-arcadia_plus}
DB_USERNAME: ${PLUS_DB_USER:-plus}
DB_PASSWORD: ${PLUS_DB_PASSWORD}
SESSION_DRIVER: redis
REDIS_HOST: redis
ARCADIA_URL: https://${DOMAIN}
ARCADIA_SSO_SECRET: ${SSO_SECRET}
volumes:
- plus_storage:/var/www/html/storage
depends_on:
plus-db:
condition: service_healthy
networks:
- arcadia-internal
# ── Apache Superset (BI) ─────────────────────────────────────────────────────
# Perfil `bi` — suba com: docker compose --profile bi up
superset:
image: apache/superset:4.1.0
restart: always
profiles: [bi]
environment:
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/arcadia_superset
ARCADIA_DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@arcadia.app}
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
SUPERSET_WEBSERVER_PORT: "8088"
PYTHONPATH: /app/pythonpath
volumes:
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./docker/superset/init.sh:/app/docker/init.sh:ro
- superset_home:/app/superset_home
depends_on:
db:
condition: service_healthy
command: ["/bin/bash", "/app/docker/init.sh"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks:
- arcadia-internal
# ── ERPNext (Frappe Framework) ───────────────────────────────────────────────
# Perfil `erpnext` — suba com: docker compose --profile erpnext up
erpnext-db:
image: mariadb:10.6
restart: always
profiles: [erpnext]
environment:
MYSQL_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD}
MYSQL_DATABASE: _frappe
MYSQL_USER: frappe
MYSQL_PASSWORD: ${ERPNEXT_DB_PASSWORD}
volumes:
- erpnext_db:/var/lib/mysql
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--skip-character-set-client-handshake
--skip-innodb-read-only-compressed
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=${ERPNEXT_DB_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- arcadia-internal
erpnext:
image: frappe/erpnext:version-15
restart: always
profiles: [erpnext]
environment:
FRAPPE_SITE_NAME_HEADER: erpnext.local
ERPNEXT_DB_ROOT_PASSWORD: ${ERPNEXT_DB_ROOT_PASSWORD}
ERPNEXT_ADMIN_PASSWORD: ${ERPNEXT_ADMIN_PASSWORD}
volumes:
- erpnext_sites:/home/frappe/frappe-bench/sites
- erpnext_logs:/home/frappe/frappe-bench/logs
- ./docker/erpnext/init.sh:/usr/local/bin/init-erpnext.sh:ro
depends_on:
erpnext-db:
condition: service_healthy
command: ["/bin/bash", "/usr/local/bin/init-erpnext.sh"]
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/method/frappe.ping || exit 1"]
interval: 30s
timeout: 10s
retries: 10
start_period: 120s
networks:
- arcadia-internal
# ── Arcádia Plus (Laravel + MySQL) ──────────────────────────────────────────
# Perfil `plus` — suba com: docker compose --profile plus up
@ -297,6 +430,9 @@ services:
SERVICE_NAME: embeddings
SERVICE_PORT: 8001
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
LITELLM_BASE_URL: http://litellm:4000
LITELLM_API_KEY: ${LITELLM_API_KEY}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
depends_on:
db:
condition: service_healthy
@ -314,12 +450,13 @@ services:
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LITELLM_MASTER_KEY: ${LITELLM_API_KEY}
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/litellm
# Ollama: se instalado no host use http://host-gateway:11434
# Se usar container Docker, mantém http://ollama:11434
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
# LLMFit: URL do serviço de modelos fine-tuned
LLMFIT_BASE_URL: ${LLMFIT_BASE_URL:-}
LLMFIT_API_KEY: ${LLMFIT_API_KEY:-}
# Providers externos opcionais (soberania: só habilitados se configurados)
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GROQ_API_KEY: ${GROQ_API_KEY:-}
@ -328,6 +465,7 @@ services:
condition: service_healthy
networks:
- arcadia-internal
- coolify
# ── Ollama (LLMs locais — soberania total) ────────────────────────────────────
# OPÇÃO A (padrão): Ollama como container Docker
@ -359,7 +497,7 @@ services:
- litellm
networks:
- arcadia-internal
- arcadia-public
- coolify
labels:
- "traefik.enable=true"
- "traefik.http.routers.webui.rule=Host(`ai.${DOMAIN}`)"
@ -368,17 +506,55 @@ services:
- "traefik.http.services.webui.loadbalancer.server.port=8080"
profiles: [ai]
# ── Superset BI ───────────────────────────────────────────────────────────────
superset:
build:
context: ./docker/superset
dockerfile: Dockerfile
restart: always
environment:
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY}
SQLALCHEMY_DATABASE_URI: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/superset
PYTHONPATH: /app/pythonpath
SUPERSET_ADMIN_USERNAME: ${SUPERSET_ADMIN_USERNAME:-admin}
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD}
SUPERSET_ADMIN_EMAIL: ${SUPERSET_ADMIN_EMAIL:-admin@onboardbi.com.br}
ARCADIA_DATABASE_URL: postgresql+psycopg2://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
command: ["/bin/bash", "/app/docker/init.sh"]
volumes:
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./docker/superset/init.sh:/app/docker/init.sh:ro
- superset_home:/app/superset_home
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
- coolify
labels:
- "traefik.enable=true"
- "traefik.docker.network=coolify"
- "traefik.http.routers.superset.entrypoints=https"
- "traefik.http.routers.superset.rule=Host(`${SUPERSET_DOMAIN:-bi.onboardbi.com.br}`)"
- "traefik.http.routers.superset.tls=true"
- "traefik.http.routers.superset.tls.certresolver=letsencrypt"
- "traefik.http.services.superset.loadbalancer.server.port=8088"
profiles: [bi]
networks:
arcadia-internal:
driver: bridge
arcadia-public:
driver: bridge
coolify:
external: true
volumes:
pgdata:
redis_data:
ollama_models:
open_webui_data:
plus_db:
plus_storage:
superset_home:

237
docker-compose.prod.yml.bak Normal file
View File

@ -0,0 +1,237 @@
# ─── Arcádia Suite — Produção (Coolify) ───────────────────────────────────────
# Este arquivo é usado pelo Coolify para deploy automático.
# NÃO inclui volumes de código-fonte — só artefatos de build.
# Configurar no Coolify: Environment Variables para todas as vars ${...}
name: arcadia-prod
services:
# ── Banco de dados com pgvector ─────────────────────────────────────────────
db:
image: pgvector/pgvector:pg16
restart: always
environment:
POSTGRES_DB: ${PGDATABASE:-arcadia}
POSTGRES_USER: ${PGUSER:-arcadia}
POSTGRES_PASSWORD: ${PGPASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init-pgvector.sql:/docker-entrypoint-initdb.d/01-pgvector.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PGUSER:-arcadia}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- arcadia-internal
# ── Redis ────────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
restart: always
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- arcadia-internal
# ── App principal ─────────────────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
restart: always
environment:
NODE_ENV: production
PORT: 5000
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
REDIS_URL: redis://redis:6379
DOCKER_MODE: "true"
CONTABIL_PYTHON_URL: http://contabil:8003
BI_PYTHON_URL: http://bi:8004
AUTOMATION_PYTHON_URL: http://automation:8005
FISCO_PYTHON_URL: http://fisco:8002
PYTHON_SERVICE_URL: http://embeddings:8001
SESSION_SECRET: ${SESSION_SECRET}
SSO_SECRET: ${SSO_SECRET}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LITELLM_BASE_URL: http://litellm:4000
LITELLM_API_KEY: ${LITELLM_API_KEY}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
# ── Manus Agent — aponta para LiteLLM como gateway unificado ──────────
# LiteLLM roteia para Ollama (local), LLMFit (fine-tuned) ou externo
AI_INTEGRATIONS_OPENAI_BASE_URL: http://litellm:4000/v1
AI_INTEGRATIONS_OPENAI_API_KEY: ${LITELLM_API_KEY}
ports:
- "5000:5000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- arcadia-internal
- arcadia-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.arcadia.tls=true"
- "traefik.http.routers.arcadia.tls.certresolver=letsencrypt"
# ── Microserviços Python ─────────────────────────────────────────────────────
contabil:
build:
context: .
dockerfile: Dockerfile.python
restart: always
environment:
SERVICE_NAME: contabil
SERVICE_PORT: 8003
CONTABIL_PORT: 8003
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
bi:
build:
context: .
dockerfile: Dockerfile.python
restart: always
environment:
SERVICE_NAME: bi
SERVICE_PORT: 8004
BI_PORT: 8004
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
automation:
build:
context: .
dockerfile: Dockerfile.python
restart: always
environment:
SERVICE_NAME: automation
SERVICE_PORT: 8005
AUTOMATION_PORT: 8005
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
fisco:
build:
context: .
dockerfile: Dockerfile.python
restart: always
environment:
SERVICE_NAME: fisco
SERVICE_PORT: 8002
FISCO_PORT: 8002
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
embeddings:
build:
context: .
dockerfile: Dockerfile.python
restart: always
environment:
SERVICE_NAME: embeddings
SERVICE_PORT: 8001
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
# ── LiteLLM (gateway unificado de LLM — soberania dos dados) ─────────────────
# Roteia: LLMFit (fine-tuned) → Ollama (local) → externo (opt-in)
litellm:
image: ghcr.io/berriai/litellm:main-latest
restart: always
volumes:
- ./docker/litellm-config.yaml:/app/config.yaml
command: ["--config", "/app/config.yaml", "--port", "4000"]
environment:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
LITELLM_MASTER_KEY: ${LITELLM_API_KEY}
DATABASE_URL: postgresql://${PGUSER:-arcadia}:${PGPASSWORD}@db:5432/${PGDATABASE:-arcadia}
# Ollama: se instalado no host use http://host-gateway:11434
# Se usar container Docker, mantém http://ollama:11434
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
# LLMFit: URL do serviço de modelos fine-tuned
LLMFIT_BASE_URL: ${LLMFIT_BASE_URL:-}
# Providers externos opcionais (soberania: só habilitados se configurados)
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GROQ_API_KEY: ${GROQ_API_KEY:-}
depends_on:
db:
condition: service_healthy
networks:
- arcadia-internal
# ── Ollama (LLMs locais — soberania total) ────────────────────────────────────
# OPÇÃO A (padrão): Ollama como container Docker
# OPÇÃO B: Ollama no host → comente este serviço e defina
# OLLAMA_BASE_URL=http://host-gateway:11434 nas env vars
ollama:
image: ollama/ollama:latest
restart: always
volumes:
- ollama_models:/root/.ollama
networks:
- arcadia-internal
# Remova 'profiles: [ai]' para ativar por padrão no deploy
profiles: [ai]
# ── Open WebUI (interface para Ollama + LLMFit) ───────────────────────────────
open-webui:
image: ghcr.io/open-webui/open-webui:main
restart: always
environment:
# Pode apontar para LiteLLM para ter acesso a todos os modelos via WebUI
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
OPENAI_API_BASE_URL: http://litellm:4000/v1
OPENAI_API_KEY: ${LITELLM_API_KEY}
WEBUI_SECRET_KEY: ${WEBUI_SECRET_KEY}
volumes:
- open_webui_data:/app/backend/data
depends_on:
- litellm
networks:
- arcadia-internal
- arcadia-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.webui.rule=Host(`ai.${DOMAIN}`)"
- "traefik.http.routers.webui.tls=true"
- "traefik.http.routers.webui.tls.certresolver=letsencrypt"
- "traefik.http.services.webui.loadbalancer.server.port=8080"
profiles: [ai]
networks:
arcadia-internal:
driver: bridge
arcadia-public:
driver: bridge
volumes:
pgdata:
redis_data:
ollama_models:
open_webui_data:

View File

@ -273,6 +273,30 @@ services:
retries: 10
start_period: 30s
# ── Neo4j (Knowledge Graph) ──────────────────────────────────────────────────
neo4j:
image: neo4j:5.20-community
restart: unless-stopped
profiles: [agentic]
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
NEO4J_PLUGINS: '["apoc"]'
NEO4J_dbms_security_procedures_unrestricted: apoc.*
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
ports:
- "7474:7474"
- "7687:7687"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
interval: 20s
timeout: 10s
retries: 5
start_period: 30s
networks:
- arcadia
# ── ERPNext (Frappe Framework + ERPNext app) ─────────────────────────────────
erpnext:
image: frappe/erpnext:version-15
@ -308,6 +332,8 @@ volumes:
erpnext_db:
erpnext_sites:
erpnext_logs:
neo4j_data:
neo4j_logs:
networks:
arcadia:

View File

@ -13,35 +13,22 @@
model_list:
# ── TIER 1: LLMFit (modelos fine-tuned locais — máxima soberania) ────────────
# Descomente quando o LLMFit estiver rodando no servidor
# O LLMFit expõe API compatível com OpenAI — basta apontar a URL
#
# - model_name: arcadia-finetuned
# litellm_params:
# model: openai/arcadia-v1 # nome do modelo no LLMFit
# api_base: os.environ/LLMFIT_BASE_URL
# api_key: llmfit-internal
#
# - model_name: arcadia-embed
# litellm_params:
# model: openai/arcadia-embed-v1 # modelo de embeddings fine-tuned
# api_base: os.environ/LLMFIT_BASE_URL
# api_key: llmfit-internal
- model_name: arcadia-finetuned
litellm_params:
model: openai/llama3.2:3b
api_base: os.environ/LLMFIT_BASE_URL
api_key: os.environ/LLMFIT_API_KEY
- model_name: arcadia-embed
litellm_params:
model: openai/nomic-embed-text:latest
api_base: os.environ/LLMFIT_BASE_URL
api_key: os.environ/LLMFIT_API_KEY
# ── TIER 2: Ollama (LLMs locais — soberania total) ───────────────────────────
- model_name: llama3.3
- model_name: llama3.2
litellm_params:
model: ollama/llama3.3
api_base: os.environ/OLLAMA_BASE_URL
- model_name: qwen2.5-coder
litellm_params:
model: ollama/qwen2.5-coder:7b
api_base: os.environ/OLLAMA_BASE_URL
- model_name: deepseek-r1
litellm_params:
model: ollama/deepseek-r1:7b
model: ollama/llama3.2:3b
api_base: os.environ/OLLAMA_BASE_URL
- model_name: nomic-embed-text
@ -49,6 +36,12 @@ model_list:
model: ollama/nomic-embed-text
api_base: os.environ/OLLAMA_BASE_URL
# Alias OpenAI-compatible para embedding (Manus usa este nome por padrão)
- model_name: text-embedding-3-small
litellm_params:
model: ollama/nomic-embed-text
api_base: os.environ/OLLAMA_BASE_URL
# ── TIER 3: OpenAI (opt-in — só ativo se OPENAI_API_KEY configurado) ─────────
- model_name: gpt-4o
litellm_params:
@ -73,29 +66,26 @@ model_list:
# api_key: os.environ/GROQ_API_KEY
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
# Prioridade: LLMFit → OpenAI (se configurado) → Ollama (sempre disponível)
# Para soberania total: remova o fallback para gpt-4o-mini
# Prioridade: LLMFit (TIER 1) → Ollama (TIER 2 — fallback)
- model_name: arcadia-default
litellm_params:
model: ollama/llama3.3
api_base: os.environ/OLLAMA_BASE_URL
model_info:
# fallbacks: ["gpt-4o-mini"] # descomente para habilitar fallback externo
model: openai/llama3.2:3b
api_base: os.environ/LLMFIT_BASE_URL
api_key: os.environ/LLMFIT_API_KEY
router_settings:
routing_strategy: least-busy
fallbacks:
- {"gpt-4o": ["llama3.3"]}
- {"gpt-4o-mini": ["llama3.3"]}
- {"arcadia-default": ["llama3.3"]}
- {"gpt-4o": ["llama3.2"]}
- {"gpt-4o-mini": ["llama3.2"]}
- {"arcadia-default": ["llama3.2"]}
- {"arcadia-finetuned": ["llama3.2"]}
- {"arcadia-embed": ["nomic-embed-text"]}
litellm_settings:
drop_params: true
request_timeout: 120
set_verbose: false
# Loga todas as chamadas no banco — essencial para auditoria e soberania
success_callback: ["langfuse"]
failure_callback: ["langfuse"]
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY

View File

@ -0,0 +1,5 @@
FROM apache/superset:latest
USER root
RUN pip install psycopg2-binary && \
sed -i 's/include-system-site-packages = false/include-system-site-packages = true/' /app/.venv/pyvenv.cfg
USER superset

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

58
docs/.env.example Normal file
View File

@ -0,0 +1,58 @@
# ===========================================
# Arcádia Suite - Variáveis de Ambiente
# ===========================================
# Copie este arquivo para .env na raiz do projeto
# e preencha com seus valores
# -----------------------------
# Banco de Dados PostgreSQL
# -----------------------------
DATABASE_URL=postgresql://arcadia:SUA_SENHA_AQUI@localhost:5432/arcadia_suite
PGHOST=localhost
PGPORT=5432
PGUSER=arcadia
PGPASSWORD=SUA_SENHA_AQUI
PGDATABASE=arcadia_suite
# -----------------------------
# Ambiente
# -----------------------------
NODE_ENV=production
PORT=5000
# -----------------------------
# Sessão e Segurança
# -----------------------------
# Gere uma chave aleatória: openssl rand -base64 32
SESSION_SECRET=GERE_UMA_CHAVE_ALEATORIA_LONGA_AQUI
# -----------------------------
# ERPNext (opcional)
# -----------------------------
ERPNEXT_URL=https://seu-erpnext.com
ERPNEXT_API_KEY=sua_api_key
ERPNEXT_API_SECRET=seu_api_secret
# -----------------------------
# OpenAI (opcional - para IA)
# -----------------------------
OPENAI_API_KEY=sk-sua-chave-openai
# -----------------------------
# WhatsApp Baileys (opcional)
# -----------------------------
# As sessões são armazenadas automaticamente em ./baileys_sessions
# -----------------------------
# Fiscal - SEFAZ (opcional)
# -----------------------------
# Caminho para o certificado digital A1 (.pfx)
CERT_PATH=/path/to/certificado.pfx
CERT_PASSWORD=senha_do_certificado
SEFAZ_AMBIENTE=2 # 1=Produção, 2=Homologação
# -----------------------------
# ChromaDB (opcional)
# -----------------------------
CHROMA_HOST=localhost
CHROMA_PORT=8000

View File

@ -0,0 +1,394 @@
# Análise Atendeon → XOS
> **Projeto:** https://github.com/JonasRodriguesPachceo/atendeon
> **Stack:** Node.js (TypeScript) + React + PostgreSQL + Baileys
> **Data:** 29/01/2026
---
## Resumo do Projeto Atendeon
O Atendeon é uma **central de atendimento WhatsApp multi-empresa** (SaaS) completa com:
- **477 arquivos TypeScript** no backend
- **41 Models** (Sequelize/PostgreSQL)
- **35+ Services** organizados por domínio
- Integração completa com **Baileys** (WhatsApp não-oficial)
- Suporte a **Typebot**, **N8N** e **OpenAI**
---
## Funcionalidades do Atendeon vs XOS Atual
### ✅ O que o Atendeon tem que NOS FALTA
| Funcionalidade | Atendeon | XOS Atual | Prioridade |
|----------------|----------|-----------|------------|
| **Filas de Atendimento (Queues)** | ✅ Completo | ⚪ Não existe | 🔴 Alta |
| **Distribuição Round-Robin** | ✅ Por fila/equipe | ⚪ Não existe | 🔴 Alta |
| **Notas Internas (TicketNote)** | ✅ Model dedicado | ⚪ Não existe | 🔴 Alta |
| **Mensagens Rápidas (QuickMessage)** | ✅ Com atalhos | ⚪ Não existe | 🟠 Média |
| **Agendamento de Mensagens** | ✅ Schedule model | ⚪ Não existe | 🟠 Média |
| **Tracking de Ticket** | ✅ TicketTraking | ⚪ Não existe | 🟠 Média |
| **Horário de Funcionamento** | ✅ outOfHoursMessage | ⚪ Não existe | 🟡 Baixa |
| **Rating/Avaliação** | ✅ UserRating | ⚪ Não existe | 🟡 Baixa |
| **Campos Customizados** | ✅ ContactCustomField | ✅ customFields JSONB | ✅ OK |
| **Multi-WhatsApp** | ✅ Whatsapp model | ⚪ Não integrado | 🔴 Alta |
| **Chatbot/Typebot** | ✅ Integração nativa | ⚪ Não existe | 🟠 Média |
| **Integração N8N** | ✅ QueueIntegrations | ⚪ Não existe | 🟠 Média |
| **Prompts OpenAI** | ✅ Prompt model | ✅ Manus Agent | ✅ OK |
| **Campanhas com 5 msgs** | ✅ message1-5 | 🟡 content único | 🟠 Média |
| **Disparo em massa** | ✅ CampaignShipping | 🟡 Estrutura só | 🔴 Alta |
| **Listas de Contatos** | ✅ ContactList/Item | ⚪ Não existe | 🟠 Média |
| **Tags em Tickets** | ✅ TicketTag M:N | 🟡 Array no ticket | ✅ OK |
| **Importação de Contatos** | ✅ ImportService | ⚪ Não existe | 🟠 Média |
---
## Models do Atendeon que Devemos Criar
### 1. **Filas de Atendimento** (Queue)
```typescript
// Atendeon: Queue.ts
{
name: string,
color: string,
greetingMessage: string, // Mensagem de boas-vindas
outOfHoursMessage: string, // Fora do expediente
schedules: JSONB, // Horários de funcionamento
orderQueue: number, // Ordem na distribuição
}
// Relacionamentos:
// - BelongsToMany → Users (UserQueue)
// - BelongsToMany → Whatsapps (WhatsappQueue)
```
**Sugestão para XOS:**
```sql
CREATE TABLE xos_queues (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
name VARCHAR(100) NOT NULL,
color VARCHAR(20),
greeting_message TEXT,
out_of_hours_message TEXT,
schedules JSONB, -- [{dayOfWeek: 1, startTime: "09:00", endTime: "18:00"}]
order_priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true
);
CREATE TABLE xos_queue_users (
queue_id INTEGER REFERENCES xos_queues(id),
user_id VARCHAR REFERENCES users(id),
is_active BOOLEAN DEFAULT true,
PRIMARY KEY (queue_id, user_id)
);
```
### 2. **Notas Internas** (TicketNote)
```typescript
// Atendeon: TicketNote.ts
{
note: string,
userId: number, // Quem escreveu
contactId: number, // Sobre qual contato
ticketId: number, // Em qual ticket
}
```
**Sugestão para XOS:**
```sql
CREATE TABLE xos_internal_notes (
id SERIAL PRIMARY KEY,
conversation_id INTEGER REFERENCES xos_conversations(id),
contact_id INTEGER REFERENCES xos_contacts(id),
user_id VARCHAR REFERENCES users(id),
content TEXT NOT NULL,
is_pinned BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 3. **Mensagens Rápidas** (QuickMessage)
```typescript
// Atendeon: QuickMessage.ts
{
shortcode: string, // Ex: "/preco", "/horario"
message: string,
mediaPath: string, // Anexo opcional
mediaName: string,
userId: number, // Dono da msg rápida
}
```
**Sugestão para XOS:**
```sql
CREATE TABLE xos_quick_messages (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
shortcode VARCHAR(50) NOT NULL, -- "/preco"
title VARCHAR(200),
content TEXT NOT NULL,
media_url TEXT,
media_type VARCHAR(30), -- image, file, audio
scope VARCHAR(20) DEFAULT 'personal', -- personal, team, company
user_id VARCHAR REFERENCES users(id),
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4. **Agendamento de Mensagens** (Schedule)
```typescript
// Atendeon: Schedule.ts
{
body: string, // Conteúdo da mensagem
sendAt: Date, // Quando enviar
sentAt: Date, // Quando foi enviada
status: string, // pending, sent, failed
contactId: number,
ticketId: number,
mediaPath: string,
}
```
**Sugestão para XOS:**
```sql
CREATE TABLE xos_scheduled_messages (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
contact_id INTEGER REFERENCES xos_contacts(id),
conversation_id INTEGER REFERENCES xos_conversations(id),
content TEXT NOT NULL,
media_url TEXT,
media_type VARCHAR(30),
scheduled_at TIMESTAMP NOT NULL,
sent_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, failed, cancelled
error_message TEXT,
created_by VARCHAR REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
```
### 5. **Tracking de Atendimento** (TicketTraking)
```typescript
// Atendeon: TicketTraking.ts
{
ticketId: number,
startedAt: Date, // Início do atendimento
queuedAt: Date, // Quando entrou na fila
finishedAt: Date, // Quando finalizou
ratingAt: Date, // Quando foi avaliado
chatbotAt: Date, // Quando passou pelo bot
rated: boolean,
}
```
**Sugestão para XOS:**
```sql
CREATE TABLE xos_conversation_tracking (
id SERIAL PRIMARY KEY,
conversation_id INTEGER REFERENCES xos_conversations(id) UNIQUE,
queued_at TIMESTAMP,
first_response_at TIMESTAMP,
assigned_at TIMESTAMP,
chatbot_ended_at TIMESTAMP,
human_started_at TIMESTAMP,
resolved_at TIMESTAMP,
rated_at TIMESTAMP,
rating_score INTEGER, -- 1-5
rating_comment TEXT,
total_duration_seconds INTEGER, -- calculado
response_time_seconds INTEGER -- calculado
);
```
### 6. **Conexões WhatsApp** (Whatsapp)
```typescript
// Atendeon: Whatsapp.ts
{
name: string,
session: string, // Dados da sessão Baileys
qrcode: string, // QR code atual
status: string, // CONNECTED, DISCONNECTED, QR
greetingMessage: string,
farewellMessage: string,
complationMessage: string, // Ao finalizar ticket
ratingMessage: string,
expiresTicket: number, // Minutos para expirar
expiresInactiveMessage: string,
maxUseBotQueues: number,
}
```
**Sugestão para XOS:**
```sql
CREATE TABLE xos_whatsapp_connections (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
name VARCHAR(100) NOT NULL,
phone_number VARCHAR(20),
status VARCHAR(30) DEFAULT 'disconnected', -- connected, disconnected, qr_pending
qr_code TEXT,
session_data JSONB, -- Dados Baileys
greeting_message TEXT,
farewell_message TEXT,
completion_message TEXT,
rating_message TEXT,
out_of_hours_message TEXT,
ticket_expires_minutes INTEGER DEFAULT 1440, -- 24h
inactive_message TEXT,
is_default BOOLEAN DEFAULT false,
last_seen_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE xos_whatsapp_queue_links (
whatsapp_id INTEGER REFERENCES xos_whatsapp_connections(id),
queue_id INTEGER REFERENCES xos_queues(id),
PRIMARY KEY (whatsapp_id, queue_id)
);
```
---
## Services do Atendeon para Referência
### Distribuição de Tickets
O Atendeon usa o conceito de **Filas** para distribuir leads:
```typescript
// FindOrCreateTicketService.ts (simplificado)
const ticket = await Ticket.create({
contactId,
whatsappId,
status: "pending", // Aguardando na fila
queueId: defaultQueue.id,
// NÃO atribui userId ainda - fica na fila
});
// UpdateTicketService.ts - Quando atendente pega o ticket
ticket.userId = attendantId;
ticket.status = "open";
```
### Lógica de Round-Robin
```typescript
// Distribui entre usuários da fila
const usersInQueue = await UserQueue.findAll({
where: { queueId },
include: [{ model: User, where: { online: true }}]
});
// Pega o próximo da lista (round-robin)
const nextUser = usersInQueue[currentIndex % usersInQueue.length];
```
### Chatbot com Opções
```typescript
// QueueOption.ts - Menu do chatbot
{
title: string, // "Vendas"
option: string, // "1"
message: string, // Resposta quando selecionar
parentId: number, // Para sub-menus
queueId: number,
}
```
---
## Plano de Implementação
### Fase 1: Estrutura Base (1-2 semanas)
1. ✅ Criar tabelas: `xos_queues`, `xos_queue_users`
2. ✅ Criar tabelas: `xos_internal_notes`, `xos_quick_messages`
3. ✅ Criar tabelas: `xos_scheduled_messages`
4. ✅ Criar tabelas: `xos_conversation_tracking`
5. ✅ Atualizar APIs XOS com novos endpoints
### Fase 2: Distribuição de Leads (1 semana)
1. Implementar lógica de filas
2. Round-robin entre atendentes
3. Status: pending → open → resolved
### Fase 3: Integração WhatsApp (2-3 semanas)
1. Criar `xos_whatsapp_connections`
2. Integrar Baileys existente do projeto
3. Sincronizar mensagens com `xos_messages`
4. QR Code e gestão de sessões
### Fase 4: Features Avançadas (2 semanas)
1. Chatbot com menu de opções
2. Horário de funcionamento
3. Mensagens automáticas
4. Sistema de avaliação
---
## Código que Podemos Reutilizar
### 1. `wbotMessageListener.ts` (62KB)
- Lógica completa de recebimento de mensagens
- Tratamento de mídia (áudio, imagem, vídeo)
- Integração com OpenAI
- Fluxo de chatbot
### 2. `providers.ts` (79KB)
- Abstração do Baileys
- Gestão de sessões
- Envio de mensagens
### 3. `UpdateTicketService.ts`
- Lógica de transferência entre filas
- Tracking de tempos
- Notificações
### 4. `CampaignService/`
- Disparo em massa
- Agendamento
- Estatísticas
---
## Comparativo Final
| Aspecto | Atendeon | XOS |
|---------|----------|-----|
| **Foco** | WhatsApp CRM puro | Plataforma multi-propósito |
| **Multi-tenant** | Por company | Por tenant |
| **ORM** | Sequelize | Drizzle |
| **Real-time** | Socket.IO | Socket.IO |
| **WhatsApp** | Baileys integrado | Baileys separado |
| **AI** | OpenAI básico | Manus Agent completo |
| **Código** | ~500 arquivos | Integrado na Suite |
### Vantagens do Atendeon
- Sistema maduro e testado
- Lógica de filas pronta
- Chatbot funcional
### Vantagens do XOS
- Arquitetura mais moderna
- IA mais avançada (Manus)
- Integração com ERP
- Multi-canal nativo
---
## Recomendação
**Estratégia:** Não copiar código diretamente, mas **usar como referência** para:
1. **Schema de dados** → Adaptar models do Atendeon para Drizzle/PostgreSQL
2. **Lógica de negócio** → Implementar distribuição de leads similar
3. **Fluxos de chatbot** → Replicar QueueOption para menus
4. **Tracking** → Copiar conceito de TicketTraking
**Estimativa de Desenvolvimento:**
- Estrutura básica: 2-3 semanas
- Integração WhatsApp: 2-3 semanas
- Features completas: 4-6 semanas
**Total:** ~2 meses para paridade com Atendeon

View File

@ -0,0 +1,412 @@
# Análise de Requisitos CRM - XOS vs Documento
> **Data:** 29/01/2026
> **Documento Base:** CRM_1769651134584.pdf
---
## Resumo Executivo
| Categoria | Status Geral | Cobertura |
|-----------|--------------|-----------|
| **Estrutura de Dados** | ✅ Implementado | 90% |
| **Live Chat + CRM** | 🟡 Parcial | 50% |
| **Distribuição de Leads** | 🟡 Estrutura | 30% |
| **Carteirização** | 🟡 Estrutura | 40% |
| **Automações Low-Code** | 🟡 Estrutura | 35% |
| **Follow-up Lists** | ⚪ Não impl. | 10% |
| **Disparos em Massa** | 🟡 Estrutura | 25% |
| **Gestão Meta/Templates** | ⚪ Não impl. | 0% |
| **Multi-Conexões API** | ⚪ Não impl. | 0% |
| **API/Webhook** | ⚪ Não impl. | 10% |
| **Controle de Acessos** | 🟡 Parcial | 40% |
| **Aba de Contatos** | ✅ Implementado | 85% |
| **Métricas/Dashboards** | 🟡 Parcial | 45% |
**Cobertura Geral Estimada: ~40%**
---
## Análise Detalhada por Requisito
### 1. Live Chat (WhatsApp API) + CRM
**Requisito:** Chat com lead onde pode conversar, mandar audio, documento, imagens.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Chat em tempo real | 🟡 | Estrutura de conversas e mensagens existe (`xos_conversations`, `xos_messages`) |
| Envio de áudio/doc/imagem | 🟡 | Campo `content_type` e `attachments` no schema |
| Notas internas no chat | ⚪ | Não implementado - precisa campo `is_internal` |
| Mini CRM dentro do chat | 🟡 | Dados do contato vinculados via `contact_id` |
| Troca de etapas do pipeline | ✅ | Pipeline e stages implementados |
| Transferir lead p/ outro membro | 🟡 | Campo `assigned_to` existe, UI não implementada |
| Finalizar lead (ganho/perda) | ✅ | Campos `status`, `won_reason`, `lost_reason` no Deal |
| Informações de contato padrões | ✅ | Nome, email, telefone, tags, etc. |
| Campos personalizados | ✅ | Campo `custom_fields` (JSONB) |
| Rastreamento UTM | ✅ | Campos `source`, `source_details` |
| Filtros avançados | 🟡 | Filtro básico por tipo/status/busca, **falta:** tag, etapa funil, status Meta, sem resposta, data |
| Ordenação múltipla | ⚪ | Apenas por data, falta opções avançadas |
**Cobertura: 50%** - Estrutura existe, falta UI de chat real e filtros avançados.
---
### 2. Distribuição Automática de Leads
**Requisito:** Lead chega e é atribuído automaticamente via fila.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Fila round-robin | ⚪ | Não implementado |
| Distribuição por percentual | ⚪ | Não implementado |
| Configuração de regras | ⚪ | Não implementado |
**O que temos:**
- Campo `assigned_to` em Contacts e Deals
- Estrutura de automações (`xos_automations`) que poderia acionar distribuição
**Cobertura: 30%** - Estrutura permite, lógica não implementada.
---
### 3. Carteirização
**Requisito:** Lead "pertence" a um consultor por X período.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Ownership por período | ⚪ | Não tem campo de expiração |
| Redirecionamento automático | ⚪ | Não implementado |
| Histórico de ownership | ⚪ | Não implementado |
**O que temos:**
- Campo `assigned_to` no contato
- Campo `last_contact_at` para tracking
**Cobertura: 40%** - Precisa adicionar `ownership_expires_at` e lógica.
---
### 4. Automações Low-Code
**Requisito:** Automações no-code/low-code com opções pré-dispostas.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Triggers (mensagem, clique, etc.) | ✅ | Campo `trigger_type` e `trigger_config` |
| Ações (tag, mensagem, distribuir) | ✅ | Campo `actions` (array de ações) |
| Condições | ✅ | Campo `conditions` (array) |
| UI visual de criação | 🟡 | Tela básica criada, não funcional |
| Execução real | ⚪ | Não implementado no backend |
**O que temos:**
```sql
xos_automations (
trigger_type: "contact_created", "deal_stage_changed", "form_submitted"
trigger_config: { ... }
actions: [{ type: "add_tag", config: {...} }, { type: "send_message", config: {...} }]
conditions: [{ field: "...", operator: "...", value: "..." }]
)
```
**Cobertura: 35%** - Schema pronto, execução não implementada.
---
### 5. Listas de Follow-up
**Requisito:** Lista de mensagens automáticas com delays.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Criação de lista | ⚪ | Não existe tabela específica |
| Sequência com delays | ⚪ | Não implementado |
| Saída automática ao responder | ⚪ | Não implementado |
| Integração com automações | ⚪ | Poderia usar `xos_automations` |
**Cobertura: 10%** - Precisa nova tabela `xos_follow_up_sequences`.
---
### 6. Central de Disparos em Massa (WhatsApp API)
**Requisito:** Fluxo para disparos em massa com agendamento.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Campanhas WhatsApp | ✅ | `xos_campaigns` com type "whatsapp" |
| Segmentação de contatos | ✅ | Campo `segment_query` (JSONB) |
| Agendamento | ✅ | Campo `scheduled_at` |
| Estatísticas | ✅ | Campo `stats` (sent, delivered, opened, etc.) |
| Fluxo de automação | 🟡 | Básico, não visual |
| Execução real | ⚪ | Não implementado |
**Cobertura: 25%** - Estrutura completa, execução não implementada.
---
### 7. Gestão de Templates Meta (WhatsApp)
**Requisito:** Criar/gerenciar templates da Meta Business.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Listagem de templates | ⚪ | Não implementado |
| Criação de templates | ⚪ | Não implementado |
| Aprovação Meta | ⚪ | Não implementado |
| Integração API Meta | ⚪ | Não implementado |
**Cobertura: 0%** - Requer integração com Meta Business API.
---
### 8. Múltiplas Conexões de API WhatsApp
**Requisito:** Conectar mais de um número WhatsApp.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Multi-número | ⚪ | Não implementado |
| Gestão de conexões | ⚪ | Não implementado |
| Configuração por equipe | ⚪ | Não implementado |
**O que temos:**
- Sistema WhatsApp via Baileys existe no projeto (pasta `whatsapp/`)
- Falta integração com XOS
**Cobertura: 0%** - Precisa integrar WhatsApp existente ao XOS.
---
### 9. API do CRM + Webhook
**Requisito:** API externa e webhooks para integrações.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| API REST | ✅ | Rotas `/api/xos/*` completas |
| Webhooks de entrada | ⚪ | Não implementado |
| Webhooks de saída | ⚪ | Não implementado |
| Documentação API | ⚪ | Não documentado |
**Cobertura: 10%** - API existe, webhooks não.
---
### 10. Gerenciar Acessos (Hierarquia)
**Requisito:** Diferenciação de acessos por perfil.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Perfis de usuário | ✅ | Tabela `users` com `role` |
| Permissões por recurso | 🟡 | Sistema de roles básico existe |
| Visibilidade filtrada | ⚪ | Não implementado por tenant/equipe |
| Configuração de acessos | ⚪ | Não tem UI |
**O que temos:**
- Multi-tenancy (`tenant_id` em todas tabelas XOS)
- Usuários com roles
**Cobertura: 40%** - Estrutura existe, UI e lógica fina não.
---
### 11. Aba de Contatos
**Requisito:** Listagem com busca, filtros, import/export.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Listagem completa | ✅ | Implementado |
| Busca por nome/email | ✅ | Implementado |
| Filtro por tipo/status | ✅ | Implementado |
| Filtro por tag | ⚪ | Não implementado |
| Detalhes do contato | ✅ | Implementado com relacionamentos |
| Exportação | ⚪ | Não implementado |
| Importação em massa | ⚪ | Não implementado |
**Cobertura: 85%** - Falta import/export e filtro por tags.
---
### 12. Métricas e Dashboards
**Requisito:** Dashboards personalizáveis com KPIs.
| Sub-requisito | Status | Detalhe |
|---------------|--------|---------|
| Dashboard geral | ✅ | XosCentral com stats |
| Métricas de vendas | ✅ | Deals por stage, valor total |
| Métricas de atendimento | 🟡 | Conversas, tempo resposta básico |
| Métricas por vendedor | ⚪ | Não filtrado por usuário |
| Personalização | ⚪ | Layout fixo |
| Filtro de período | ⚪ | Não implementado |
**O que temos:**
```javascript
// Endpoint /api/xos/stats retorna:
{
contacts: { total, leads, customers },
deals: { total, open, won, lost, totalValue },
conversations: { total, open, resolved },
campaigns: { total, active }
}
```
**Cobertura: 45%** - Básico funciona, falta personalização e filtros.
---
## Comparativo com CRMs de Referência
| Recurso | Kommo | Clint | Front CRM | **XOS Atual** |
|---------|-------|-------|-----------|---------------|
| Live Chat WhatsApp | ✅ | ✅ | ✅ | 🟡 |
| Visual limpo | ⚪ | ✅ | ✅ | ✅ |
| Distribuição leads | ✅ | ✅ | ✅ | 🟡 |
| Automações | ✅ | 💰 | ✅ | 🟡 |
| Follow-ups | ✅ | ✅ | ✅ | ⚪ |
| Disparos massa | ✅ | ✅ | ✅ | 🟡 |
| Templates Meta | ✅ | ✅ | ✅ | ⚪ |
| Dashboards custom | ✅ | ✅ | ⚪ | ⚪ |
| API/Webhooks | ✅ | ✅ | ✅ | 🟡 |
| Multi-número | ✅ | ✅ | ✅ | ⚪ |
---
## Plano de Ação Prioritário
### Prioridade 1 (Crítico para Operação)
1. **Integrar WhatsApp ao XOS**
- Conectar sistema Baileys existente
- Criar canal `whatsapp` funcional em `xos_conversations`
- UI de chat real-time
2. **Filtros Avançados no Chat**
- Por tag, etapa, status, data
- Ordenação múltipla
3. **Distribuição Automática de Leads**
- Criar tabela `xos_lead_distribution_rules`
- Implementar lógica round-robin
### Prioridade 2 (Diferencial Competitivo)
4. **Listas de Follow-up**
- Criar tabela `xos_follow_up_sequences`
- Engine de execução com delays
5. **Execução Real de Automações**
- Worker para processar triggers
- Ações reais (enviar mensagem, add tag, etc.)
6. **Carteirização**
- Campo `ownership_expires_at`
- Lógica de redirecionamento
### Prioridade 3 (Escala e Profissionalização)
7. **Gestão de Templates Meta**
- Integração Meta Business API
8. **Multi-Número WhatsApp**
- Tabela de conexões
- Seletor de número por equipe
9. **Dashboards Personalizáveis**
- Widgets configuráveis
- Filtros por período/vendedor
10. **Import/Export de Contatos**
- CSV/Excel upload
- Exportação com filtros
---
## Schema Sugerido para Expansão
```sql
-- Follow-up Sequences
CREATE TABLE xos_follow_up_sequences (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
name VARCHAR(200) NOT NULL,
trigger_event VARCHAR(50), -- 'contact_created', 'deal_created'
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE xos_follow_up_steps (
id SERIAL PRIMARY KEY,
sequence_id INTEGER REFERENCES xos_follow_up_sequences(id),
step_order INTEGER,
delay_hours INTEGER,
action_type VARCHAR(30), -- 'send_template', 'send_message', 'add_tag'
action_config JSONB,
exit_on_reply BOOLEAN DEFAULT true
);
CREATE TABLE xos_follow_up_enrollments (
id SERIAL PRIMARY KEY,
sequence_id INTEGER REFERENCES xos_follow_up_sequences(id),
contact_id INTEGER REFERENCES xos_contacts(id),
current_step INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'active', -- 'active', 'completed', 'exited'
started_at TIMESTAMP DEFAULT NOW(),
next_action_at TIMESTAMP,
exited_at TIMESTAMP
);
-- Lead Distribution
CREATE TABLE xos_distribution_rules (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
name VARCHAR(100),
rule_type VARCHAR(30), -- 'round_robin', 'percentage', 'load_balanced'
config JSONB, -- { users: [{ user_id, percentage }] }
is_active BOOLEAN DEFAULT true
);
-- WhatsApp Connections
CREATE TABLE xos_whatsapp_connections (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
name VARCHAR(100),
phone_number VARCHAR(20),
status VARCHAR(20), -- 'connected', 'disconnected', 'qr_pending'
session_data JSONB,
assigned_teams JSONB, -- array of team IDs
created_at TIMESTAMP DEFAULT NOW()
);
-- Meta Templates
CREATE TABLE xos_whatsapp_templates (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
connection_id INTEGER REFERENCES xos_whatsapp_connections(id),
meta_template_id VARCHAR(100),
name VARCHAR(100),
category VARCHAR(50),
language VARCHAR(10),
status VARCHAR(30), -- 'APPROVED', 'PENDING', 'REJECTED'
components JSONB,
synced_at TIMESTAMP
);
```
---
## Conclusão
O XOS tem uma **base sólida** com ~40% dos requisitos cobertos estruturalmente. Os principais gaps são:
1. **Integração real com WhatsApp** (estrutura existe, execução não)
2. **Listas de Follow-up** (não existe)
3. **Execução de automações** (schema existe, engine não)
4. **Templates Meta** (não existe)
5. **Dashboards personalizáveis** (básico existe)
A boa notícia é que a arquitetura multi-tenant e os schemas JSONB flexíveis facilitam a expansão. Recomendo focar primeiro na integração WhatsApp + Filtros Avançados, que são os requisitos mais críticos para operação comercial diária.

View File

@ -0,0 +1,600 @@
│ 2⃣ ANÁLISE TRIBUTÁRIA (Estruturante) │
│ • Simulações de regime │
│ • Comparativos │
│ • Identificação de créditos │
│ • Alertas de risco │
│ │
│ 3⃣ PLANEJAMENTO (Governança) │
│ • Dashboard de impostos │
│ • Projeções │
│ • Compliance check │
└─────────────────────────────────────────────────────────────┘
```
### 4.5 RH
```
RECEBE AJUSTA ANALISA SIMULA
─────────────────────────────────────────────────────────────────────
• Estrutura org. • Alocação custo • Custo real • CLT × PJ
• Colaboradores • Tipo despesa • Produtividade • Cenários
• Eventos • Aprovar férias • Competências • Headcount
```
---
## 5. O MANUS (AGENTE CENTRAL)
### 5.1 Posição na Arquitetura
O Manus **não está em uma camada**. Ele **permeia todas**:
```
┌─────────────┐
│ MANUS │
│ (vê tudo) │
└──────┬──────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
Apps Core Estruturantes Execução
```
### 5.2 Capacidades
| Capacidade | Descrição |
|------------|-----------|
| **Observação** | Acesso a todos os dados via Knowledge Graph |
| **Raciocínio** | Conecta pontos, identifica padrões |
| **Ação** | Alerta, sugere, prepara, executa (com limites) |
| **Aprendizado** | Melhora com cada interação |
### 5.3 Níveis de Autonomia
| Nível | O Que Faz | Exemplo |
|-------|-----------|---------|
| Informar | Notifica | "Estoque crítico de iPhone 13" |
| Sugerir | Propõe ação | "Sugiro pedir 10 unidades" |
| Preparar | Cria rascunho | "Preparei o pedido, quer enviar?" |
| Executar | Age (se autorizado) | "Enviei o pedido automaticamente" |
### 5.4 Limites do Manus
| Ação | Autonomia |
|------|-----------|
| Alertar | ✅ Sempre |
| Sugerir | ✅ Sempre |
| Criar rascunho | ✅ Sempre |
| Enviar mensagem | 🔶 Configurável |
| Criar pedido | 🔶 Configurável |
| Aprovar pagamento | ❌ Nunca |
| Excluir dados | ❌ Nunca |
---
## 6. SISTEMA DE AUTOMAÇÕES
### 6.1 Relação com o Manus
```
MANUS (Cérebro)
AUTOMAÇÕES (Sistema Nervoso)
MÓDULOS (Órgãos)
```
### 6.2 Três Níveis de Ação
| Nível | Quem Decide | Quem Executa |
|-------|-------------|--------------|
| Manual | Humano | Humano |
| Automação | Regra pré-definida | Sistema |
| Agêntico | Manus (IA) | Sistema |
### 6.3 Exemplo de Fluxo
```
EVENTO: Cliente comprou iPhone 14 por R$ 4.500
1. AUTOMAÇÃO (regra fixa)
IF venda > R$1.000 THEN enviar_email_agradecimento
→ Executa automaticamente
2. MANUS (inteligência)
"Este cliente comprou 3x nos últimos 60 dias.
Sugestão: Criar programa VIP."
→ Sugere ao vendedor
3. MANUS + AUTOMAÇÃO (híbrido)
Manus cria nova automação:
"Clientes com 3+ compras em 60 dias → convite VIP"
```
---
## 7. CONCEITO DE APPS
### 7.1 Analogia
| Smartphone | Arcádia Suite |
|------------|---------------|
| iOS/Android | Arcádia Core |
| Apps nativos | Módulos Core |
| Apps instaláveis | Apps complementares |
| App Store | Arcádia Marketplace |
| Siri/Assistant | Manus |
### 7.2 Tipos de Apps
```
┌─────────────────────────────────────────────────────────────┐
│ TIPOS DE APPS │
│ │
│ CORE (Sempre presentes) │
│ └── Financeiro, Contábil, Fiscal, RH, Comunicação, BI │
│ │
│ EXECUÇÃO (Por modelo de negócio) │
│ └── Retail, Serviços, Produção, Logística, Food │
│ │
│ COMPLEMENTARES (Funções extras) │
│ └── LMS, Marketing, CRM, Documentos, Agenda, OKRs │
│ │
│ SEGMENTO (Verticais específicas) │
│ └── Eng. Ambiental, Jurídico, Saúde, Agro, Construção │
└─────────────────────────────────────────────────────────────┘
```
### 7.3 Comunicação Entre Apps (Event Bus)
```
App Retail ──────┐
App Marketing ───┼───► EVENTO: "sale.completed"
│ │
App LMS ─────────┘ ▼
┌──────────────┐
│ Quem escuta: │
│ • Financeiro │ → Gera recebível
│ • Fiscal │ → Emite NF-e
│ • CRM │ → Atualiza cliente
│ • Marketing │ → Envia email
│ • Manus │ → Analisa padrão
└──────────────┘
```
---
## 8. CAMADA DE CONSULTORIA
### 8.1 Módulos
| Módulo | Função | Usuários |
|--------|--------|----------|
| **Process Compass** | Diagnóstico, mapeamento, roadmap | Consultores, gestores |
| **Produção** | Gestão de projetos de implementação | Gestores de projeto |
| **Suporte** | Atendimento e resolução | Equipe de suporte |
### 8.2 Modelo de Acesso
| Perfil | Vê Consultoria? | Vê Cliente? |
|--------|-----------------|-------------|
| Usuário final | ❌ | ✅ Só seu tenant |
| Gestor empresa | ❌ | ✅ Só seu tenant |
| Consultor Arcádia | ✅ | ✅ Todos os clientes |
| Parceiro | ✅ | ✅ Seus clientes |
| Gestor avançado | ✅ (Process Compass) | ✅ Seu tenant |
---
## 9. CAMADA DE PLATAFORMA
### 9.1 Componentes
| Componente | Direção | Função |
|------------|---------|--------|
| **Central API** | Arcádia → Mundo | Orquestra conexões de saída |
| **Hub API** | Mundo → Arcádia | Expõe dados para parceiros |
| **MCP/A2A** | IAs ↔ Arcádia | Protocolo agêntico |
| **Ciência Dados** | Interno | Cria modelos de IA |
| **IDE** | Interno | Desenvolve extensões |
### 9.2 Protocolos Agênticos (Já Implementados)
| Protocolo | Endpoint | Status |
|-----------|----------|--------|
| MCP | `/api/mcp/v1/tools` | ✅ 56 ferramentas |
| A2A | `/.well-known/agent.json` | ✅ Funcionando |
| Knowledge Graph | Interno | ✅ Funcionando |
| Learning System | Interno | ✅ Funcionando |
---
## 10. INTEGRAÇÃO COM ESCRITÓRIO CONTÁBIL
### 10.1 Modelo de Três Níveis
```
EMPRESA (Operação)
↓ gera fatos
ARCÁDIA (Retaguarda Inteligente)
↓ dados pré-classificados
ESCRITÓRIO PARCEIRO (Execução Legal)
```
### 10.2 Fluxo Bidirecional
```
┌─────────────────┐ ┌─────────────────┐
│ ARCÁDIA │ ──API──→│ ESCRITÓRIO │
│ │ │ │
│ Envia: │ │ Recebe: │
│ - Lançamentos │ │ - Dados prontos│
│ - Notas │ │ - Classificados│
│ - Eventos RH │ │ │
│ │ ←─API── │ Retorna: │
│ Recebe: │ │ - Status │
│ - Validações │ │ - Alertas │
│ - Obrigações OK│ │ - Pendências │
└─────────────────┘ └─────────────────┘
```
### 10.3 Integrações Prioritárias
1. Domínio Sistemas (Thomson Reuters)
2. Alterdata
3. Fortes
4. Prosoft
5. Questor
---
## 11. ARCÁDIA PLUS (Motor ERP Laravel)
### 11.1 Visão Geral
O **Arcádia Plus** é o ERP canônico desenvolvido em PHP/Laravel, funcionando como um "lote dentro da cidade" Arcádia Suite.
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 🏙️ CIDADE ARCÁDIA SUITE │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Retail │ │ Fisco │ │ Manus │ │ BI │ │
│ │ 🏠 │ │ 🏠 │ │ 🏠 │ │ 🏠 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ ═══════════════════════════════════════════════════ │
│ 🛣️ AVENIDA API │
│ ═══════════════════════════════════════════════════ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 🏭 LOTE ARCÁDIA PLUS │ │
│ │ (Laravel/PHP) │ │
│ │ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │Comp │ │Estoq│ │Finan│ │ RH │ │ NF-e│ │ │
│ │ │ras │ │ ue │ │eiro│ │ │ │ │ │ │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 11.2 Características do Plus
| Aspecto | Detalhe |
|---------|---------|
| **Stack** | PHP 8 + Laravel 10+ |
| **Banco** | MySQL (migração para PostgreSQL planejada) |
| **Fiscal** | phpnfe (NF-e/NFC-e) |
| **Multi-tenant** | SaaS pronto |
| **Origem** | GitHub (repositório existente) |
### 11.3 Modelo de Integração
```
USUÁRIO
┌────────────────┐
│ Arcádia Suite │
│ (Frontend) │
└───────┬────────┘
┌────────────────┐
│ Central API │
│ (Orquestrador)│
└───────┬────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Retail │ │ PLUS │ │ Fisco │
│ (Node) │ │(Laravel)│ │(Python) │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
▼ ▼ ▼
PostgreSQL MySQL PostgreSQL
```
### 11.4 Acesso via Rota `/plus`
O Plus é acessado transparentemente dentro do Suite:
```
arcadia.app/ → Cockpit
arcadia.app/retail → Arcádia Retail
arcadia.app/erp → Arcádia ERP
arcadia.app/plus → Arcádia Plus (Laravel embutido)
arcadia.app/fisco → Arcádia Fisco
```
**Implementação via Proxy Reverso:**
```typescript
// server/routes.ts
app.use('/plus', createProxyMiddleware({
target: process.env.ARCADIA_PLUS_URL,
changeOrigin: true,
pathRewrite: { '^/plus': '' }
}));
```
### 11.5 Comunicação Suite ↔ Plus
```
SUITE (Orquestrador) PLUS (Motor ERP)
────────────────────────────────────────────────────
1. Suite solicita dados
GET /api/v1/customers ────────► Plus retorna JSON
2. Plus executa operação │
│ │
▼ ▼
POST /api/v1/orders ◄──────── Suite envia pedido
3. Plus emite evento
webhook: order.created ───────► Suite captura
Knowledge Graph armazena
Manus analisa
```
### 11.6 SSO (Login Único)
```
1. Usuário loga no Suite
2. Suite gera token JWT
3. Token é passado para o Plus via header
4. Plus valida e cria sessão Laravel
```
### 11.7 Desenvolvimento Unificado (Monorepo)
Após importação do GitHub, estrutura do workspace:
```
/arcadia-workspace
├── /suite # Node.js (atual)
│ ├── /client
│ ├── /server
│ └── package.json
├── /plus # Laravel (importado)
│ ├── /app
│ ├── /routes
│ ├── artisan
│ └── composer.json
├── /fisco # Python/FastAPI
│ └── main.py
└── workflows.json # Orquestra todos os serviços
```
**Workflows Múltiplos:**
```yaml
Suite: cd suite && npm run dev # :5000
Plus: cd plus && php artisan serve # :8080
Fisco: cd fisco && uvicorn main:app # :8002
```
### 11.8 Roadmap de Integração
| Fase | Ação | Status |
|------|------|--------|
| 1 | Expor API REST no Plus | 🔶 Pendente |
| 2 | Criar conector na Central API | 🔶 Pendente |
| 3 | Implementar proxy `/plus` | 🔶 Pendente |
| 4 | SSO com JWT compartilhado | 🔶 Pendente |
| 5 | Importar código para Replit | 🔶 Pendente |
| 6 | Migração MySQL → PostgreSQL | 🔶 Futuro |
### 11.9 Benefícios da Abordagem
| Aspecto | Benefício |
|---------|-----------|
| **Risco** | Zero - Plus continua funcionando |
| **Tempo** | Rápido - Só precisa de API |
| **Custo** | Baixo - Não reescreve nada |
| **Evolução** | Gradual - Migra quando quiser |
| **Manus** | Vê tudo via Knowledge Graph |
| **IDE** | Desenvolve tudo no mesmo lugar |
---
## 12. REGRAS DE PROTEÇÃO (Não Quebrar)
| # | Regra |
|---|-------|
| 1 | Retail nunca vira ERP completo |
| 2 | Governança não executa |
| 3 | ERP não decide |
| 4 | Arcádia não lança (contabilmente) |
| 5 | Fonte da verdade é única (ERP) |
| 6 | Segmentação fora da Suite |
| 7 | Apps conversam por eventos |
| 8 | Manus nunca deleta dados |
---
## 13. DECISÕES PENDENTES
### 13.1 Arquitetura
| Decisão | Opções | Status |
|---------|--------|--------|
| Estoque: Arcádia ou ERPNext? | Híbrido sugerido | 🔶 Pendente |
| Fonte da verdade em conflito | ERPNext sempre | 🔶 Pendente |
| Offline para PDV | PWA com sync | 🔶 Pendente |
### 13.2 Produto
| Decisão | Opções | Status |
|---------|--------|--------|
| App mobile nativo | Sim/PWA | 🔶 Pendente |
| White label | Sim/Não | 🔶 Pendente |
| Modelo de pricing | Híbrido sugerido | 🔶 Pendente |
### 13.3 Compliance
| Decisão | Opções | Status |
|---------|--------|--------|
| LGPD | Implementar | 🔶 Pendente |
| Backup/DR | Definir SLA | 🔶 Pendente |
---
## 14. ROADMAP SUGERIDO
### Fase 1: Fundação ✅
- [x] Identity & Permissions
- [x] Event Bus básico
- [x] Retail Core
- [x] Integração ERPNext
- [x] MCP/A2A
### Fase 2: Estruturantes (Em Andamento)
- [ ] Financeiro completo
- [ ] Contábil completo
- [ ] Fiscal híbrido
- [ ] RH completo
- [ ] Integração escritório contábil
### Fase 3: Inteligência
- [ ] Knowledge Graph avançado
- [ ] Manus com autonomia configurável
- [ ] Ciência de Dados
- [ ] Automações visuais
### Fase 4: Escala
- [ ] Marketplace de Apps
- [ ] Apps de segmento
- [ ] Parceiros desenvolvedores
- [ ] White label
---
## 15. DIFERENCIAL COMPETITIVO
| ERP Tradicional | Arcádia Suite |
|-----------------|---------------|
| IA é add-on | IA é o núcleo |
| Módulos isolados | Tudo conectado (Knowledge Graph) |
| Executa operações | Governa decisões |
| Usuário navega módulos | Usuário trabalha em UX única |
| Automações manuais | Manus cria automações |
| Relatórios estáticos | Insights proativos |
---
## 16. GLOSSÁRIO
| Termo | Definição |
|-------|-----------|
| **Arcádia Suite** | Plataforma completa (sistema operacional empresarial) |
| **Arcádia Plus** | ERP canônico em Laravel, funciona como "lote" dentro da Suite |
| **Arcádia Retail** | Motor de varejo nativo (Node.js) |
| **Arcádia Fisco** | Motor fiscal híbrido (Python/FastAPI) |
| **Manus** | Agente autônomo central (consciência) |
| **Knowledge Graph** | Grafo de conhecimento que conecta todas as entidades |
| **Estruturante** | Módulo com autonomia para ajustar, não criar |
| **Motor** | Sistema que executa operações (Retail, ERP, Plus) |
| **App** | Funcionalidade modular instalável |
| **Event Bus** | Barramento de comunicação entre apps |
| **MCP** | Model Context Protocol (protocolo agêntico) |
| **A2A** | Agent to Agent Protocol |
| **Proxy Reverso** | Técnica para rotear `/plus` ao Laravel transparentemente |
| **Monorepo** | Estrutura onde Suite, Plus e Fisco ficam no mesmo workspace |
---
## 17. ANEXOS
### 17.1 Diagrama de Eventos Principais
```
sale.completed
→ Financeiro: gera recebível
→ Fiscal: emite NF-e
→ Estoque: baixa
→ CRM: atualiza cliente
→ Manus: analisa
service.completed
→ Financeiro: gera recebível
→ Comunicação: notifica cliente
→ Manus: aprende padrão
tradein.approved
→ Estoque: cria dispositivo
→ Financeiro: gera crédito cliente
→ Serviços: cria O.S. preparação
payment.received
→ Financeiro: baixa recebível
→ Contábil: lançamento
→ Manus: atualiza score cliente
```
### 17.2 Matriz de Responsabilidades
```
│ Decide │ Ajusta │ Executa │ Registra │
────────────────────┼────────┼────────┼─────────┼──────────┤
Suite (Cockpit) │ ✅ │ ❌ │ ❌ │ ❌ │
Estruturantes │ ✅ │ ✅ │ ❌ │ ❌ │
Retail │ ❌ │ ❌ │ ✅ │ ❌ │
Fiscal (Emissão) │ ❌ │ ❌ │ ✅ │ ❌ │
ERP │ ❌ │ ❌ │ ✅ │ ✅ │
Manus │ ✅ │ ❌ │ 🔶* │ ❌ │
* Manus executa apenas ações configuradas como automáticas
```
---
**Documento mantido por:** Equipe Arcádia
**Última atualização:** Janeiro 2026
**Próxima revisão:** Após decisões pendentes

427
docs/DEPLOY_VPS.md Normal file
View File

@ -0,0 +1,427 @@
# Guia de Deploy - Arcádia Suite em VPS
Este guia explica como fazer o deploy do Arcádia Suite em um servidor VPS (Ubuntu 22.04).
---
## Requisitos Mínimos do Servidor
| Recurso | Mínimo | Recomendado |
|---------|--------|-------------|
| RAM | 4 GB | 8 GB |
| CPU | 2 cores | 4 cores |
| Armazenamento | 40 GB SSD | 80 GB SSD |
| Sistema | Ubuntu 22.04 LTS | Ubuntu 22.04 LTS |
---
## 1. Preparação do Servidor
### 1.1 Atualizar o sistema
```bash
sudo apt update && sudo apt upgrade -y
```
### 1.2 Instalar dependências básicas
```bash
sudo apt install -y curl wget git build-essential software-properties-common
```
---
## 2. Instalar Node.js 20
```bash
# Adicionar repositório NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Instalar Node.js
sudo apt install -y nodejs
# Verificar instalação
node --version # Deve mostrar v20.x.x
npm --version
```
---
## 3. Instalar Python 3.11
```bash
# Adicionar repositório
sudo add-apt-repository ppa:deadsnakes/ppa -y
sudo apt update
# Instalar Python 3.11
sudo apt install -y python3.11 python3.11-venv python3.11-dev python3-pip
# Definir como padrão (opcional)
sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
# Verificar instalação
python3.11 --version
```
---
## 4. Instalar e Configurar PostgreSQL
### 4.1 Instalar PostgreSQL
```bash
sudo apt install -y postgresql postgresql-contrib
```
### 4.2 Criar banco de dados e usuário
```bash
# Acessar PostgreSQL
sudo -u postgres psql
# Executar os comandos SQL:
CREATE USER arcadia WITH PASSWORD 'sua_senha_segura';
CREATE DATABASE arcadia_suite OWNER arcadia;
GRANT ALL PRIVILEGES ON DATABASE arcadia_suite TO arcadia;
\q
```
### 4.3 Configurar acesso (se necessário)
```bash
# Editar pg_hba.conf para permitir conexões locais
sudo nano /etc/postgresql/14/main/pg_hba.conf
# Adicionar linha:
# local arcadia_suite arcadia md5
# Reiniciar PostgreSQL
sudo systemctl restart postgresql
```
---
## 5. Instalar Nginx
```bash
sudo apt install -y nginx
# Habilitar na inicialização
sudo systemctl enable nginx
sudo systemctl start nginx
```
---
## 6. Instalar PM2 (Gerenciador de Processos)
```bash
sudo npm install -g pm2
# Configurar para iniciar automaticamente
pm2 startup systemd
```
---
## 7. Clonar e Configurar o Projeto
### 7.1 Criar diretório e clonar
```bash
# Criar diretório da aplicação
sudo mkdir -p /var/www/arcadia
sudo chown $USER:$USER /var/www/arcadia
# Clonar o repositório (substitua pela URL do seu repo)
cd /var/www/arcadia
git clone https://github.com/seu-usuario/arcadia-suite.git .
```
### 7.2 Instalar dependências Node.js
```bash
npm install
```
### 7.3 Configurar ambiente Python
```bash
# Criar ambiente virtual
python3.11 -m venv venv
# Ativar ambiente
source venv/bin/activate
# Instalar dependências Python
pip install fastapi uvicorn nfelib lxml signxml python-dotenv psycopg2-binary
# Desativar ambiente
deactivate
```
---
## 8. Configurar Variáveis de Ambiente
### 8.1 Criar arquivo .env
```bash
nano /var/www/arcadia/.env
```
### 8.2 Adicionar variáveis
```env
# Banco de Dados
DATABASE_URL=postgresql://arcadia:sua_senha_segura@localhost:5432/arcadia_suite
PGHOST=localhost
PGPORT=5432
PGUSER=arcadia
PGPASSWORD=sua_senha_segura
PGDATABASE=arcadia_suite
# Ambiente
NODE_ENV=production
# ERPNext (se usar)
ERPNEXT_URL=https://seu-erpnext.com
ERPNEXT_API_KEY=sua_api_key
ERPNEXT_API_SECRET=seu_api_secret
# OpenAI (se usar IA)
OPENAI_API_KEY=sua_chave_openai
# Sessão
SESSION_SECRET=gere_uma_chave_aleatoria_longa_aqui
```
---
## 9. Build da Aplicação
```bash
cd /var/www/arcadia
# Build do frontend
npm run build
# Executar migrações do banco
npm run db:push
```
---
## 10. Configurar PM2
### 10.1 Criar arquivo de configuração
```bash
nano /var/www/arcadia/ecosystem.config.js
```
### 10.2 Conteúdo do arquivo
```javascript
module.exports = {
apps: [
{
name: 'arcadia-main',
script: 'npm',
args: 'run start',
cwd: '/var/www/arcadia',
env: {
NODE_ENV: 'production',
PORT: 5000
},
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G'
},
{
name: 'arcadia-fisco',
script: '/var/www/arcadia/venv/bin/python',
args: '-m uvicorn server.fisco.main:app --host 0.0.0.0 --port 8002',
cwd: '/var/www/arcadia',
autorestart: true,
watch: false
},
{
name: 'arcadia-contabil',
script: '/var/www/arcadia/venv/bin/python',
args: '-m uvicorn server.contabil.main:app --host 0.0.0.0 --port 8003',
cwd: '/var/www/arcadia',
autorestart: true,
watch: false
}
]
};
```
### 10.3 Iniciar aplicações
```bash
cd /var/www/arcadia
pm2 start ecosystem.config.js
pm2 save
```
---
## 11. Configurar Nginx
### 11.1 Criar configuração do site
```bash
sudo nano /etc/nginx/sites-available/arcadia
```
### 11.2 Conteúdo da configuração
```nginx
server {
listen 80;
server_name seu-dominio.com.br www.seu-dominio.com.br;
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
# WebSocket para Socket.IO
location /socket.io/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# APIs FastAPI
location /api/fisco/ {
proxy_pass http://localhost:8002/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/contabil/ {
proxy_pass http://localhost:8003/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### 11.3 Ativar site
```bash
sudo ln -s /etc/nginx/sites-available/arcadia /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## 12. Instalar Certificado SSL (Let's Encrypt)
```bash
# Instalar Certbot
sudo apt install -y certbot python3-certbot-nginx
# Obter certificado
sudo certbot --nginx -d seu-dominio.com.br -d www.seu-dominio.com.br
# Renovação automática (já configurado por padrão)
sudo certbot renew --dry-run
```
---
## 13. Firewall (UFW)
```bash
# Habilitar UFW
sudo ufw enable
# Permitir SSH
sudo ufw allow OpenSSH
# Permitir HTTP e HTTPS
sudo ufw allow 'Nginx Full'
# Verificar status
sudo ufw status
```
---
## 14. Comandos Úteis
### Gerenciamento PM2
```bash
# Ver status das aplicações
pm2 status
# Ver logs
pm2 logs arcadia-main
pm2 logs --lines 100
# Reiniciar aplicação
pm2 restart arcadia-main
# Reiniciar todas
pm2 restart all
# Parar aplicação
pm2 stop arcadia-main
```
### Atualizar Aplicação
```bash
cd /var/www/arcadia
# Puxar atualizações
git pull
# Instalar novas dependências
npm install
# Rebuild
npm run build
# Reiniciar
pm2 restart all
```
### Backup do Banco de Dados
```bash
# Backup
pg_dump -U arcadia arcadia_suite > backup_$(date +%Y%m%d).sql
# Restaurar
psql -U arcadia arcadia_suite < backup_arquivo.sql
```
---
## 15. Checklist Final
- [ ] PostgreSQL rodando e acessível
- [ ] Variáveis de ambiente configuradas (.env)
- [ ] Build do frontend concluído
- [ ] Migrações do banco executadas
- [ ] PM2 iniciado com todas as aplicações
- [ ] Nginx configurado e rodando
- [ ] Certificado SSL instalado
- [ ] Firewall configurado
- [ ] Domínio apontando para o servidor
---
## Suporte
Em caso de problemas:
1. Verifique os logs: `pm2 logs`
2. Verifique o Nginx: `sudo tail -f /var/log/nginx/error.log`
3. Verifique o PostgreSQL: `sudo journalctl -u postgresql`
---
*Guia criado para Arcádia Suite - Business Operating System*

567
docs/INSTALACAO_SERVIDOR.md Normal file
View File

@ -0,0 +1,567 @@
# Manual de Instalação - Arcádia Suite
## Requisitos do Servidor
### Hardware Mínimo
- CPU: 2 cores
- RAM: 4GB
- Disco: 40GB SSD
- Rede: 100 Mbps
### Hardware Recomendado
- CPU: 4+ cores
- RAM: 8GB+
- Disco: 100GB SSD
- Rede: 1 Gbps
### Sistema Operacional
- Ubuntu 22.04 LTS (recomendado)
- Debian 12
- Rocky Linux 9
---
## 1. Preparação do Servidor
### 1.1 Atualizar Sistema
```bash
sudo apt update && sudo apt upgrade -y
```
### 1.2 Instalar Dependências Básicas
```bash
sudo apt install -y curl wget git unzip build-essential
```
---
## 2. Instalar Node.js 20
```bash
# Adicionar repositório NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Instalar Node.js
sudo apt install -y nodejs
# Verificar instalação
node --version # v20.x.x
npm --version # 10.x.x
```
---
## 3. Instalar Python 3.11
```bash
# Instalar Python e pip
sudo apt install -y python3.11 python3.11-venv python3-pip
# Criar link simbólico (se necessário)
sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
# Verificar instalação
python3 --version # Python 3.11.x
```
---
## 4. Instalar PostgreSQL 15
```bash
# Adicionar repositório oficial
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt update
# Instalar PostgreSQL
sudo apt install -y postgresql-15
# Iniciar e habilitar serviço
sudo systemctl start postgresql
sudo systemctl enable postgresql
# Criar usuário e banco de dados
sudo -u postgres psql << EOF
CREATE USER arcadia WITH PASSWORD 'SuaSenhaSegura123!';
CREATE DATABASE arcadia_suite OWNER arcadia;
GRANT ALL PRIVILEGES ON DATABASE arcadia_suite TO arcadia;
\q
EOF
```
---
## 5. Instalar Redis (Opcional - Cache/Sessions)
```bash
sudo apt install -y redis-server
sudo systemctl start redis-server
sudo systemctl enable redis-server
# Verificar
redis-cli ping # PONG
```
---
## 6. Clonar Projeto
```bash
# Criar diretório de aplicação
sudo mkdir -p /opt/arcadia
sudo chown $USER:$USER /opt/arcadia
cd /opt/arcadia
# Clonar repositório
git clone https://github.com/seu-usuario/arcadia-suite.git .
# Ou extrair arquivo ZIP
# unzip arcadia-suite.zip -d /opt/arcadia
```
---
## 7. Configurar Variáveis de Ambiente
### 7.1 Criar arquivo .env
```bash
cd /opt/arcadia
cp .env.example .env
nano .env
```
### 7.2 Conteúdo do .env
```env
# Banco de Dados
DATABASE_URL=postgresql://arcadia:SuaSenhaSegura123!@localhost:5432/arcadia_suite
PGHOST=localhost
PGPORT=5432
PGUSER=arcadia
PGPASSWORD=SuaSenhaSegura123!
PGDATABASE=arcadia_suite
# Aplicação
NODE_ENV=production
PORT=5000
SESSION_SECRET=sua-chave-secreta-muito-longa-e-aleatoria-aqui
# OpenAI (para recursos de IA)
OPENAI_API_KEY=sk-sua-chave-openai
# ERPNext (opcional)
ERPNEXT_URL=https://seu-erpnext.com
ERPNEXT_API_KEY=sua-api-key
ERPNEXT_API_SECRET=sua-api-secret
# WhatsApp (opcional)
# Configurado via interface
# Protocolos MCP/A2A
PROTOCOL_API_KEYS=chave1,chave2,chave3
```
---
## 8. Instalar Dependências do Projeto
### 8.1 Node.js (Frontend + Backend)
```bash
cd /opt/arcadia
npm install
```
### 8.2 Python (Serviços de IA)
```bash
cd /opt/arcadia
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
---
## 9. Build da Aplicação
```bash
cd /opt/arcadia
# Build do frontend (Vite)
npm run build
# Verificar build
ls -la dist/
```
---
## 10. Executar Migrações do Banco
```bash
cd /opt/arcadia
# Gerar migrações (se necessário)
npm run db:generate
# Aplicar migrações
npm run db:push
```
---
## 11. Configurar Serviço Systemd
### 11.1 Criar serviço principal
```bash
sudo nano /etc/systemd/system/arcadia-suite.service
```
```ini
[Unit]
Description=Arcadia Suite - Office Estratégico
After=network.target postgresql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/arcadia
Environment=NODE_ENV=production
EnvironmentFile=/opt/arcadia/.env
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### 11.2 Criar serviço Python (IA/Fiscal)
```bash
sudo nano /etc/systemd/system/arcadia-python.service
```
```ini
[Unit]
Description=Arcadia Suite - Python Services
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/arcadia/python
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/opt/arcadia/.env
ExecStart=/opt/arcadia/venv/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8001
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### 11.3 Ativar serviços
```bash
# Ajustar permissões
sudo chown -R www-data:www-data /opt/arcadia
# Recarregar systemd
sudo systemctl daemon-reload
# Habilitar e iniciar serviços
sudo systemctl enable arcadia-suite
sudo systemctl enable arcadia-python
sudo systemctl start arcadia-suite
sudo systemctl start arcadia-python
# Verificar status
sudo systemctl status arcadia-suite
sudo systemctl status arcadia-python
```
---
## 12. Configurar Nginx (Proxy Reverso)
### 12.1 Instalar Nginx
```bash
sudo apt install -y nginx
```
### 12.2 Configurar Virtual Host
```bash
sudo nano /etc/nginx/sites-available/arcadia
```
```nginx
server {
listen 80;
server_name seu-dominio.com.br;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name seu-dominio.com.br;
# SSL (usar certbot para gerar)
ssl_certificate /etc/letsencrypt/live/seu-dominio.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/seu-dominio.com.br/privkey.pem;
# SSL Settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Logs
access_log /var/log/nginx/arcadia.access.log;
error_log /var/log/nginx/arcadia.error.log;
# Tamanho máximo de upload (para migrações)
client_max_body_size 500M;
# Proxy para aplicação Node.js
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# WebSocket para Socket.IO
location /socket.io/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# API Python (IA/Fiscal)
location /api/python/ {
proxy_pass http://127.0.0.1:8001/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### 12.3 Ativar configuração
```bash
sudo ln -s /etc/nginx/sites-available/arcadia /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## 13. Certificado SSL (Let's Encrypt)
```bash
# Instalar Certbot
sudo apt install -y certbot python3-certbot-nginx
# Gerar certificado
sudo certbot --nginx -d seu-dominio.com.br
# Renovação automática (já configurada)
sudo certbot renew --dry-run
```
---
## 14. Configurar Firewall
```bash
# Instalar UFW
sudo apt install -y ufw
# Configurar regras
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Ativar firewall
sudo ufw enable
sudo ufw status
```
---
## 15. Backup Automático
### 15.1 Script de backup
```bash
sudo nano /opt/arcadia/scripts/backup.sh
```
```bash
#!/bin/bash
BACKUP_DIR="/var/backups/arcadia"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
# Backup do banco
pg_dump -h localhost -U arcadia arcadia_suite | gzip > $BACKUP_DIR/db_$DATE.sql.gz
# Backup dos arquivos de upload
tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz /opt/arcadia/uploads
# Manter últimos 7 dias
find $BACKUP_DIR -type f -mtime +7 -delete
echo "Backup concluído: $DATE"
```
### 15.2 Agendar no cron
```bash
chmod +x /opt/arcadia/scripts/backup.sh
sudo crontab -e
# Adicionar linha (backup às 3h da manhã)
0 3 * * * /opt/arcadia/scripts/backup.sh >> /var/log/arcadia-backup.log 2>&1
```
---
## 16. Monitoramento
### 16.1 Logs em tempo real
```bash
# Aplicação Node.js
sudo journalctl -u arcadia-suite -f
# Serviço Python
sudo journalctl -u arcadia-python -f
# Nginx
sudo tail -f /var/log/nginx/arcadia.access.log
sudo tail -f /var/log/nginx/arcadia.error.log
```
### 16.2 Verificar saúde
```bash
# Status dos serviços
sudo systemctl status arcadia-suite
sudo systemctl status arcadia-python
sudo systemctl status postgresql
sudo systemctl status nginx
# Uso de recursos
htop
df -h
free -m
```
---
## 17. Primeiro Acesso
1. Acesse: `https://seu-dominio.com.br`
2. Faça login com credenciais iniciais:
- Email: `admin@arcadia.com.br`
- Senha: `admin123` (altere imediatamente!)
3. Configure seu tenant/empresa
4. Configure integrações (ERPNext, WhatsApp, OpenAI)
---
## 18. Comandos Úteis
```bash
# Reiniciar aplicação
sudo systemctl restart arcadia-suite
# Ver logs
sudo journalctl -u arcadia-suite -n 100
# Atualizar código
cd /opt/arcadia
git pull origin main
npm install
npm run build
sudo systemctl restart arcadia-suite
# Executar migrações
npm run db:push
# Limpar cache
redis-cli FLUSHALL
```
---
## 19. Solução de Problemas
### Aplicação não inicia
```bash
# Verificar logs
sudo journalctl -u arcadia-suite -n 50
# Verificar permissões
ls -la /opt/arcadia
# Testar manualmente
cd /opt/arcadia
node dist/index.js
```
### Erro de conexão com banco
```bash
# Verificar PostgreSQL
sudo systemctl status postgresql
psql -h localhost -U arcadia -d arcadia_suite
# Verificar .env
cat /opt/arcadia/.env | grep DATABASE
```
### WebSocket não conecta
```bash
# Verificar configuração Nginx
sudo nginx -t
# Verificar porta
netstat -tlpn | grep 5000
```
---
## 20. Estrutura de Módulos Instalados
| Categoria | Módulos |
|-----------|---------|
| Inteligência | Agent, Scientist, Knowledge, Automações, MCP, A2A |
| Comunicação | XOS Inbox, Chat, Comunidades, XOS Central, NPS |
| Negócios | CRM, Compass, Produção, Suporte, Valuation, Fisco, Contábil, People, ERP, Financeiro, Retail, Plus, Marketplace |
| Analytics | Insights, Central APIs |
| Desenvolvimento | IDE, Canvas, API Hub, API Tester, DocType/Page Builder, Engineering |
| Educação | LMS |
| Operações | Field Ops, Quality ISO, Assistência Técnica, Suppliers |
| Administração | Admin, Home, Migration XOS, Super Admin |
**Total: 40+ módulos**
---
## Suporte
- Documentação: `/docs/`
- Email: suporte@arcadia.com.br
- API Docs: `https://seu-dominio.com.br/api-hub`
---
*Arcádia Suite - O Office Estratégico da Empresa Moderna*

View File

@ -0,0 +1,481 @@
# Plano de Implementação - Módulo Retail (Loja de Celular e Assistência Técnica)
**Versão:** 1.1
**Data:** Janeiro 2026
**Status:** Em Desenvolvimento
---
## 0. Requisitos Especiais Identificados
### 0.1 Fluxo Trade-In → O.S. Interna → Estoque
Quando um aparelho Trade-In é aprovado para aquisição, deve:
1. Criar automaticamente uma **Ordem de Serviço Interna** para revisão/manutenção
2. Ao finalizar a O.S., o aparelho entra no **estoque como produto normal**
3. O estoque deve **diferenciar a origem**: Trade-In vs Fornecedor
```
┌─────────────────┐ ┌───────────────────┐ ┌─────────────────┐
│ TRADE-IN │ │ O.S. INTERNA │ │ ESTOQUE │
│ (Avaliação) │ ──→ │ (Revisão) │ ──→ │ (Disponível) │
└─────────────────┘ └───────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
• Checklist OK • Limpeza • Origem: Trade-In
• Valor acordado • Teste funcional • Custo: valor + OS
• Assinatura • Troca peças? • Preço venda
• Documento legal • Laudo técnico • IMEI rastreado
```
### 0.2 Cadastro Unificado de Pessoas
Uma única tabela de **Pessoas** com múltiplos papéis:
- Cliente
- Fornecedor
- Colaborador/Funcionário
- Técnico
- Representante
```
┌─────────────────────────────────────────────────────────────────┐
│ PESSOA (person) │
│ • id, nome, cpf_cnpj, email, telefone, endereço │
└─────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ CLIENTE │ │FORNECEDOR│ │COLABORADOR│
│ (role) │ │ (role) │ │ (role) │
└──────────┘ └──────────┘ └──────────┘
```
---
## 1. Análise do Estado Atual
### 1.1 O Que Já Temos Implementado
| Módulo | Funcionalidade | Status | Observações |
|--------|----------------|--------|-------------|
| **Estrutura Base** | Schema de tabelas (mobile_devices, device_evaluations, service_orders, pos_sales, stock_transfers) | ✅ Completo | Alinhado com requisitos |
| **Lojas/Filiais** | CRUD de lojas (retail_stores) e armazéns (retail_warehouses) | ✅ Completo | Suporte multi-loja |
| **Dispositivos/IMEI** | Cadastro, busca por IMEI, listagem | ✅ Básico | Falta histórico (Kardex) |
| **Trade-In** | Avaliação com checklist customizável, termo de transferência, assinatura digital | ✅ Completo | Documento imprimível |
| **Ordens de Serviço** | CRUD básico, itens da OS | ✅ Parcial | Falta fluxo completo de status |
| **PDV** | Sessões, vendas básicas, itens | ✅ Parcial | Falta carrinho funcional no frontend |
| **Transferências** | Estrutura para transferência entre lojas | ✅ Parcial | Falta workflow completo |
| **Dashboard** | Estatísticas básicas | ✅ Básico | Pode ser expandido |
### 1.2 O Que Está Faltando (Gap Analysis)
| Requisito (Documento) | Status | Prioridade | Esforço |
|-----------------------|--------|------------|---------|
| Histórico completo de IMEI (Kardex) | ❌ Não implementado | Alta | Médio |
| Geração de etiquetas com código de barras | ❌ Não implementado | Alta | Baixo |
| Fluxo completo de status da O.S. | ⚠️ Parcial | Alta | Médio |
| Orçamentação automática | ❌ Não implementado | Alta | Médio |
| Notificação ao cliente (WhatsApp/SMS) | ⚠️ Integração existe | Média | Baixo |
| Portal público de consulta O.S. | ❌ Não implementado | Média | Baixo |
| PDV com carrinho funcional | ⚠️ Parcial | Alta | Alto |
| Validação IMEI na venda | ❌ Não implementado | Alta | Baixo |
| Baixa automática de peças | ❌ Não implementado | Alta | Médio |
| Separação estoque venda/reparo | ⚠️ Via warehouses | Média | Baixo |
| Impressão de recibos/comprovantes | ❌ Não implementado | Média | Médio |
| Relatórios específicos | ⚠️ Parcial (BI existe) | Média | Médio |
---
## 2. Arquitetura de Integração
### 2.1 Reutilização de Módulos Existentes
```
┌─────────────────────────────────────────────────────────────────┐
│ ARCÁDIA SUITE (Base) │
├─────────────────────────────────────────────────────────────────┤
│ ✅ Clientes (customers) → Usar cadastro existente │
│ ✅ Produtos (products) → Usar cadastro existente │
│ ✅ Fornecedores (suppliers) → Usar cadastro existente │
│ ✅ Usuários/Funcionários (users) → Usar sistema de auth │
│ ✅ WhatsApp (Baileys) → Integrar notificações │
│ ✅ ERPNext → Sincronizar dados quando conectado │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ MÓDULO RETAIL (Novo) │
├─────────────────────────────────────────────────────────────────┤
│ 📱 Dispositivos (mobile_devices) → Controle por IMEI │
│ 🔧 Ordens de Serviço (service_orders) → Assistência Técnica │
│ 💰 PDV (pos_sales) → Ponto de Venda │
│ 📦 Estoque Retail (via warehouses) → Peças e Aparelhos │
│ 🔄 Trade-In (device_evaluations) → Avaliação de usados │
│ 🏪 Multi-Loja (retail_stores) → Franquias │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 Fluxo de Status da O.S. (Implementar)
```
┌──────────┐ ┌────────────┐ ┌───────────┐ ┌────────────────────┐
│ ABERTA │ → │ DIAGNÓSTICO│ → │ ORÇAMENTO │ → │ AGUARDANDO_APROVAÇÃO│
└──────────┘ └────────────┘ └───────────┘ └────────────────────┘
┌───────────────────────────────────────┴────┐
▼ ▼
┌────────────┐ ┌────────────┐
│ RECUSADA │ │ APROVADA │
└────────────┘ └────────────┘
┌─────────────────────────────────────────┘
┌─────────────────┐ ┌───────────┐ ┌──────────┐
│ AGUARDANDO_PECA │ ← │ EM_REPARO │ → │ QUALIDADE │
└─────────────────┘ └───────────┘ └──────────┘
┌─────────────────────────┐
│ AGUARDANDO_RETIRADA │
└─────────────────────────┘
┌─────────────┐
│ FINALIZADA │
└─────────────┘
```
---
## 3. Plano de Implementação por Fases
### Fase 0: Estrutura Base - Pessoas e Trade-In Flow (Semana 0-1) ⭐ PRIORIDADE
#### 0.1 Cadastro Unificado de Pessoas
- [ ] Criar tabela `persons` com campos base (nome, cpf_cnpj, email, telefone, endereço)
- [ ] Criar tabela `person_roles` (person_id, role_type, dados específicos do papel)
- [ ] Tipos de papel: `customer`, `supplier`, `employee`, `technician`, `partner`
- [ ] API CRUD `/persons` com filtro por papel
- [ ] Migrar referências de customers/suppliers para usar persons
- [ ] UI unificada de cadastro de pessoas com seleção de papéis
#### 0.2 Fluxo Trade-In → O.S. Interna → Estoque
- [ ] Adicionar campo `origin_type` em mobile_devices (trade_in, supplier, internal)
- [ ] Adicionar campo `is_internal` em service_orders para diferenciar OS cliente vs interna
- [ ] Criar trigger automático: ao aprovar Trade-In → criar O.S. Interna
- [ ] O.S. Interna com tipo: revisão, limpeza, manutenção preventiva
- [ ] Ao finalizar O.S. Interna → criar dispositivo no estoque
- [ ] Calcular custo total: valor_trade_in + custo_os
- [ ] Sugerir preço de venda baseado em margem configurável
- [ ] UI de acompanhamento do fluxo Trade-In completo
#### 0.3 Diferenciação de Estoque por Origem
- [ ] Campo `acquisition_type` em mobile_devices: trade_in, purchase, consignment
- [ ] Campo `acquisition_cost` (custo de aquisição)
- [ ] Campo `related_evaluation_id` (link com avaliação Trade-In)
- [ ] Campo `related_service_order_id` (link com O.S. de revisão)
- [ ] Filtros no estoque por origem
- [ ] Relatório de margem por origem
### Fase 1: Completar o Núcleo (Semana 2-3)
#### 1.1 Histórico de IMEI (Kardex)
- [ ] Criar tabela `imei_history` no schema
- [ ] API para registrar movimentações automáticas
- [ ] Endpoint GET `/devices/:imei/history`
- [ ] UI de visualização do histórico do dispositivo
#### 1.2 Fluxo Completo de O.S.
- [ ] Atualizar enum de status conforme fluxograma
- [ ] API PATCH `/service-orders/:id/status` com validação de transições
- [ ] Registrar automaticamente em `imei_history`
- [ ] UI com workflow visual de status
- [ ] Diferenciar O.S. Cliente vs O.S. Interna na listagem
#### 1.3 Orçamentação
- [ ] API POST `/service-orders/:id/generate-quote`
- [ ] Cálculo automático baseado em itens adicionados
- [ ] UI para adicionar peças/serviços e visualizar orçamento
- [ ] Impressão de orçamento
#### 1.4 Geração de Etiquetas
- [ ] API GET `/devices/:imei/label` retornando PDF
- [ ] Incluir código de barras (IMEI + número OS)
- [ ] Botão de impressão na tela de dispositivo/OS
### Fase 2: PDV Funcional (Semana 3-4)
#### 2.1 Carrinho de Vendas
- [ ] Componente de carrinho no frontend
- [ ] Busca de produtos por código/nome
- [ ] Leitura de IMEI para aparelhos
- [ ] Validação de IMEI disponível para venda
#### 2.2 Integração Venda + O.S.
- [ ] Adicionar O.S. finalizada como item da venda
- [ ] Baixa automática de peças no fechamento da OS
#### 2.3 Formas de Pagamento
- [ ] UI para múltiplas formas de pagamento
- [ ] Cálculo de troco
- [ ] Integração Trade-In como forma de pagamento
#### 2.4 Recibos e Comprovantes
- [ ] Geração de recibo de venda (impressão)
- [ ] Comprovante de entrega de OS
### Fase 3: Automação e Comunicação (Semana 5-6)
#### 3.1 Notificações ao Cliente
- [ ] Integrar com WhatsApp existente (Baileys)
- [ ] Templates de mensagem: orçamento pronto, OS pronta, lembrete
- [ ] API POST `/service-orders/:id/notify-customer`
- [ ] Histórico de notificações enviadas
#### 3.2 Portal do Cliente (Público)
- [ ] Rota pública GET `/public/os/:orderNumber`
- [ ] Página simples de consulta de status
- [ ] QR Code na etiqueta/recibo
#### 3.3 Relatórios Específicos
- [ ] OS por período/técnico/status
- [ ] Vendas por vendedor/produto/período
- [ ] Tempo médio de reparo
- [ ] Peças mais utilizadas
### Fase 4: Estoque Avançado (Semana 7-8)
#### 4.1 Separação Estoque Venda/Reparo
- [ ] Categorização de warehouses (tipo: venda/reparo)
- [ ] Regras de transferência entre tipos
- [ ] Alertas de estoque mínimo por categoria
#### 4.2 Baixa Automática de Peças
- [ ] Trigger ao finalizar reparo
- [ ] Reserva de peças ao aprovar OS
- [ ] Liberação se OS cancelada
#### 4.3 Inventário por IMEI
- [ ] Listagem de aparelhos em estoque por IMEI
- [ ] Localização física (prateleira/loja)
- [ ] Tempo em estoque
---
## 4. APIs a Implementar
### 4.0 Módulo Pessoas (Prioridade Máxima) ⭐
| Método | Endpoint | Descrição | Status |
|--------|----------|-----------|--------|
| GET | `/persons` | Listar pessoas (filtro por papel) | ❌ Fazer |
| POST | `/persons` | Criar pessoa | ❌ Fazer |
| GET | `/persons/:id` | Detalhes da pessoa | ❌ Fazer |
| PUT | `/persons/:id` | Atualizar pessoa | ❌ Fazer |
| POST | `/persons/:id/roles` | Adicionar papel à pessoa | ❌ Fazer |
| DELETE | `/persons/:id/roles/:role` | Remover papel | ❌ Fazer |
| GET | `/persons/search?q=` | Buscar por nome/CPF | ❌ Fazer |
### 4.1 Módulo Trade-In Flow (Prioridade Máxima) ⭐
| Método | Endpoint | Descrição | Status |
|--------|----------|-----------|--------|
| POST | `/evaluations/:id/approve-and-process` | Aprovar e criar O.S. Interna | ❌ Fazer |
| GET | `/evaluations/:id/full-flow` | Status completo do fluxo | ❌ Fazer |
| POST | `/service-orders/:id/finalize-internal` | Finalizar OS e criar no estoque | ❌ Fazer |
### 4.2 Módulo IMEI (Prioridade Alta)
| Método | Endpoint | Descrição | Status |
|--------|----------|-----------|--------|
| GET | `/devices/:imei/history` | Histórico completo (Kardex) | ❌ Fazer |
| GET | `/devices/:imei/label` | Gerar etiqueta PDF | ❌ Fazer |
| PATCH | `/devices/:id/status` | Atualizar status c/ registro | ❌ Fazer |
| GET | `/devices?origin=trade_in` | Filtrar por origem | ❌ Fazer |
### 4.3 Módulo O.S. (Prioridade Alta)
| Método | Endpoint | Descrição | Status |
|--------|----------|-----------|--------|
| POST | `/service-orders/:id/generate-quote` | Gerar orçamento | ❌ Fazer |
| PATCH | `/service-orders/:id/status` | Workflow de status | ⚠️ Melhorar |
| POST | `/service-orders/:id/notify-customer` | Enviar notificação | ❌ Fazer |
| GET | `/public/os/:orderNumber` | Consulta pública | ❌ Fazer |
| GET | `/service-orders?is_internal=true` | Filtrar OS internas | ❌ Fazer |
### 4.4 Módulo Vendas (Prioridade Alta)
| Método | Endpoint | Descrição | Status |
|--------|----------|-----------|--------|
| POST | `/sales/validate-imei` | Validar IMEI para venda | ❌ Fazer |
| GET | `/sales/:id/receipt` | Gerar recibo PDF | ❌ Fazer |
### 4.5 Módulo Estoque (Prioridade Média)
| Método | Endpoint | Descrição | Status |
|--------|----------|-----------|--------|
| POST | `/inventory/:id/decrease` | Baixar do estoque | ❌ Fazer |
| GET | `/inventory/low-stock` | Itens abaixo do mínimo | ❌ Fazer |
| GET | `/inventory/by-origin` | Relatório por origem | ❌ Fazer |
---
## 5. Componentes de UI a Criar/Melhorar
### 5.0 Componentes Novos - Fase 0 ⭐
| Componente | Descrição | Prioridade |
|------------|-----------|------------|
| `PersonForm` | Cadastro unificado de pessoas com seleção de papéis | Máxima |
| `PersonSearch` | Busca de pessoas por nome/CPF com indicador de papéis | Máxima |
| `TradeInFlowTracker` | Acompanhamento visual: Avaliação → O.S. → Estoque | Máxima |
| `InternalServiceOrder` | Formulário de O.S. interna (revisão/manutenção) | Máxima |
| `DeviceOriginBadge` | Badge indicando origem (Trade-In / Fornecedor) | Alta |
### 5.1 Novos Componentes - Outras Fases
| Componente | Descrição | Prioridade |
|------------|-----------|------------|
| `IMEIHistory` | Timeline de movimentações do IMEI | Alta |
| `ServiceOrderWorkflow` | Painel visual de status da OS | Alta |
| `QuoteGenerator` | Formulário de orçamentação | Alta |
| `POSCart` | Carrinho do PDV funcional | Alta |
| `PaymentMethods` | Seletor de formas de pagamento | Alta |
| `ReceiptPreview` | Visualização/impressão de recibos | Média |
| `LabelPrint` | Impressão de etiquetas | Média |
| `CustomerPortal` | Página pública de consulta | Média |
| `NotificationHistory` | Histórico de mensagens enviadas | Baixa |
### 5.2 Melhorias em Componentes Existentes
| Componente | Melhoria | Prioridade |
|------------|----------|------------|
| `ArcadiaRetail.tsx` | Adicionar carrinho no PDV | Alta |
| Aba Estoque | Filtrar por origem (Trade-In/Fornecedor) | Alta |
| Aba Estoque | Separar por tipo (venda/reparo) | Média |
| Aba Serviços | Workflow visual de status | Alta |
| Aba Serviços | Diferenciar O.S. Cliente vs O.S. Interna | Alta |
| Aba Trade-In | Botão "Aprovar e Processar" com fluxo completo | Alta |
| Dashboard | KPIs específicos do negócio | Média |
---
## 6. Estimativa de Esforço
| Fase | Duração | Complexidade | Dependências |
|------|---------|--------------|--------------|
| **Fase 0 - Pessoas + Trade-In Flow** | 1-2 semanas | Alta | Nenhuma |
| Fase 1 - Núcleo (IMEI, OS, Orçamento) | 2 semanas | Média | Fase 0 |
| Fase 2 - PDV | 2 semanas | Alta | Fase 1 |
| Fase 3 - Automação | 2 semanas | Média | Fase 1, WhatsApp |
| Fase 4 - Estoque Avançado | 2 semanas | Média | Fases 1-2 |
**Total Estimado:** 8-10 semanas para MVP completo
---
## 7. Schema de Dados - Novas Tabelas
### 7.1 Tabela `persons` (Pessoas Unificadas)
```sql
CREATE TABLE persons (
id SERIAL PRIMARY KEY,
tenant_id INTEGER REFERENCES tenants(id),
full_name VARCHAR(255) NOT NULL,
cpf_cnpj VARCHAR(20) UNIQUE,
email VARCHAR(255),
phone VARCHAR(20),
phone2 VARCHAR(20),
address TEXT,
city VARCHAR(100),
state VARCHAR(2),
zip_code VARCHAR(10),
birth_date DATE,
notes TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 7.2 Tabela `person_roles` (Papéis da Pessoa)
```sql
CREATE TABLE person_roles (
id SERIAL PRIMARY KEY,
person_id INTEGER REFERENCES persons(id) NOT NULL,
role_type VARCHAR(20) NOT NULL, -- customer, supplier, employee, technician, partner
-- Campos específicos por papel
credit_limit NUMERIC(12,2), -- para customer
payment_terms VARCHAR(50), -- para supplier
employee_code VARCHAR(20), -- para employee
commission_rate NUMERIC(5,2), -- para employee/technician
hire_date DATE, -- para employee
specialization VARCHAR(100), -- para technician
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 7.3 Campos Adicionais em `mobile_devices`
```sql
ALTER TABLE mobile_devices ADD COLUMN acquisition_type VARCHAR(20) DEFAULT 'purchase';
-- Valores: trade_in, purchase, consignment, internal_transfer
ALTER TABLE mobile_devices ADD COLUMN acquisition_cost NUMERIC(12,2);
ALTER TABLE mobile_devices ADD COLUMN related_evaluation_id INTEGER REFERENCES device_evaluations(id);
ALTER TABLE mobile_devices ADD COLUMN related_service_order_id INTEGER REFERENCES service_orders(id);
ALTER TABLE mobile_devices ADD COLUMN person_id INTEGER REFERENCES persons(id); -- substituir supplier_id
```
### 7.4 Campos Adicionais em `service_orders`
```sql
ALTER TABLE service_orders ADD COLUMN is_internal BOOLEAN DEFAULT false;
ALTER TABLE service_orders ADD COLUMN internal_type VARCHAR(30); -- revision, cleaning, maintenance
ALTER TABLE service_orders ADD COLUMN source_evaluation_id INTEGER REFERENCES device_evaluations(id);
```
### 7.5 Tabela `imei_history` (Kardex do IMEI)
```sql
CREATE TABLE imei_history (
id SERIAL PRIMARY KEY,
device_id INTEGER REFERENCES mobile_devices(id) NOT NULL,
action VARCHAR(50) NOT NULL,
-- entrada, venda, devolucao, transferencia, reparo_iniciado, reparo_finalizado, trade_in_aprovado
previous_status VARCHAR(50),
new_status VARCHAR(50),
related_order_id INTEGER, -- pode ser OS, venda, transferência
related_order_type VARCHAR(20), -- service_order, sale, transfer, evaluation
notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
---
## 7. Próximos Passos Imediatos
1. **Criar tabela `imei_history`** para rastreamento completo
2. **Implementar API de histórico** GET `/devices/:imei/history`
3. **Melhorar fluxo de status** da O.S. com validações
4. **Criar componente de carrinho** funcional no PDV
5. **Implementar geração de orçamento** automático
---
## 8. Integrações Existentes a Utilizar
| Sistema | Uso | Status |
|---------|-----|--------|
| ERPNext | Sincronizar clientes, produtos, fornecedores | ✅ Conectado |
| WhatsApp (Baileys) | Notificações ao cliente | ✅ Disponível |
| OpenAI | Sugestões inteligentes | ✅ Disponível |
| PostgreSQL | Banco de dados | ✅ Em uso |
---
*Este documento deve ser atualizado conforme o progresso da implementação.*

391
docs/XOS_PLANEJAMENTO.md Normal file
View File

@ -0,0 +1,391 @@
# Arcádia XOS - Planejamento Consolidado
## Visão Geral
**XOS (Experience Operating System)** é o sistema operacional de experiência do cliente do Arcádia Suite, unificando marketing, vendas, atendimento e operações em uma única plataforma nativa.
### Mantra
> "O cliente no centro. A experiência como diferencial. A tecnologia como facilitadora."
### Inspiração
Baseado nos conceitos do erxes XOS, implementado nativamente na stack do Arcádia Suite (React + Express + PostgreSQL).
---
## Arquitetura
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ARCÁDIA XOS │
│ Experience Operating System │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ SITE BUILDER │ │ MARKETING │ │ CRM AVANÇADO │ │ ATENDIMENTO │ │
│ │ │ │ HUB │ │ │ │ │ │
│ │ • Editor │ │ • Campanhas │ │ • Pipeline │ │ • Inbox │ │
│ │ • Templates │ │ • Automações │ │ • Contatos │ │ • Tickets │ │
│ │ • Importador │ │ • Landing │ │ • Lead Score │ │ • Chatbot │ │
│ │ • Domínios │ │ • Email/SMS │ │ • Segmentos │ │ • SLA │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ └──────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ VISÃO 360° DO CLIENTE │ │
│ │ Histórico completo: conversas, compras, tickets, interações │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ CAMADA DE INTELIGÊNCIA │ │
│ │ Manus AI | Knowledge Graph | Dev Agent | Scientist │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Módulos do XOS
### 1. XOS Central (Hub)
**Rota:** `/xos`
| Componente | Descrição |
|------------|-----------|
| Dashboard | Visão geral de métricas (leads, tickets, conversões) |
| Visão 360° | Perfil completo do cliente em uma tela |
| Timeline | Histórico de todas as interações |
| Quick Actions | Ações rápidas (enviar mensagem, criar ticket, etc) |
### 2. CRM Avançado
**Rota:** `/xos/crm`
| Funcionalidade | Descrição |
|----------------|-----------|
| Pipeline Visual | Kanban de oportunidades com drag & drop |
| Contatos | Gestão unificada de leads e clientes |
| Empresas | Gestão de contas B2B |
| Segmentação | Criar grupos por comportamento/atributos |
| Lead Scoring | Pontuação automática de leads |
| Atividades | Tarefas, ligações, reuniões agendadas |
| Relatórios | Funil, conversão, performance de vendedores |
### 3. Marketing Hub
**Rota:** `/xos/marketing`
| Funcionalidade | Descrição |
|----------------|-----------|
| Campanhas | Criar e gerenciar campanhas multicanal |
| Email Marketing | Templates, envio, rastreamento |
| SMS Marketing | Integração Twilio/Zenvia |
| WhatsApp Marketing | Campanhas via WhatsApp Business |
| Automações | Fluxos de nutrição e follow-up |
| Landing Pages | Páginas de captura integradas |
| Formulários | Captura de leads com campos customizados |
| Pop-ups | Widgets de captura para sites |
| Analytics | ROI de campanhas, conversões |
### 4. Atendimento (Inbox Omnichannel)
**Rota:** `/xos/inbox`
| Funcionalidade | Descrição |
|----------------|-----------|
| Inbox Unificado | WhatsApp + Chat + Email em uma tela |
| Tickets | Sistema de suporte com prioridades |
| SLA | Tempo de resposta e resolução |
| Atribuição | Distribuição automática para atendentes |
| Tags | Categorização de conversas |
| Macros | Respostas rápidas padronizadas |
| Knowledge Base | Base de conhecimento para clientes |
| Chatbot | Atendimento automático inicial |
| Satisfação | Pesquisa NPS/CSAT após atendimento |
### 5. Site Builder
**Rota:** `/xos/sites`
| Funcionalidade | Descrição |
|----------------|-----------|
| Editor Visual | Drag & drop de blocos |
| Templates | Modelos prontos por segmento |
| Importador | Importar sites existentes (HTML/CSS/URL) |
| Páginas | Criar páginas ilimitadas |
| Blog | Sistema de publicação de conteúdo |
| SEO | Meta tags, sitemap, Open Graph |
| Domínios | Conectar domínio próprio |
| Analytics | Visitas, conversões, origem |
| Responsivo | Preview mobile/tablet/desktop |
### 6. Automações
**Rota:** `/xos/automations`
| Funcionalidade | Descrição |
|----------------|-----------|
| Workflow Builder | Editor visual de fluxos |
| Triggers | Eventos que disparam automações |
| Ações | Email, WhatsApp, criar tarefa, webhook |
| Condições | Filtros e ramificações |
| Agendamento | Executar em horário específico |
| Logs | Histórico de execuções |
---
## Estrutura de Dados (PostgreSQL)
### Tabelas Principais
```sql
-- Contatos unificados
xos_contacts (
id, company_id, type, -- lead, customer, partner
name, email, phone, whatsapp,
avatar_url, tags[], custom_fields,
lead_score, lead_status, -- new, qualified, customer
source, -- website, whatsapp, manual, import
assigned_to, -- user_id
created_at, updated_at
)
-- Empresas/Contas
xos_companies (
id, company_id, name, domain,
industry, size, revenue,
address, city, state,
contacts[], -- array de contact_ids
custom_fields,
created_at, updated_at
)
-- Pipeline de Vendas
xos_pipelines (
id, company_id, name,
stages[], -- array de estágios
is_default, created_at
)
xos_deals (
id, company_id, pipeline_id, stage_id,
contact_id, company_id,
title, value, currency,
expected_close_date,
assigned_to, status, -- open, won, lost
lost_reason, won_reason,
created_at, updated_at, closed_at
)
-- Inbox / Conversas
xos_conversations (
id, company_id, contact_id,
channel, -- whatsapp, chat, email
channel_id, -- session_id do canal
status, -- open, pending, resolved
assigned_to, tags[],
first_response_at, resolved_at,
satisfaction_score,
created_at, updated_at
)
xos_messages (
id, conversation_id,
direction, -- inbound, outbound
sender_type, sender_id, -- contact ou user
content, content_type, -- text, image, file
metadata, -- attachments, etc
read_at, created_at
)
-- Tickets
xos_tickets (
id, company_id, contact_id, conversation_id,
subject, description,
priority, -- low, medium, high, urgent
status, -- open, pending, in_progress, resolved, closed
category, tags[],
assigned_to,
sla_due_at, first_response_at, resolved_at,
satisfaction_score,
created_at, updated_at
)
-- Campanhas
xos_campaigns (
id, company_id, name,
type, -- email, sms, whatsapp
status, -- draft, scheduled, running, completed
segment_id, -- filtro de contatos
content, template_id,
scheduled_at, started_at, completed_at,
stats, -- sent, delivered, opened, clicked
created_at, updated_at
)
-- Automações
xos_automations (
id, company_id, name,
trigger_type, trigger_config,
actions[], -- array de ações
conditions[], -- filtros
is_active,
stats, -- executions, success, failures
created_at, updated_at
)
-- Sites
xos_sites (
id, company_id, name,
domain, subdomain,
template_id,
settings, -- colors, fonts, etc
is_published,
created_at, updated_at
)
xos_pages (
id, site_id,
title, slug, path,
blocks[], -- array de blocos do editor
meta_title, meta_description,
is_published,
created_at, updated_at
)
-- Blocos reutilizáveis
xos_blocks (
id, company_id, name,
type, -- header, hero, features, pricing, etc
content, -- JSON do bloco
is_global, -- disponível para todos
created_at
)
```
---
## Integrações Nativas
| Sistema | Integração |
|---------|------------|
| **WhatsApp** | Baileys (já existe) → alimenta Inbox |
| **Chat Interno** | Já existe → alimenta Inbox |
| **ERPNext** | Clientes, produtos, pedidos |
| **Arcádia Fisco** | NF-e vinculada a deals |
| **Arcádia Retail** | Vendas PDV → histórico cliente |
| **Knowledge Graph** | Indexa conversas e interações |
| **Manus AI** | Atendimento automático, sugestões |
| **Dev Agent** | Customiza todos os módulos |
| **Scientist** | Análises e relatórios avançados |
---
## Casos de Uso
### 1. Venda Interna do Arcádia Suite
```
Landing Page → Formulário → Lead no CRM →
Automação de nutrição → Manus qualifica →
Demo agendada → Proposta → Fechamento →
Onboarding automático
```
### 2. E-commerce (Cliente usando)
```
Visitante → Pop-up com cupom → Lead capturado →
Automação de abandono de carrinho →
WhatsApp com lembrete → Compra →
Pós-venda via Inbox → Pesquisa NPS
```
### 3. Serviços (Cliente usando)
```
Site com formulário → Lead → Pipeline de propostas →
Proposta enviada → Follow-up automático →
Contrato fechado → Projeto criado →
Suporte via Tickets → Renovação automática
```
---
## Fases de Implementação
### Fase 1: Fundação (2 semanas)
- [ ] Estrutura de dados (tabelas PostgreSQL)
- [ ] API base do XOS
- [ ] XOS Central (dashboard e navegação)
- [ ] Migração de contatos existentes
### Fase 2: CRM (2 semanas)
- [ ] Pipeline visual (Kanban)
- [ ] Gestão de contatos
- [ ] Lead scoring básico
- [ ] Atividades e tarefas
### Fase 3: Inbox Omnichannel (2 semanas)
- [ ] Inbox unificado
- [ ] Integração WhatsApp existente
- [ ] Integração Chat existente
- [ ] Sistema de tickets
### Fase 4: Marketing (2 semanas)
- [ ] Campanhas de email
- [ ] Campanhas WhatsApp
- [ ] Automações básicas
- [ ] Landing pages
### Fase 5: Site Builder (2 semanas)
- [ ] Editor visual
- [ ] Biblioteca de blocos
- [ ] Importador de sites
- [ ] Publicação e domínios
### Fase 6: Inteligência (2 semanas)
- [ ] Visão 360° completa
- [ ] Integração Manus AI
- [ ] Analytics e relatórios
- [ ] Chatbot avançado
---
## Monetização (Marketplace)
| Plano | Preço/mês | Inclui |
|-------|-----------|--------|
| **XOS Starter** | R$ 149 | CRM + Inbox (até 1.000 contatos) |
| **XOS Pro** | R$ 299 | + Marketing + Automações (até 10.000 contatos) |
| **XOS Business** | R$ 499 | + Site Builder + API (até 50.000 contatos) |
| **XOS Enterprise** | Sob consulta | Ilimitado + Suporte dedicado |
---
## Métricas de Sucesso
| Métrica | Meta |
|---------|------|
| Tempo de implementação | 12 semanas |
| Conversão de leads | +30% |
| Tempo de resposta | -50% |
| Satisfação do cliente | NPS > 50 |
| Adoção do Marketplace | 100 assinaturas em 6 meses |
---
## Tecnologias
| Camada | Tecnologia |
|--------|------------|
| Frontend | React + TypeScript + Tailwind + shadcn/ui |
| Backend | Express + Drizzle ORM |
| Banco | PostgreSQL |
| Real-time | Socket.IO (já existe) |
| Email | Integração SMTP / Amazon SES |
| SMS | Twilio / Zenvia |
| WhatsApp | Baileys (já existe) |
| IA | OpenAI API (já existe) |
---
## Próximos Passos
1. **Aprovar planejamento**
2. **Criar estrutura de dados** (tabelas)
3. **Implementar XOS Central** (hub)
4. **Desenvolver módulos** em fases
5. **Testes e ajustes**
6. **Lançamento no Marketplace**

View File

@ -0,0 +1,63 @@
-- Migration 0003: Agentic Suite — Skills (POO) + Skill Executions
-- Fase 1 do planejamento estratégico Arcádia Agentic Suite
CREATE TABLE IF NOT EXISTS "skills" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenant_id" INTEGER,
"company_id" INTEGER,
"user_id" VARCHAR,
"name" VARCHAR(255) NOT NULL,
"slug" VARCHAR(255) NOT NULL,
"namespace" VARCHAR(20) NOT NULL DEFAULT 'tenant',
"description" TEXT,
"version" VARCHAR(20) NOT NULL DEFAULT '1.0.0',
"tags" TEXT[] DEFAULT '{}',
"extends" TEXT[] DEFAULT '{}',
"implements" TEXT[] DEFAULT '{}',
"dependencies" JSONB DEFAULT '[]',
"execute_visibility" VARCHAR(20) DEFAULT 'public',
"parameters_visibility" VARCHAR(20) DEFAULT 'public',
"trigger_type" VARCHAR(30),
"trigger_config" JSONB,
"parameters_schema" JSONB DEFAULT '{}',
"return_schema" JSONB DEFAULT '{}',
"body" TEXT,
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
"is_system" BOOLEAN DEFAULT false,
"created_by" VARCHAR,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Índice único: mesmo slug não pode repetir dentro do mesmo tenant
CREATE UNIQUE INDEX IF NOT EXISTS "skills_tenant_slug_idx"
ON "skills"("tenant_id", "slug")
WHERE "tenant_id" IS NOT NULL;
-- Skills de sistema (sem tenant) também precisam slug único
CREATE UNIQUE INDEX IF NOT EXISTS "skills_system_slug_idx"
ON "skills"("slug")
WHERE "tenant_id" IS NULL AND "is_system" = true;
CREATE TABLE IF NOT EXISTS "skill_executions" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"skill_id" UUID NOT NULL REFERENCES "skills"("id") ON DELETE CASCADE,
"tenant_id" INTEGER,
"triggered_by" VARCHAR(30) NOT NULL DEFAULT 'manual',
"triggered_by_user_id" VARCHAR,
"triggered_by_agent_id" VARCHAR(100),
"parameters" JSONB DEFAULT '{}',
"result" JSONB,
"error" TEXT,
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"duration_ms" INTEGER,
"steps_count" INTEGER DEFAULT 0,
"correlation_id" UUID DEFAULT gen_random_uuid(),
"audit_hash" VARCHAR(64),
"started_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "skill_executions_skill_id_idx" ON "skill_executions"("skill_id");
CREATE INDEX IF NOT EXISTS "skill_executions_tenant_id_idx" ON "skill_executions"("tenant_id");
CREATE INDEX IF NOT EXISTS "skill_executions_started_at_idx" ON "skill_executions"("started_at" DESC);

View File

@ -0,0 +1,104 @@
-- Migration 0003: Arcádia Agentic Suite — Skills POO
-- Criado em: 2026-03-24 | Autor: João
-- Adiciona tabelas arcadia_skills e skill_executions
-- NÃO altera nem remove xos_skill_registry (modelo XOS legado preservado)
-- ─── Tabela principal de Skills (modelo orientado a objetos) ─────────────────
CREATE TABLE IF NOT EXISTS "arcadia_skills" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identidade
"name" VARCHAR(255) NOT NULL,
"slug" VARCHAR(255) NOT NULL,
"description" TEXT,
"version" VARCHAR(50) NOT NULL DEFAULT '1.0.0',
"icon" VARCHAR(100),
"tags" TEXT[],
-- Namespace multi-tenant (system > tenant > company > user)
"namespace" VARCHAR(20) NOT NULL DEFAULT 'tenant',
"tenant_id" INTEGER REFERENCES "tenants"("id") ON DELETE CASCADE,
"company_id" INTEGER,
"user_id" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL,
-- Herança POO: lista de slugs referenciando outras skills
"extends" TEXT[],
-- Interfaces / contratos implementados
"implements" TEXT[],
-- Encapsulamento
"visibility_execute" VARCHAR(20) DEFAULT 'public',
"visibility_params" VARCHAR(20) DEFAULT 'public',
-- Composição: dependências como referências /tipo/caminho
"dependencies" TEXT[],
-- Trigger automático
"trigger_type" VARCHAR(30),
"trigger_config" JSONB,
-- Corpo da skill (Markdown com blocos /skill/, /kg/, /tool/, etc.)
"body" TEXT,
-- Schemas de entrada e saída
"parameters_schema" JSONB,
"return_schema" JSONB,
-- Estado
"status" VARCHAR(20) NOT NULL DEFAULT 'draft',
"is_system" BOOLEAN DEFAULT false,
-- Autoria
"author" VARCHAR(255),
"created_by" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Slug único por namespace + tenant
CREATE UNIQUE INDEX IF NOT EXISTS idx_arcadia_skills_slug_tenant
ON arcadia_skills(slug, tenant_id, namespace);
-- Índices de consulta frequente
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_tenant ON arcadia_skills(tenant_id);
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_namespace ON arcadia_skills(namespace);
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_status ON arcadia_skills(status);
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_created_by ON arcadia_skills(created_by);
-- ─── Tabela de execuções de skills ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "skill_executions" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"skill_id" UUID NOT NULL REFERENCES "arcadia_skills"("id") ON DELETE CASCADE,
-- Contexto de execução
"tenant_id" INTEGER REFERENCES "tenants"("id"),
"company_id" INTEGER,
"user_id" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL,
-- Origem
"triggered_by" VARCHAR(30),
"automation_id" INTEGER,
"parent_execution_id" UUID,
-- Dados
"input_params" JSONB,
"output_result" JSONB,
"resolved_dependencies" JSONB,
-- Estado
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"error_message" TEXT,
"duration_ms" INTEGER,
-- Imutabilidade / auditoria
"audit_hash" VARCHAR(64),
"started_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP
);
-- Índices de consulta frequente
CREATE INDEX IF NOT EXISTS idx_skill_executions_skill_id ON skill_executions(skill_id);
CREATE INDEX IF NOT EXISTS idx_skill_executions_tenant ON skill_executions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status);
CREATE INDEX IF NOT EXISTS idx_skill_executions_started ON skill_executions(started_at DESC);

View File

@ -0,0 +1,74 @@
-- Migration 0004: OpenClaw — Detecção de Padrões e Sugestões de Skills Emergentes
-- Fase 4 do planejamento estratégico Arcádia Agentic Suite
-- Tabela de padrões detectados
CREATE TABLE IF NOT EXISTS "detected_patterns" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenant_id" INTEGER REFERENCES "tenants"("id"),
"user_id" VARCHAR REFERENCES "users"("id"),
-- Padrão detectado
"action_type" VARCHAR(100) NOT NULL,
"description" TEXT,
"frequency" INTEGER NOT NULL DEFAULT 0,
"confidence" NUMERIC(4, 3) NOT NULL DEFAULT '0',
-- Janela de análise
"first_seen_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_seen_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Metadados do padrão (contexto, módulos envolvidos, etc.)
"metadata" JSONB DEFAULT '{}',
-- Ciclo de vida: active | archived | converted
"status" VARCHAR(20) NOT NULL DEFAULT 'active',
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "detected_patterns_tenant_user_idx"
ON "detected_patterns"("tenant_id", "user_id");
CREATE INDEX IF NOT EXISTS "detected_patterns_action_type_idx"
ON "detected_patterns"("action_type");
CREATE INDEX IF NOT EXISTS "detected_patterns_status_idx"
ON "detected_patterns"("status");
-- Tabela de sugestões de skills emergentes
CREATE TABLE IF NOT EXISTS "skill_suggestions" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"pattern_id" UUID REFERENCES "detected_patterns"("id") ON DELETE CASCADE,
"tenant_id" INTEGER REFERENCES "tenants"("id"),
"user_id" VARCHAR REFERENCES "users"("id"),
-- Skill sugerida
"suggested_skill_name" VARCHAR(200) NOT NULL,
"suggested_description" TEXT,
"estimated_automation" TEXT,
"confidence" NUMERIC(4, 3) NOT NULL DEFAULT '0',
-- Skill gerada (após confirmação)
"generated_skill_id" UUID REFERENCES "skills"("id"),
-- Estado da sugestão: pending | accepted | rejected | expired
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
-- Rastreabilidade
"source" VARCHAR(50) NOT NULL DEFAULT 'openclaw',
"reviewed_by" VARCHAR REFERENCES "users"("id"),
"reviewed_at" TIMESTAMP,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "skill_suggestions_pattern_id_idx"
ON "skill_suggestions"("pattern_id");
CREATE INDEX IF NOT EXISTS "skill_suggestions_tenant_user_idx"
ON "skill_suggestions"("tenant_id", "user_id");
CREATE INDEX IF NOT EXISTS "skill_suggestions_status_idx"
ON "skill_suggestions"("status");

View File

@ -0,0 +1,36 @@
-- Migration: Add skill_versions table for Git-like versioning
-- Phase 2.4: Versionamento Git-like de skills
CREATE TABLE IF NOT EXISTS skill_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
skill_id UUID NOT NULL REFERENCES arcadia_skills(id) ON DELETE CASCADE,
sha VARCHAR(64) NOT NULL,
message TEXT NOT NULL,
author VARCHAR(255) NOT NULL,
content JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Índices
UNIQUE(skill_id, sha),
INDEX skill_versions_skill_id (skill_id),
INDEX skill_versions_created_at (created_at)
);
-- Trigger para criar versão inicial ao criar skill (opcional)
-- CREATE OR REPLACE FUNCTION create_initial_skill_version()
-- RETURNS TRIGGER AS $$
-- BEGIN
-- INSERT INTO skill_versions (skill_id, sha, message, author, content)
-- VALUES (
-- NEW.id,
-- encode(digest(NEW.id || NEW.content::text, 'sha256'), 'hex'),
-- 'Initial version',
-- 'system',
-- NEW.content
-- );
-- RETURN NEW;
-- END;
-- $$ LANGUAGE plpgsql;
--
-- CREATE TRIGGER skill_initial_version AFTER INSERT ON arcadia_skills
-- FOR EACH ROW EXECUTE FUNCTION create_initial_skill_version();

View File

@ -0,0 +1,41 @@
-- Phase 4: OpenClaw — Tabelas de detecção de padrões e sugestões de skills emergentes
-- Executar se as tabelas ainda não existirem (schema já definido em shared/schema.ts)
CREATE TABLE IF NOT EXISTS detected_patterns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id INTEGER REFERENCES tenants(id),
user_id VARCHAR REFERENCES users(id),
action_type VARCHAR(100) NOT NULL,
description TEXT,
frequency INTEGER NOT NULL DEFAULT 0,
confidence NUMERIC(4,3) NOT NULL DEFAULT 0,
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata JSONB DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS skill_suggestions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pattern_id UUID REFERENCES detected_patterns(id) ON DELETE CASCADE,
tenant_id INTEGER REFERENCES tenants(id),
user_id VARCHAR REFERENCES users(id),
suggested_skill_name VARCHAR(200) NOT NULL,
suggested_description TEXT,
estimated_automation TEXT,
confidence NUMERIC(4,3) NOT NULL DEFAULT 0,
generated_skill_id UUID REFERENCES arcadia_skills(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
source VARCHAR(50) NOT NULL DEFAULT 'openclaw',
reviewed_by VARCHAR REFERENCES users(id),
reviewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_detected_patterns_status ON detected_patterns(status);
CREATE INDEX IF NOT EXISTS idx_detected_patterns_user ON detected_patterns(user_id, action_type);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_status ON skill_suggestions(status);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_pattern ON skill_suggestions(pattern_id);

14430
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,10 @@
"build": "tsx script/build.ts",
"start": "NODE_ENV=production node dist/index.cjs",
"check": "tsc",
"db:push": "drizzle-kit push"
"db:push": "drizzle-kit push",
"test": "vitest run",
"test:watch": "vitest",
"test:openclaw": "vitest run tests/openclaw"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",

18
plus/.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

77
plus/.env.example Normal file
View File

@ -0,0 +1,77 @@
APP_NAME="MyApp"
APP_FONE="55000000000"
APP_ENV=local
# Atenção: APP_KEY é obrigatória no .env.
# Ela pode estar vazia (APP_KEY=) antes do build,
# pois será gerada automaticamente pelo "php artisan key:generate".
# Apenas não deve ser removida do arquivo .env.
APP_KEY=
APP_DEBUG=true
APP_URL=https://app.exemplo.com/
PATH_STORE="store.exemplo.com"
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=myapp_db
DB_USERNAME=myapp_user
DB_PASSWORD=myapp_pass
MYSQL_ROOT_PASSWORD=rootpass123
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=smtp.exemplo.com
MAIL_PORT=465
MAIL_USERNAME="contato@exemplo.com"
MAIL_PASSWORD="minhasenha123"
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS="no-reply@exemplo.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
SIGNUP=1
MAILMASTER="master@exemplo.com"
SITE_SUPORTE="https://suporte.exemplo.com"
MARKETPLACE=1
ECOMMERCE=1
MERCADOLIVRE=1
NUVEMSHOP=1
WOOCOMMERCE=1
CONTADOR=1
PAGINACAO=30
RESERVAS=1

120
plus/.gitignore vendored Normal file
View File

@ -0,0 +1,120 @@
# ========================================
# LARAVEL / PHP
# ========================================
/vendor/
/node_modules/
# Composer PHAR
composer.phar
# Laravel storage & cache
/storage/*.key
/storage/app/*
/storage/framework/cache/data/*
/storage/framework/sessions/*
/storage/framework/testing/*
/storage/framework/views/*
/storage/logs/*
.npm/
.composer/
psysh_history
# Bootstrap cache: versionar só a pasta, não os arquivos gerados
/bootstrap/cache/*
!/bootstrap/cache/.gitignore
# Laravel .env files
.env
.env.backup
.env.local
.env.development
.env.production
.env.testing
.bash*
# Laravel Homestead
Homestead.yaml
Homestead.json
# SQLite databases
database/*.sqlite
# Cache
.phpunit.result.cache
.phpunit.cache
.phpstan/resultCache.php
phpstan.neon.tmp
# Ignorar debugbar (se usar)
public/debugbar/*
# Ignorar Telescope (se usar)
storage/telescope/*
storage/logs/telescope.log
# ========================================
# IDEs & Sistemas Operacionais
# ========================================
# VSCode
.vscode/
.history/
*.code-workspace
# PHPStorm / JetBrains
.idea/
*.iml
# MacOS
.DS_Store
# Windows
Thumbs.db
Desktop.ini
# ========================================
# NODE / FRONTEND
# ========================================
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
# Vite / Mix
/public/hot
/public/build
/public/mix-manifest.json
/public/vite.svg
resources/js/*.hot-update.js
resources/css/*.hot-update.js
# ========================================
# DOCKER
# ========================================
docker-compose.override.yml
docker-compose.override.yaml
docker/*.env
docker/.env
.docker/
.dockerignore
docker-data/
*.pid
# ========================================
# TESTS
# ========================================
/coverage/*
/reports/*
/tests/tmp/*
# ========================================
# zip do projeto
# ========================================
control_plus.zip
*.zip

3
plus/.htaccess Normal file
View File

@ -0,0 +1,3 @@
RewriteEngine on
RewriteCond %{REQUEST_URI} !^public
RewriteRule ^(.*)$ public/$1 [L]

61
plus/Dockerfile Normal file
View File

@ -0,0 +1,61 @@
FROM php:8.2-fpm
ARG UID=1000
ARG GID=33
# Sistema + extensões PHP + Node 18
RUN apt-get update && apt-get install -y \
git \
unzip \
curl \
libzip-dev \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libicu-dev \
libonig-dev \
libxml2-dev \
zip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
bcmath \
exif \
gd \
intl \
pcntl \
pdo_mysql \
sockets \
zip \
soap \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Ajusta UID/GID do www-data para bater com o host (rob: dockershared)
RUN groupmod -g ${GID} www-data \
&& usermod -u ${UID} -g ${GID} www-data
# Composer da imagem oficial
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Copia o script de entrypoint (como root mesmo)
COPY docker/check-env.sh /usr/local/bin/check-env.sh
RUN chmod +x /usr/local/bin/check-env.sh
WORKDIR /var/www/html
# A partir daqui o usuário padrão é www-data (mesmo UID do rob)
USER www-data
# Define o home do www-data
ENV HOME=/var/www/html
# Configs úteis do Composer
ENV COMPOSER_MEMORY_LIMIT=-1
ENV COMPOSER_ALLOW_SUPERUSER=1
EXPOSE 9000 5173
ENTRYPOINT ["/usr/local/bin/check-env.sh"]
CMD ["php-fpm"]

137
plus/Makefile Normal file
View File

@ -0,0 +1,137 @@
# ---------------------------------------
# Controle do ambiente Docker - Laravel + Vite + MySQL
# ---------------------------------------
COMPOSE = docker compose
APP_SERVICE = app
DB_SERVICE = db
DB_WAIT_RETRIES ?= 30
DB_WAIT_SECONDS ?= 2
REQUIRED_ENV = \
APP_ENV \
APP_URL \
APP_KEY \
DB_CONNECTION \
DB_HOST \
DB_PORT \
DB_DATABASE \
DB_USERNAME \
DB_PASSWORD
.PHONY: setup up dev \
check-env wait-db \
perms docker-up docker-up-build docker-down docker-down-volumes \
composer-install npm-install assets-build \
key migrate-seed
# ---------------------------
# Setup completo (instalação inicial)
# ---------------------------
setup: check-env docker-up-build perms composer-install npm-install wait-db key migrate-seed assets-build docker-down
@echo "✅ Setup completo (containers parados)."
# ---------------------------
# Subir ambiente normal (sem Vite)
# ---------------------------
up: check-env docker-up
@echo "✅ Containers ativos:"
@echo " Backend: http://localhost:8000"
# ---------------------------
# Subir ambiente dev (com Vite)
# ---------------------------
dev: check-env docker-up
$(COMPOSE) exec -d $(APP_SERVICE) npm run dev -- --host 0.0.0.0 --port 5173
@echo "✅ Ambiente DEV iniciado:"
@echo " Backend: http://localhost:8000"
@echo " Vite: http://localhost:5173"
# ---------------------------
# Remove tudo relacionado ao projeto e reconstrói do zero
# ---------------------------
rebuild-force:
@echo "🧨 Removendo containers, volumes, redes e imagens relacionadas..."
$(COMPOSE) down -v --rmi all --remove-orphans
@echo "🧨 Removendo imagens órfãs..."
docker image prune -f
docker volume prune -f
@echo "🔨 Reconstruindo tudo do zero..."
$(MAKE) setup
# ---------------------------
# Helpers internos
# ---------------------------
check-env:
@echo "Verificando .env..."
@if [ ! -f .env ]; then \
echo "ERRO: .env não encontrado."; \
exit 1; \
fi; \
missing=""; \
for var in $(REQUIRED_ENV); do \
if ! grep -q "^$$var=" .env; then \
if [ "$$var" = "APP_KEY" ]; then \
echo "ERRO: APP_KEY deve existir no .env (pode estar vazia: APP_KEY=, ou conter uma chave válida)."; \
fi; \
missing="$$missing $$var"; \
fi; \
done; \
if [ -n "$$missing" ]; then \
echo "ERRO: variáveis faltando no .env:"; \
echo " $$missing"; \
exit 1; \
fi; \
echo ".env OK."
# Espera real pelo MySQL
wait-db:
@echo "Aguardando MySQL..."
@$(COMPOSE) exec -T $(DB_SERVICE) sh -c '\
c=0; \
while ! mysqladmin ping -h127.0.0.1 --silent 2>/dev/null; do \
c=$$((c+1)); \
if [ $$c -ge $(DB_WAIT_RETRIES) ]; then \
echo "ERRO: MySQL não respondeu."; \
exit 1; \
fi; \
echo " aguardando... (tentativa $$c)"; \
sleep $(DB_WAIT_SECONDS); \
done; \
echo "✅ MySQL está pronto."; \
'
perms:
$(COMPOSE) exec -T $(APP_SERVICE) sh -c "\
mkdir -p storage/logs bootstrap/cache && \
chown -R www-data:www-data storage bootstrap/cache && \
chmod -R 775 storage bootstrap/cache \
"
docker-up-build:
$(COMPOSE) up -d --build
docker-up:
$(COMPOSE) up -d
docker-down:
$(COMPOSE) down
docker-down-volumes:
$(COMPOSE) down -v
composer-install:
$(COMPOSE) exec -T $(APP_SERVICE) composer install --no-interaction --prefer-dist
npm-install:
$(COMPOSE) exec -T $(APP_SERVICE) npm install
assets-build:
$(COMPOSE) exec -T $(APP_SERVICE) npm run build
key:
$(COMPOSE) exec -T $(APP_SERVICE) php artisan key:generate || true
migrate-seed:
$(COMPOSE) exec -T $(APP_SERVICE) php artisan migrate --seed

78
plus/README.md Normal file
View File

@ -0,0 +1,78 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# Ambiente Laravel com Docker + Makefile
## Pré-requisitos
- Docker
- Docker Compose
## UID/GID (permissões)
Ajuste o usuário local no `docker-compose.yml`:
```
id -u # UID
id -g # GID
```
Atualize:
```yaml
args:
UID: <seu-uid>
GID: <seu-gid>
```
## Configuração do .env
Crie e valide:
```
cp .env.example .env
make check-env
```
## Comandos principais
### Containers
```
make up
make down
make restart
make logs
make build
```
### Setup inicial
```
make first-build
```
### Reset completo
```
make fresh-install
```
### Utilidades
```
make composer COMPOSER_CMD="update"
make npm NPM_CMD="run dev"
make artisan ARTISAN_CMD="config:cache"
make migrate
make rollback
make seed
make fresh
make bash
make dbbash
make perms
```
## Acesso
- http://localhost:8000
- Ou URL definida em `APP_URL`

View File

@ -0,0 +1,252 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Empresa;
use App\Models\ContaReceber;
use App\Models\ContaPagar;
use App\Models\Notificacao;
use App\Models\ItemNfe;
use App\Models\Produto;
use App\Models\Estoque;
use App\Models\ConfigGeral;
use App\Models\Agendamento;
use App\Models\MensagemAgendamentoLog;
use App\Models\ConfiguracaoAgendamento;
use App\Utils\WhatsAppUtil;
class AlertaCron extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'alerta:cron';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Cria alertas para empresas';
/**
* Execute the console command.
*/
protected $whatsAppUtil;
public function __construct(WhatsAppUtil $whatsAppUtil){
parent::__construct();
$this->whatsAppUtil = $whatsAppUtil;
}
public function handle()
{
$empresas = Empresa::where('status', 1)->get();
foreach($empresas as $empresa){
$config = ConfigGeral::where('empresa_id', $empresa->id)->first();
$alertasAtivos = null;
if($config != null){
$alertasAtivos = json_decode($config->notificacoes);
}
// dd($alertasAtivos);
if($alertasAtivos == null || in_array('Contas a receber', $alertasAtivos)){
$contasReceber = ContaReceber::where('empresa_id', $empresa->id)
->where('status', 0)
->whereDate('data_vencimento', date('Y-m-d'))
->get();
foreach($contasReceber as $conta){
$descricaoCurta = $conta->cliente->razao_social . " R$" . __moeda($conta->valor_integral);
$this->criaNotificacao('conta_recebers', $conta->id, $empresa, 'Conta a receber', $descricaoCurta, $conta);
}
}
if($alertasAtivos == null || in_array('Contas a pagar', $alertasAtivos)){
$contasPagar = ContaPagar::where('empresa_id', $empresa->id)
->where('status', 0)
->whereDate('data_vencimento', date('Y-m-d'))
->get();
foreach($contasPagar as $conta){
$descricaoCurta = $conta->fornecedor->razao_social . " R$" . __moeda($conta->valor_integral);
$this->criaNotificacao('conta_pagars', $conta->id, $empresa, 'Conta a pagar', $descricaoCurta, $conta);
}
}
if($alertasAtivos == null || in_array('Alerta de validade', $alertasAtivos)){
$produtosComAlertaValidade = Produto::where('empresa_id', $empresa->id)
->where('alerta_validade', '>', 0)->get();
foreach($produtosComAlertaValidade as $produto){
$date = date('Y-m-d', strtotime(date('Y-m-d'). "+$produto->alerta_validade days"));
$itens = ItemNfe::where('produto_id', $produto->id)
->whereDate('data_vencimento', $date)
->get();
foreach($itens as $i){
$descricaoCurta = $i->produto->nome;
$this->criaNotificacao('compras', $i->id, $empresa, 'Alerta de vencimento', $descricaoCurta, $i, 'media');
}
}
}
if($alertasAtivos == null || in_array('Alerta de estoque', $alertasAtivos)){
$produtosComEstoqueMinimo = Produto::where('empresa_id', $empresa->id)
->where('estoque_minimo', '>', 0)->get();
foreach($produtosComEstoqueMinimo as $produto){
$estoque = Estoque::where('produto_id', $produto->id)->first();
if($estoque != null && $estoque->quantidade <= $produto->estoque_minimo){
$descricaoCurta = $produto->nome;
$this->criaNotificacao('estoques', $estoque->id, $empresa, 'Alerta de estoque', $descricaoCurta, $estoque, 'media');
}
}
}
$configuracaoAgendamento = ConfiguracaoAgendamento::where('empresa_id', $empresa->id)
->first();
if($configuracaoAgendamento != null && $configuracaoAgendamento->token_whatsapp){
$this->criaAlertaAgendamento($configuracaoAgendamento);
}
}
}
private function criaAlertaAgendamento($config){
$agendamentos = $this->getAgendamentosHoje($config->empresa_id);
if($config->msg_wpp_manha && $config->msg_wpp_manha_horario){
$dataAtual = date('Y-m-d H:i');
$dataEnvio = date('Y-m-d ') . $config->msg_wpp_manha_horario;
if(strtotime($dataAtual) >= strtotime($dataEnvio)){
foreach($agendamentos as $a){
if($a->cliente->telefone && $a->msg_wpp_manha_horario == 0){
$msg = $this->criaMensagemAgendamento($a, $config->mensagem_manha);
if($msg != ""){
$telefone = "55".preg_replace('/[^0-9]/', '', $a->cliente->telefone);
// dd($telefone);
$retorno = $this->whatsAppUtil->sendMessageWithToken($telefone, $msg, $config->empresa_id, $config->token_whatsapp);
$retorno = json_decode($retorno);
if($retorno->success){
$a->msg_wpp_manha_horario = 1;
$a->save();
MensagemAgendamentoLog::create([
'empresa_id' => $config->empresa_id,
'mensagem' => $msg,
'cliente_id' => $a->cliente_id
]);
}else{
// dd($retorno);
}
}
}
}
}
}
if($config->msg_wpp_alerta){
$dataAtual = date('Y-m-d H:i');
foreach($agendamentos as $a){
$dataEnvio = date('Y-m-d H:i', strtotime($a->data . " " . $a->inicio . "- $config->msg_wpp_alerta_minutos_antecedencia minutes"));
if(strtotime($dataAtual) >= strtotime($dataEnvio)){
// foreach($agendamentos as $a){
if($a->cliente->telefone && $a->msg_wpp_alerta_horario == 0){
$msg = $this->criaMensagemAgendamento($a, $config->mensagem_alerta);
if($msg != ""){
$telefone = "55".preg_replace('/[^0-9]/', '', $a->cliente->telefone);
$retorno = $this->whatsAppUtil->sendMessageWithToken($telefone, $msg, $config->empresa_id, $config->token_whatsapp);
$retorno = json_decode($retorno);
if($retorno->success){
$a->msg_wpp_alerta_horario = 1;
$a->save();
MensagemAgendamentoLog::create([
'empresa_id' => $config->empresa_id,
'mensagem' => $msg,
'cliente_id' => $a->cliente_id
]);
}else{
// dd($retorno);
}
}
}
// }
}
}
}
}
private function criaMensagemAgendamento($agendamento, $msg){
if(strlen(trim($msg)) == 0) return "";
$msg = str_replace("%nome%", $agendamento->cliente->razao_social, $msg);
$msg = str_replace("%data%", __data_pt($agendamento->data, 0), $msg);
$msg = str_replace("%hora%", substr($agendamento->inicio, 0, 5), $msg);
$textoServico = "SERVIÇOS\n";
foreach($agendamento->itens as $i){
if($i->servico){
$textoServico .= number_format($i->quantidade, 0) . "X ";
$textoServico .= " " . $i->servico->nome;
$textoServico .= " R$ " . __moeda($i->valor);
$textoServico .= " = R$ " . __moeda($i->valor);
$textoServico .= "\n";
}
}
$msg = str_replace("%serviços%", $textoServico, $msg);
return $msg;
}
private function getAgendamentosHoje($empresa_id){
return Agendamento::where('empresa_id', $empresa_id)
->whereDate('data', date('Y-m-d'))->get();
}
private function criaNotificacao($tabela, $referencia, $empresa, $titulo, $descricaoCurta, $objeto, $prioridade = 'baixa'){
$item = Notificacao::where('empresa_id', $empresa->id)
->where('tabela', $tabela)
->where('referencia', $referencia)->first();
if($item == null){
$descricao = $this->getDescricao($tabela, $objeto);
Notificacao::create([
'empresa_id' => $empresa->id,
'tabela' => $tabela,
'descricao' => $descricao,
'descricao_curta' => $descricaoCurta,
'referencia' => $referencia,
'status' => 1,
'por_sistema' => 1,
'prioridade' => $prioridade,
'visualizada' => 0,
'titulo' => $titulo
]);
}
}
private function getDescricao($tabela, $item){
if($tabela == 'conta_recebers'){
return view('notificacao.partials.conta_receber', compact('item'));
}
if($tabela == 'conta_pagars'){
return view('notificacao.partials.conta_pagar', compact('item'));
}
if($tabela == 'compras'){
return view('notificacao.partials.compras', compact('item'));
}
if($tabela == 'estoques'){
return view('notificacao.partials.estoques', compact('item'));
}
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Utils\WhatsAppUtil;
use App\Models\Cliente;
use App\Models\MensagemPadraoCrm;
use App\Models\ConfigGeral;
use App\Models\EmailConfig;
use App\Utils\EmailUtil;
use App\Models\FilaEnvioCron;
use Mail;
class AniversarioCron extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'aniversario:cron';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Envio de mensagens aniversario clientes';
protected $whatsAppUtil;
protected $emailUtil;
public function __construct(WhatsAppUtil $whatsAppUtil, EmailUtil $emailUtil){
parent::__construct();
$this->whatsAppUtil = $whatsAppUtil;
$this->emailUtil = $emailUtil;
}
public function handle()
{
$data = MensagemPadraoCrm::where('status', 1)
->where('tipo', 'aniversario')->get();
$hoje = \Carbon\Carbon::now();
foreach($data as $item){
$clientes = Cliente::where('empresa_id', $item->empresa_id)
->whereDay('data_nascimento', $hoje->day)
->whereMonth('data_nascimento', $hoje->month)
->get();
$config = ConfigGeral::where('empresa_id', $item->empresa_id)->first();
foreach($clientes as $cliente){
$texto = $this->__replaceText($item, $cliente);
try{
if($item->enviar_email){
$this->__enviarEmail($cliente, $texto);
}
if($item->enviar_whatsapp){
$this->__enviarWhatsApp($cliente, $texto, $config);
}
FilaEnvioCron::create([
'empresa_id' => $cliente->empresa_id,
'mensagem' => $texto,
'erro' => '',
'status' => 'enviado',
'enviar_whatsapp' => $item->enviar_whatsapp,
'enviar_email' => $item->enviar_email,
'whatsapp' => $cliente->telefone,
'cliente_id' => $cliente->id,
'tipo' => $item->tipo,
'email' => $cliente->email,
'agendar_para' => date('Y-m-d')
]);
}catch(\Exception $e){
FilaEnvioCron::create([
'empresa_id' => $cliente->empresa_id,
'mensagem' => $texto,
'erro' => $e->getMessage(),
'status' => 'erro',
'enviar_whatsapp' => $item->enviar_whatsapp,
'enviar_email' => $item->enviar_email,
'whatsapp' => $cliente->telefone,
'cliente_id' => $cliente->id,
'tipo' => $item->tipo,
'email' => $cliente->email,
'agendar_para' => date('Y-m-d')
]);
}
}
}
}
private function __replaceText($item, $cliente){
$texto = str_replace("[nome_cliente]", $cliente->razao_social, $item->mensagem);
return $texto;
}
private function __enviarEmail($cliente, $texto){
if(!$cliente->email){
return null;
}
$emailConfig = EmailConfig::where('empresa_id', $cliente->empresa_id)
->where('status', 1)
->first();
if($emailConfig != null){
$result = $this->emailUtil->enviaEmailPHPMailer($cliente->email, 'Feliz aniversário', $texto, $emailConfig);
}else{
Mail::send('mail.envio_cron', ['texto' => $texto], function($m) use ($cliente){
$nomeEmail = env('MAIL_FROM_NAME');
$m->from(env('MAIL_USERNAME'), $nomeEmail);
$m->subject('Feliz aniversário');
$m->to($cliente->email);
});
}
}
private function __enviarWhatsApp($cliente, $texto, $config){
if(!$cliente->telefone){
return null;
}
$token = '';
if($config && strlen($config->token_whatsapp) > 10){
$token = $config->token_whatsapp;
}
if($token != ''){
$numero = preg_replace('/[^0-9]/', '', $cliente->telefone);
$retorno = $this->whatsAppUtil->sendMessageWithToken('55'.$numero, $texto, $cliente->empresa_id, $token);
}
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Empresa;
use App\Models\ConfiguracaoSuper;
use App\Models\FinanceiroBoleto;
use App\Models\LogBoleto;
class BoletoCron extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:boleto-cron';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Gerar boletos de planos com asaas';
/**
* Execute the console command.
*/
public function handle()
{
$config = ConfiguracaoSuper::first();
$empresas = Empresa::orderBy('nome')
->where('receber_com_boleto', 1)
->where('status', 1)
->where('dia_vencimento_boleto', '!=', null)
->get();
foreach($empresas as $empresa){
if($empresa->plano){
if($empresa->asaas_id == null){
$dataCliente = [
'name' => $empresa->nome,
'cpfCnpj' => $empresa->cpf_cnpj,
'email' => $empresa->email,
'mobilePhone' => preg_replace('/[^0-9]/', '', $empresa->telefone),
'address' => $empresa->rua,
'addressNumber' => $empresa->numero,
'province' => $empresa->bairro,
'postalCode' => preg_replace('/[^0-9]/', '', $empresa->cep),
];
$endPoint = 'https://api-sandbox.asaas.com/v3/customers';
if($config->sandbox_boleto == 0){
$endPoint = 'https://api.asaas.com/v3/customers';
}
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $endPoint, [
'body' => json_encode($dataCliente),
'headers' => [
'accept' => 'application/json',
'access_token' => $config->asaas_token_boleto,
],
]);
$data = json_decode($response->getBody(),true);
$empresa->asaas_id = $data['id'];
$empresa->save();
}
$valor = $empresa->plano->valor;
$vencimento = date('Y-m') . "-" . $empresa->dia_vencimento_boleto;
$financeiroBoleto = FinanceiroBoleto::where('empresa_id', $empresa->id)
->whereMonth('vencimento', date('m'))->first();
if($financeiroBoleto == null){
$dataBoleto = [
'customer' => $empresa->asaas_id,
'billingType' => 'BOLETO',
'value' => $valor,
'dueDate' => $vencimento
];
if($config->percentual_juros_padrao_boleto){
$dataBoleto['interest'] = [
'value' => $config->percentual_juros_padrao_boleto
];
}
if($config->percentual_multa_padrao_boleto){
$dataBoleto['fine'] = [
'value' => $config->percentual_multa_padrao_boleto
];
}
$endPoint = 'https://api-sandbox.asaas.com/v3/payments';
if($config->sandbox_boleto == 0){
$endPoint = 'https://api.asaas.com/v3/payments';
}
$client = new \GuzzleHttp\Client();
$response = $client->request('POST', $endPoint, [
'body' => json_encode($dataBoleto),
'headers' => [
'accept' => 'application/json',
'access_token' => $config->asaas_token_boleto,
],
]);
$data = json_decode($response->getBody(),true);
if($data){
$boleto = [
'empresa_id' => $empresa->id,
'valor' => $valor,
'vencimento' => $vencimento,
'pdf_boleto' => $data['bankSlipUrl'],
'valor_recebido' => 0,
'juros' => $config->percentual_juros_padrao_boleto,
'multa' => $config->percentual_multa_padrao_boleto,
'status' => 0,
'plano_id' => $empresa->plano->plano_id,
'_id' => $data['id']
];
FinanceiroBoleto::create($boleto);
LogBoleto::create([
'tipo' => 'geracao',
'empresa_id' => $empresa->id,
'status' => 1,
'descricao' => 'Cron mensal'
]);
}
}
}
}
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\CashBackCliente;
use App\Models\Empresa;
use App\Models\CashBackConfig;
use App\Models\ConfigGeral;
use App\Utils\WhatsAppUtil;
class CashBackCron extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cash-back:cron';
protected $util;
/**
* The console command description.
*
* @var string
*/
protected $description = 'Eventos do cashback';
public function __construct(WhatsAppUtil $util)
{
parent::__construct();
$this->util = $util;
}
/**
* Execute the console command.
*/
public function handle()
{
// $current = "John Smith";
// file_put_contents('aaaa.txt', $current);
$this->enviaMensagensDia();
$this->enviaMensagens5Dias();
}
private function enviaMensagensDia(){
$empresas = Empresa::all();
$date = date('Y-m-d', strtotime('+1 days'));
foreach($empresas as $e){
$data = CashBackCliente::whereDate('data_expiracao', $date)
->where('empresa_id', $e->id)
->get();
$config = CashBackConfig::where('empresa_id', $e->id)->first();
$configGeral = ConfigGeral::where('empresa_id', $e->id)->first();
$token = '';
if($configGeral && strlen($configGeral->token_whatsapp) > 10){
$token = $configGeral->token_whatsapp;
}
if($config){
foreach($data as $cashback){
if($cashback->status_mensagem_1_dia == 0 && $cashback->status == 1){
$numero = preg_replace('/[^0-9]/', '', $cashback->cliente->telefone);
$texto = $config->mensagem_automatica_1_dia;
$nomeCliente = $cashback->cliente->razao_social;
if($cashback->cliente->nome_fantasia != ''){
$nomeCliente = $cashback->cliente->nome_fantasia;
}
// echo $cashback->cliente;
$texto = str_replace("{credito}", moeda($cashback->valor_credito), $texto);
$texto = str_replace("{expiracao}", __date($cashback->data_expiracao, 0), $texto);
$texto = str_replace("{nome}", $nomeCliente, $texto);
if($numero != ''){
if($token == ''){
$retorno = $this->util->sendMessage('55'.$numero, $texto, $e->id);
}else{
$retorno = $this->util->sendMessageWithToken('55'.$numero, $texto, $e->id, $token);
}
$retorno = json_decode($retorno);
if($retorno->success == true){
if($retorno){
$cashback->status_mensagem_1_dia = 1;
$cashback->save();
}
}
}
}
}
}
}
}
private function enviaMensagens5Dias(){
$empresas = Empresa::all();
$date = date('Y-m-d', strtotime('+5 days'));
foreach($empresas as $e){
$data = CashBackCliente::whereDate('data_expiracao', $date)
->where('empresa_id', $e->id)
->get();
$config = CashBackConfig::where('empresa_id', $e->id)->first();
$configGeral = ConfigGeral::where('empresa_id', $e->id)->first();
$token = '';
if($configGeral && strlen($configGeral->token_whatsapp) > 10){
$token = $configGeral->token_whatsapp;
}
if($config){
foreach($data as $cashback){
if($cashback->status_mensagem_5_dias == 0 && $cashback->status == 1){
$numero = preg_replace('/[^0-9]/', '', $cashback->cliente->telefone);
$texto = $config->mensagem_automatica_5_dias;
$nomeCliente = $cashback->cliente->razao_social;
if($cashback->cliente->nome_fantasia != ''){
$nomeCliente = $cashback->cliente->nome_fantasia;
}
// echo $cashback->cliente;
$texto = str_replace("{credito}", moeda($cashback->valor_credito), $texto);
$texto = str_replace("{expiracao}", __date($cashback->data_expiracao, 0), $texto);
$texto = str_replace("{nome}", $nomeCliente, $texto);
if($numero != ''){
// $retorno = $this->util->sendMessage('55'.$numero, $texto, $e->id);
if($token == ''){
$retorno = $this->util->sendMessage('55'.$numero, $texto, $e->id);
}else{
$retorno = $this->util->sendMessageWithToken('55'.$numero, $texto, $e->id, $token);
}
$retorno = json_decode($retorno);
if($retorno->success == true){
if($retorno){
$cashback->status_mensagem_5_dias = 1;
$cashback->save();
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\FilaEnvioCron as FilaEnvio;
use App\Models\ConfigGeral;
use App\Models\EmailConfig;
use App\Utils\WhatsAppUtil;
use App\Utils\EmailUtil;
use Mail;
class FilaEnvioCron extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fila-envio:cron';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fila de envio';
protected $whatsAppUtil;
protected $emailUtil;
public function __construct(WhatsAppUtil $whatsAppUtil, EmailUtil $emailUtil){
parent::__construct();
$this->whatsAppUtil = $whatsAppUtil;
$this->emailUtil = $emailUtil;
}
public function handle()
{
$data = FilaEnvio::where(function($q) {
$q->where('status', 'pendente')->orWhere('status', 'erro');
})
->whereDate('agendar_para', date('Y-m-d'))
->get();
foreach($data as $item){
$config = ConfigGeral::where('empresa_id', $item->empresa_id)->first();
if($item->cliente){
try{
if($item->enviar_whatsapp){
$this->__enviarWhatsApp($item->cliente, $item->texto, $config);
}
if($item->enviar_email){
$this->__enviarEmail($item->cliente, $item->texto);
}
$item->enviado_em = date('Y-m-d H:i:s');
$item->status = 'enviado';
$item->save();
}catch(\Exception $e){
$item->erro = $e->getMessage();
$item->status = 'erro';
$item->save();
}
}
}
}
private function __enviarEmail($cliente, $texto){
if(!$cliente->email){
return null;
}
$emailConfig = EmailConfig::where('empresa_id', $cliente->empresa_id)
->where('status', 1)
->first();
if($emailConfig != null){
$result = $this->emailUtil->enviaEmailPHPMailer($cliente->email, 'Como foi sua experiência', $texto, $emailConfig);
}else{
Mail::send('mail.envio_cron', ['texto' => $texto], function($m) use ($cliente){
$nomeEmail = env('MAIL_FROM_NAME');
$m->from(env('MAIL_USERNAME'), $nomeEmail);
$m->subject('Como foi sua experiência');
$m->to($cliente->email);
});
}
}
private function __enviarWhatsApp($cliente, $texto, $config){
if(!$cliente->telefone){
return null;
}
$token = '';
if($config && strlen($config->token_whatsapp) > 10){
$token = $config->token_whatsapp;
}
if($token != ''){
$numero = preg_replace('/[^0-9]/', '', $cliente->telefone);
$retorno = $this->whatsAppUtil->sendMessageWithToken('55'.$numero, $texto, $cliente->empresa_id, $token);
}
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\ConfigGeral;
use App\Models\EmailConfig;
use App\Models\FilaEnvioCron;
use App\Utils\WhatsAppUtil;
use App\Utils\EmailUtil;
use Mail;
use App\Models\MensagemPadraoCrm;
use App\Models\Nfe;
use App\Models\Nfce;
use App\Models\Cliente;
class ReativarCron extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reativar:cron';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clientes que algum tempo não compram';
protected $whatsAppUtil;
protected $emailUtil;
public function __construct(WhatsAppUtil $whatsAppUtil, EmailUtil $emailUtil){
parent::__construct();
$this->whatsAppUtil = $whatsAppUtil;
$this->emailUtil = $emailUtil;
}
public function handle()
{
$data = MensagemPadraoCrm::where('status', 1)
->where('tipo', 'reativacao')->get();
foreach($data as $item){
$config = ConfigGeral::where('empresa_id', $item->empresa_id)->first();
$nves = Nfe::where('empresa_id', $item->empresa_id)
->where('created_at', '<=', \Carbon\Carbon::now()->subDays($item->dias_ultima_venda))
->pluck('cliente_id');
$nfces = Nfce::where('empresa_id', $item->empresa_id)
->where('created_at', '<=', \Carbon\Carbon::now()->subDays($item->dias_ultima_venda))
->pluck('cliente_id');
$clientesIds = $nves->merge($nfces)->filter()->unique()->values();
foreach($clientesIds as $cid){
$cliente = Cliente::find($cid);
if($cliente){
$texto = $this->__replaceText($item, $cliente);
try{
if($item->enviar_email){
$this->__enviarEmail($cliente, $texto);
}
if($item->enviar_whatsapp){
$this->__enviarWhatsApp($cliente, $texto, $config);
}
FilaEnvioCron::create([
'empresa_id' => $cliente->empresa_id,
'mensagem' => $texto,
'erro' => '',
'status' => 'enviado',
'enviar_whatsapp' => $item->enviar_whatsapp,
'enviar_email' => $item->enviar_email,
'whatsapp' => $cliente->telefone,
'cliente_id' => $cliente->id,
'tipo' => $item->tipo,
'email' => $cliente->email,
'agendar_para' => date('Y-m-d')
]);
}catch(\Exception $e){
FilaEnvioCron::create([
'empresa_id' => $cliente->empresa_id,
'mensagem' => $texto,
'erro' => $e->getMessage(),
'status' => 'erro',
'enviar_whatsapp' => $item->enviar_whatsapp,
'enviar_email' => $item->enviar_email,
'whatsapp' => $cliente->telefone,
'cliente_id' => $cliente->id,
'tipo' => $item->tipo,
'email' => $cliente->email,
'agendar_para' => date('Y-m-d')
]);
}
}
}
}
}
private function __replaceText($item, $cliente){
$texto = str_replace("[nome_cliente]", $cliente->razao_social, $item->mensagem);
$texto = str_replace("[dias_inativo]", $item->dias_ultima_venda, $texto);
return $texto;
}
private function __enviarEmail($cliente, $texto){
if(!$cliente->email){
return null;
}
$emailConfig = EmailConfig::where('empresa_id', $cliente->empresa_id)
->where('status', 1)
->first();
if($emailConfig != null){
$result = $this->emailUtil->enviaEmailPHPMailer($cliente->email, 'Feliz aniversário', $texto, $emailConfig);
}else{
Mail::send('mail.envio_cron', ['texto' => $texto], function($m) use ($cliente){
$nomeEmail = env('MAIL_FROM_NAME');
$m->from(env('MAIL_USERNAME'), $nomeEmail);
$m->subject('Feliz aniversário');
$m->to($cliente->email);
});
}
}
private function __enviarWhatsApp($cliente, $texto, $config){
if(!$cliente->telefone){
return null;
}
$token = '';
if($config && strlen($config->token_whatsapp) > 10){
$token = $config->token_whatsapp;
}
if($token != ''){
$numero = preg_replace('/[^0-9]/', '', $cliente->telefone);
$retorno = $this->whatsAppUtil->sendMessageWithToken('55'.$numero, $texto, $cliente->empresa_id, $token);
}
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
// $schedule->command('alerta:cron')->dailyAt(1,13);
$schedule->command('alerta:cron')->everyMinute();
$schedule->command('cash-back:cron')->dailyAt('08:00');
$schedule->command('app:boleto-cron')->monthly();
$schedule->command('aniversario:cron')->hourly();
$schedule->command('fila-envio:cron')->dailyAt('08:00');
$schedule->command('reativar:cron')->dailyAt('09:00');
}
/**
* Register the commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
require base_path('routes/console.php');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exports;
use Illuminate\Contracts\View\View;
use Maatwebsite\Excel\Concerns\FromView;
class ContaPagarExport implements FromView
{
protected $data;
public function __construct($data)
{
$this->data = $data;
}
public function view(): View
{
return view('exports.contas_pagar', [
'data' => $this->data
]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exports;
use Illuminate\Contracts\View\View;
use Maatwebsite\Excel\Concerns\FromView;
class ContaReceberExport implements FromView
{
protected $data;
public function __construct($data)
{
$this->data = $data;
}
public function view(): View
{
return view('exports.contas_receber', [
'data' => $this->data
]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exports;
use Illuminate\Contracts\View\View;
use Maatwebsite\Excel\Concerns\FromView;
class ProdutoExport implements FromView
{
protected $data;
public function __construct($data)
{
$this->data = $data;
}
public function view(): View
{
return view('exports.produtos', [
'data' => $this->data
]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exports;
use Illuminate\Contracts\View\View;
use Maatwebsite\Excel\Concerns\FromView;
class RelatorioCrmExport implements FromView
{
protected $data;
public function __construct($data)
{
$this->data = $data;
}
public function view(): View
{
return view('exports.relatorio_crm', [
'data' => $this->data
]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exports;
use Illuminate\Contracts\View\View;
use Maatwebsite\Excel\Concerns\FromView;
class RelatorioEstoqueExport implements FromView
{
protected $data;
public function __construct($data)
{
$this->data = $data;
}
public function view(): View
{
return view('exports.relatorio_estoque', [
'data' => $this->data
]);
}
}

View File

@ -0,0 +1,972 @@
<?php
use App\Models\Caixa;
use App\Models\ContadorEmpresa;
use App\Models\Localizacao;
use App\Models\UsuarioLocalizacao;
use App\Models\AcaoLog;
use App\Models\ApiLog;
use App\Models\ApiConfig;
use App\Models\ConfigGeral;
use App\Models\ConfiguracaoSuper;
use App\Models\FinanceiroBoleto;
use App\Models\ProdutoTributacaoLocal;
use App\Models\MarketPlaceConfig;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
function __convert_value_bd($valor)
{
if (strlen($valor) >= 8) {
$valor = str_replace(".", "", $valor);
}
$valor = str_replace(",", ".", $valor);
return (float)$valor;
}
function __removerAcentos(string $texto): string
{
$texto = trim($texto);
$map = [
'Á'=>'A','À'=>'A','Â'=>'A','Ã'=>'A','Ä'=>'A',
'á'=>'a','à'=>'a','â'=>'a','ã'=>'a','ä'=>'a',
'É'=>'E','È'=>'E','Ê'=>'E','Ë'=>'E',
'é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
'Í'=>'I','Ì'=>'I','Î'=>'I','Ï'=>'I',
'í'=>'i','ì'=>'i','î'=>'i','ï'=>'i',
'Ó'=>'O','Ò'=>'O','Ô'=>'O','Õ'=>'O','Ö'=>'O',
'ó'=>'o','ò'=>'o','ô'=>'o','õ'=>'o','ö'=>'o',
'Ú'=>'U','Ù'=>'U','Û'=>'U','Ü'=>'U',
'ú'=>'u','ù'=>'u','û'=>'u','ü'=>'u',
'Ç'=>'C','ç'=>'c'
];
$texto = strtr($texto, $map);
return preg_replace('/[^A-Za-z0-9\s\-\.\,\/]/', '', $texto);
}
function __usaICMSST($empresa, $cst, $cfop){
if (!$empresa->substituto_tributario) {
return false;
}
$cstPermitidos = ['10','30','70','90'];
if (!in_array($cst, $cstPermitidos)) {
return false;
}
$cfopsST = ['5401','5402','5403','6401','6402','6403'];
if (!in_array($cfop, $cfopsST)) {
return false;
}
return true;
}
function __itensPagina(){
if(!Auth::user()->empresa){
return env('PAGINACAO') ?? 30;
}
$empresa_id = Auth::user()->empresa->empresa_id;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config == null){
return env('PAGINACAO') ?? 30;
}
return $config->itens_por_pagina ?? 30;
}
function __validaObjetoEmpresa($objeto)
{
if(!Auth::user()->empresa){
return true;
}
$empresa_id = Auth::user()->empresa->empresa_id;
if(isset($objeto->empresa_id)){
if($objeto->empresa_id != $empresa_id){
abort(403);
}
}
return true;
}
function valor_por_extenso($valor = 0, $maiusculas = false) {
$singular = array("centavo", "real", "mil", "milhão", "bilhão", "trilhão", "quatrilhão");
$plural = array("centavos", "reais", "mil", "milhões", "bilhões", "trilhões",
"quatrilhões");
$c = array("", "cem", "duzentos", "trezentos", "quatrocentos",
"quinhentos", "seiscentos", "setecentos", "oitocentos", "novecentos");
$d = array("", "dez", "vinte", "trinta", "quarenta", "cinquenta",
"sessenta", "setenta", "oitenta", "noventa");
$d10 = array("dez", "onze", "doze", "treze", "quatorze", "quinze",
"dezesseis", "dezesete", "dezoito", "dezenove");
$u = array("", "um", "dois", "três", "quatro", "cinco", "seis",
"sete", "oito", "nove");
$z = 0;
$rt = "";
$valor = number_format($valor, 2, ".", ".");
$inteiro = explode(".", $valor);
for($i=0;$i<count($inteiro);$i++)
for($ii=strlen($inteiro[$i]);$ii<3;$ii++)
$inteiro[$i] = "0".$inteiro[$i];
$fim = count($inteiro) - ($inteiro[count($inteiro)-1] > 0 ? 1 : 2);
for ($i=0;$i<count($inteiro);$i++) {
$valor = $inteiro[$i];
$rc = (($valor > 100) && ($valor < 200)) ? "cento" : $c[$valor[0]];
$rd = ($valor[1] < 2) ? "" : $d[$valor[1]];
$ru = ($valor > 0) ? (($valor[1] == 1) ? $d10[$valor[2]] : $u[$valor[2]]) : "";
$r = $rc.(($rc && ($rd || $ru)) ? " e " : "").$rd.(($rd &&
$ru) ? " e " : "").$ru;
$t = count($inteiro)-1-$i;
$r .= $r ? " ".($valor > 1 ? $plural[$t] : $singular[$t]) : "";
if ($valor == "000")$z++; elseif ($z > 0) $z--;
if (($t==1) && ($z>0) && ($inteiro[0] > 0)) $r .= (($z>1) ? " de " : "").$plural[$t];
if ($r) $rt = $rt . ((($i > 0) && ($i <= $fim) &&
($inteiro[0] > 0) && ($z < 1)) ? ( ($i < $fim) ? ", " : " e ") : " ") . $r;
}
if(!$maiusculas){
return($rt ? $rt : "zero");
} else {
if ($rt) $rt=ereg_replace(" E "," e ",ucwords($rt));
return (($rt) ? ($rt) : "Zero");
}
}
function __validaObjetoEmpresaContador($contador_empresa_id, $empresa_id)
{
$item = ContadorEmpresa::where('empresa_id', $empresa_id)
->where('contador_id', $contador_empresa_id)->first();
if($item == null){
abort(403, 'Você não possui acesso a esse registro!');
}
return true;
}
function __tipoMenu()
{
if(!Auth::user()->empresa){
return env('MENU_PADRAO');
}
$empresa_id = Auth::user()->empresa->empresa_id;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config == null){
return env('MENU_PADRAO');
}
return $config->tipo_menu;
}
function __tipoSmallHeader()
{
if(!Auth::user()->empresa){
return '../images/small/small-4.jpg';
}
$empresa_id = Auth::user()->empresa->empresa_id;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config == null){
return '../images/small/small-4.jpg';
}
return '../images/small/'.$config->small_header_user;
}
function __infoTopoMenu(){
$config = ConfiguracaoSuper::first();
if($config == null || $config->info_topo_menu == 1) return 1;
return 0;
}
function __casas_decimais_quantidade()
{
try{
if(!Auth::user()->empresa){
return 2;
}
$empresa_id = Auth::user()->empresa->empresa_id;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config == null){
return 2;
}
return $config->casas_decimais_quantidade;
}catch(\Exception $e){
return 2;
}
}
function __dataTopBar()
{
if(!Auth::user()->empresa){
return 'light';
}
$empresa_id = Auth::user()->empresa->empresa_id;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config == null){
return 'light';
}
return $config->cor_top_bar;
}
function __usuarioEscolherPlano(){
$config = ConfiguracaoSuper::first();
if($config == null || $config->usuario_alterar_plano == 1) return 1;
return 0;
}
function __finalizacaoPdv(){
if(Auth::user()->finalizacao_pdv == null) return 'todos';
return Auth::user()->finalizacao_pdv;
}
function __dataMenuBar()
{
if(!Auth::user()->empresa){
return 'light';
}
$empresa_id = Auth::user()->empresa->empresa_id;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config == null){
return 'light';
}
return $config->cor_menu;
}
function __dataThemeDefault()
{
if(Auth::user() && Auth::user()->tema_padrao){
return Auth::user()->tema_padrao;
}
$config = ConfiguracaoSuper::first();
if($config == null){
return 'light';
}
return $config->tema_padrao;
}
function __moeda($valor, $casas_decimais = 2)
{
return number_format($valor, $casas_decimais, ',', '.');
}
function __qtd($valor)
{
return number_format($valor, __casas_decimais_quantidade(), ',', '.');
}
function __calcPercentual($v1, $v2){
if($v1 > $v2){
return number_format(100+(($v2-$v1)/$v1*100), 1);
}else{
return 100;
}
}
function __moedaInput($valor, $casas_decimais = 2)
{
return number_format($valor, $casas_decimais, ',', '');
}
function __data_pt($data, $hora = true)
{
if ($hora) {
return \Carbon\Carbon::parse($data)->format('d/m/Y H:i');
} else {
return \Carbon\Carbon::parse($data)->format('d/m/Y');
}
}
function __hora_pt($data)
{
return \Carbon\Carbon::parse($data)->format('H:i');
}
function __isMaster()
{
if (Auth::user()->email == env("MAILMASTER")) {
return 1;
}
return 0;
}
function __isSuporte()
{
return Auth::user()->suporte;
}
function __isEmpresaMaster($empresa)
{
foreach($empresa->usuarios as $u){
if($u->usuario->email == env("MAILMASTER")){
return 1;
}
}
return 0;
}
function __isContador()
{
if (Auth::user()->tipo_contador == 1) {
return 1;
}
return 0;
}
function __isContadorPlano()
{
if (Auth::user()->empresa->empresa->cadastrar_planos == 1) {
return 1;
}
return 0;
}
function __escolheLocalidade()
{
return Auth::user()->escolher_localidade_venda;
}
function __empresasDoContador()
{
$contador_id = Auth::user()->empresa->empresa_id;
return ContadorEmpresa::where('contador_id', $contador_id)->get();
}
function __faturaBoleto()
{
if(!Auth::user()->empresa) return null;
$empresa_id = Auth::user()->empresa->empresa_id;
if(Auth::user()->empresa->empresa->receber_com_boleto == 0) return null;
$fatura = FinanceiroBoleto::where('empresa_id', $empresa_id)
->whereMonth('vencimento', date('m'))
->where('status', 0)
->first();
if($fatura != null){
$config = ConfiguracaoSuper::first();
$dif = strtotime($fatura->vencimento) - strtotime(date("Y-m-d"));
$dif = (int)floor($dif / (60 * 60 * 24));
if($dif <= $config->dias_alerta_boleto){
// dd($fatura);
return $fatura;
}
}
return null;
}
function __isAdmin()
{
return Auth::user()->admin;
}
function __getError($e)
{
return "Linha: " . $e->getLine() . ", mensagem: " . $e->getMessage() . ", arquivo: " . $e->getFile();
}
function __isCaixaAberto()
{
$usuario_id = Auth::user()->id;
return Caixa::where('usuario_id', $usuario_id)->where('status', 1)->first();
}
function get_id_user()
{
$usr = Auth::user()->id;
return $usr;
}
function get_name_user()
{
$usr = Auth::user()->name;
return $usr;
}
function __mask($val, $mask){
$maskared = '';
$k = 0;
for ($i = 0; $i <= strlen($mask) - 1; ++$i) {
if ($mask[$i] == '#') {
if (isset($val[$k])) {
$maskared .= $val[$k++];
}
} else {
if (isset($mask[$i])) {
$maskared .= $mask[$i];
}
}
}
return $maskared;
}
function __setMask($doc){
$doc = preg_replace('/[^0-9]/', '', $doc);
$mask = '##.###.###/####-##';
if (strlen($doc) == 11) {
$mask = '###.###.###-##';
}
return __mask($doc, $mask);
}
function __isPlanoFiscal(){
$empresa = auth::user()->empresa;
if(!$empresa) return false;
$plano = $empresa->empresa->plano;
if($plano){
if($plano->plano->fiscal) return 1;
}
if(__isContador()){
return 1;
}
return false;
}
function __periodoExpirar(){
$empresa = auth::user()->empresa;
if(!$empresa) return false;
$plano = $empresa->empresa->plano;
if(!$plano) return false;
// $plano->data_expiracao = "2025-04-16";
$dif = strtotime($plano->data_expiracao) - strtotime(date("Y-m-d"));
$dif = floor($dif / (60 * 60 * 24));
if($dif <= 5) return 1;
return 0;
}
function __isActivePlan($empresa, $menu){
if(!$empresa) return false;
$plano = $empresa->empresa->plano;
if($plano){
$modulos = json_decode($plano->plano->modulos) ?? [];
if(in_array($menu, $modulos)) return true;
else return false;
}
return false;
}
function __isInternacionalizar($empresa){
if(!$empresa) return false;
$config = $empresa->empresa->configuracaoCardapio;
if(!$config) return false;
if($config->intercionalizar == 1) return 1;
return false;
}
function __isNotificacao($empresa){
if(!$empresa) return false;
$config = $empresa->empresa->configuracaoCardapio;
if(!$config) return false;
return 1;
}
function __isNotificacaoMarketPlace($empresa){
if(!$empresa) return false;
$config = $empresa->empresa->configuracaoMarketPlace;
if(!$config) return false;
return 1;
}
function __isNotificacaoEcommerce($empresa){
if(!$empresa) return false;
$config = $empresa->empresa->configuracaoEcommerce;
if(!$config) return false;
return 1;
}
function __countLocalAtivo(){
try{
if(!Auth::user()->empresa){
return 0;
}
$empresa_id = Auth::user()->empresa->empresa_id;
return Localizacao::where('empresa_id', $empresa_id)
->where('status', 1)->count();
}catch(\Exception $e){
return 0;
}
}
function __getLocaisAtivos(){
$empresa_id = Auth::user()->empresa->empresa_id;
return Localizacao::where('empresa_id', $empresa_id)
->where('status', 1)->get();
}
function __getLocalAtivo(){
if(!Auth::user()->empresa){
return 0;
}
$empresa_id = Auth::user()->empresa->empresa_id;
return Localizacao::where('empresa_id', $empresa_id)
->where('status', 1)->first();
}
function __getLocaisAtivoUsuario(){
$usuario_id = Auth::user()->id;
return Localizacao::where('usuario_localizacaos.usuario_id', $usuario_id)
->select('localizacaos.*')
->join('usuario_localizacaos', 'usuario_localizacaos.localizacao_id', '=', 'localizacaos.id')
->where('localizacaos.status', 1)->get();
}
function __objetoParaEmissao($empresa, $local_id){
$primeiraLocalizacao = Localizacao::where('empresa_id', $empresa->id)
->where('status', 1)->first();
$count = Localizacao::where('empresa_id', $empresa->id)
->where('status', 1)->count();
if($count <= 1) return $empresa;
$localizacao = Localizacao::findOrFail($local_id);
if($primeiraLocalizacao == $localizacao) return $empresa;
return $localizacao;
}
function __createLog($empresa_id, $local, $acao, $descricao){
AcaoLog::create([
'empresa_id' => $empresa_id,
'local' => $local,
'acao' => $acao,
'descricao' => substr($descricao, 0, 255),
]);
}
function __createApiLog($empresa_id, $token, $status, $descricao, $tipo, $prefixo){
ApiLog::create([
'empresa_id' => $empresa_id,
'token' => $token,
'status' => $status,
'descricao' => substr($descricao, 0, 255),
'tipo' => $tipo,
'prefixo' => $prefixo
]);
}
function __validaPermissaoToken($token, $permissao){
$item = ApiConfig::where('token', $token)->first();
if($item){
$permissoes_acesso = $item->permissoes_acesso != 'null' ? json_decode($item->permissoes_acesso) : [];
if(in_array($permissao, $permissoes_acesso)) return 1;
}
return 0;
}
function __isSegmentoPlanoOtica(){
$empresa = auth::user()->empresa;
if(!$empresa) return false;
$plano = $empresa->empresa->plano;
if($plano){
if($plano->plano->segmento && $plano->plano->segmento->nome == 'Ótica') return 1;
}
return false;
}
function __isSegmentoServico($empresa_id){
$config = MarketPlaceConfig::where('empresa_id', $empresa_id)->first();
if($config == null) return 0;
$segmento = json_decode($config->segmento);
if(in_array('servicos', $segmento)) return 1;
return 0;
}
function __isSegmentoProduto($empresa_id){
$config = MarketPlaceConfig::where('empresa_id', $empresa_id)->first();
if($config == null) return 0;
$segmento = json_decode($config->segmento);
if(in_array('produtos', $segmento)) return 1;
return 0;
}
function __isProdutoServicoDelivery($empresa_id){
if(__isSegmentoProduto($empresa_id) && __isSegmentoServico($empresa_id)) return 1;
return 0;
}
function __tributacaoProdutoLocal($item, $campo, $local_id){
$itemLocal = ProdutoTributacaoLocal::where('produto_id', $item->id)
->where('local_id', $local_id)->first();
if($itemLocal != null){
return $itemLocal[$campo];
}
return $item[$campo];
}
function __tributacaoProdutoLocalNcm($item, $local_id){
$itemLocal = ProdutoTributacaoLocal::where('produto_id', $item->id)
->where('local_id', $local_id)->first();
if($itemLocal != null){
return $itemLocal->_ncm ? [$itemLocal->ncm => $itemLocal->_ncm->descricao] : [];
}
return $item->_ncm ? [$item->ncm => $item->_ncm->descricao] : [];
}
function __primeiroLocal($local_id, $empresa_id){
$local = Localizacao::where('empresa_id', $empresa_id)
->where('status', 1)->first();
return $local_id == $local->id;
}
function __tributacaoProdutoLocalVenda($produto, $local_id){
$itemLocal = ProdutoTributacaoLocal::where('produto_id', $produto->id)
->where('local_id', $local_id)->first();
if($itemLocal == null || __primeiroLocal($local_id, $produto->empresa_id)){
return $produto;
}
$produto->ncm = $itemLocal->ncm;
$produto->perc_icms = $itemLocal->perc_icms;
$produto->perc_pis = $itemLocal->perc_pis;
$produto->perc_cofins = $itemLocal->perc_cofins;
$produto->perc_ipi = $itemLocal->perc_ipi;
$produto->cest = $itemLocal->cest;
$produto->origem = $itemLocal->origem;
$produto->cst_csosn = $itemLocal->cst_csosn;
$produto->cst_pis = $itemLocal->cst_pis;
$produto->cst_cofins = $itemLocal->cst_cofins;
$produto->cst_ipi = $itemLocal->cst_ipi;
$produto->valor_unitario = $itemLocal->valor_unitario;
$produto->cfop_estadual = $itemLocal->cfop_estadual;
$produto->cfop_outro_estado = $itemLocal->cfop_outro_estado;
return $produto;
}
function __valorProdutoLocal($produto, $local_id){
$itemLocal = ProdutoTributacaoLocal::where('produto_id', $produto->id)
->where('local_id', $local_id)->first();
if($itemLocal == null || __primeiroLocal($local_id, $produto->empresa_id)){
return $produto->valor_unitario;
}
return $itemLocal->valor_unitario;
}
function __getPais($codigo){
if(isset(__getPaises()[$codigo])){
return __getPaises()[$codigo];
}
return 'Brasil';
}
function __getPaises(){
return [
'0132' => "Afeganistão",
'7560' => "África do Sul",
'0175' => "Albânia, República da",
'0230' => "Alemanha",
'0370' => "Andorra Sim",
'0400' => "Angola",
'0418' => "Anguilla Sim",
'0434' => "Antigua e Barbuda Sim",
'0477' => "Antilhas Holandesas Sim",
'0531' => "Arábia Saudita",
'0590' => "Argélia",
'0639' => "Argentina",
'0647' => "Armênia, República da",
'0655' => "Aruba",
'0698' => "Austrália",
'0728' => "Áustria",
'0736' => "Azerbaijão, República do",
'0779' => "Bahamas, Ilhas Sim",
'0809' => "Bahrein, Ilhas Sim",
'0817' => "Bangladesh",
'0833' => "Barbados Sim",
'0850' => "Belarus",
'0876' => "Bélgica",
'0884' => "Belize Sim",
'2291' => "Benin",
'0906' => "Bermudas Sim",
'0973' => "Bolívia",
'0981' => "Bósnia-Herzegovina",
'1015' => "Botsuana",
'1058' => "Brasil",
'1082' => "Brunei",
'1112' => "Bulgária, República da",
'0310' => "Burkina Faso",
'1155' => "Burundi",
'1198' => "Butão",
'1279' => "Cabo Verde, República de",
'1457' => "Camarões",
'1414' => "Camboja",
'1490' => "Canadá",
'1504' => "Canal, Ilhas do (Jersey e Guernsey) Sim",
'1511' => "Canárias, Ilhas",
'1546' => "Catar",
'1376' => "Cayman, Ilhas Sim",
'1538' => "Cazaquistão, República do",
'7889' => "Chade",
'1589' => "Chile",
'1600' => "China, República Popular da",
'1635' => "Chipre Sim",
'5118' => "Christmas, Ilha (Navidad)",
'7412' => "Cingapura",
'1651' => "Cocos (Keeling), Ilhas",
'1694' => "Colômbia",
'1732' => "Comores, Ilhas",
'8885' => "Congo, República Democrática do",
'1775' => "Congo, República do",
'1830' => "Cook, Ilhas Sim",
'1872' => "Coréia, Rep. Pop. Democrática da",
'1902' => "Coréia, República da",
'1937' => "Costa do Marfim",
'1961' => "Costa Rica Sim",
'1988' => "Coveite",
'1953' => "Croácia, República da",
'1996' => "Cuba",
'2321' => "Dinamarca",
'7838' => "Djibuti Sim",
'2356' => "Dominica, Ilha Sim",
'2402' => "Egito",
'6874' => "El Salvador",
'2445' => "Emirados Árabes Unidos",
'2399' => "Equador",
'2437' => "Eritréia",
'6289' => "Escócia",
'2470' => "Eslovaca, República",
'2461' => "Eslovênia, República da",
'2453' => "Espanha",
'2496' => "Estados Unidos",
'2518' => "Estônia, República da",
'2534' => "Etiópia",
'2550' => "Falkland (Ilhas Malvinas)",
'2593' => "Feroe, Ilhas",
'8702' => "Fiji",
'2674' => "Filipinas",
'2712' => "Finlândia",
'1619' => "Formosa (Taiwan)",
'2755' => "França",
'2810' => "Gabão",
'6289' => "Gales, País de",
'2852' => "Gâmbia",
'2895' => "Gana",
'2917' => "Geórgia, República da",
'2933' => "Gibraltar Sim",
'6289' => "Grã-Bretanha",
'2976' => "Granada Sim",
'3018' => "Grécia",
'3050' => "Groenlândia",
'3093' => "Guadalupe",
'3131' => "Guam",
'3174' => "Guatemala",
'3379' => "Guiana",
'3255' => "Guiana Francesa",
'3298' => "Guiné",
'3344' => "Guiné-Bissau",
'3310' => "Guiné-Equatorial",
'3417' => "Haiti",
'5738' => "Holanda (Países Baixos)",
'3450' => "Honduras",
'3514' => "Hong Kong, Região Adm. Especial",
'3557' => "Hungria, República da",
'3573' => "Iêmen",
'3611' => "Índia",
'3654' => "Indonésia",
'6289' => "Inglaterra",
'3727' => "Irã, República Islâmica do",
'3697' => "Iraque",
'3751' => "Irlanda",
'6289' => "Irlanda do Norte",
'3794' => "Islândia",
'3832' => "Israel",
'3867' => "Itália",
'3883' => "Iugoslávia, República Fed. da",
'3913' => "Jamaica",
'3999' => "Japão",
'3964' => "Johnston, Ilhas",
'4030' => "Jordânia",
'4111' => "Kiribati",
'4200' => "Laos, Rep. Pop. Democrática do",
'4235' => "Lebuan Sim",
'4260' => "Lesoto",
'4278' => "Letônia, República da",
'4316' => "Líbano",
'4340' => "Libéria Sim",
'4383' => "Líbia",
'4405' => "Liechtenstein Sim",
'4421' => "Lituânia, República da",
'4456' => "Luxemburgo",
'4472' => "Macau",
'4499' => "Macedônia",
'4502' => "Madagascar",
'4525' => "Madeira, Ilha da Sim",
'4553' => "Malásia",
'4588' => "Malavi",
'4618' => "Maldivas",
'4642' => "Máli",
'4677' => "Malta Sim",
'3595' => "Man, Ilhas Sim",
'4723' => "Marianas do Norte",
'4740' => "Marrocos",
'4766' => "Marshall, Ilhas Sim",
'4774' => "Martinica",
'4855' => "Maurício Sim",
'4880' => "Mauritânia",
'4936' => "México",
'0930' => "Mianmar (Birmânia)",
'4995' => "Micronésia",
'4901' => "Midway, Ilhas",
'5053' => "Moçambique",
'4944' => "Moldávia, República da",
'4952' => "Mônaco Sim",
'4979' => "Mongólia",
'5010' => "Montserrat, Ilhas Sim",
'5070' => "Namíbia",
'5088' => "Nauru Sim",
'5177' => "Nepal",
'5215' => "Nicarágua",
'5258' => "Niger",
'5282' => "Nigéria",
'5312' => "Niue, Ilha Sim",
'5355' => "Norfolk, Ilha",
'5380' => "Noruega",
'5428' => "Nova Caledônia",
'5487' => "Nova Zelândia",
'5568' => "Omã",
'5738' => "Países Baixos (Holanda)",
'5754' => "Palau",
'5800' => "Panamá Sim",
'5452' => "Papua Nova Guiné",
'5762' => "Paquistão",
'5860' => "Paraguai",
'5894' => "Peru",
'5932' => "Pitcairn, Ilha",
'5991' => "Polinésia Francesa",
'6033' => "Polônia, República da",
'6114' => "Porto Rico",
'6076' => "Portugal",
'6238' => "Quênia",
'6254' => "Quirguiz, República",
'6289' => "Reino Unido",
'6408' => "República Centro-Africana",
'6475' => "República Dominicana",
'6602' => "Reunião, Ilha",
'6700' => "Romênia",
'6750' => "Ruanda",
'6769' => "Rússia",
'6858' => "Saara Ocidental",
'6777' => "Salomão, Ilhas",
'6904' => "Samoa Sim",
'6912' => "Samoa Americana",
'6971' => "San Marino Sim",
'7102' => "Santa Helena",
'7153' => "Santa Lúcia Sim",
'6955' => "São Cristóvão e Neves Sim",
'7005' => "São Pedro e Miquelon",
'7200' => "São Tomé e Príncipe, Ilhas",
'7056' => "São Vicente e Granadinas Sim",
'7285' => "Senegal",
'7358' => "Serra Leoa",
'7315' => "Seychelles Sim",
'7447' => "Síria, República Árabe da",
'7480' => "Somália",
'7501' => "Sri Lanka",
'7544' => "Suazilândia",
'7595' => "Sudão",
'7641' => "Suécia",
'7676' => "Suíça",
'7706' => "Suriname",
'7722' => "Tadjiquistão",
'7765' => "Tailândia",
'7803' => "Tanzânia, República Unida da",
'7919' => "Tcheca, República",
'7820' => "Território Britânico Oc. Índico ",
'7951' => "Timor Leste",
'8001' => "Togo",
'8109' => "Tonga Sim",
'8052' => "Toquelau, Ilhas",
'8150' => "Trinidad e Tobago",
'8206' => "Tunísia",
'8230' => "Turcas e Caicos, Ilhas Sim",
'8249' => "Turcomenistão, República do",
'8273' => "Turquia",
'8281' => "Tuvalu",
'8311' => "Ucrânia",
'8338' => "Uganda",
'8451' => "Uruguai",
'8478' => "Uzbequistão, República do",
'5517' => "Vanuatu Sim",
'8486' => "Vaticano, Estado da Cidade do",
'8508' => "Venezuela",
'8583' => "Vietnã",
'8630' => "Virgens, Ilhas (Britânicas) Sim",
'8664' => "Virgens, Ilhas (E.U.A.) Sim",
'8737' => "Wake, Ilha",
'8753' => "Wallis e Futuna, Ilhas",
'8907' => "Zâmbia",
'6653' => "Zimbábue",
'8958' => "Zona do Canal do Panamá"
];
}
function __getUltimoNumeroSequencial($empresa_id, $tabela){
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
$ultimo = DB::table($tabela)->where('empresa_id', $empresa_id)
->orderBy('numero_sequencial', 'desc')
->where('numero_sequencial', '>', 0)->first();
$numero = $ultimo != null ? $ultimo->numero_sequencial : 0;
if($config == null){
return $numero;
}
if($tabela == 'produtos'){
return $config->ultimo_codigo_produto > $numero ? $config->ultimo_codigo_produto : $numero;
}
if($tabela == 'clientes'){
return $config->ultimo_codigo_cliente > $numero ? $config->ultimo_codigo_cliente : $numero;
}
if($tabela == 'fornecedors'){
return $config->ultimo_codigo_fornecedor > $numero ? $config->ultimo_codigo_fornecedor : $numero;
}
}
function __setUltimoNumeroSequencial($empresa_id, $tabela, $numero){
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
if($config != null){
if($tabela == 'produtos'){
$config->ultimo_codigo_produto = (int)$numero;
}
if($tabela == 'clientes'){
$config->ultimo_codigo_cliente = (int)$numero;
}
if($tabela == 'fornecedors'){
$config->ultimo_codigo_fornecedor = (int)$numero;
}
$config->save();
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\DiaSemana;
use App\Models\Funcionamento;
use App\Models\Funcionario;
use App\Models\Interrupcoes;
use App\Models\Agendamento;
use App\Models\Servico;
use App\Models\User;
use Illuminate\Http\Request;
class AgendamentoController extends Controller
{
public function buscarHorarios(Request $request)
{
$servicos = json_decode($request->servicos);
$data = $request->data;
$start_time = $request->start_time;
$empresa_id = $request->empresa_id;
$funcionario_id = $request->funcionario_id;
$diaSemanaNumerico = date('w', strtotime($data));
$diaStr = DiaSemana::getDia($diaSemanaNumerico);
$totalServico = 0;
$tempoServico = 0;
foreach($servicos as $s){
$item = Servico::findOrFail($s);
$tempoServico += (float)$item->tempo_servico;
$totalServico += (float)$item->valor;
}
$funcionarios = Funcionario::where('funcionarios.empresa_id', $empresa_id)
->select('funcionarios.*')
->join('dia_semanas', 'dia_semanas.funcionario_id', '=', 'funcionarios.id')
->where(function($q) use ($diaStr) {
return $q->orWhere('dia', 'LIKE', '%'.$diaStr.'%');
})
->when(!empty($funcionario_id), function ($query) use ($funcionario_id) {
return $query->where('funcionarios.id', $funcionario_id);
})
->get();
$horarios = [];
$minutosEspaco = 5;
foreach($funcionarios as $f){
$funcionamento = Funcionamento
::where('funcionario_id', $f->id)
->where('dia_id', $diaStr)
->first(); // funcionamento do dia
$inicio = $funcionamento->inicio;
if($start_time){
$inicio = $start_time;
}
$fim = $funcionamento->fim;
if($data == date('Y-m-d')){
$inicio = strtotime("$data $inicio");
$agora = strtotime("$data " . date('H:i'));
$horaAgora = date('H:i');
if($agora > $inicio){
// $inicio = date('H:i', strtotime(date('Y-m-d H:i') . "+ $minutosEspaco minutes"));
$inicio = $this->proximoMinuto($minutosEspaco);
// return $inicio;
}
}
$dif = strtotime("$data $fim") - strtotime("$data $inicio");
$minutosDif = $dif/(60);
if($minutosDif > 0 && $tempoServico > 0){
$contador = $minutosDif/$tempoServico;
$interrupcoes = Interrupcoes::where('funcionario_id', $f->id)
->where('status', 1)
->where('dia_id', $diaStr)->get();
$inicio = strtotime("$data $inicio");
for($i=0; $i<$contador; $i++){
$fim = strtotime("+".$tempoServico." minutes", $inicio);
$temp = [
'funcionario_id' => $f->id,
'funcionario_nome' => $f->nome,
'inicio' => date('H:i', $inicio),
'fim' => date('H:i', $fim),
'data' => $data,
'total' => $totalServico,
'tempoServico' => $tempoServico
];
$add = true;
$interrupcao = Interrupcoes::where('funcionario_id', $f->id)
->where('dia_id', $diaStr)
->whereTime('inicio', '<=', date('H:i', $inicio))
->whereTime('fim', '>=', date('H:i', $inicio))
->first();
if($interrupcao != null){
$add = false;
}else{
$agendamento = Agendamento::where('funcionario_id', $f->id)
->whereDate('data', $data)
->whereTime('inicio', '<=', date('H:i', $inicio))
->whereTime('termino', '>=', date('H:i', $inicio))
->first();
if($agendamento != null){
$add = false;
}
}
if($add == true){
array_push($horarios, $temp);
}
$inicio = $fim;
}
}
}
return view('agendamento.partials.agenda_row', compact('horarios'));
}
private function proximoMinuto($minutosEspaco){
$inicio = date('H:i', strtotime(date('Y-m-d H:i') . "+ $minutosEspaco minutes"));
$minuto = date('i', strtotime($inicio));
$hora = date('H', strtotime($inicio));
$divisao = (int)($minuto/15);
if($divisao == 0){
$inicio = date('H').':15';
}else if($divisao == 1){
$inicio = date('H').':30';
}
else if($divisao == 2){
$inicio = date('H').':45';
}else{
$inicio = date('H', strtotime(date('Y-m-d H:i') . "+ 1 hour")).':00';
}
return $inicio;
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\API\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ContaPagar;
use App\Models\CategoriaConta;
use App\Models\Localizacao;
class ContaPagarController extends Controller
{
public function index(Request $request){
$data = ContaPagar::where('empresa_id', $request->empresa_id)
->select('id', 'fornecedor_id', 'descricao', 'valor_integral', 'valor_pago', 'status', 'data_vencimento', 'data_pagamento',
'observacao', 'tipo_pagamento', 'categoria_conta_id', 'created_at')
->with(['fornecedor', 'categoria'])
->orderBy('created_at', 'desc')
->get();
return response()->json($data, 200);
}
public function categorias(Request $request){
$data = CategoriaConta::where('empresa_id', $request->empresa_id)
->select('id', 'nome')
->orderBy('nome', 'asc')
->where('status', 1)
->where('tipo', 'pagar')
->get();
return response()->json($data, 200);
}
public function store(Request $request){
try {
$local = Localizacao::where('empresa_id', $request->empresa_id)
->where('status', 1)->first();
$request->merge([
'valor_integral' => $request->valor,
'valor_original' => $request->valor,
'valor_pago' => $request->status ? $request->valor : 0,
'data_pagamento' => $request->status ? $request->data_vencimento : null,
'local_id' => $local->id
]);
$conta = ContaPagar::create($request->all());
return response()->json($conta, 200);
} catch (\Exception $e) {
__createLog($request->empresa_id, 'Conta a Pagar', 'erro', $e->getMessage());
return response()->json($e->getMessage(), 401);
}
}
public function update(Request $request, $id){
try {
$item = ContaPagar::findOrFail($id);
$request->merge([
'valor_integral' => $request->valor,
'valor_original' => $request->valor,
'valor_pago' => $request->status ? $request->valor : 0,
'data_pagamento' => $request->status ? $request->data_vencimento : null,
]);
$item->fill($request->all())->save();
return response()->json($item, 200);
} catch (\Exception $e) {
__createLog($request->empresa_id, 'Conta a Pagar', 'erro', $e->getMessage());
return response()->json($e->getMessage(), 401);
}
}
public function pay(Request $request){
try {
$item = ContaPagar::findOrFail($request->conta_id);
$item->status = 1;
$item->valor_pago = $request->valor;
$item->data_pagamento = $request->data_recebimento;
$item->tipo_pagamento = $request->tipo_pagamento;
$item->save();
return response()->json($item, 200);
} catch (\Exception $e) {
return response()->json($e->getMessage(), 401);
}
}
public function destroy($id){
$item = ContaPagar::findOrFail($id);
try{
$item->delete();
return response()->json("ok", 200);
}catch(\Exception $e){
__createLog($item->empresa_id, 'Conta a Pagar', 'erro', $e->getMessage());
return response()->json($e->getMessage(), 401);
}
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\API\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Fornecedor;
use App\Models\ContaPagar;
class FornecedorController extends Controller
{
public function index(Request $request){
$data = Fornecedor::where('empresa_id', $request->empresa_id)
->select('id', 'razao_social', 'cpf_cnpj', 'ie', 'rua', 'numero', 'bairro', 'telefone', 'email', 'complemento', 'cep', 'cidade_id',
'created_at')
->with('cidade')
->orderBy('razao_social', 'desc')
->get();
foreach($data as $c){
$c->soma_contas = ContaPagar::where('fornecedor_id', $c->id)
->where('status', 0)->sum('valor_integral');
}
return response()->json($data, 200);
}
public function store(Request $request){
try{
Fornecedor::create($request->all());
return response()->json("ok", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
public function update(Request $request, $id){
try{
$item = Fornecedor::findOrFail($id);
$item->fill($request->all())->save();
return response()->json("ok", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
public function destroy($id){
try{
$item = Fornecedor::findOrFail($id);
if(sizeof($item->compras) > 0){
return response()->json("Não é possível remover um fornecedor com compras!", 401);
}
$item->produtoFornecedor()->delete();
$item->delete();
return response()->json("ok", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
}

View File

@ -0,0 +1,732 @@
<?php
namespace App\Http\Controllers\API\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Nfe;
use App\Models\ItemNfe;
use App\Models\Empresa;
use App\Models\Cliente;
use App\Models\ComissaoVenda;
use App\Models\Produto;
use App\Models\MargemComissao;
use App\Models\FaturaNfe;
use App\Models\ConfigGeral;
use App\Models\ContaReceber;
use App\Models\Caixa;
use App\Models\Funcionario;
use App\Models\NaturezaOperacao;
use App\Models\Transportadora;
use Illuminate\Support\Facades\DB;
use App\Utils\EstoqueUtil;
use App\Utils\EmailUtil;
use App\Utils\SiegUtil;
use NFePHP\DA\NFe\Danfe;
use App\Services\NFeService;
use Dompdf\Dompdf;
use NFePHP\DA\NFe\Daevento;
class NfeController extends Controller
{
protected $estoqueUtil;
protected $emailUtil;
protected $siegUtil;
public function __construct(EmailUtil $emailUtil, SiegUtil $siegUtil, EstoqueUtil $estoqueUtil){
$this->emailUtil = $emailUtil;
$this->siegUtil = $siegUtil;
$this->estoqueUtil = $estoqueUtil;
}
public function find(Request $request){
$item = Nfe::with(['itens', 'cliente', 'fatura'])->findOrFail($request->id);
return response()->json($item, 200);
}
public function all(Request $request){
$estado = $request->estado;
$cliente = $request->cliente;
$start_date = $request->dataInicio;
$end_date = $request->dataFim;
$data = Nfe::where('empresa_id', $request->empresa_id)
->with(['itens', 'cliente', 'fatura'])
->orderBy('id', 'desc')
->select('numero_sequencial', 'total', 'cliente_id', 'desconto', 'acrescimo', 'created_at', 'estado', 'id')
->when($estado, function ($query) use ($estado) {
return $query->where('estado', $estado);
})
->when($cliente, function ($query) use ($cliente) {
return $query->where('cliente_id', $cliente);
})
->when(($start_date), function ($query) use ($start_date) {
return $query->whereDate('created_at', '>=', \Carbon\Carbon::parse($start_date)->format('Y-m-d'));
})
->when(($end_date), function ($query) use ($end_date) {
return $query->whereDate('created_at', '<=', \Carbon\Carbon::parse($end_date)->format('Y-m-d'));
})
// ->get();
->paginate($request->get('per_page', env("PAGINACAO")));
return response()->json($data, 200);
}
public function store(Request $request){
try{
$nfe = DB::transaction(function () use ($request) {
$empresa = Empresa::find($request->empresa_id);
$cliente = Cliente::find($request->cliente);
$numero_nfe = $empresa->numero_ultima_nfe_producao+1;
if ($empresa->ambiente == 2) {
$numero_nfe = $empresa->numero_ultima_nfe_homologacao+1;
}
$total = __convert_value_bd(str_replace("R$ ", "", $request->total));
$desconto = __convert_value_bd(str_replace("R$ ", "", $request->desconto));
$acrescimo = __convert_value_bd(str_replace("R$ ", "", $request->acrescimo));
$valor_recebido = __convert_value_bd(str_replace("R$ ", "", $request->valor_recebido));
$funcionario = Funcionario::where('codigo', $request->codigo_operador)
->where('empresa_id', $request->empresa_id)->first();
if(!$funcionario->usuario_id){
return response()->json("Usuário não vinculado ao funcionário!", 401);
}
$caixa = Caixa::where('usuario_id', $funcionario->usuario_id)
->where('status', 1)
->first();
if($caixa == null){
return response()->json("Caixa está fechado!", 401);
}
$objetoVenda = [
'cliente_id' => $request->cliente,
'natureza_id' => $request->natureza_id,
'ambiente' => $empresa->ambiente,
'total' => $total,
'desconto' => $desconto,
'acrescimo' => $acrescimo,
'estado' => 'novo',
'numero' => $numero_nfe,
'chave' => '',
'observacao' => $request->observacao,
'local_id' => $caixa->local_id,
'empresa_id' => $request->empresa_id,
'numero_serie' => $empresa->numero_serie_nfce ?? 1,
'cliente_cpf_cnpj' => $request->cpf_nota,
'cliente_nome' => '',
'troco' => $valor_recebido > 0 ? ($valor_recebido - $total) : 0,
'emissor_nome' => $empresa->nome,
'emissor_cpf_cnpj' => $empresa->cpf_cnpj,
'caixa_id' => $caixa->id,
'local_id' => $caixa->local_id,
'user_id' => $funcionario->usuario_id,
'numero_sequencial' => $this->getLastNumero($request->empresa_id)
];
$nfe = Nfe::create($objetoVenda);
foreach($request->itens as $item){
$product = Produto::findOrFail($item['produto_id']);
$variacao_id = isset($item['variacao_id']) ? $item['variacao_id'] : null;
$cfop = $product->cfop_estadual;
if($empresa->cidade->uf != $cliente->cidade->uf){
$cfop = $product->cfop_outro_estado;
}
$itemNfe = ItemNfe::create([
'nfe_id' => $nfe->id,
'produto_id' => $product->id,
'quantidade' => $item['quantidade'],
'valor_unitario' => $product->valor_unitario,
'sub_total' => $item['sub_total'],
'perc_icms' => __convert_value_bd($product->perc_icms),
'perc_pis' => __convert_value_bd($product->perc_pis),
'perc_cofins' => __convert_value_bd($product->perc_cofins),
'perc_ipi' => __convert_value_bd($product->perc_ipi),
'cst_csosn' => $product->cst_csosn,
'cst_pis' => $product->cst_pis,
'cst_cofins' => $product->cst_cofins,
'cst_ipi' => $product->cst_ipi,
'cfop' => $cfop,
'ncm' => $product->ncm,
'variacao_id' => $variacao_id,
]);
if ($product->gerenciar_estoque) {
$this->estoqueUtil->reduzEstoque($product->id, $item['quantidade'], $variacao_id, $caixa->local_id);
}
$tipo = 'reducao';
$codigo_transacao = $nfe->id;
$tipo_transacao = 'venda_nfe';
$this->estoqueUtil->movimentacaoProduto($product->id, $item['quantidade'], $tipo, $codigo_transacao, $tipo_transacao, $funcionario->usuario_id, $variacao_id);
}
foreach($request->fatura as $key => $fatura){
FaturaNfe::create([
'nfe_id' => $nfe->id,
'tipo_pagamento' => $fatura['tipo_pagamento'],
'data_vencimento' => $fatura['vencimento'],
'valor' => $item['valor']
]);
if(strtotime($fatura['vencimento']) > strtotime(date('Y-m-d'))){
ContaReceber::create([
'empresa_id' => $nfe->empresa_id,
'nfe_id' => $nfe->id,
'cliente_id' => $request->cliente,
'valor_integral' => $item['valor'],
'tipo_pagamento' => $fatura['tipo_pagamento'],
'data_vencimento' => $fatura['vencimento'],
'local_id' => $caixa->local_id,
'descricao' => "Parcela " . $key+1 . " de " . sizeof($request->fatura)
]);
}
}
$comissao = $funcionario->comissao;
$valorRetorno = $this->calcularComissaoVenda($nfe, $comissao, $nfe->empresa_id);
if($valorRetorno > 0){
ComissaoVenda::create([
'funcionario_id' => $funcionario->id,
'nfce_id' => null,
'nfe_id' => $nfe->id,
'tabela' => 'nfe',
'valor' => $valorRetorno,
'valor_venda' => $nfe->total,
'status' => 0,
'empresa_id' => $nfe->empresa_id
]);
}
return $nfe;
});
return response()->json($nfe, 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
public function update(Request $request){
try{
$nfe = DB::transaction(function () use ($request) {
$nfe = Nfe::findOrFail($request->id);
$empresa = $nfe->empresa;
$cliente = $nfe->cliente;
$total = __convert_value_bd(str_replace("R$ ", "", $request->total));
$desconto = __convert_value_bd(str_replace("R$ ", "", $request->desconto));
$acrescimo = __convert_value_bd(str_replace("R$ ", "", $request->acrescimo));
$valor_recebido = __convert_value_bd(str_replace("R$ ", "", $request->valor_recebido));
$funcionario = Funcionario::where('codigo', $request->codigo_operador)
->where('empresa_id', $request->empresa_id)->first();
if(!$funcionario->usuario_id){
return response()->json("Usuário não vinculado ao funcionário!", 401);
}
$caixa = Caixa::where('usuario_id', $funcionario->usuario_id)
->where('status', 1)
->first();
if($caixa == null){
return response()->json("Caixa está fechado!", 401);
}
$objetoVenda = [
'cliente_id' => $request->cliente,
'natureza_id' => $request->natureza_id,
'ambiente' => $empresa->ambiente,
'total' => $total,
'desconto' => $desconto,
'acrescimo' => $acrescimo,
'observacao' => $request->observacao,
'cliente_cpf_cnpj' => $request->cpf_nota,
'cliente_nome' => '',
'troco' => $valor_recebido > 0 ? ($valor_recebido - $total) : 0
];
$nfe->update($objetoVenda)->save();
foreach($nfe->itens as $it){
$it->itensDimensao()->delete();
$it->delete();
}
$item->fatura()->delete();
ContaReceber::where('nfe_id', $item->id)->delete();
foreach($request->itens as $item){
$product = Produto::findOrFail($item['produto_id']);
$variacao_id = isset($item['variacao_id']) ? $item['variacao_id'] : null;
$cfop = $product->cfop_estadual;
if($empresa->cidade->uf != $cliente->cidade->uf){
$cfop = $product->cfop_outro_estado;
}
$itemNfe = ItemNfe::create([
'nfe_id' => $nfe->id,
'produto_id' => $product->id,
'quantidade' => $item['quantidade'],
'valor_unitario' => $product->valor_unitario,
'sub_total' => $item['sub_total'],
'perc_icms' => __convert_value_bd($product->perc_icms),
'perc_pis' => __convert_value_bd($product->perc_pis),
'perc_cofins' => __convert_value_bd($product->perc_cofins),
'perc_ipi' => __convert_value_bd($product->perc_ipi),
'cst_csosn' => $product->cst_csosn,
'cst_pis' => $product->cst_pis,
'cst_cofins' => $product->cst_cofins,
'cst_ipi' => $product->cst_ipi,
'cfop' => $cfop,
'ncm' => $product->ncm,
'variacao_id' => $variacao_id,
]);
if ($product->gerenciar_estoque) {
$this->estoqueUtil->reduzEstoque($product->id, $item['quantidade'], $variacao_id, $caixa->local_id);
}
$tipo = 'reducao';
$codigo_transacao = $nfe->id;
$tipo_transacao = 'venda_nfe';
$this->estoqueUtil->movimentacaoProduto($product->id, $item['quantidade'], $tipo, $codigo_transacao, $tipo_transacao, $funcionario->usuario_id, $variacao_id);
}
foreach($request->fatura as $key => $fatura){
FaturaNfe::create([
'nfe_id' => $nfe->id,
'tipo_pagamento' => $fatura['tipo_pagamento'],
'data_vencimento' => $fatura['vencimento'],
'valor' => $item['valor']
]);
if(strtotime($fatura['vencimento']) > strtotime(date('Y-m-d'))){
ContaReceber::create([
'empresa_id' => $nfe->empresa_id,
'nfe_id' => $nfe->id,
'cliente_id' => $request->cliente,
'valor_integral' => $item['valor'],
'tipo_pagamento' => $fatura['tipo_pagamento'],
'data_vencimento' => $fatura['vencimento'],
'local_id' => $caixa->local_id,
'descricao' => "Parcela " . $key+1 . " de " . sizeof($request->fatura)
]);
}
}
$comissao = ComissaoVenda::where('empresa_id', $nfe->empresa_id)
->where('nfe_id', $nfe->id)->first();
if($comissao){
$comissao->delete();
}
$comissao = $funcionario->comissao;
$valorRetorno = $this->calcularComissaoVenda($nfe, $comissao, $nfe->empresa_id);
if($valorRetorno > 0){
ComissaoVenda::create([
'funcionario_id' => $funcionario->id,
'nfce_id' => null,
'nfe_id' => $nfe->id,
'tabela' => 'nfe',
'valor' => $valorRetorno,
'valor_venda' => $nfe->total,
'status' => 0,
'empresa_id' => $nfe->empresa_id
]);
}
return $nfe;
});
return response()->json($nfe, 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
private function calcularComissaoVenda($nfce, $comissao, $empresa_id)
{
$valorRetorno = 0;
$config = ConfigGeral::where('empresa_id', $empresa_id)->first();
$tipoComissao = 'percentual_vendedor';
if($config != null && $config->tipo_comissao == 'percentual_margem'){
$tipoComissao = 'percentual_margem';
}
if($tipoComissao == 'percentual_vendedor'){
$valorRetorno = ((float)$nfce->total * (float)$comissao) / 100;
}else{
foreach ($nfce->itens as $i) {
$percentualLucro = ((($i->produto->valor_compra-$i->valor_unitario)/$i->produto->valor_compra)*100)*-1;
$margens = MargemComissao::where('empresa_id', $empresa_id)->get();
$margemComissao = null;
$dif = 0;
$difAnterior = 100;
foreach($margens as $m){
$margem = $m->margem;
if($percentualLucro >= $margem){
$dif = $percentualLucro - $margem;
if($dif < $difAnterior){
$margemComissao = $m;
$difAnterior = $dif;
}
}
}
if($margemComissao){
$valorRetorno += ($i->sub_total * $margemComissao->percentual) / 100;
}
}
}
return $valorRetorno;
}
private function getLastNumero($empresa_id){
$last = Nfe::where('empresa_id', $empresa_id)
->orderBy('numero_sequencial', 'desc')
->where('numero_sequencial', '>', 0)->first();
$numero = $last != null ? $last->numero_sequencial : 0;
$numero++;
return $numero;
}
public function naturezaOperacao(Request $request){
$data = NaturezaOperacao::where('empresa_id', $request->empresa_id)
->select('id', 'descricao', 'padrao')
->get();
return response()->json($data, 200);
}
public function transportadoras(Request $request){
$data = Transportadora::where('empresa_id', $request->empresa_id)
->select('id', 'razao_social', 'cpf_cnpj')
->get();
return response()->json($data, 200);
}
public function emitir(Request $request)
{
$nfe = Nfe::findOrFail($request->id);
$empresa = Empresa::findOrFail($nfe->empresa_id);
if ($empresa->arquivo == null) {
return response()->json("Certificado não encontrado para este emitente", 401);
}
$nfe_service = new NFeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj),
// "schemes" => "PL_009_V4",
"schemes" => "PL_010_V1.21",
"versao" => "4.00",
], $empresa);
$doc = $nfe_service->gerarXml($nfe);
if(!isset($doc['erros_xml'])){
$xml = $doc['xml'];
$chave = $doc['chave'];
$xmlTemp = simplexml_load_string($xml);
$itensComErro = "";
$regime = $empresa->tributacao;
foreach ($xmlTemp->infNFe->det as $item) {
if (isset($item->imposto->ICMS)) {
$icms = (array_values((array)$item->imposto->ICMS));
if(sizeof($icms) == 0){
$itensComErro .= " Produto " . $item->prod->xProd . " não formando a TAG ICMS, confira se o CST do item corresponde a tributação, regime configurado: $regime";
}
}
}
if($itensComErro){
return response()->json($itensComErro, 403);
}
try{
$signed = $nfe_service->sign($xml);
$resultado = $nfe_service->transmitir($signed, $doc['chave']);
if ($resultado['erro'] == 0) {
$nfe->chave = $doc['chave'];
$nfe->estado = 'aprovado';
if($empresa->ambiente == 2){
$empresa->numero_ultima_nfe_homologacao = $doc['numero'];
}else{
$empresa->numero_ultima_nfe_producao = $doc['numero'];
}
$nfe->numero = $doc['numero'];
$nfe->recibo = $resultado['success'];
$nfe->data_emissao = date('Y-m-d H:i:s');
$nfe->save();
$empresa->save();
$data = [
'recibo' => $resultado['success'],
'chave' => $nfe->chave
];
$descricaoLog = "Emitida número $nfe->numero - $nfe->chave APROVADA";
__createLog($nfe->empresa_id, 'NFe', 'transmitir', $descricaoLog);
try{
$fileDir = public_path('xml_nfe/').$nfe->chave.'.xml';
$this->emailUtil->enviarXmlContador($nfe->empresa_id, $fileDir, 'NFe', $nfe->chave);
}catch(\Exception $e){
}
try{
$fileDir = public_path('xml_nfe/').$nfe->chave.'.xml';
$this->siegUtil->enviarXml($nfe->empresa_id, $fileDir);
}catch(\Exception $e){
}
return response()->json($data, 200);
}else{
$error = $resultado['error'];
$recibo = isset($resultado['recibo']) ? $resultado['recibo'] : null;
$motivo = '';
if(isset($error['protNFe'])){
$motivo = $error['protNFe']['infProt']['xMotivo'];
$cStat = $error['protNFe']['infProt']['cStat'];
$nfe->motivo_rejeicao = substr("[$cStat] $motivo", 0, 200);
}
if($nfe->chave == ''){
$nfe->chave = $doc['chave'];
}
$descricaoLog = "REJEITADA $nfe->chave - $motivo";
__createLog($nfe->empresa_id, 'NFe', 'erro', $descricaoLog);
if($nfe->signed_xml == null){
$nfe->signed_xml = $signed;
}
if($nfe->recibo == null){
$nfe->recibo = $recibo;
}
$nfe->estado = 'rejeitado';
$nfe->save();
if(isset($error['protNFe'])){
return response()->json("[$cStat] $motivo", 403);
}else{
return response()->json($error, 403);
}
}
}catch(\Exception $e){
return response()->json($e->getMessage(), 404);
}
}else{
return response()->json($doc['erros_xml'], 401);
}
return response()->json($doc, 200);
}
public function cancelar(Request $request)
{
$nfe = Nfe::findOrFail($request->id);
$empresa = Empresa::findOrFail($nfe->empresa_id);
if ($nfe != null) {
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$nfe_service = new NFeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$nfe->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj),
// "schemes" => "PL_009_V4",
"schemes" => "PL_010_V1.21",
"versao" => "4.00",
], $empresa);
$doc = $nfe_service->cancelar($nfe, $request->motivo);
if (!isset($doc['erro'])) {
$nfe->estado = 'cancelado';
$nfe->save();
foreach ($nfe->itens as $i) {
if ($i->produto->gerenciar_estoque) {
if ($nfe->tpNF == 1) {
$this->estoqueUtil->incrementaEstoque($i->produto_id, $i->quantidade, $i->variacao_id, $nfe->local_id);
}else{
$this->estoqueUtil->reduzEstoque($i->produto_id, $i->quantidade, $i->variacao_id, $nfe->local_id);
}
}
}
// return response()->json($doc, 200);
$motivo = $doc['retEvento']['infEvento']['xMotivo'];
$cStat = $doc['retEvento']['infEvento']['cStat'];
if($cStat == 135){
$descricaoLog = "CANCELADA $nfe->chave";
__createLog($nfe->empresa_id, 'NFe', 'cancelar', $descricaoLog);
try{
$fileDir = public_path('xml_nfe_cancelada/').$nfe->chave.'.xml';
$this->siegUtil->enviarXml($nfe->empresa_id, $fileDir);
}catch(\Exception $e){
}
return response()->json("[$cStat] $motivo", 200);
}else{
$descricaoLog = "ERRO CANCELAR: $nfe->chave";
__createLog($nfe->empresa_id, 'NFe', 'erro', $descricaoLog);
return response()->json("[$cStat] $motivo", 401);
}
} else {
$arr = $doc['data'];
$cStat = $arr['retEvento']['infEvento']['cStat'];
$motivo = $arr['retEvento']['infEvento']['xMotivo'];
$descricaoLog = "ERRO CANCELAR: $nfe->chave - $motivo";
__createLog($nfe->empresa_id, 'NFe', 'erro', $descricaoLog);
return response()->json("[$cStat] $motivo", $doc['status']);
}
} else {
return response()->json('Consulta não encontrada', 404);
}
}
public function danfe(Request $request){
$item = Nfe::findOrFail($request->id);
try{
if (!is_dir(public_path('comprovantes_nfe'))) {
mkdir(public_path('comprovantes_nfe'), 0777, true);
}else{
$destino = public_path('comprovantes_nfe');
$this->clearFolder($destino);
}
if (file_exists(public_path('xml_nfe/') . $item->chave . '.xml')) {
$xml = file_get_contents(public_path('xml_nfe/') . $item->chave . '.xml');
$empresa = $item->empresa;
$danfe = new Danfe($xml);
if($empresa->logo){
$logo = 'data://text/plain;base64,'. base64_encode(file_get_contents(public_path('/uploads/logos/') .
$empresa->logo));
$danfe->logoParameters($logo, 'L');
}
$danfe->exibirTextoFatura = 1;
$pdf = $danfe->render();
$fileName = "nfe_$item->id.pdf";
file_put_contents(public_path('comprovantes_nfe/') . $fileName , $pdf);
$url = env("APP_URL"). "/comprovantes_nfe/".$fileName;
return response()->json($url, 200);
} else {
return response()->json("Arquivo não encontrado", 401);
}
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
public function danfeCancela(Request $request){
if (!is_dir(public_path('comprovantes_nfe'))) {
mkdir(public_path('comprovantes_nfe'), 0777, true);
}else{
$destino = public_path('comprovantes_nfe');
$this->clearFolder($destino);
}
$item = Nfe::findOrFail($request->id);
try{
$xml = file_get_contents(public_path('xml_nfe_cancelada/') . $item->chave . '.xml');
$dadosEmitente = $this->getEmitente($item->empresa);
$daevento = new Daevento($xml, $dadosEmitente);
$daevento->debugMode(true);
$pdf = $daevento->render();
$fileName = "nfe_$item->id.pdf";
file_put_contents(public_path('comprovantes_nfe/') . $fileName , $pdf);
$url = env("APP_URL"). "/comprovantes_nfe/".$fileName;
return response()->json($url, 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
private function getEmitente($empresa)
{
return [
'razao' => $empresa->nome,
'logradouro' => $empresa->rua,
'numero' => $empresa->numero,
'complemento' => '',
'bairro' => $empresa->bairro,
'CEP' => preg_replace('/[^0-9]/', '', $empresa->cep),
'municipio' => $empresa->cidade->nome,
'UF' => $empresa->cidade->uf,
'telefone' => $empresa->telefone,
'email' => ''
];
}
public function imprimirPedido(Request $request){
$item = Nfe::findOrFail($request->id);
if (!is_dir(public_path('comprovantes_nfe'))) {
mkdir(public_path('comprovantes_nfe'), 0777, true);
}else{
$destino = public_path('comprovantes_nfe');
$this->clearFolder($destino);
}
$config = $item->empresa;
$configGeral = ConfigGeral::where('empresa_id', $item->empresa_id)->first();
if($configGeral && $configGeral->mensagem_padrao_impressao_venda){
$item->observacao .= " " . $configGeral->mensagem_padrao_impressao_venda;
}
$p = view('nfe.imprimir', compact('config', 'item', 'configGeral'));
$domPdf = new Dompdf(["enable_remote" => true]);
$domPdf->loadHtml($p);
$domPdf->setPaper("A4");
$domPdf->render();
$fileName = "pedido_$item->id.pdf";
file_put_contents(public_path('comprovantes_nfe/') . $fileName , $domPdf->output());
$url = env("APP_URL"). "/comprovantes_nfe/".$fileName;
return response()->json($url, 200);
}
private function clearFolder($destino){
$files = glob($destino."/*");
foreach($files as $file){
if(is_file($file)) unlink($file);
}
}
}

View File

@ -0,0 +1,380 @@
<?php
namespace App\Http\Controllers\API\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Nfe;
use App\Models\Nfce;
use App\Models\Empresa;
use App\Models\MovimentacaoProduto;
use App\Models\Estoque;
use App\Models\Produto;
use Dompdf\Dompdf;
class RelatorioController extends Controller
{
public function vendas(Request $request){
$tipo = $request->tipo;
$start_date = $request->data_inicial;
if($start_date){
$start_date = \Carbon\Carbon::createFromFormat('d/m/Y', $start_date)->format('Y-m-d');
}
$end_date = $request->data_final;
if($end_date){
$end_date = \Carbon\Carbon::createFromFormat('d/m/Y', $end_date)->format('Y-m-d');
}
$local_id = $request->local_id;
$cliente_id = $request->cliente_id;
$start_time = $request->horario_inicial;
$end_time = $request->horario_final;
$estado = $request->estado;
if($start_date){
if($start_time){
$start_date .= " $start_time:59";
}else{
$start_date .= " 00:00:00";
}
}
if($end_date){
if($end_time){
$end_date .= " $end_time:59";
}else{
$end_date .= " 23:59:59";
}
}
$vendas = Nfe::where('empresa_id', $request->empresa_id)->where('tpNF', 1)
->when(!empty($start_date), function ($query) use ($start_date) {
return $query->where('created_at', '>=', $start_date);
})
->when(!empty($end_date), function ($query) use ($end_date) {
return $query->where('created_at', '<=', $end_date);
})
// ->where('nves.estado', '!=', 'cancelado')
->when(!empty($estado), function ($query) use ($estado) {
return $query->where('estado', $estado);
})
->when(empty($estado), function ($query) use ($estado) {
return $query->where('estado', '!=', 'cancelado');
})
->limit($total_resultados ?? 1000000)
// ->when($local_id, function ($query) use ($local_id) {
// return $query->where('local_id', $local_id);
// })
// ->when(!$local_id, function ($query) use ($locais) {
// return $query->whereIn('local_id', $locais);
// })
->when($cliente_id, function ($query) use ($cliente_id) {
return $query->where('cliente_id', $cliente_id);
})
->get();
$vendasCaixa = Nfce::where('empresa_id', $request->empresa_id)
->when(!empty($start_date), function ($query) use ($start_date) {
return $query->where('created_at', '>=', $start_date);
})
->when(!empty($end_date), function ($query) use ($end_date) {
return $query->where('created_at', '<=', $end_date);
})
->where('nfces.empresa_id', $request->empresa_id)
->when(!empty($estado), function ($query) use ($estado) {
return $query->where('estado', $estado);
})
->when(empty($estado), function ($query) use ($estado) {
return $query->where('estado', '!=', 'cancelado');
})
->limit($total_resultados ?? 1000000)
// ->when($local_id, function ($query) use ($local_id) {
// return $query->where('local_id', $local_id);
// })
// ->when(!$local_id, function ($query) use ($locais) {
// return $query->whereIn('local_id', $locais);
// })
->when($cliente_id, function ($query) use ($cliente_id) {
return $query->where('cliente_id', $cliente_id);
})
->get();
$data = $this->uneArrayVendas($vendas, $vendasCaixa);
usort($data, function($a, $b){
return $a['data'] > $b['data'] ? 1 : -1;
});
$p = view('relatorios.vendas', compact('data', 'tipo'))
->with('title', 'Relatório de Vendas');
$domPdf = new Dompdf(["enable_remote" => true]);
$domPdf->loadHtml($p);
$pdf = ob_get_clean();
$domPdf->setPaper("A4", "landscape");
$domPdf->render();
if (!is_dir(public_path('relatorios_app'))) {
mkdir(public_path('relatorios_app'), 0777, true);
}
// limpar pasta
$files = glob(public_path('relatorios_app/')."/*");
foreach($files as $file){
if(is_file($file)) unlink($file);
}
$empresa = Empresa::findOrFail($request->empresa_id);
$doc = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$fileName = "Relatorio_vendas_$doc.pdf";
file_put_contents(public_path('relatorios_app/') . $fileName , $domPdf->output());
$url = env("APP_URL"). "/relatorios_app/".$fileName;
return response()->json($url, 200);
}
private function uneArrayVendas($vendas, $vendasCaixa)
{
$adicionados = [];
$arr = [];
foreach ($vendas as $v) {
$temp = [
'id' => $v->numero_sequencial,
'data' => $v->created_at,
'tipo' => 'Pedido',
'total' => $v->total,
'cliente' => $v->cliente ? $v->cliente->info : '--',
'localizacao' => $v->localizacao
// 'itens' => $v->itens
];
array_push($adicionados, $v->id);
array_push($arr, $temp);
}
foreach ($vendasCaixa as $v) {
$temp = [
'id' => $v->numero_sequencial,
'data' => $v->created_at,
'tipo' => 'PDV',
'total' => $v->total,
'cliente' => $v->cliente ? $v->cliente->info : '--',
'localizacao' => $v->localizacao
// 'itens' => $v->itens
];
array_push($adicionados, $v->id);
array_push($arr, $temp);
}
return $arr;
}
public function estoque(Request $request){
$estoque_minimo = $request->estoque_minimo;
$start_date = $request->data_inicial;
if($start_date){
$start_date = \Carbon\Carbon::createFromFormat('d/m/Y', $start_date)->format('Y-m-d');
}
$end_date = $request->data_final;
if($end_date){
$end_date = \Carbon\Carbon::createFromFormat('d/m/Y', $end_date)->format('Y-m-d');
}
$categoria_id = $request->categoria_id;
$data = [];
if($estoque_minimo == 1){
$produtosComEstoqueMinimo = Produto::where('produtos.empresa_id', $request->empresa_id)
->select('produtos.*')
->when($categoria_id, function ($query) use ($categoria_id) {
return $query->where('categoria_id', $categoria_id);
})
// ->when(!empty($local_id), function ($query) use ($local_id) {
// return $query->join('produto_localizacaos', 'produto_localizacaos.produto_id', '=', 'produtos.id')
// ->where('produto_localizacaos.localizacao_id', $local_id);
// })
// ->when(!$local_id, function ($query) use ($locais) {
// return $query->join('produto_localizacaos', 'produto_localizacaos.produto_id', '=', 'produtos.id')
// ->whereIn('produto_localizacaos.localizacao_id', $locais);
// })
->when(!empty($start_date), function ($query) use ($start_date) {
return $query->whereDate('produtos.created_at', '>=', $start_date);
})
->when(!empty($end_date), function ($query) use ($end_date,) {
return $query->whereDate('produtos.created_at', '<=', $end_date);
})
// ->limit(20)
->where('produtos.estoque_minimo', '>', 0)->get();
foreach($produtosComEstoqueMinimo as $produto){
$estoque = Estoque::where('produto_id', $produto->id)->first();
if($estoque == null || $estoque->quantidade <= $produto->estoque_minimo){
if(sizeof($produto->variacoes) == 0){
$qtd = $estoque ? $estoque->quantidade : '0';
if(!$local_id){
$qtd = $produto->estoqueTotalProduto();
}
$linha = [
'produto' => $produto->nome,
'quantidade' => $qtd,
'estoque_minimo' => $produto->estoque_minimo,
'valor_compra' => $produto->valor_compra,
'valor_venda' => $produto->valor_unitario,
'categoria' => $produto->categoria ? $produto->categoria->nome : '--',
'data_cadastro' => __data_pt($produto->created_at)
];
array_push($data, $linha);
}else{
foreach($produto->variacoes as $v){
$linha = [
'produto' => $produto->nome . " " . $v->descricao,
'quantidade' => $v->estoque ? $v->estoque->quantidade : '',
'estoque_minimo' => $produto->estoque_minimo,
'valor_compra' => $produto->valor_compra,
'valor_venda' => $v->valor,
'categoria' => $produto->categoria ? $produto->categoria->nome : '--',
'data_cadastro' => __data_pt($produto->created_at)
];
array_push($data, $linha);
}
}
}
}
}else if($start_date || $end_date){
$movimentacoes = MovimentacaoProduto::
select('movimentacao_produtos.*')
->when(!empty($start_date), function ($query) use ($start_date) {
return $query->whereDate('movimentacao_produtos.created_at', '>=', $start_date);
})
->when(!empty($end_date), function ($query) use ($end_date,) {
return $query->whereDate('movimentacao_produtos.created_at', '<=', $end_date);
})
->join('produtos', 'produtos.id', '=', 'movimentacao_produtos.produto_id')
->when($categoria_id, function ($query) use ($categoria_id) {
return $query->where('produtos.categoria_id', $categoria_id);
})
// ->when(!empty($local_id), function ($query) use ($local_id) {
// return $query->join('produto_localizacaos', 'produto_localizacaos.produto_id', '=', 'produtos.id')
// ->where('produto_localizacaos.localizacao_id', $local_id);
// })
// ->when(!$local_id, function ($query) use ($locais) {
// return $query->join('produto_localizacaos', 'produto_localizacaos.produto_id', '=', 'produtos.id')
// ->whereIn('produto_localizacaos.localizacao_id', $locais);
// })
->where('produtos.empresa_id', $request->empresa_id)
->groupBy('produtos.id')
->orderBy('movimentacao_produtos.created_at', 'desc')
->get();
foreach($movimentacoes as $m){
$produto = $m->produto;
if(sizeof($produto->variacoes) == 0){
$linha = [
'produto' => $m->produto->nome,
'quantidade' => $m->produto->estoqueTotalProduto(),
'estoque_minimo' => $m->produto->estoque_minimo,
'valor_compra' => $m->produto->valor_compra,
'valor_venda' => $m->produto->valor_unitario,
'categoria' => $m->produto->categoria ? $m->produto->categoria->nome : '--',
'data_cadastro' => __data_pt($m->produto->created_at)
];
array_push($data, $linha);
}else{
foreach($produto->variacoes as $v){
$linha = [
'produto' => $m->produto->nome . " " . $v->descricao,
'quantidade' => $v->estoque ? $v->estoque->quantidade : '',
'estoque_minimo' => $m->produto->estoque_minimo,
'valor_compra' => $m->produto->valor_compra,
'valor_venda' => $v->valor,
'categoria' => $m->produto->categoria ? $m->produto->categoria->nome : '--',
'data_cadastro' => __data_pt($m->produto->created_at)
];
array_push($data, $linha);
}
}
}
}else{
$estoque = Estoque::
select('estoques.*')
->join('produtos', 'produtos.id', '=', 'estoques.produto_id')
->groupBy('produtos.id')
->when(!empty($local_id), function ($query) use ($local_id) {
return $query->join('produto_localizacaos', 'produto_localizacaos.produto_id', '=', 'produtos.id')
->where('estoques.local_id', $local_id);
})
->when($categoria_id, function ($query) use ($categoria_id) {
return $query->where('produtos.categoria_id', $categoria_id);
})
->where('produtos.empresa_id', $request->empresa_id)->get();
foreach($estoque as $m){
$produto = $m->produto;
if(sizeof($produto->variacoes) == 0){
$linha = [
'produto' => $m->produto->nome,
'quantidade' => $m->quantidade,
'estoque_minimo' => $m->produto->estoque_minimo,
'valor_compra' => $m->produto->valor_compra,
'valor_venda' => $m->produto->valor_unitario,
'categoria' => $m->produto->categoria ? $m->produto->categoria->nome : '--',
'data_cadastro' => __data_pt($m->produto->created_at)
];
array_push($data, $linha);
}else{
foreach($produto->variacoes as $v){
$linha = [
'produto' => $m->produto->nome . " " . $v->descricao,
'quantidade' => $v->estoque ? $v->estoque->quantidade : '',
'estoque_minimo' => $m->produto->estoque_minimo,
'valor_compra' => $m->produto->valor_compra,
'valor_venda' => $v->valor,
'categoria' => $m->produto->categoria ? $m->produto->categoria->nome : '--',
'data_cadastro' => __data_pt($m->produto->created_at)
];
array_push($data, $linha);
}
}
}
}
$localizacao = null;
$p = view('relatorios.estoque', compact('data', 'start_date', 'end_date', 'estoque_minimo', 'localizacao'))
->with('title', 'Relatório de Estoque');
$domPdf = new Dompdf(["enable_remote" => true]);
$domPdf->loadHtml($p);
$pdf = ob_get_clean();
$domPdf->setPaper("A4", "landscape");
$domPdf->render();
if (!is_dir(public_path('relatorios_app'))) {
mkdir(public_path('relatorios_app'), 0777, true);
}
// limpar pasta
$files = glob(public_path('relatorios_app/')."/*");
foreach($files as $file){
if(is_file($file)) unlink($file);
}
$empresa = Empresa::findOrFail($request->empresa_id);
$doc = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$fileName = "Relatorio_vendas_$doc.pdf";
file_put_contents(public_path('relatorios_app/') . $fileName , $domPdf->output());
$url = env("APP_URL"). "/relatorios_app/".$fileName;
return response()->json($url, 200);
}
}

View File

@ -0,0 +1,565 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Cte;
use App\Models\Cliente;
use App\Models\Cidade;
use App\Models\ComponenteCte;
use App\Models\ChaveNfeCte;
use App\Models\Veiculo;
use App\Models\Empresa;
use App\Models\MedidaCte;
use App\Models\NaturezaOperacao;
use App\Services\CTeService;
use Illuminate\Support\Facades\DB;
use NFePHP\DA\CTe\Dacte;
use NFePHP\DA\CTe\Daevento;
class CTeController extends Controller
{
public function __construct(){
if (!is_dir(public_path('xml_cte'))) {
mkdir(public_path('xml_cte'), 0777, true);
}
if (!is_dir(public_path('xml_cte_cancelada'))) {
mkdir(public_path('xml_cte_cancelada'), 0777, true);
}
if (!is_dir(public_path('xml_cte_correcao'))) {
mkdir(public_path('xml_cte_correcao'), 0777, true);
}
if (!is_dir(public_path('dacte_temp'))) {
mkdir(public_path('dacte_temp'), 0777, true);
}
if (!is_dir(public_path('dacte'))) {
mkdir(public_path('dacte'), 0777, true);
}
if (!is_dir(public_path('dacte_cancelamento'))) {
mkdir(public_path('dacte_cancelamento'), 0777, true);
}
}
public function emitir(Request $request){
$documento = $request->documento;
$remetente = $request->remetente;
$destinatario = $request->destinatario;
$recebedor = $request->recebedor;
$expedidor = $request->expedidor;
$tomador = $request->tomador;
$referencia = $request->referencia;
$documentoAuxiliar = $request->documento_auxiliar;
$componentes = $request->componentes;
$medidas = $request->medidas;
$veiculo = $request->veiculo;
$empresa = Empresa::findOrFail($request->empresa_id);
// return response()->json($remetente, 200);
if($empresa->arquivo == null){
return response()->json("Certificado não encontrado para este emitente", 401);
}
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$cte = DB::transaction(function () use ($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador, $referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo) {
$cte = $this->criaCte($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador,
$referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo);
return $cte;
});
$doc = $cte_service->gerarCTe($cte);
if (!isset($doc['erros_xml'])) {
$xml = $doc['xml'];
$chave = $doc['chave'];
try{
$signed = $cte_service->sign($xml);
$resultado = $cte_service->transmitir($signed, $doc['chave']);
if ($resultado['erro'] == 0) {
$cte->chave = $doc['chave'];
$cte->estado = 'aprovado';
$cte->numero = $doc['nCte'];
$cte->recibo = $resultado['success'];
if($empresa->ambiente == 2){
$empresa->numero_ultima_cte_homologacao = $doc['nCte'];
}else{
$empresa->numero_ultima_cte_producao = $doc['nCte'];
}
$empresa->save();
$cte->save();
$xml = file_get_contents(public_path('xml_cte/') . $cte->chave . '.xml');
$danfe = new Dacte($xml);
$pdf = $danfe->render();
file_put_contents(public_path('dacte/') . $cte->chave . '.pdf', $pdf);
$pathPrint = env("APP_URL") . "/dacte/$cte->chave.pdf";
$data = [
'recibo' => $resultado['success'],
'url_print' => $pathPrint,
'chave' => $cte->chave
];
return response()->json($data, 200);
}else{
$error = $resultado['error'];
// return response()->json($resultado, 401);
if(isset($error['protCTe'])){
$motivo = $error['protCTe']['infProt']['xMotivo'];
$cStat = $error['protCTe']['infProt']['cStat'];
$cte->motivo_rejeicao = substr("[$cStat] $motivo", 0, 200);
}
$cte->numero = isset($documento['numero_cte']) ? $documento['numero_cte'] :
Nfe::lastNumero($empresa);
$cte->chave = $doc['chave'];
$cte->estado = 'rejeitado';
$cte->save();
if(isset($error['protCTe'])){
return response()->json("[$cStat] $motivo", 403);
}else{
return response()->json($error, 403);
}
}
return response()->json($xml, 200);
}catch(\Exception $e){
return response()->json(__getError($e), 404);
}
}else{
return response()->json($doc['erros_xml'], 401);
}
}
public function xmlTemporario(Request $request){
$documento = $request->documento;
$remetente = $request->remetente;
$destinatario = $request->destinatario;
$recebedor = $request->recebedor;
$expedidor = $request->expedidor;
$tomador = $request->tomador;
$referencia = $request->referencia;
$documentoAuxiliar = $request->documento_auxiliar;
$componentes = $request->componentes;
$medidas = $request->medidas;
$veiculo = $request->veiculo;
$empresa = Empresa::findOrFail($request->empresa_id);
// return response()->json($remetente, 200);
if($empresa->arquivo == null){
return response()->json("Certificado não encontrado para este emitente", 401);
}
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$cte = DB::transaction(function () use ($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador, $referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo) {
$cte = $this->criaCte($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador,
$referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo);
return $cte;
});
$doc = $cte_service->gerarCTe($cte);
if($cte != null){
$cte->componentes()->delete();
$cte->medidas()->delete();
$cte->chaves_nfe()->delete();
$cte->delete();
}
if (!isset($doc['erros_xml'])) {
$xml = $doc['xml'];
return response()->json($xml, 200);
}else{
return response()->json($doc['erros_xml'], 401);
}
}
public function dacteTemporario(Request $request){
$documento = $request->documento;
$remetente = $request->remetente;
$destinatario = $request->destinatario;
$recebedor = $request->recebedor;
$expedidor = $request->expedidor;
$tomador = $request->tomador;
$referencia = $request->referencia;
$documentoAuxiliar = $request->documento_auxiliar;
$componentes = $request->componentes;
$medidas = $request->medidas;
$veiculo = $request->veiculo;
$empresa = Empresa::findOrFail($request->empresa_id);
// return response()->json($remetente, 200);
if($empresa->arquivo == null){
return response()->json("Certificado não encontrado para este emitente", 401);
}
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$cte = DB::transaction(function () use ($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador, $referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo) {
$cte = $this->criaCte($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador,
$referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo);
return $cte;
});
$doc = $cte_service->gerarCTe($cte);
if($cte != null){
$cte->componentes()->delete();
$cte->medidas()->delete();
$cte->chaves_nfe()->delete();
$cte->delete();
}
if (!isset($doc['erros_xml'])) {
$xml = $doc['xml'];
$chave = $doc['chave'];
$danfe = new Dacte($xml);
$pdf = $danfe->render();
file_put_contents(public_path('dacte_temp/') . $chave . '.pdf', $pdf);
$pathPrint = env("APP_URL") . "/dacte_temp/$chave.pdf";
return response()->json($pathPrint, 200);
return response()->json($xml, 200);
}else{
return response()->json($doc['erros_xml'], 401);
}
}
private function criaCte($empresa, $documento, $remetente, $destinatario, $recebedor, $expedidor, $tomador,
$referencia, $documentoAuxiliar, $componentes, $medidas, $veiculo){
$remetente_id = $this->criaCliente($remetente, $empresa->id);
$destinatario_id = $this->criaCliente($destinatario, $empresa->id);
$recebedor_id = null;
$expedidor_id = null;
if($recebedor){
$recebedor_id = $this->criaCliente($recebedor, $empresa->id);
}
if($expedidor){
$expedidor_id = $this->criaCliente($expedidor, $empresa->id);
}
$natureza_id = null;
$natureza_id = $this->criaNatureza($documento['natureza_operacao'], $empresa->id);
$veiculo_id = $this->criaVeiculo($veiculo, $empresa->id);
$numero = isset($documento['numero_cte']) ? $documento['numero_cte'] : 0;
if($numero == 0){
$empresa = Empresa::findOrFail($empresa->id);
$numero = Cte::lastNumero($empresa);
}
$cidadeEnvio = Cidade::where('codigo', $documento['municipio_envio_ibge'])->first();
$cidadeInicio = Cidade::where('codigo', $documento['municipio_inicio_ibge'])->first();
$cidadeFim = Cidade::where('codigo', $documento['municipio_fim_ibge'])->first();
$cidadeTomador = Cidade::where('codigo', $tomador['cod_municipio_ibge'])->first();
$data = [
'empresa_id' => $empresa->id,
'remetente_id' => $remetente_id,
'destinatario_id' => $destinatario_id,
'natureza_id' => $natureza_id,
'veiculo_id' => $veiculo_id,
'chave' => "",
'numero_serie' => $documento['numero_serie'],
'cfop' => $documento['cfop'],
'numero' => $numero,
'estado' => 'novo',
'tomador' => $tomador['tipo'],
'logradouro_tomador' => $tomador['rua'],
'numero_tomador' => $tomador['numero'],
'bairro_tomador' => $tomador['bairro'],
'cep_tomador' => $tomador['cep'],
'municipio_tomador' => $cidadeTomador->id,
'municipio_envio' => $cidadeEnvio->id,
'municipio_inicio' => $cidadeInicio->id,
'municipio_fim' => $cidadeFim->id,
'api' => 1,
'motivo_rejeicao' => "",
'sequencia_cce' => 0,
'ambiente' => $empresa->ambiente,
'valor_transporte' => $documento['valor_transporte'],
'valor_receber' => $documento['valor_receber'],
'valor_carga' => $documento['valor_carga'],
'produto_predominante' => $documento['produto_predominante'],
'data_prevista_entrega' => $documento['data_prevista_entrega'],
'observacao' => isset($documento['observacao']) ? $documento['observacao'] : '',
'retira' => isset($documento['retira']) ? $documento['retira'] : 0,
'detalhes_retira' => isset($documento['detalhes_retira']) ? $documento['detalhes_retira'] : '',
'modal' => $documento['modal'],
'tpDoc' => $documentoAuxiliar ? $documentoAuxiliar['tipo'] : '',
'descOutros' => $documentoAuxiliar ? $documentoAuxiliar['descricao'] : '',
'nDoc' => $documentoAuxiliar ? $documentoAuxiliar['numero'] : 0,
'vDocFisc' => $documentoAuxiliar ? $documentoAuxiliar['valor'] : 0,
'globalizado' => isset($documento['globalizado']) ? $documento['globalizado'] : '',
'cst' => isset($documento['cst']) ? $documento['cst'] : '00',
'perc_icms' => __convert_value_bd($documento['perc_icms']),
'perc_red_bc' => __convert_value_bd($documento['perc_red_bc']),
];
$item = Cte::create($data);
$this->criaCompenentes($componentes, $item->id);
$this->criaMedidas($medidas, $item->id);
if($referencia){
$this->criaReferencia($referencia, $item->id);
}
return $item;
}
private function criaReferencia($referencia, $cte_id){
foreach($referencia as $r){
ChaveNfeCte::create([
'chave' => $r['chave'],
'cte_id' => $cte_id
]);
}
}
private function criaCompenentes($componentes, $cte_id){
foreach($componentes as $c){
ComponenteCte::create([
'nome' => $c['nome'],
'valor' => __convert_value_bd($c['valor']),
'cte_id' => $cte_id
]);
}
}
private function criaMedidas($medidas, $cte_id){
foreach($medidas as $m){
MedidaCte::create([
'cte_id' => $cte_id,
'tipo_medida' => $m['tipo_medida'],
'quantidade' => __convert_value_bd($m['quantidade']),
'cod_unidade' => $m['cod_unidade']
]);
}
}
private function criaNatureza($descricao, $empresa_id){
$natureza = NaturezaOperacao::where('descricao', $descricao)->first();
if($natureza != null){
return $natureza->id;
}
$natureza = NaturezaOperacao::create([
'empresa_id' => $empresa_id,
'descricao' => $descricao,
]);
return $natureza->id;
}
private function criaVeiculo($veiculo, $empresa_id){
$item = Veiculo::where('placa', $veiculo)->first();
if($item != null){
return $natureza->id;
}
$item = Veiculo::create([
'empresa_id' => $empresa_id,
'tipo' => $veiculo['tipo'],
'placa' => $veiculo['placa'],
'uf' => $veiculo['uf'],
'cor' => $veiculo['cor'],
'marca' => $veiculo['marca'],
'modelo' => $veiculo['modelo'],
'rntrc' => isset($veiculo['rntrc']) ? $veiculo['rntrc'] : '',
'tipo_carroceria' => $veiculo['tipo_carroceria'],
'tipo_rodado' => $veiculo['tipo_rodado'],
'tara' => __convert_value_bd($veiculo['tara']),
'capacidade' => __convert_value_bd($veiculo['capacidade']),
'proprietario_documento' => $veiculo['proprietario_documento'],
'proprietario_nome' => $veiculo['proprietario_nome'],
'proprietario_ie' => isset($veiculo['proprietario_ie']) ? $veiculo['proprietario_ie'] : '',
'proprietario_uf' => $veiculo['proprietario_uf'],
'proprietario_tp' => $veiculo['proprietario_tp'],
'taf' => isset($veiculo['taf']) ? $veiculo['taf'] : '',
'renavam' => isset($veiculo['renavam']) ? $veiculo['renavam'] : '',
'numero_registro_estadual' => isset($veiculo['numero_registro_estadual']) ? $veiculo['numero_registro_estadual'] : ''
]);
return $item->id;
}
private function criaCliente($cliente, $empresa_id){
$cpf_cnpj = preg_replace('/[^0-9]/', '', $cliente['cpf_cnpj']);
if(strlen($cpf_cnpj) == 11){
$doc = $this->setMask($cliente["cpf_cnpj"], '###.###.###-##');
}else{
$doc = $this->setMask($cliente["cpf_cnpj"], '##.###.###/####-##');
}
$cli = Cliente::where('cpf_cnpj', $doc)->first();
if($cli != null){
return $cli->id;
}
$cidade = Cidade::where('codigo', $cliente['cod_municipio_ibge'])->first();
$cli = Cliente::create([
'empresa_id' => $empresa_id,
'razao_social' => $cliente['nome'],
'nome_fantasia' => $cliente['nome'],
'cpf_cnpj' => $doc,
'ie' => isset($cliente['ie']) ? $cliente['ie'] : '',
'contribuinte' => $cliente['contribuinte'],
'consumidor_final' => $cliente['consumidor_final'],
'email' => isset($cliente['email']) ? $cliente['email'] : '',
'telefone' => isset($cliente['telefone']) ? $cliente['telefone'] : '',
'cidade_id' => $cidade->id,
'rua' => $cliente['rua'],
'cep' => $cliente['cep'],
'numero' => $cliente['numero'],
'bairro' => $cliente['bairro'],
'complemento' => isset($cliente['complemento']) ? $cliente['complemento'] : '',
]);
return $cli->id;
}
private function setMask($val, $mask) {
$maskared = '';
$k = 0;
for($i = 0; $i<=strlen($mask)-1; $i++) {
if($mask[$i] == '#') {
if(isset($val[$k])) $maskared .= $val[$k++];
} else {
if(isset($mask[$i])) {
if($mask[$i] == $val[$k]) {
$k++;
}
$maskared .= $mask[$i];
}
}
}
return $maskared;
}
public function cancelar(Request $request)
{
$chave = $request->chave;
$cte = Cte::where('chave', $chave)->first();
if ($cte != null) {
$empresa = $cte->empresa;
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$doc = $cte_service->cancelar($cte, $request->motivo);
if (!isset($doc['erro'])) {
$cte->estado = 'cancelado';
$cte->save();
// return response()->json($doc, 200);
// if(!isset($doc['retEvento'])){
// return response()->json($doc, 401);
// }
$motivo = $doc['infEvento']['xMotivo'];
$cStat = $doc['infEvento']['cStat'];
$xml = file_get_contents(public_path('xml_cte_cancelada/') . $cte->chave . '.xml');
$dadosEmitente = $this->getEmitente($cte->empresa);
$daevento = new Daevento($xml, $dadosEmitente);
$pdf = $daevento->render();
file_put_contents(public_path('dacte_cancelamento/') . $cte->chave . '.pdf', $pdf);
$pathPrint = env("APP_URL") . "/dacte_cancelamento/$cte->chave.pdf";
$data = [
'url_print' => $pathPrint,
'status' => "[$cStat] $motivo"
];
return response()->json($data, 200);
} else {
$arr = $doc['data'];
if(isset($arr['infEvento'])){
$cStat = $arr['infEvento']['cStat'];
$motivo = $arr['infEvento']['xMotivo'];
return response()->json("[$cStat] $motivo", $doc['status']);
}else{
return response()->json($arr, $doc['status']);
}
}
} else {
return response()->json('Consulta não encontrada', 404);
}
}
private function getEmitente($empresa){
return [
'razao' => $empresa->nome,
'logradouro' => $empresa->rua,
'numero' => $empresa->numero,
'complemento' => '',
'bairro' => $empresa->bairro,
'CEP' => preg_replace('/[^0-9]/', '', $empresa->cep),
'municipio' => $empresa->cidade->nome,
'UF' => $empresa->cidade->uf,
'telefone' => $empresa->telefone,
'email' => ''
];
}
}

View File

@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\CteOs;
use App\Models\Empresa;
use App\Services\CTeOsService;
use Illuminate\Http\Request;
use NFePHP\DA\CTe\Dacte;
use NFePHP\DA\CTe\Daevento;
class CTeOsPainelController extends Controller
{
public function emitir(Request $request)
{
$item = CteOs::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cte_service = new CTeOsService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$doc = $cte_service->gerarCTe($item);
if (!isset($doc['erros_xml'])) {
$xml = $doc['xml'];
$chave = $doc['chave'];
$signed = $cte_service->sign($xml);
$resultado = $cte_service->transmitir($signed, $chave);
// return response()->json($resultado, 403);
if ($resultado['erro'] == 0) {
$item->chave = $doc['chave'];
$item->estado_emissao = 'aprovado';
if ($empresa->ambiente == 2) {
$empresa->numero_ultima_cte_homologacao = $doc['nCte'];
} else {
$empresa->numero_ultima_cte_producao = $doc['nCte'];
}
$empresa->save();
$item->numero_emissao = $doc['nCte'];
$item->recibo = $resultado['success'];
$item->save();
$data = [
'recibo' => $resultado['success'],
'chave' => $item->chave
];
return response()->json($data, 200);
} else {
$error = $resultado['error'];
if (isset($error['protCTe'])) {
$motivo = $error['protCTe']['infProt']['xMotivo'];
$cStat = $error['protCTe']['infProt']['cStat'];
$item->motivo_rejeicao = substr("[$cStat] $motivo", 0, 200);
}
$item->chave = $doc['chave'];
$item->estado_emissao = 'rejeitado';
$item->save();
if (isset($error['protCTe'])) {
return response()->json("[$cStat] $motivo", 403);
} else {
return response()->json($error, 403);
}
}
} else {
return response()->json($doc['erros_xml'], 401);
}
}
public function consultar(Request $request)
{
$item = CteOs::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
if ($item != null) {
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$cte_service = new CTeOsService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$consulta = $cte_service->consultar($item);
if (!isset($consulta['erro'])) {
$motivo = $consulta['protCTe']['infProt']['xMotivo'];
$cStat = $consulta['protCTe']['infProt']['cStat'];
if ($cStat == 100) {
return response()->json("[$cStat] $motivo", 200);
} else {
return response()->json("[$cStat] $motivo", 401);
}
} else {
return response()->json($consulta['data'], $consulta['status']);
}
} else {
return response()->json('Consulta não encontrada', 404);
}
}
public function cancelar(Request $request)
{
$item = CteOs::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
$empresa = __objetoParaEmissao($empresa, $item->local_id);
if ($item != null) {
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$cte_service = new CTeOsService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$doc = $cte_service->cancelar($item, $request->motivo);
if (!isset($doc['erro'])) {
$item->estado_emissao = 'cancelado';
$item->save();
$motivo = $doc['infEvento']['xMotivo'];
$cStat = $doc['infEvento']['cStat'];
if ($cStat == 135) {
return response()->json("[$cStat] $motivo", 200);
} else {
return response()->json("[$cStat] $motivo", 401);
}
} else {
$arr = $doc['data'];
$cStat = $arr['infEvento']['cStat'];
$motivo = $arr['infEvento']['xMotivo'];
return response()->json("[$cStat] $motivo", $doc['status']);
}
} else {
return response()->json('Consulta não encontrada', 404);
}
}
public function corrigir(Request $request)
{
$item = CteOs::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$cte_service = new CTeOsService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$cte = $cte_service->cartaCorrecao($item, $request->grupo, $request->campo, $request->motivo);
if (!isset($cte['erro'])) {
return response()->json($cte['infEvento']['cStat'] . " - " . $cte['infEvento']['xMotivo'], 200);
} else {
return response()->json($cte['data'], $cte['status']);
}
}
}

View File

@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Cte;
use App\Models\Empresa;
use App\Services\CTeService;
use App\Utils\EmailUtil;
use App\Utils\SiegUtil;
class CTePainelController extends Controller
{
protected $emailUtil;
protected $siegUtil;
public function __construct(EmailUtil $util, SiegUtil $siegUtil){
$this->emailUtil = $util;
$this->siegUtil = $siegUtil;
}
public function emitir(Request $request){
$item = Cte::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$doc = $cte_service->gerarCTe($item);
if (!isset($doc['erros_xml'])) {
$xml = $doc['xml'];
$chave = $doc['chave'];
$signed = $cte_service->sign($xml);
$resultado = $cte_service->transmitir($signed, $chave);
// return response()->json($resultado, 403);
if ($resultado['erro'] == 0) {
$item->chave = $doc['chave'];
$item->estado = 'aprovado';
if($empresa->ambiente == 2){
$empresa->numero_ultima_cte_homologacao = $doc['nCte'];
}else{
$empresa->numero_ultima_cte_producao = $doc['nCte'];
}
$item->numero = $doc['nCte'];
$item->recibo = $resultado['success'];
$item->save();
$empresa->save();
$data = [
'recibo' => $resultado['success'],
'chave' => $item->chave
];
$descricaoLog = "Emitida número $item->numero - $item->chave APROVADA";
__createLog($item->empresa_id, 'CTe', 'transmitir', $descricaoLog);
try{
$fileDir = public_path('xml_cte/').$item->chave.'.xml';
$this->emailUtil->enviarXmlContador($empresa->id, $fileDir, 'CTe', $item->chave);
}catch(\Exception $e){
}
try{
$fileDir = public_path('xml_cte/').$item->chave.'.xml';
$this->siegUtil->enviarXml($item->empresa_id, $fileDir);
}catch(\Exception $e){
}
return response()->json($data, 200);
}else{
$error = $resultado['error'];
$motivo = '';
if(isset($error['protCTe'])){
$motivo = $error['protCTe']['infProt']['xMotivo'];
$cStat = $error['protCTe']['infProt']['cStat'];
$item->motivo_rejeicao = substr("[$cStat] $motivo", 0, 200);
}
$item->chave = $doc['chave'];
$item->estado = 'rejeitado';
$item->save();
$descricaoLog = "REJEITADA $item->chave - $motivo";
__createLog($item->empresa_id, 'CTe', 'erro', $descricaoLog);
if(isset($error['protCTe'])){
return response()->json("[$cStat] $motivo", 403);
}else{
return response()->json($error, 403);
}
}
} else {
return response()->json($doc['erros_xml'], 401);
}
}
public function consultar(Request $request)
{
$item = Cte::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
if ($item != null) {
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $empresa->cpf_cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$consulta = $cte_service->consultar($item);
if (!isset($consulta['erro'])) {
$motivo = $consulta['protCTe']['infProt']['xMotivo'];
$cStat = $consulta['protCTe']['infProt']['cStat'];
if($cStat == 100){
return response()->json("[$cStat] $motivo", 200);
}else{
return response()->json("[$cStat] $motivo", 401);
}
}else{
return response()->json($consulta['data'], $consulta['status']);
}
} else {
return response()->json('Consulta não encontrada', 404);
}
}
public function cancelar(Request $request)
{
$item = Cte::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
if ($item != null) {
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$doc = $cte_service->cancelar($item, $request->motivo);
if (!isset($doc['erro'])) {
$item->estado = 'cancelado';
$item->save();
$motivo = $doc['infEvento']['xMotivo'];
$cStat = $doc['infEvento']['cStat'];
if($cStat == 135){
try{
$fileDir = public_path('xml_cte_cancelada/').$item->chave.'.xml';
$this->siegUtil->enviarXml($item->empresa_id, $fileDir);
}catch(\Exception $e){
}
return response()->json("[$cStat] $motivo", 200);
}else{
return response()->json("[$cStat] $motivo", 401);
}
} else {
try{
$arr = $doc['data'];
$cStat = $arr['infEvento']['cStat'];
$motivo = $arr['infEvento']['xMotivo'];
return response()->json("[$cStat] $motivo", $doc['status']);
}catch(\Exception $e){
return response()->json($doc, 401);
}
}
} else {
return response()->json('Consulta não encontrada', 404);
}
}
public function corrigir(Request $request){
$item = Cte::findOrFail($request->id);
$empresa = Empresa::findOrFail($item->empresa_id);
$empresa = __objetoParaEmissao($empresa, $item->local_id);
$cnpj = preg_replace('/[^0-9]/', '', $empresa->cpf_cnpj);
$cte_service = new CTeService([
"atualizacao" => date('Y-m-d h:i:s'),
"tpAmb" => (int)$empresa->ambiente,
"razaosocial" => $empresa->nome,
"siglaUF" => $empresa->cidade->uf,
"cnpj" => $cnpj,
"schemes" => "PL_009_V4",
"versao" => "4.00",
], $empresa);
$cte = $cte_service->cartaCorrecao($item, $request->grupo, $request->campo, $request->motivo);
if(!isset($cte['erro'])){
return response()->json($cte['infEvento']['cStat']." - ". $cte['infEvento']['xMotivo'], 200);
}else{
try{
$fileDir = public_path('xml_cte_correcao/').$item->chave.'.xml';
$this->siegUtil->enviarXml($item->empresa_id, $fileDir);
}catch(\Exception $e){
}
return response()->json($cte['data'], $cte['status']);
}
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\API\Cardapio;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\ItemCarrinhoCardapio;
use App\Models\CarrinhoCardapio;
class CarrinhoController extends Controller
{
public function carrinhoCount(Request $request){
$item = ItemCarrinhoCardapio::join('carrinho_cardapios', 'carrinho_cardapios.id', 'item_carrinho_cardapios.carrinho_id')
->where('carrinho_cardapios.session_cart_cardapio', $request->session_cart_cardapio)
->where('carrinho_cardapios.session_cart_user', $request->session_cart_user)
->count();
return response()->json($item, 200);
}
public function validaEstoque(Request $request){
$item = ItemCarrinhoCardapio::findOrFail($request->item_id);
if($item->servico){
return response()->json("estoque ok", 200);
}
$quantidade = $request->quantidade;
$produto = $item->produto;
if($produto->gerenciar_estoque){
if(!$produto->estoque || $produto->estoque->quantidade < $quantidade){
return response()->json("Estoque insuficiente!", 401);
}
}
return response()->json("estoque ok", 200);
}
public function atualizaQuantidade(Request $request){
$item = ItemCarrinhoCardapio::findOrFail($request->item_id);
$item->quantidade = $request->quantidade;
$item->sub_total = $item->quantidade * $item->valor_unitario;
$item->save();
$carrinho = CarrinhoCardapio::findOrFail($item->carrinho_id);
$carrinho->valor_total = $carrinho->itens->sum('sub_total') + $carrinho->valor_frete;
$carrinho->save();
$item->total_carrinho = $carrinho->valor_total;
return response()->json($item, 200);
}
public function removeItem(Request $request){
$item = ItemCarrinhoCardapio::findOrFail($request->item_id);
$item->adicionais()->delete();
$item->sabores()->delete();
$item->delete();
$carrinho = $item->carrinho;
$carrinho->valor_total = $carrinho->itens->sum('sub_total') + $carrinho->valor_frete - $carrinho->valor_desconto;
$carrinho->save();
return response()->json($carrinho, 200);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\API\Cardapio;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Produto;
use App\Models\TamanhoPizza;
use App\Models\ConfiguracaoCardapio;
use App\Models\CategoriaAdicional;
use App\Models\ItemCarrinhoCardapio;
class ProdutoController extends Controller
{
public function produtoModal($hash){
$item = Produto::where('hash_delivery', $hash)->first();
$tamanho = TamanhoPizza::where('empresa_id', $item->empresa_id)
->orderBy('maximo_sabores', 'desc')->first();
$maximo_sabores_pizza = 0;
if($tamanho != null){
$maximo_sabores_pizza = $tamanho->maximo_sabores;
}
$config = ConfiguracaoCardapio::where('empresa_id', $item->empresa_id)->first();
$tamanhosPizza = TamanhoPizza::where('empresa_id', $item->empresa_id)
->where('status', 1)
->with('produtos')
->get();
$tipoPizza = 0;
if($item->categoria && $item->categoria->tipo_pizza){
$tipoPizza = 1;
}
$categoriasAdicional = CategoriaAdicional::where('categoria_adicionals.empresa_id', $item->empresa_id)
->where('categoria_adicionals.status', 1)
->select('categoria_adicionals.*')
->join('adicionals', 'adicionals.categoria_id', '=', 'categoria_adicionals.id')
->join('produto_adicionals', 'produto_adicionals.adicional_id', '=', 'adicionals.id')
->where('produto_adicionals.produto_id', $item->id)
->groupBy('categoria_adicionals.id')
->get();
$link = request()->link;
return view('qr_code_cardapio.partials.produto_modal',
compact('item', 'config', 'maximo_sabores_pizza', 'tamanhosPizza', 'tipoPizza', 'categoriasAdicional', 'link'))->render();
}
}

View File

@ -0,0 +1,294 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\CategoriaProduto;
use App\Models\CarrosselCardapio;
use App\Models\ConfiguracaoCardapio;
use App\Models\Produto;
use App\Models\Pedido;
use App\Models\ItemPedido;
use App\Models\ItemPizzaPedido;
use App\Models\ItemAdicional;
use App\Models\TamanhoPizza;
use App\Models\NotificaoCardapio;
use App\Models\ImpressoraPedidoProduto;
use App\Models\Nfe;
class CardapioController extends Controller
{
public function setConfig(Request $request){
$item = ConfiguracaoCardapio::where('api_token', $request->token)
->first();
if($item == null){
return response()->json("Configuração não encontrada!", 401);
}
return response()->json($item, 200);
}
public function categorias(Request $request){
$data = CategoriaProduto::where('empresa_id', $request->empresa_id)
->orderBy('nome', 'asc')
->where('status', 1)
->where('categoria_id', null)
->where('cardapio', 1)->get();
return response()->json($data, 200);
}
public function destaques(Request $request){
$data = CarrosselCardapio::where('empresa_id', $request->empresa_id)
->where('status', 1)->get();
return response()->json($data, 200);
}
public function config(Request $request){
$item = ConfiguracaoCardapio::where('empresa_id', $request->empresa_id)
->first();
return response()->json($item, 200);
}
public function categoria(Request $request, $id){
$item = CategoriaProduto::where('empresa_id', $request->empresa_id)
->where('id', $id)
->with('produtosCardapio')
->first();
return response()->json($item, 200);
}
public function produto(Request $request, $id){
$item = Produto::where('empresa_id', $request->empresa_id)
->where('id', $id)
->with(['adicionais', 'ingredientes'])
->first();
return response()->json($item, 200);
}
public function ingredientes(Request $request){
$produtos = [];
foreach($request->data as $c){
$produto = Produto::findOrFail($c['id']);
array_push($produtos, $produto);
}
$ingredientes = [];
$ingredientesTemp = [];
foreach($produtos as $p){
foreach($p->ingredientes as $i){
if(!in_array($i->ingrediente, $ingredientesTemp)){
array_push($ingredientesTemp, $i->ingrediente);
array_push($ingredientes, $i);
}
}
}
return response()->json($ingredientes, 200);
}
private function validaItemImpressao($produto_id){
$imprime = ImpressoraPedidoProduto::where('produto_id', $produto_id)->first();
return $imprime != null ? 0 : 1;
}
public function storePedido(Request $request){
$item = Pedido::where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->where('mesa', $request->mesa)
->first();
if($item == null){
return response()->json("nada encontrado", 404);
}
//salvando itens
foreach($request->carrinho as $cartItem){
$cartItem = (object)$cartItem;
$observacao = $cartItem->observacao;
if($cartItem->remover_ingredientes != ''){
$observacao .= ' - remover: '. $cartItem->remover_ingredientes;
}
$estado = 'novo';
$prod = Produto::findOrFail($cartItem->produto_id);
if($prod->tempo_preparo){
$estado = 'pendente';
}
$impresso = $this->validaItemImpressao($cartItem->produto_id);
$itemPedido = ItemPedido::create([
'pedido_id' => $item->id,
'produto_id' => $cartItem->produto_id,
'observacao' => $observacao,
'quantidade' => $cartItem->quantidade,
'valor_unitario' => $cartItem->valor_unitario > 0 ? $cartItem->valor_unitario : $cartItem->produto_valor,
'sub_total' => $cartItem->sub_total,
'ponto_carne' => $cartItem->ponto_carne,
'tamanho_id' => $cartItem->tamanho_id,
'estado' => $estado,
'impresso' => $impresso
]);
foreach($cartItem->sabores as $s){
$s = (object)$s;
ItemPizzaPedido::create([
'item_pedido_id' => $itemPedido->id,
'produto_id' => $s->id
]);
}
foreach($cartItem->adicionais as $a){
$a = (object)$a;
if($a){
$dataItemAdicional = [
'item_pedido_id' => $itemPedido->id,
'adicional_id' => $a->adicional_id,
];
ItemAdicional::create($dataItemAdicional);
}
}
}
$item->sumTotal();
return response()->json($item, 200);
}
public function storeMesa(Request $request){
$item = Pedido::where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->where('mesa', $request->mesa)
->first();
// dd($request->all());
if($item != null){
return response()->json("esta mesa já se encontra aberta", 401);
}
$item = Pedido::create([
'empresa_id' => $request->empresa_id,
'cliente_nome' => $request->nome,
'cliente_fone' => $request->telefone,
'mesa' => $request->mesa,
'total' => 0,
'comanda' => 'M'.$request->mesa,
'local_pedido' => 'App'
]);
return response()->json($item, 200);
}
public function conta(Request $request){
$item = Pedido::where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->where('mesa', $request->mesa)
->with('itens')
->first();
foreach($item->itens as $it){
if($it->produto->tempo_preparo > 0){
$it->tempo_preparo = $it->tempoPreparoRestante();
}
}
return response()->json($item, 200);
}
public function chamarGarcom(Request $request){
$item = Pedido::where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('mesa', $request->mesa)
->first();
if($item == null){
return response()->json("nada encontrado", 404);
}
$notificacao = NotificaoCardapio::create([
'empresa_id' => $request->empresa_id,
'mesa' => $request->mesa,
'tipo' => 'garcom'
]);
return response()->json($notificacao, 200);
}
public function tiposDePagamento(){
$data = Nfe::tiposPagamento();
$tipos = [];
foreach($data as $key => $t){
array_push($tipos, [
'codigo' => $key,
'nome' => $t
]);
}
return response()->json($tipos, 200);
}
public function finalizarConta(Request $request){
$item = Pedido::where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->where('mesa', $request->mesa)
->first();
if($item == null){
return response()->json("nada encontrado", 404);
}
$notificacao = NotificaoCardapio::create([
'empresa_id' => $request->empresa_id,
'mesa' => $request->mesa,
'pedido_id' => $item->id,
'tipo' => 'fechar_mesa',
'observacao' => $request->observacao,
'avaliacao' => $request->avaliacao,
'tipo_pagamento' => $request->tipo_pagamento,
]);
$item->em_atendimento = 0;
$item->save();
return response()->json($notificacao, 200);
}
public function emAtendimento(Request $request){
$item = Pedido::where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->where('mesa', $request->mesa)
->first();
if($item == null){
return response()->json("nada encontrado", 404);
}
return response()->json($item, 200);
}
public function pesquisa(Request $request){
$data = Produto::where('empresa_id', $request->empresa_id)
->where('nome', 'like', "%$request->pesquisa%")
->where('cardapio', 1)
->get();
return response()->json($data, 200);
}
public function tamanhosPizza(Request $request){
$data = TamanhoPizza::where('empresa_id', $request->empresa_id)
->get();
return response()->json($data, 200);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\CategoriaProduto;
class CategoriaProdutoController extends Controller
{
public function categoriaParaSubcategoria(Request $request){
$data = CategoriaProduto::where('empresa_id', $request->empresa_id)
->when(!is_numeric($request->pesquisa), function ($q) use ($request) {
return $q->where('nome', 'LIKE', "%$request->pesquisa%");
})
->where('status', 1)
->where('categoria_id', null)
->get();
return response()->json($data, 200);
}
public function subcategorias(Request $request){
$data = CategoriaProduto::
when(!is_numeric($request->pesquisa), function ($q) use ($request) {
return $q->where('nome', 'LIKE', "%$request->pesquisa%");
})
->where('categoria_id', $request->categoria_id)
->get();
return response()->json($data, 200);
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\Cliente;
use App\Models\ContaReceber;
use App\Models\CashBackConfig;
use App\Models\ConfigGeral;
use App\Models\ItemNfe;
use App\Models\ItemNfce;
use Illuminate\Http\Request;
class ClienteController extends Controller
{
public function find($id)
{
$item = Cliente::with(['cidade', 'listaPreco', 'tributacao', 'fatura'])->findOrFail($id);
$config = ConfigGeral::where('empresa_id', $item->empresa_id)->first();
if($config == null || $config->limitar_cliente_inadimplente == 0){
$item->inadimplente = false;
}else{
$item->inadimplente = ContaReceber::where('cliente_id', $id)
->where('status', 0)
->whereDate('data_vencimento', '<', now()->toDateString())
->exists();
}
return response()->json($item, 200);
}
public function produtosHistorico(Request $request){
$cliente_id = $request->cliente_id;
$pesquisa = $request->pesquisa;
$itensNfe = ItemNfe::select('item_nves.*')
->join('nves', 'nves.id', '=', 'item_nves.nfe_id')
->where('cliente_id', $cliente_id)
->join('produtos', 'produtos.id', '=', 'item_nves.produto_id')
->where('nves.tpNF', 1)
->where('produtos.nome', 'LIKE', "%$pesquisa%")
->get();
$itensNfce = ItemNfce::select('item_nfces.*')
->join('nfces', 'nfces.id', '=', 'item_nfces.nfce_id')
->where('cliente_id', $cliente_id)
->join('produtos', 'produtos.id', '=', 'item_nfces.produto_id')
->where('produtos.nome', 'LIKE', "%$pesquisa%")
->get();
$itens = $itensNfe
->merge($itensNfce)
->sortByDesc('created_at')
->values();
return view('clientes.partials.tabela_historico', compact('itens'))->render();
}
public function cashback($id)
{
$item = Cliente::with('cidade')->findOrFail($id);
$config = CashBackConfig::where('empresa_id', $item->empresa_id)->first();
if($config == null){
return response()->json(null, 404);
}
$config->valor_cashback = $item->valor_cashback;
return response()->json($config, 200);
}
public function pesquisa(Request $request)
{
$pesquisa = $request->pesquisa;
$pesquisaOriginal = trim($pesquisa);
$pesquisaNumerica = preg_replace('/\D/', '', $pesquisaOriginal);
$data = Cliente::orderBy('razao_social', 'desc')
->where('empresa_id', $request->empresa_id)
->where('status', 1)
// ->where('razao_social', 'like', "%$request->pesquisa%")
// ->where(function($q) use ($pesquisa){
// $q->where('razao_social', 'like', "%$pesquisa%")->orWhere('nome_fantasia', 'like', "%$pesquisa%")
// ->orWhere('numero_sequencial', 'LIKE', "%$pesquisa%");
// })
->when($pesquisaNumerica !== '', function ($query) use ($pesquisaNumerica) {
$clean = "%{$pesquisaNumerica}%";
$query->where(function ($q) use ($clean) {
$q->whereRaw("
REPLACE(REPLACE(REPLACE(REPLACE(cpf_cnpj, '.', ''), '-', ''), '/', ''), ' ', '') LIKE ?
", [$clean])
->orWhereRaw("
REPLACE(REPLACE(REPLACE(REPLACE(numero_sequencial, '.', ''), '-', ''), '/', ''), ' ', '') LIKE ?
", [$clean]);
});
})
->when($pesquisaNumerica === '', function ($query) use ($pesquisaOriginal) {
$like = "%{$pesquisaOriginal}%";
$query->where(function($q) use ($like){
$q->where('razao_social', 'like', $like)
->orWhere('nome_fantasia', 'like', $like);
});
})
->get();
return response()->json($data, 200);
}
public function pesquisaDelivery(Request $request)
{
$data = Cliente::orderBy('razao_social', 'desc')
->where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('uid', '!=', '')
->when(!is_numeric($request->pesquisa), function ($q) use ($request) {
return $q->where('razao_social', 'LIKE', "%$request->pesquisa%");
})
->when(is_numeric($request->pesquisa), function ($q) use ($request) {
return $q->where('telefone', 'LIKE', "%$request->pesquisa%");
})
->get();
return response()->json($data, 200);
}
public function store(Request $request){
$cliente = Cliente::where('empresa_id', $request->empresa_id)
->where('cpf_cnpj', $request->cpf_cnpj)
->first();
if($cliente != null){
return response()->json("Cliente já cadastrado", 401);
}
$cliente = Cliente::create($request->all());
return response()->json($cliente, 200);
}
public function consultaDebitos(Request $request){
$totalVenda = $request->total;
$somaContas = ContaReceber::where('cliente_id', $request->cliente_id)
->where('status', 0)
->sum('valor_integral');
$cliente = Cliente::findOrFail($request->cliente_id);
if($somaContas+$totalVenda > $cliente->valor_credito){
return response()->json("Valor ultrapassa o limite definido no cadastro do cliente", 403);
}
return response()->json($cliente->valor_credito, 200);
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\API\Comanda;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Cliente;
use App\Models\ContaReceber;
use App\Models\Cidade;
use App\Models\Empresa;
class ClienteController extends Controller
{
public function findCliente(Request $request){
$mask = '(##) #####-####';
$telefone = preg_replace('/[^0-9]/', '', $request->telefone);
$telefone = __mask($telefone, $mask);
$item = Cliente::where('telefone', $telefone)
->where('empresa_id', $request->empresa_id)->first();
return response()->json($item, 200);
}
public function index(Request $request){
$data = Cliente::where('empresa_id', $request->empresa_id)
->select('id', 'razao_social', 'cpf_cnpj', 'ie', 'rua', 'numero', 'bairro', 'telefone', 'email', 'complemento', 'cep', 'cidade_id',
'limite_credito', 'status', 'lista_preco_id', 'created_at')
->with('cidade')
->orderBy('razao_social', 'desc')
->where('status', 1)
->get();
foreach($data as $c){
$c->soma_contas = ContaReceber::where('cliente_id', $c->id)
->where('status', 0)->sum('valor_integral');
}
return response()->json($data, 200);
}
public function cidades(Request $request){
$cidades = Cidade::select('id', 'nome', 'uf', 'codigo')
->get();
$empresa = Empresa::findOrFail($request->empresa_id);
$data = [
'cidades' => $cidades,
'cidade_padrao' => $empresa->cidade_id
];
return response()->json($data, 200);
}
public function cidades2(Request $request){
$data = Cidade::select('id', 'nome', 'uf', 'codigo')
->get();
return response()->json($data, 200);
}
public function store(Request $request){
try{
Cliente::create($request->all());
return response()->json("ok", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
public function update(Request $request, $id){
try{
$item = Cliente::findOrFail($id);
$item->fill($request->all())->save();
return response()->json("ok", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
public function destroy($id){
try{
$item = Cliente::findOrFail($id);
$item->tributacao()->delete();
$item->enderecosEcommerce()->delete();
foreach($item->carrinhosDelivery as $c){
$c->itens()->delete();
$c->delete();
}
$item->delete();
return response()->json("ok", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 401);
}
}
}

View File

@ -0,0 +1,467 @@
<?php
namespace App\Http\Controllers\API\Comanda;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Pedido;
use App\Models\ItemPedido;
use App\Models\Produto;
use App\Models\Funcionario;
use App\Models\ItemAdicional;
use App\Models\ItemPizzaPedido;
use App\Models\ConfigGeral;
use App\Models\TamanhoPizza;
use App\Models\ProdutoPizzaValor;
use App\Models\ConfiguracaoCardapio;
use App\Models\MarketPlaceConfig;
use App\Models\ImpressoraPedidoProduto;
class ComandaController extends Controller
{
public function comandas(Request $request){
$abertas = Pedido::
where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->orderBy('comanda', 'asc')
->get();
$config = ConfigGeral::where('empresa_id', request()->empresa_id)->first();
foreach($abertas as $item){
if($item->comanda < 10){
$item->comanda = "00$item->comanda";
}elseif($item->comanda >= 10 && $item->comanda < 99){
$item->comanda = "0$item->comanda";
}else{
$item->comanda = "$item->comanda";
}
$item->total = $item->total + $item->acrescimo - $item->desconto;
}
$fechadas = [];
if($config && $config->numero_inicial_comanda && $config->numero_final_comanda){
for($i=$config->numero_inicial_comanda; $i<=$config->numero_final_comanda; $i++){
$pedido = Pedido::where('empresa_id', request()->empresa_id)
->where('status', 1)->where('comanda', $i)->first();
if($pedido == null){
if($i < 10){
$com = "00$i";
}elseif($i >= 10 && $i < 99){
$com = "0$i";
}else{
$com = "$i";
}
$fechadas[] = [
'comanda' => $com,
'mesa' => ($pedido != null && $pedido->_mesa) ? $pedido->_mesa->nome : '',
'total' => 0,
];
}
}
}
$data = [
'abertas' => $abertas,
'fechadas' => $fechadas
];
return response()->json($abertas, 200);
}
public function buscarComandas(Request $request){
$abertas = Pedido::
where('empresa_id', $request->empresa_id)
->where('status', 1)
->where('em_atendimento', 1)
->orderBy('comanda', 'asc')
->get();
$config = ConfigGeral::where('empresa_id', request()->empresa_id)->first();
foreach($abertas as $item){
if($item->comanda < 10){
$item->comanda = "00$item->comanda";
}elseif($item->comanda >= 10 && $item->comanda < 99){
$item->comanda = "0$item->comanda";
}else{
$item->comanda = "$item->comanda";
}
$item->total = $item->total + $item->acrescimo - $item->desconto;
}
$fechadas = [];
if($config && $config->numero_inicial_comanda && $config->numero_final_comanda){
for($i=$config->numero_inicial_comanda; $i<=$config->numero_final_comanda; $i++){
$pedido = Pedido::where('empresa_id', request()->empresa_id)
->where('status', 1)->where('comanda', $i)->first();
if($pedido == null){
if($i < 10){
$com = "00$i";
}elseif($i >= 10 && $i < 99){
$com = "0$i";
}else{
$com = "$i";
}
$fechadas[] = [
'comanda' => $com,
'mesa' => ($pedido != null && $pedido->_mesa) ? $pedido->_mesa->nome : '',
'total' => 0,
];
}
}
}
$data = [
'abertas' => $abertas,
'fechadas' => $fechadas
];
return response()->json($data, 200);
}
public function find(Request $request){
$item = Pedido::with(['itens'])
->findOrFail($request->comanda_id);
$config = ConfiguracaoCardapio::where('empresa_id', $item->empresa_id)
->first();
if($item->comanda < 10){
$item->comanda = "00$item->comanda";
}elseif($item->comanda >= 10 && $item->comanda < 99){
$item->comanda = "0$item->comanda";
}else{
$item->comanda = "$item->comanda";
}
foreach($item->itens as $i){
$i->adds = $i->adicionais->pluck('adicional_id')->toArray();
$i->pizzas = $i->pizzas->pluck('produto_id')->toArray();
}
$item->taxa_servico = 0;
if($config){
$item->taxa_servico = $config->percentual_taxa_servico;
}
return response()->json($item, 200);
}
public function produtos(Request $request){
$data = Produto::where('empresa_id', $request->empresa_id)
->where('cardapio', 1)
->with(['categoria', 'variacoes'])
->select('id', 'nome', 'valor_cardapio', 'categoria_id', 'valor_unitario', 'codigo_barras')
->get();
return response()->json($data, 200);
}
public function produto(Request $request){
$item = Produto::
with(['adicionais', 'categoria'])
->findOrFail($request->produto_id);
return response()->json($item, 200);
}
private function validaItemImpressao($produto_id){
$imprime = ImpressoraPedidoProduto::where('produto_id', $produto_id)->first();
return $imprime != null ? 0 : 1;
}
public function destroy($id){
$item = Pedido::findOrFail($id);
if($item->_mesa){
$mesa = $item->_mesa;
$mesa->ocupada = 0;
$mesa->save();
}
try {
foreach($item->itens as $it){
$it->adicionais()->delete();
$it->pizzas()->delete();
$it->delete();
}
$item->notificacoes()->delete();
$item->delete();
return response()->json("ok", 200);
} catch (\Exception $e) {
return response()->json($e->getMessage(), 403);
}
}
public function storeItem(Request $request){
$produto = Produto::findOrFail($request->produto_id);
$pedido = Pedido::findOrFail($request->pedido_id);
try{
$codigo_garcom = $request->codigo_operador;
$funcionario = null;
if($codigo_garcom){
$funcionario = Funcionario::where('empresa_id', $request->empresa_id)
->where('codigo', $codigo_garcom)->first();
}
$valorUnitario = $produto->valor_unitario;
if($produto->valor_cardapio > 0){
$valorUnitario = $produto->valor_cardapio;
}
$impresso = $this->validaItemImpressao($request->produto_id);
$data = [
'pedido_id' => $request->pedido_id,
'produto_id' => $request->produto_id,
'observacao' => $request->observacao,
'estado' => 'novo',
'quantidade' => $request->quantidade,
'valor_unitario' => $valorUnitario,
'sub_total' => __convert_value_bd($request->sub_total),
'tamanho_id' => $request->tamanho_id,
'funcionario_id' => $funcionario ? $funcionario->id : null,
'impresso' => $impresso
];
$itemPedido = ItemPedido::create($data);
foreach($request->adicionais as $a){
ItemAdicional::create([
'item_pedido_id' => $itemPedido->id,
'adicional_id' => $a
]);
}
if($request->sabores && sizeof($request->sabores) >= 1){
ItemPizzaPedido::create([
'item_pedido_id' => $itemPedido->id,
'produto_id' => $request->produto_id
]);
}
foreach($request->sabores as $s){
ItemPizzaPedido::create([
'item_pedido_id' => $itemPedido->id,
'produto_id' => $s
]);
}
$config = ConfiguracaoCardapio::where('empresa_id', $pedido->empresa_id)
->first();
$pedido->total = $pedido->itens->sum('sub_total');
if($config && $config->percentual_taxa_servico){
$pedido->acrescimo = $pedido->total * ($config->percentual_taxa_servico/100);
}
$pedido->save();
return response()->json($pedido, 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 403);
}
}
public function storeItensClear(Request $request){
$pedido = Pedido::findOrFail($request->pedido_id);
foreach($pedido->itens as $item){
$item->adicionais()->delete();
$item->pizzas()->delete();
$item->delete();
}
try{
$soma = 0;
foreach($request->itens as $item){
$codigo_garcom = $item['codigo_operador'];
$produto = Produto::findOrFail($item['produto_id']);
$funcionario = null;
if($codigo_garcom){
$funcionario = Funcionario::where('empresa_id', $request->empresa_id)
->where('codigo', $codigo_garcom)->first();
}
$valorUnitario = $produto->valor_unitario;
if($produto->valor_cardapio > 0){
$valorUnitario = $produto->valor_cardapio;
}
$impresso = $this->validaItemImpressao($produto->id);
$data = [
'pedido_id' => $pedido->id,
'produto_id' => $item['produto_id'],
'observacao' => $item['observacao'],
'estado' => 'novo',
'quantidade' => $item['quantidade'],
'valor_unitario' => $valorUnitario,
'sub_total' => __convert_value_bd($item['sub_total']),
'tamanho_id' => $item['tamanho_id'],
'funcionario_id' => $funcionario ? $funcionario->id : null,
'impresso' => $impresso
];
$soma += __convert_value_bd($item['sub_total']);
$itemPedido = ItemPedido::create($data);
foreach($item['adicionais'] as $a){
ItemAdicional::create([
'item_pedido_id' => $itemPedido->id,
'adicional_id' => $a
]);
}
if($item['sabores'] && sizeof($item['sabores']) >= 1){
ItemPizzaPedido::create([
'item_pedido_id' => $itemPedido->id,
'produto_id' => $request->produto_id
]);
}
foreach($item['sabores'] as $s){
ItemPizzaPedido::create([
'item_pedido_id' => $itemPedido->id,
'produto_id' => $s
]);
}
}
$pedido->total = $soma;
$pedido->save();
return response()->json($pedido, 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 403);
}
}
public function removeItem(Request $request){
$item = ItemPedido::findOrfail($request->item_id);
try{
$pedido = $item->pedido;
$item->adicionais()->delete();
$item->pizzas()->delete();
$item->delete();
$pedido->total = $pedido->itens->sum('sub_total');
$pedido->save();
return response()->json("Item removido!", 200);
}catch(\Exception $e){
return response()->json($e->getMessage(), 403);
}
}
public function fecharComanda(Request $request){
$pedido = Pedido::findOrFail($request->comanda_id);
$pedido->em_atendimento = 0;
$pedido->save();
return response()->json($pedido, 200);
}
public function openComanda(Request $request){
$cliente_id = $request->cliente_id;
$clienteNome = $request->nome;
$clienteFone = $request->telefone;
$comanda = $request->comanda;
$mesa = $request->mesa;
$item = Pedido::where('status', 1)
->where('empresa_id', $request->empresa_id)
->where('comanda', $comanda)
->first();
if($item != null){
return response()->json('Comanda já está aberta', 403);
}
$data = [
'cliente_id' => $cliente_id,
'cliente_nome' => $clienteNome,
'cliente_fone' => $clienteFone,
'comanda' => $comanda,
'mesa' => $mesa,
'total' => 0,
'empresa_id' => $request->empresa_id,
'local_pedido' => 'App'
];
$pedido = Pedido::create($data);
return response()->json($pedido, 200);
}
public function getTamanhos(Request $request){
$data = TamanhoPizza::where('empresa_id', $request->empresa_id)
->where('status', 1)
->get();
return response()->json($data, 200);
}
public function getSabores(Request $request){
$sabores = Produto::orderBy('produtos.nome', 'desc')
->select('produtos.*')
->where('produtos.empresa_id', $request->empresa_id)
->where('produtos.cardapio',1)
->where('produtos.id', '!=', $request->sabor_principal)
->join('categoria_produtos', 'categoria_produtos.id', '=', 'produtos.categoria_id')
->where('categoria_produtos.tipo_pizza', 1)
->with(['pizzaValores'])
->get();
foreach($sabores as $p){
$produtoPizzaValor = ProdutoPizzaValor::where('produto_id', $p->id)
->where('tamanho_id', $request->tamanho_id)->first();
if($produtoPizzaValor){
$p->valor = $produtoPizzaValor->valor;
}
$p->checked = false;
}
$tamanho = TamanhoPizza::findOrFail($request->tamanho_id);
$data = [
'tamanho' => $tamanho,
'sabores' => $sabores
];
return response()->json($data, 200);
}
public function valorPizza(Request $request){
$tamanho_id = $request->tamanho_id;
$empresa_id = $request->empresa_id;
$sabores = $request->sabores;
$soma = 0;
$maiorValor = 0;
$config = MarketPlaceConfig::where('empresa_id', $empresa_id)->first();
foreach($sabores as $s){
$produtoPizzaValor = ProdutoPizzaValor::where('produto_id', $s)
->where('tamanho_id', $tamanho_id)->first();
if($produtoPizzaValor){
$soma += $produtoPizzaValor->valor;
if($produtoPizzaValor->valor > $maiorValor){
$maiorValor = $produtoPizzaValor->valor;
}
}
}
$valor = $maiorValor;
if($config->tipo_divisao_pizza == 'divide'){
$valor = $soma/sizeof($sabores);
}
$valor = number_format($valor, 2);
return response()->json($valor, 200);
}
}

Some files were not shown because too many files have changed in this diff Show More