feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
'use strict'
const { execute, queryOne, query } = require('../database/postgres')
class Cotacao {
static findById(id) {
return queryOne(`
SELECT c.*,
COALESCE(cl.nome, u.nome) AS cliente_nome,
COALESCE(cl.tipo, 'PF') AS cliente_tipo
FROM cotacoes c
LEFT JOIN clientes cl ON cl.id = c.cliente_id
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = $1
`, [id])
}
static listByCliente(clienteId, tenantId) {
return query(
`SELECT * FROM cotacoes WHERE cliente_id = $1 AND tenant_id = $2 ORDER BY created_at DESC`,
[clienteId, tenantId]
)
}
static listByUser(userId, tenantId) {
return query(
`SELECT DISTINCT ON (c.id) c.*,
p.id AS pedido_id,
p.protocolo AS pedido_protocolo,
p.status AS pedido_status,
p.forma_pagto AS pedido_forma_pagto,
p.tipo_entrega AS pedido_tipo_entrega,
p.financeiro_status AS pedido_financeiro_status,
(
SELECT json_agg(json_build_object(
'status', sl.status,
'created_at', sl.created_at
) ORDER BY sl.created_at ASC)
FROM pedido_status_log sl
WHERE sl.pedido_id = p.id
) AS pedido_status_log,
(
SELECT json_agg(json_build_object(
'id', pi.id,
'nome_produto', pi.nome_produto,
'sku', pi.sku,
'quantidade', pi.quantidade,
'subtotal', pi.subtotal,
'observacao', pi.observacao,
'alerta_tipo', pi.alerta_tipo,
'alerta_obs', pi.alerta_obs,
'alerta_foto', pi.alerta_foto,
'alerta_confirmado',pi.alerta_confirmado,
'alerta_em', pi.alerta_em
) ORDER BY pi.id ASC)
FROM pedido_itens pi
WHERE pi.pedido_id = p.id
) AS itens
FROM cotacoes c
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
WHERE c.user_id = $1 AND c.tenant_id = $2
ORDER BY c.id DESC, p.id DESC
LIMIT 50`,
[userId, tenantId]
)
}
static listAdmin(tenantId, { status, limit = 50, offset = 0 } = {}) {
const params = [tenantId]
let where = 'WHERE c.tenant_id = $1'
if (status) { params.push(status); where += ` AND c.status = $${params.length}` }
params.push(Math.min(Number(limit) || 50, 200))
params.push(Math.max(Number(offset) || 0, 0))
return query(
`SELECT c.*,
COALESCE(cl.nome, u.nome) AS cliente_nome,
COALESCE(cl.tipo, 'PF') AS cliente_tipo
FROM cotacoes c
LEFT JOIN clientes cl ON cl.id = c.cliente_id
LEFT JOIN users u ON u.id = c.user_id
${where} ORDER BY c.created_at DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
)
}
static create({ tenant_id, cliente_id, user_id, status = 'rascunho', forma_pagto, total, desconto, observacao }) {
return execute(
`INSERT INTO cotacoes (tenant_id,cliente_id,user_id,status,forma_pagto,total,desconto,observacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[tenant_id, cliente_id || null, user_id || null, status,
forma_pagto || null, total || 0, desconto || 0, observacao || null]
)
}
static update(id, { status, forma_pagto, total, desconto, observacao }) {
return execute(
`UPDATE cotacoes SET status=$1,forma_pagto=$2,total=$3,desconto=$4,observacao=$5,updated_at=NOW()
WHERE id=$6`,
[status, forma_pagto || null, total, desconto || 0, observacao || null, id]
)
}
// ── Itens ─────────────────────────────────────────────────
static listItens(cotacaoId) {
return query(
`SELECT ci.*, p.imagem AS produto_imagem
FROM cotacao_itens ci
LEFT JOIN produtos p ON p.id = ci.produto_id
WHERE ci.cotacao_id = $1 ORDER BY ci.id ASC`,
[cotacaoId]
)
}
static addItem({ cotacao_id, produto_id, nome_produto, sku, quantidade, preco_unitario, preco_promocional, desconto_pct, observacao }) {
const precoFinal = preco_promocional || preco_unitario
const subtotal = precoFinal * quantidade * (1 - (desconto_pct || 0) / 100)
return execute(
`INSERT INTO cotacao_itens
(cotacao_id,produto_id,nome_produto,sku,quantidade,preco_unitario,preco_promocional,desconto_pct,subtotal,observacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
[cotacao_id, produto_id || null, nome_produto, sku || null,
quantidade, preco_unitario, preco_promocional || null, desconto_pct || 0, subtotal, observacao || null]
)
}
static updateItemObs(itemId, observacao) {
return execute(
`UPDATE cotacao_itens SET observacao=$1 WHERE id=$2`,
[observacao || null, itemId]
)
}
static removeItem(itemId) {
return execute('DELETE FROM cotacao_itens WHERE id = $1', [itemId])
}
static clearItens(cotacaoId) {
return execute('DELETE FROM cotacao_itens WHERE cotacao_id = $1', [cotacaoId])
}
static async recalcTotal(cotacaoId) {
await execute(
`UPDATE cotacoes SET total = (
SELECT COALESCE(SUM(subtotal),0) FROM cotacao_itens WHERE cotacao_id = $1
), updated_at = NOW() WHERE id = $1`,
[cotacaoId]
)
}
// ── Converter cotação em pedido ───────────────────────────
static async toOrder(cotacaoId, { endereco_id, tipo_entrega, observacao } = {}) {
const cotacao = await Cotacao.findById(cotacaoId)
if (!cotacao) throw new Error('Cotação não encontrada')
const itens = await Cotacao.listItens(cotacaoId)
// Strategy 2 — se veio do portal (user_id preenchido mas cliente_id nulo),
// garante um registro em clientes para manter rastreabilidade no admin
let clienteId = cotacao.cliente_id
if (!clienteId && cotacao.user_id) {
// verifica se já existe um clientes vinculado a este user_id
const existing = await queryOne(
`SELECT id FROM clientes WHERE user_id = $1 AND tenant_id = $2 LIMIT 1`,
[cotacao.user_id, cotacao.tenant_id]
)
if (existing) {
clienteId = existing.id
} else {
// cria o registro usando dados do users
const user = await queryOne(`SELECT * FROM users WHERE id = $1`, [cotacao.user_id])
if (user) {
const novo = await execute(
`INSERT INTO clientes (tenant_id, user_id, nome, email, cpf_cnpj, tipo, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,'PF',NOW(),NOW()) RETURNING id`,
[cotacao.tenant_id, user.id, user.nome, user.email || null, user.cpf || null]
)
clienteId = novo.rows[0]?.id || null
}
}
}
const pedidoResult = await execute(
`INSERT INTO pedidos
(tenant_id,cotacao_id,cliente_id,user_id,endereco_id,status,forma_pagto,tipo_entrega,total,desconto,observacao)
VALUES ($1,$2,$3,$4,$5,'aguardando',$6,$7,$8,$9,$10) RETURNING *`,
[cotacao.tenant_id, cotacaoId, clienteId, cotacao.user_id,
endereco_id || null, cotacao.forma_pagto, tipo_entrega || 'retirada',
cotacao.total, cotacao.desconto, observacao || cotacao.observacao]
)
const pedido = pedidoResult.rows[0]
if (!pedido) throw new Error('Falha ao criar pedido')
for (const item of itens) {
await execute(
`INSERT INTO pedido_itens (pedido_id,produto_id,nome_produto,sku,quantidade,preco_unitario,desconto_pct,subtotal,observacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[pedido.id, item.produto_id, item.nome_produto, item.sku,
item.quantidade, item.preco_unitario, item.desconto_pct, item.subtotal, item.observacao || null]
)
}
// WPP-10 — gera protocolo único por pedido
const dt = new Date()
const yyyymmdd = `${dt.getFullYear()}${String(dt.getMonth()+1).padStart(2,'0')}${String(dt.getDate()).padStart(2,'0')}`
const protocolo = `PED-${yyyymmdd}-${pedido.id}`
await execute(`UPDATE pedidos SET protocolo=$1 WHERE id=$2`, [protocolo, pedido.id])
await execute(
`UPDATE cotacoes SET status='finalizada', updated_at=NOW() WHERE id=$1`,
[cotacaoId]
)
return { ...pedido, protocolo }
}
}
module.exports = Cotacao