@danhachuel/prodmaker 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/app/api/dashboard.py +67 -0
- package/app/api/infoprodutos.py +10 -1
- package/app/api/lancamentos.py +81 -0
- package/app/api/notificacoes.py +40 -0
- package/app/api/paginas.py +8 -0
- package/app/main.py +8 -2
- package/app/services/campaigns.py +5 -0
- package/app/services/database.py +27 -0
- package/app/services/lancamentos.py +148 -0
- package/app/services/notify.py +127 -0
- package/app/services/vendas.py +11 -1
- package/frontend/dist/assets/index-EOri3RlA.js +74 -0
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/frontend/dist/assets/index-CKt-bO8f.js +0 -70
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
Todas as versões seguem a etiqueta visual `MAJOR.MINOR.PATCH` com patch de dois dígitos
|
|
4
4
|
(o registry NPM normaliza `0.1.00` → `0.1.0`).
|
|
5
5
|
|
|
6
|
+
## 0.1.10 — 2026-10-30
|
|
7
|
+
|
|
8
|
+
- **Parte 8 — AdsBot (Lançamento Assistido) + Dashboard real + Notificações (adsNotify).**
|
|
9
|
+
- **AdsBot:** wizard de 6 etapas (Produto → Página → Publicar → Campanhas → Tracking →
|
|
10
|
+
Conclusão) com progresso salvo no banco e retomável; marcação de etapas concluídas;
|
|
11
|
+
**Relatório de Lançamento** (produto + resumo da análise IA, páginas vinculadas com URLs
|
|
12
|
+
públicas, campanhas com CPA/orçamento e o checklist final do seed).
|
|
13
|
+
- **Dashboard com dados reais:** conversões, receita, comissão, visitas, produtos, páginas,
|
|
14
|
+
campanhas e infoprodutos; banner do lançamento em andamento ("faltam X passos" — efeito
|
|
15
|
+
meta); últimas vendas e últimas páginas. ROAS explicitamente ausente até haver registro
|
|
16
|
+
de investimento (nunca inventado).
|
|
17
|
+
- **Notificações (adsNotify):** toggles por evento (venda recebida, campanha publicada,
|
|
18
|
+
página publicada, infoproduto gerado), canais apprise por URL (Telegram entra
|
|
19
|
+
automaticamente quando Bot Token + Chat ID estão em Chaves de API), teste de envio e
|
|
20
|
+
histórico local com status/erro. Hooks disparados nos fluxos reais — falha de envio
|
|
21
|
+
nunca quebra a operação principal.
|
|
22
|
+
- Novas tabelas: `lancamentos`, `notificacoes`. Novos endpoints: `/api/lancamentos/*`,
|
|
23
|
+
`/api/notificacoes/*`, `/api/dashboard`.
|
|
24
|
+
|
|
6
25
|
## 0.1.09 — 2026-10-30
|
|
7
26
|
|
|
8
27
|
- **Parte 7 — AdsWords & AdsCreator (palavras-chave e campanhas).**
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Dashboard — agregação de dados reais de todos os módulos."""
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
|
|
4
|
+
from ..services.database import get_db
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("")
|
|
10
|
+
def dashboard():
|
|
11
|
+
with get_db() as conn:
|
|
12
|
+
produtos = {
|
|
13
|
+
"total": conn.execute("SELECT COUNT(*) AS n FROM produtos").fetchone()["n"],
|
|
14
|
+
"aprovados": conn.execute("SELECT COUNT(*) AS n FROM produtos WHERE status = 'aprovado'").fetchone()["n"],
|
|
15
|
+
"em_analise": conn.execute("SELECT COUNT(*) AS n FROM produtos WHERE status = 'em_analise'").fetchone()["n"],
|
|
16
|
+
}
|
|
17
|
+
paginas = {
|
|
18
|
+
"total": conn.execute("SELECT COUNT(*) AS n FROM paginas").fetchone()["n"],
|
|
19
|
+
"publicadas": conn.execute("SELECT COUNT(*) AS n FROM paginas WHERE status = 'publicada'").fetchone()["n"],
|
|
20
|
+
"rascunho": conn.execute("SELECT COUNT(*) AS n FROM paginas WHERE status != 'publicada'").fetchone()["n"],
|
|
21
|
+
}
|
|
22
|
+
campanhas = {
|
|
23
|
+
"total": conn.execute("SELECT COUNT(*) AS n FROM campanhas").fetchone()["n"],
|
|
24
|
+
"publicadas": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'publicada'").fetchone()["n"],
|
|
25
|
+
"rascunho": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'rascunho'").fetchone()["n"],
|
|
26
|
+
"erro": conn.execute("SELECT COUNT(*) AS n FROM campanhas WHERE status = 'erro'").fetchone()["n"],
|
|
27
|
+
}
|
|
28
|
+
vendas_row = conn.execute(
|
|
29
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(valor),0) AS receita, COALESCE(SUM(comissao),0) AS comissao FROM vendas WHERE status = 'aprovada'"
|
|
30
|
+
).fetchone()
|
|
31
|
+
infoprodutos = {"total": conn.execute("SELECT COUNT(*) AS n FROM infoprodutos").fetchone()["n"]}
|
|
32
|
+
tracking = {
|
|
33
|
+
"visitas": conn.execute("SELECT COUNT(*) AS n FROM visitas WHERE event = 'pageview'").fetchone()["n"],
|
|
34
|
+
"cliques": conn.execute("SELECT COUNT(*) AS n FROM visitas WHERE event = 'cta_click'").fetchone()["n"],
|
|
35
|
+
"conversoes_pendentes": conn.execute(
|
|
36
|
+
"SELECT COUNT(*) AS n FROM vendas WHERE status = 'aprovada' AND gclid != '' AND conversao_status != 'enviada'"
|
|
37
|
+
).fetchone()["n"],
|
|
38
|
+
}
|
|
39
|
+
ultimas_vendas = [
|
|
40
|
+
{key: row[key] for key in row.keys()}
|
|
41
|
+
for row in conn.execute("SELECT id, produto_nome, valor, fonte, status, created_at FROM vendas ORDER BY id DESC LIMIT 5")
|
|
42
|
+
]
|
|
43
|
+
ultimas_paginas = [
|
|
44
|
+
{key: row[key] for key in row.keys()}
|
|
45
|
+
for row in conn.execute("SELECT id, titulo, tipo, status, created_at FROM paginas ORDER BY id DESC LIMIT 5")
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
from ..services.lancamentos import atual, progresso
|
|
49
|
+
lancamento_atual = atual()
|
|
50
|
+
progresso_atual = progresso(lancamento_atual) if lancamento_atual else None
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"produtos": produtos,
|
|
54
|
+
"paginas": paginas,
|
|
55
|
+
"campanhas": campanhas,
|
|
56
|
+
"infoprodutos": infoprodutos,
|
|
57
|
+
"vendas": {
|
|
58
|
+
"total": vendas_row["n"],
|
|
59
|
+
"receita": round(vendas_row["receita"], 2),
|
|
60
|
+
"comissao": round(vendas_row["comissao"], 2),
|
|
61
|
+
},
|
|
62
|
+
"tracking": tracking,
|
|
63
|
+
"ultimas_vendas": ultimas_vendas,
|
|
64
|
+
"ultimas_paginas": ultimas_paginas,
|
|
65
|
+
"lancamento": lancamento_atual,
|
|
66
|
+
"progresso": progresso_atual,
|
|
67
|
+
}
|
package/app/api/infoprodutos.py
CHANGED
|
@@ -88,7 +88,16 @@ def create_infoproduto(body: InfoprodutoBody):
|
|
|
88
88
|
(provider, rel_pdf, rel_md, record_id),
|
|
89
89
|
)
|
|
90
90
|
row = conn.execute("SELECT * FROM infoprodutos WHERE id = ?", (record_id,)).fetchone()
|
|
91
|
-
|
|
91
|
+
record = row_to_dict(row)
|
|
92
|
+
|
|
93
|
+
from ..services.notify import notify_event
|
|
94
|
+
|
|
95
|
+
notify_event(
|
|
96
|
+
"infoproduto_gerado",
|
|
97
|
+
"Infoproduto gerado",
|
|
98
|
+
f"{record['titulo']} ({record['formato']}) — {record['provider']}",
|
|
99
|
+
)
|
|
100
|
+
return record
|
|
92
101
|
except (LLMError, gamma_service.GammaError, RuntimeError) as exc:
|
|
93
102
|
with get_db() as conn:
|
|
94
103
|
conn.execute("DELETE FROM infoprodutos WHERE id = ?", (record_id,))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services import lancamentos
|
|
5
|
+
from ..services.llm import LLMError
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/lancamentos", tags=["lancamentos"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IniciarBody(BaseModel):
|
|
11
|
+
produto_id: int | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EtapaBody(BaseModel):
|
|
15
|
+
etapa: int
|
|
16
|
+
dados: dict = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProdutoBody(BaseModel):
|
|
20
|
+
produto_id: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/etapas")
|
|
24
|
+
def etapas():
|
|
25
|
+
return lancamentos.ETAPAS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.get("/atual")
|
|
29
|
+
def lancamento_atual():
|
|
30
|
+
return lancamentos.atual()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("")
|
|
34
|
+
def listar():
|
|
35
|
+
return lancamentos.listar()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.post("")
|
|
39
|
+
def iniciar(body: IniciarBody):
|
|
40
|
+
return lancamentos.iniciar(body.produto_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/{lancamento_id}/etapa")
|
|
44
|
+
def salvar_etapa(lancamento_id: int, body: EtapaBody):
|
|
45
|
+
try:
|
|
46
|
+
return lancamentos.salvar_etapa(lancamento_id, body.etapa, body.dados)
|
|
47
|
+
except LLMError as exc:
|
|
48
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/{lancamento_id}/produto")
|
|
52
|
+
def definir_produto(lancamento_id: int, body: ProdutoBody):
|
|
53
|
+
try:
|
|
54
|
+
return lancamentos.definir_produto(lancamento_id, body.produto_id)
|
|
55
|
+
except LLMError as exc:
|
|
56
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.get("/{lancamento_id}/relatorio")
|
|
60
|
+
def relatorio(lancamento_id: int):
|
|
61
|
+
try:
|
|
62
|
+
return lancamentos.relatorio(lancamento_id)
|
|
63
|
+
except LLMError as exc:
|
|
64
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/{lancamento_id}/progresso")
|
|
68
|
+
def progresso(lancamento_id: int):
|
|
69
|
+
try:
|
|
70
|
+
record = lancamentos._get_row(lancamento_id)
|
|
71
|
+
except LLMError as exc:
|
|
72
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
73
|
+
return lancamentos.progresso(record)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/{lancamento_id}/concluir")
|
|
77
|
+
def concluir(lancamento_id: int):
|
|
78
|
+
try:
|
|
79
|
+
return lancamentos.concluir(lancamento_id)
|
|
80
|
+
except LLMError as exc:
|
|
81
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..services import notify
|
|
5
|
+
from ..services.llm import LLMError
|
|
6
|
+
|
|
7
|
+
router = APIRouter(prefix="/api/notificacoes", tags=["notificacoes"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigBody(BaseModel):
|
|
11
|
+
eventos: dict = {}
|
|
12
|
+
targets: list = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TesteBody(BaseModel):
|
|
16
|
+
titulo: str = "Teste ProdMaker"
|
|
17
|
+
mensagem: str = "Notificação de teste do ProdMaker."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("")
|
|
21
|
+
def config():
|
|
22
|
+
return notify.listar_config()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.put("")
|
|
26
|
+
def salvar(body: ConfigBody):
|
|
27
|
+
return notify.salvar_config(body.eventos, body.targets)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/historico")
|
|
31
|
+
def historico():
|
|
32
|
+
return notify.historico()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.post("/teste")
|
|
36
|
+
def teste(body: TesteBody):
|
|
37
|
+
try:
|
|
38
|
+
return notify.enviar("teste", body.titulo, body.mensagem, forcar=True)
|
|
39
|
+
except LLMError as exc:
|
|
40
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
package/app/api/paginas.py
CHANGED
|
@@ -130,6 +130,14 @@ def publicar(page_id: int):
|
|
|
130
130
|
)
|
|
131
131
|
updated = _get_or_404(page_id)
|
|
132
132
|
updated["publish_result"] = result
|
|
133
|
+
|
|
134
|
+
from ..services.notify import notify_event
|
|
135
|
+
|
|
136
|
+
notify_event(
|
|
137
|
+
"pagina_publicada",
|
|
138
|
+
"Página publicada",
|
|
139
|
+
f"{updated['titulo']} — {result['pages_url']}",
|
|
140
|
+
)
|
|
133
141
|
return updated
|
|
134
142
|
|
|
135
143
|
|
package/app/main.py
CHANGED
|
@@ -15,7 +15,7 @@ from .services.database import ensure_db, get_db, now_str, row_to_dict
|
|
|
15
15
|
from .services.llm import LLMError, analyze_product, list_providers
|
|
16
16
|
from .services.store import PRODMAKER_HOME, STORAGE, STATE, ensure_state_dir, get_master_code, get_settings, save_settings
|
|
17
17
|
|
|
18
|
-
APP_VERSION = "0.1.
|
|
18
|
+
APP_VERSION = "0.1.10"
|
|
19
19
|
COOKIE_NAME = "prodmaker_session"
|
|
20
20
|
SESSION_DAYS = 30
|
|
21
21
|
MASTER_ALIAS = "admin"
|
|
@@ -32,6 +32,9 @@ from .api import dominios as dominios_router # noqa: E402
|
|
|
32
32
|
from .api import tracking as tracking_router # noqa: E402
|
|
33
33
|
from .api import vendas as vendas_router # noqa: E402
|
|
34
34
|
from .api import ads as ads_router # noqa: E402
|
|
35
|
+
from .api import lancamentos as lancamentos_router # noqa: E402
|
|
36
|
+
from .api import notificacoes as notificacoes_router # noqa: E402
|
|
37
|
+
from .api import dashboard as dashboard_router # noqa: E402
|
|
35
38
|
|
|
36
39
|
app.include_router(llm_router.router)
|
|
37
40
|
app.include_router(infoprodutos_router.router)
|
|
@@ -40,6 +43,9 @@ app.include_router(dominios_router.router)
|
|
|
40
43
|
app.include_router(tracking_router.router)
|
|
41
44
|
app.include_router(vendas_router.router)
|
|
42
45
|
app.include_router(ads_router.router)
|
|
46
|
+
app.include_router(lancamentos_router.router)
|
|
47
|
+
app.include_router(notificacoes_router.router)
|
|
48
|
+
app.include_router(dashboard_router.router)
|
|
43
49
|
|
|
44
50
|
ensure_db()
|
|
45
51
|
|
|
@@ -246,7 +252,7 @@ def health():
|
|
|
246
252
|
|
|
247
253
|
@app.get("/api/version")
|
|
248
254
|
def version():
|
|
249
|
-
return {"version": APP_VERSION, "canonical": "0.1.
|
|
255
|
+
return {"version": APP_VERSION, "canonical": "0.1.10"}
|
|
250
256
|
|
|
251
257
|
|
|
252
258
|
@app.get("/api/settings")
|
|
@@ -144,6 +144,11 @@ def marcar_publicada(campanha_id: int, resultado: dict, status: str = "publicada
|
|
|
144
144
|
"UPDATE campanhas SET status = ?, resultado_json = ?, erro = ?, updated_at = ? WHERE id = ?",
|
|
145
145
|
(status, json.dumps(resultado, ensure_ascii=False), erro[:2000], now_str(), campanha_id),
|
|
146
146
|
)
|
|
147
|
+
if status == "publicada":
|
|
148
|
+
from .notify import notify_event
|
|
149
|
+
|
|
150
|
+
campanha = get(campanha_id)
|
|
151
|
+
notify_event("campanha_publicada", f"Campanha publicada ({campanha['plataforma']})", f"{campanha['nome']} — criada PAUSADA para revisão.")
|
|
147
152
|
return get(campanha_id)
|
|
148
153
|
|
|
149
154
|
|
package/app/services/database.py
CHANGED
|
@@ -163,6 +163,33 @@ def ensure_db() -> None:
|
|
|
163
163
|
columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
|
|
164
164
|
if "analysis" not in columns:
|
|
165
165
|
conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
|
|
166
|
+
conn.execute(
|
|
167
|
+
"""
|
|
168
|
+
CREATE TABLE IF NOT EXISTS lancamentos (
|
|
169
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
170
|
+
produto_id INTEGER,
|
|
171
|
+
etapa_atual INTEGER NOT NULL DEFAULT 1,
|
|
172
|
+
status TEXT NOT NULL DEFAULT 'em_andamento',
|
|
173
|
+
dados_json TEXT NOT NULL DEFAULT '{}',
|
|
174
|
+
created_at TEXT NOT NULL,
|
|
175
|
+
updated_at TEXT NOT NULL
|
|
176
|
+
)
|
|
177
|
+
"""
|
|
178
|
+
)
|
|
179
|
+
conn.execute(
|
|
180
|
+
"""
|
|
181
|
+
CREATE TABLE IF NOT EXISTS notificacoes (
|
|
182
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
183
|
+
event TEXT NOT NULL DEFAULT '',
|
|
184
|
+
titulo TEXT NOT NULL DEFAULT '',
|
|
185
|
+
mensagem TEXT NOT NULL DEFAULT '',
|
|
186
|
+
canal TEXT NOT NULL DEFAULT '',
|
|
187
|
+
status TEXT NOT NULL DEFAULT 'pendente',
|
|
188
|
+
erro TEXT NOT NULL DEFAULT '',
|
|
189
|
+
created_at TEXT NOT NULL
|
|
190
|
+
)
|
|
191
|
+
"""
|
|
192
|
+
)
|
|
166
193
|
|
|
167
194
|
|
|
168
195
|
def now_str() -> str:
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""AdsBot — Lançamento Assistido: wizard persistido + Relatório de Lançamento."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .database import get_db, now_str, row_to_dict
|
|
6
|
+
from .llm import LLMError
|
|
7
|
+
|
|
8
|
+
SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
|
|
9
|
+
ETAPAS = [
|
|
10
|
+
{"numero": 1, "nome": "Produto", "emoji": "📦", "rota": "/"},
|
|
11
|
+
{"numero": 2, "nome": "Página", "emoji": "🛡️", "rota": "/adspages"},
|
|
12
|
+
{"numero": 3, "nome": "Publicar", "emoji": "🌐", "rota": "/hosting/paginas"},
|
|
13
|
+
{"numero": 4, "nome": "Campanhas", "emoji": "🎯", "rota": "/adscreator"},
|
|
14
|
+
{"numero": 5, "nome": "Tracking", "emoji": "🔄", "rota": "/tracking/visitas"},
|
|
15
|
+
{"numero": 6, "nome": "Conclusão", "emoji": "✅", "rota": "/adsbot"},
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _decode(record: dict) -> dict:
|
|
20
|
+
try:
|
|
21
|
+
record["dados"] = json.loads(record.get("dados_json") or "{}")
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
record["dados"] = {}
|
|
24
|
+
return record
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_row(lancamento_id: int) -> dict:
|
|
28
|
+
with get_db() as conn:
|
|
29
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (lancamento_id,)).fetchone()
|
|
30
|
+
if not row:
|
|
31
|
+
raise LLMError("Lançamento não encontrado.")
|
|
32
|
+
return row_to_dict(row)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def atual() -> dict | None:
|
|
36
|
+
with get_db() as conn:
|
|
37
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE status = 'em_andamento' ORDER BY id DESC LIMIT 1").fetchone()
|
|
38
|
+
return _decode(row_to_dict(row)) if row else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def listar() -> list[dict]:
|
|
42
|
+
with get_db() as conn:
|
|
43
|
+
rows = conn.execute("SELECT * FROM lancamentos ORDER BY id DESC").fetchall()
|
|
44
|
+
return [_decode(row_to_dict(row)) for row in rows]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iniciar(produto_id: int | None = None) -> dict:
|
|
48
|
+
existing = atual()
|
|
49
|
+
if existing:
|
|
50
|
+
if produto_id and not existing.get("produto_id"):
|
|
51
|
+
return definir_produto(existing["id"], produto_id)
|
|
52
|
+
return existing
|
|
53
|
+
timestamp = now_str()
|
|
54
|
+
with get_db() as conn:
|
|
55
|
+
cursor = conn.execute(
|
|
56
|
+
"INSERT INTO lancamentos (produto_id, etapa_atual, status, dados_json, created_at, updated_at) VALUES (?, 1, 'em_andamento', '{}', ?, ?)",
|
|
57
|
+
(produto_id, timestamp, timestamp),
|
|
58
|
+
)
|
|
59
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
60
|
+
return _decode(row_to_dict(row))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def definir_produto(lancamento_id: int, produto_id: int) -> dict:
|
|
64
|
+
with get_db() as conn:
|
|
65
|
+
conn.execute("UPDATE lancamentos SET produto_id = ?, updated_at = ? WHERE id = ?", (produto_id, now_str(), lancamento_id))
|
|
66
|
+
return _get_row(lancamento_id)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def salvar_etapa(lancamento_id: int, etapa: int, dados: dict) -> dict:
|
|
70
|
+
if not 1 <= etapa <= len(ETAPAS):
|
|
71
|
+
raise LLMError(f"Etapa inválida (1–{len(ETAPAS)}).")
|
|
72
|
+
with get_db() as conn:
|
|
73
|
+
row = conn.execute("SELECT * FROM lancamentos WHERE id = ?", (lancamento_id,)).fetchone()
|
|
74
|
+
if not row:
|
|
75
|
+
raise LLMError("Lançamento não encontrado.")
|
|
76
|
+
merged = {}
|
|
77
|
+
try:
|
|
78
|
+
merged = json.loads(row["dados_json"] or "{}")
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
merged = {}
|
|
81
|
+
merged.update(dados or {})
|
|
82
|
+
conn.execute(
|
|
83
|
+
"UPDATE lancamentos SET etapa_atual = ?, dados_json = ?, updated_at = ? WHERE id = ?",
|
|
84
|
+
(max(etapa, row["etapa_atual"] if row["status"] == "concluido" else 0), json.dumps(merged, ensure_ascii=False), now_str(), lancamento_id),
|
|
85
|
+
)
|
|
86
|
+
return _get_row(lancamento_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def concluir(lancamento_id: int) -> dict:
|
|
90
|
+
with get_db() as conn:
|
|
91
|
+
conn.execute("UPDATE lancamentos SET status = 'concluido', etapa_atual = ?, updated_at = ? WHERE id = ?", (len(ETAPAS), now_str(), lancamento_id))
|
|
92
|
+
return relatorio(lancamento_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def relatorio(lancamento_id: int) -> dict:
|
|
96
|
+
record = _get_row(lancamento_id)
|
|
97
|
+
dados = json.loads(record.get("dados_json") or "{}")
|
|
98
|
+
produto_id = record.get("produto_id") or dados.get("produto_id")
|
|
99
|
+
produto = None
|
|
100
|
+
analysis = None
|
|
101
|
+
paginas = []
|
|
102
|
+
campanhas = []
|
|
103
|
+
if produto_id:
|
|
104
|
+
with get_db() as conn:
|
|
105
|
+
produto_row = conn.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)).fetchone()
|
|
106
|
+
if produto_row:
|
|
107
|
+
produto = row_to_dict(produto_row)
|
|
108
|
+
if produto and produto.get("analysis"):
|
|
109
|
+
try:
|
|
110
|
+
analysis = json.loads(produto["analysis"])
|
|
111
|
+
except Exception: # noqa: BLE001
|
|
112
|
+
analysis = None
|
|
113
|
+
with get_db() as conn:
|
|
114
|
+
paginas = [row_to_dict(r) for r in conn.execute("SELECT * FROM paginas WHERE produto_id = ? ORDER BY id DESC", (produto_id,)).fetchall()]
|
|
115
|
+
campanhas = [row_to_dict(r) for r in conn.execute("SELECT * FROM campanhas WHERE produto_id = ? ORDER BY id DESC", (produto_id,)).fetchall()]
|
|
116
|
+
checklist_path = SEED_DIR / "checklists" / "lancamento.md"
|
|
117
|
+
checklist = ""
|
|
118
|
+
if checklist_path.exists():
|
|
119
|
+
checklist = checklist_path.read_text(encoding="utf-8")
|
|
120
|
+
return {
|
|
121
|
+
"lancamento": record,
|
|
122
|
+
"produto": produto,
|
|
123
|
+
"analysis": analysis,
|
|
124
|
+
"paginas": [
|
|
125
|
+
{"id": p["id"], "titulo": p["titulo"], "tipo": p["tipo"], "status": p["status"], "published_url": p["published_url"]}
|
|
126
|
+
for p in paginas
|
|
127
|
+
],
|
|
128
|
+
"campanhas": [
|
|
129
|
+
{"id": c["id"], "nome": c["nome"], "plataforma": c["plataforma"], "status": c["status"], "cpa_alvo": c["cpa_alvo"], "orcamento_diario": c["orcamento_diario"]}
|
|
130
|
+
for c in campanhas
|
|
131
|
+
],
|
|
132
|
+
"checklist": checklist,
|
|
133
|
+
"etapas": ETAPAS,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def progresso(record: dict) -> dict:
|
|
138
|
+
etapa = record.get("etapa_atual") or 1
|
|
139
|
+
total = len(ETAPAS)
|
|
140
|
+
concluido = record.get("status") == "concluido"
|
|
141
|
+
percentual = 100 if concluido else round((etapa - 1) / total * 100)
|
|
142
|
+
return {
|
|
143
|
+
"etapa": etapa,
|
|
144
|
+
"total": total,
|
|
145
|
+
"percentual": percentual,
|
|
146
|
+
"faltam": 0 if concluido else total - etapa + 1,
|
|
147
|
+
"status": record.get("status"),
|
|
148
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""AdsNotify — notificações push (apprise) + histórico local.
|
|
2
|
+
|
|
3
|
+
- Canais: URLs apprise configuradas em settings.notifications.targets. Se houver Telegram
|
|
4
|
+
configurado em Chaves de API, o destino t://TOKEN/CHATID entra automaticamente.
|
|
5
|
+
- Eventos com toggle em settings.notifications.eventos (default: desligados).
|
|
6
|
+
- Falha de envio NUNCA quebra o fluxo — apenas registra no histórico.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .database import get_db, now_str, row_to_dict
|
|
12
|
+
from .llm import LLMError
|
|
13
|
+
from .store import get_settings
|
|
14
|
+
|
|
15
|
+
EVENTOS = {
|
|
16
|
+
"venda_recebida": "Venda recebida",
|
|
17
|
+
"campanha_publicada": "Campanha publicada",
|
|
18
|
+
"pagina_publicada": "Página publicada",
|
|
19
|
+
"infoproduto_gerado": "Infoproduto gerado",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _telegram_target() -> str | None:
|
|
24
|
+
keys = (get_settings().get("api_keys") or {}).get("telegram") or {}
|
|
25
|
+
token = (keys.get("bot_token") or "").strip()
|
|
26
|
+
chat_id = (keys.get("chat_id") or "").strip()
|
|
27
|
+
if token and chat_id:
|
|
28
|
+
return f"tgram://{token}/{chat_id}"
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def listar_config() -> dict:
|
|
33
|
+
settings = get_settings()
|
|
34
|
+
notifications = settings.get("notifications") or {}
|
|
35
|
+
return {
|
|
36
|
+
"eventos": {key: bool((notifications.get("eventos") or {}).get(key, False)) for key in EVENTOS},
|
|
37
|
+
"targets": notifications.get("targets") or [],
|
|
38
|
+
"eventos_disponiveis": EVENTOS,
|
|
39
|
+
"canal_telegram": bool(_telegram_target()),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def salvar_config(eventos: dict, targets: list) -> dict:
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
current = settings.get("notifications") or {}
|
|
46
|
+
merged_eventos = {key: bool((current.get("eventos") or {}).get(key, False)) for key in EVENTOS}
|
|
47
|
+
merged_eventos.update({key: bool(value) for key, value in (eventos or {}).items() if key in EVENTOS})
|
|
48
|
+
settings["notifications"] = {"eventos": merged_eventos, "targets": [str(item).strip() for item in (targets or []) if str(item).strip()]}
|
|
49
|
+
from .store import save_settings
|
|
50
|
+
|
|
51
|
+
save_settings(settings)
|
|
52
|
+
return listar_config()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def historico(limit: int = 50) -> list[dict]:
|
|
56
|
+
with get_db() as conn:
|
|
57
|
+
rows = conn.execute("SELECT * FROM notificacoes ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
58
|
+
return [row_to_dict(row) for row in rows]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _registrar(event: str, titulo: str, mensagem: str, canal: str, status: str, erro: str = "") -> None:
|
|
62
|
+
with get_db() as conn:
|
|
63
|
+
conn.execute(
|
|
64
|
+
"INSERT INTO notificacoes (event, titulo, mensagem, canal, status, erro, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
65
|
+
(event, titulo, mensagem, canal, status, erro[:2000], now_str()),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _alvos() -> list[str]:
|
|
70
|
+
settings = get_settings()
|
|
71
|
+
targets = list((settings.get("notifications") or {}).get("targets") or [])
|
|
72
|
+
telegram = _telegram_target()
|
|
73
|
+
if telegram and telegram not in targets:
|
|
74
|
+
targets.insert(0, telegram)
|
|
75
|
+
return [item for item in targets if item.strip()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_enabled(event: str) -> bool:
|
|
79
|
+
settings = get_settings()
|
|
80
|
+
return bool(((settings.get("notifications") or {}).get("eventos") or {}).get(event, False))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def enviar(event: str, titulo: str, mensagem: str, forcar: bool = False) -> dict:
|
|
84
|
+
"""Envia uma notificação pelos canais configurados (best-effort)."""
|
|
85
|
+
if not forcar and not _is_enabled(event):
|
|
86
|
+
return {"enviado": False, "motivo": "evento desligado"}
|
|
87
|
+
alvos = _alvos()
|
|
88
|
+
if not alvos:
|
|
89
|
+
erro = "Nenhum canal configurado (settings.notifications.targets ou Telegram em Chaves de API)."
|
|
90
|
+
_registrar(event, titulo, mensagem, "", "erro", erro)
|
|
91
|
+
if forcar:
|
|
92
|
+
raise LLMError(erro)
|
|
93
|
+
return {"enviado": False, "motivo": erro}
|
|
94
|
+
try:
|
|
95
|
+
import apprise
|
|
96
|
+
except ImportError as exc:
|
|
97
|
+
_registrar(event, titulo, mensagem, ",".join(alvos), "erro", f"apprise ausente ({exc.name}) — rode 'prodmaker install'.")
|
|
98
|
+
return {"enviado": False, "motivo": "apprise não instalado"}
|
|
99
|
+
apobj = apprise.Apprise()
|
|
100
|
+
enviados = []
|
|
101
|
+
erros = []
|
|
102
|
+
for alvo in alvos:
|
|
103
|
+
if apobj.add(alvo):
|
|
104
|
+
enviados.append(alvo)
|
|
105
|
+
else:
|
|
106
|
+
erros.append(f"canal inválido: {alvo}")
|
|
107
|
+
if not enviados:
|
|
108
|
+
erro = "; ".join(erros)
|
|
109
|
+
_registrar(event, titulo, mensagem, ",".join(alvos), "erro", erro)
|
|
110
|
+
raise LLMError(erro)
|
|
111
|
+
ok = apobj.notify(title=titulo, body=mensagem)
|
|
112
|
+
if ok:
|
|
113
|
+
_registrar(event, titulo, mensagem, ",".join(enviados), "enviada")
|
|
114
|
+
return {"enviado": True, "canais": enviados}
|
|
115
|
+
erro = "apprise não conseguiu entregar (verifique tokens/chat id)."
|
|
116
|
+
_registrar(event, titulo, mensagem, ",".join(enviados), "erro", erro)
|
|
117
|
+
raise LLMError(erro)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def notify_event(event: str, titulo: str, mensagem: str) -> None:
|
|
121
|
+
"""Hook seguro: usado pelos fluxos de venda/página/campanha/infoproduto."""
|
|
122
|
+
try:
|
|
123
|
+
enviar(event, titulo, mensagem)
|
|
124
|
+
except LLMError:
|
|
125
|
+
pass
|
|
126
|
+
except Exception: # noqa: BLE001 — notificação nunca quebra a operação principal
|
|
127
|
+
pass
|
package/app/services/vendas.py
CHANGED
|
@@ -214,7 +214,17 @@ def registrar_venda(data: dict) -> dict:
|
|
|
214
214
|
),
|
|
215
215
|
)
|
|
216
216
|
row = conn.execute("SELECT * FROM vendas WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
|
217
|
-
|
|
217
|
+
record = row_to_dict(row)
|
|
218
|
+
|
|
219
|
+
from .notify import notify_event
|
|
220
|
+
|
|
221
|
+
notify_event(
|
|
222
|
+
"venda_recebida",
|
|
223
|
+
"Nova venda recebida",
|
|
224
|
+
f"{record.get('produto_nome') or 'Produto'} — R$ {record.get('valor')} ({record.get('fonte')})"
|
|
225
|
+
+ (f" · GCLID {record['gclid'][:20]}" if record.get("gclid") else ""),
|
|
226
|
+
)
|
|
227
|
+
return record
|
|
218
228
|
|
|
219
229
|
|
|
220
230
|
def list_vendas(limit: int = 300) -> list[dict]:
|