diff --git a/ANALISE_PRECIFICACAO.md b/ANALISE_PRECIFICACAO.md new file mode 100644 index 0000000..7b329ce --- /dev/null +++ b/ANALISE_PRECIFICACAO.md @@ -0,0 +1,317 @@ +# Análise de Precificação de Produtos + +> Documento de referência para metodologia de precificação no WEBMoney +> Versão: 1.0 | Data: 14/12/2025 + +--- + +## 1. Metodologia Atual: Markup sobre CMV + +### 1.1 Fórmula Implementada + +``` +Markup = 1 / (1 - Deduções Totais) + +Onde: + Deduções Totais = Taxa de Despesas Fixas + Custos Variáveis + Taxa de Investimento + Margem de Lucro + +Preço de Venda = CMV × Markup +``` + +### 1.2 Componentes + +| Componente | Descrição | Exemplo | +|------------|-----------|---------| +| **CMV** (Custo da Mercadoria Vendida) | Soma de todos os custos diretos do produto | €15,00 | +| **Taxa de Despesas Fixas** | Despesas Fixas ÷ Faturamento Mensal | 12% | +| **Custos Variáveis** | Impostos + Comissões + Taxa Cartão + Outros | 18% | +| **Taxa de Investimento** | Percentual para reinvestimento no negócio | 5% | +| **Margem de Lucro** | Lucro líquido desejado | 15% | + +### 1.3 Exemplo Prático + +``` +Deduções = 12% + 18% + 5% + 15% = 50% +Markup = 1 / (1 - 0.50) = 2.00 + +CMV = €15,00 +Preço de Venda = €15,00 × 2.00 = €30,00 +Margem de Contribuição = €30,00 - €15,00 = €15,00 +``` + +--- + +## 2. Análise Crítica: Vantagens e Limitações + +### 2.1 ✅ Vantagens da Metodologia Atual + +| Vantagem | Descrição | +|----------|-----------| +| **Simplicidade** | Fácil de entender e aplicar | +| **Cobertura de Custos** | Garante que todos os custos são considerados | +| **Consistência** | Mesma lógica para todos os produtos | +| **Margem Garantida** | Lucro definido está embutido no preço | + +### 2.2 ⚠️ Limitações + +| Limitação | Impacto | Solução Proposta | +|-----------|---------|------------------| +| **Não considera concorrência** | Preço pode ficar acima ou abaixo do mercado | Adicionar campo "preço de referência" | +| **Markup fixo por categoria** | Produtos premium usam mesmo markup que básicos | Permitir markup por produto | +| **Não considera elasticidade** | Ignora sensibilidade do cliente ao preço | Análise de margem por categoria | +| **CMV estático** | Não atualiza com variação de fornecedores | Histórico de custos com alertas | +| **Não diferencia canais** | Mesmo preço para loja física e online | Markup diferenciado por canal | + +--- + +## 3. Metodologias Alternativas + +### 3.1 Precificação Baseada em Valor (Value-Based Pricing) + +**Conceito**: Preço definido pelo valor percebido pelo cliente, não pelo custo. + +``` +Preço = Valor Percebido pelo Cliente + +Fatores: +- Benefício único do produto +- Qualidade percebida +- Posicionamento da marca +- Alternativas disponíveis +``` + +**Quando usar**: Produtos diferenciados, marcas premium, serviços especializados. + +**Implementação sugerida**: +- Campo `premium_multiplier` no produto (1.0 a 3.0) +- `Preço Final = Preço Base × Premium Multiplier` + +--- + +### 3.2 Precificação Competitiva (Competitive Pricing) + +**Conceito**: Preço baseado nos concorrentes. + +``` +Preço = Preço Médio do Mercado ± Ajuste + +Estratégias: +- Líder de preço: -5% a -15% abaixo da média +- Paridade: Igual à média +- Premium: +10% a +30% acima da média +``` + +**Implementação sugerida**: +- Campo `competitor_price` no produto +- Campo `price_strategy` (leader, parity, premium) +- Alerta quando `preço calculado` difere muito do `competitor_price` + +--- + +### 3.3 Precificação Dinâmica (Dynamic Pricing) + +**Conceito**: Preço ajustado conforme demanda, estoque, sazonalidade. + +``` +Preço = Preço Base × Fator Demanda × Fator Estoque × Fator Sazonal + +Exemplos: +- Estoque alto → desconto automático +- Alta demanda → preço premium +- Baixa temporada → promoção +``` + +**Implementação sugerida** (futuro): +- Integração com sistema de estoque +- Regras de ajuste automático +- Dashboard de performance por produto + +--- + +### 3.4 Precificação por Margem de Contribuição + +**Conceito**: Foco na margem absoluta, não no percentual. + +``` +Margem de Contribuição = Preço - Custos Variáveis +Ponto de Equilíbrio = Custos Fixos ÷ Margem de Contribuição Média + +Análise: +- Quantas unidades preciso vender para cobrir custos fixos? +- Qual produto gera mais margem absoluta? +``` + +**Vantagem**: Permite decisões mais estratégicas sobre mix de produtos. + +--- + +## 4. Recomendação: Modelo Híbrido + +### 4.1 Abordagem Recomendada + +Combinar **Markup sobre CMV** (base) com **ajustes estratégicos**: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PREÇO FINAL │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Preço Base = CMV × Markup (atual) │ +│ ↓ │ +│ Ajuste Premium = Preço Base × Premium Multiplier │ +│ ↓ │ +│ Validação = Comparar com Preço Concorrência │ +│ ↓ │ +│ Arredondamento = Preço Psicológico (ex: €29,99) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Campos Adicionais Sugeridos para Produto + +| Campo | Tipo | Descrição | +|-------|------|-----------| +| `competitor_price` | decimal | Preço médio da concorrência | +| `min_price` | decimal | Preço mínimo aceitável (não vender abaixo) | +| `max_price` | decimal | Preço máximo (teto do mercado) | +| `premium_multiplier` | decimal | Fator de ajuste premium (1.0 - 3.0) | +| `price_strategy` | enum | 'cost_plus', 'competitive', 'premium', 'penetration' | +| `psychological_rounding` | boolean | Arredondar para .99, .90, etc | +| `target_margin_percent` | decimal | Margem desejada específica do produto | + +--- + +## 5. Evolução Proposta: Registro Completo de Produtos + +### 5.1 Estrutura do Cadastro de Produtos + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PRODUTO │ +├─────────────────────────────────────────────────────────────┤ +│ Informações Básicas │ +│ - Nome, SKU, Código de Barras │ +│ - Categoria, Subcategoria │ +│ - Unidade de medida (un, kg, m, etc) │ +│ - Foto/Imagem │ +├─────────────────────────────────────────────────────────────┤ +│ Custos (CMV) │ +│ - Custo do produto (matéria-prima) │ +│ - Embalagem │ +│ - Etiqueta │ +│ - Frete de entrada │ +│ - Manuseio/Montagem │ +│ - Outros custos diretos │ +├─────────────────────────────────────────────────────────────┤ +│ Precificação │ +│ - Configuração de Markup vinculada │ +│ - Preço calculado (automático) │ +│ - Preço de venda (pode ser override manual) │ +│ - Preço concorrência (referência) │ +│ - Estratégia de preço │ +│ - Multiplicador premium │ +├─────────────────────────────────────────────────────────────┤ +│ Estoque (futuro) │ +│ - Quantidade em estoque │ +│ - Estoque mínimo │ +│ - Localização │ +├─────────────────────────────────────────────────────────────┤ +│ Fornecedores (futuro) │ +│ - Lista de fornecedores │ +│ - Histórico de preços de compra │ +│ - Lead time de reposição │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 5.2 Fluxo de Trabalho Proposto + +``` +1. CADASTRAR PRODUTO + ├── Informações básicas + ├── Vincular a uma Configuração de Markup + └── Adicionar componentes de custo (CMV) + ↓ +2. CÁLCULO AUTOMÁTICO + ├── Sistema calcula CMV total + ├── Aplica Markup da configuração + ├── Sugere preço de venda + └── Mostra margem de contribuição + ↓ +3. AJUSTES MANUAIS (opcional) + ├── Comparar com preço concorrência + ├── Aplicar multiplicador premium + ├── Override manual do preço + └── Arredondamento psicológico + ↓ +4. ANÁLISE E RELATÓRIOS + ├── Dashboard de margens por produto + ├── Produtos abaixo da margem mínima + ├── Alertas de variação de custo + └── Comparativo com concorrência +``` + +--- + +## 6. Roadmap de Implementação + +### Fase 1: ✅ Atual (v1.28.0) +- [x] Configurações de Markup +- [x] Fichas Técnicas (CMV) +- [x] Calculadora de Preços +- [x] Multi-divisa + +### Fase 2: Cadastro de Produtos (v1.29.0) +- [ ] Migrar "Fichas Técnicas" para "Produtos" +- [ ] Adicionar campos de precificação estratégica +- [ ] Preço de concorrência e alertas +- [ ] Multiplicador premium +- [ ] Arredondamento psicológico + +### Fase 3: Análise e Relatórios (v1.30.0) +- [ ] Dashboard de margens +- [ ] Ranking de produtos por rentabilidade +- [ ] Alertas de margem mínima +- [ ] Simulador de cenários (e se o custo aumentar X%?) + +### Fase 4: Integrações (futuro) +- [ ] Integração com estoque +- [ ] Histórico de custos de fornecedores +- [ ] Precificação dinâmica básica +- [ ] Exportação para e-commerce + +--- + +## 7. Conclusão + +### A metodologia atual é adequada? + +**Sim, para começar.** O Markup sobre CMV é uma metodologia sólida e amplamente utilizada por: +- Pequenas e médias empresas +- Varejo em geral +- Negócios com margens estáveis + +### O que recomendo adicionar? + +1. **Curto prazo**: Campos de referência de concorrência e margem mínima +2. **Médio prazo**: Estratégias de preço diferenciadas por produto/categoria +3. **Longo prazo**: Precificação dinâmica e integração com estoque + +### Princípio fundamental + +> "O preço deve cobrir todos os custos, gerar lucro desejado, e ainda ser competitivo no mercado." + +A fórmula de Markup garante os dois primeiros pontos. Os campos adicionais propostos ajudam no terceiro. + +--- + +## Referências + +- Sebrae: Formação do Preço de Venda +- Kotler, P.: Administração de Marketing +- Nagle, T.: The Strategy and Tactics of Pricing +- Harvard Business Review: Pricing Strategies + +--- + +*Documento mantido pelo time de desenvolvimento WEBMoney* +*Última atualização: 14/12/2025* diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db5053..2ded634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,117 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.30.0] - 2025-12-14 + +### Added +- **Fichas Técnicas de Servicios** - Sistema completo de costos para servicios + - Duración del servicio en minutos + - Insumos/materiales con costos unitarios + - Cálculo automático de CSV (Costo del Servicio Vendido) + - Integración con configuración de negocio para horas productivas + +- **Horas Productivas** - Cálculo de costo fijo por hora + - Nueva métrica clave: `hours_per_week` (horas por semana) + - Derivación automática: `hours_per_day = hours_per_week / days_per_week` + - Fórmula: Horas Productivas = Empleados × Horas/Día × Días/Mes × Productividad% + - Costo Fijo/Hora = Gastos Fijos / Horas Productivas + +- **Seeder de Ejemplos** - `BusinessExampleSeeder` con 3 tipos de negocio + - TechStore: 5 productos (electrónicos) + - DevPro: 5 servicios (desarrollo/consultoría) + - Print & Design: 4 productos + 4 servicios (mixto) + +- **Traducciones i18n Completas** - Sección business totalmente internacionalizada + - `business.common.*`: CMV, CSV, SKU, Markup, unidades de tiempo + - `business.settings.*`: totalProductiveHours, fixedCostPerHour + - `business.products.*`: skuPlaceholder, strategyLabels, components + - `business.services.*`: codePlaceholder, fixedCost, price + - Soporte: PT-BR, ES, EN + +### Changed +- **Modal Tipo de Negocio** - Rediseño responsivo + - Antes: Radio buttons horizontales que excedían el ancho + - Ahora: Cards seleccionables en grid 3 columnas + - Visual mejorado con iconos grandes y bordes coloreados + +### Fixed +- **Error 500 en /api/service-sheets** - Columna `hours_per_day` no existe + - Causa: Eager loading usaba columna antigua en lugar de `hours_per_week` + - Solución: Actualizado ServiceSheetController con columnas correctas + +- **TypeError margin_percentage.toFixed** - Valor llegaba como string + - Solución: `parseFloat()` antes de operaciones numéricas + +- **Frontend en directorio incorrecto** - Error 500 en raíz + - Causa: Archivos en /frontend/ en vez de /frontend/dist/ + - Solución: Movidos archivos al directorio correcto + +### Technical +- Migración: `2025_12_14_130001_change_hours_per_day_to_hours_per_week` + - Renombra `hours_per_day` a `hours_per_week` + - Agrega `working_days_per_week` (días por semana) + - Conversión automática: hours_per_week = hours_per_day * 5 +- Atributo derivado en BusinessSetting: `getHoursPerDayAttribute()` +- Deploy limpio con todos los caches regenerados + + +## [1.29.0] - 2025-12-14 + +### Added +- **Campañas Promocionales** - Sistema completo de gestión de ofertas y descuentos + - **Presets Rápidos**: Black Friday, Cyber Monday, Navidad, Año Nuevo, Liquidación + - **Tipos de Descuento**: Porcentaje, Valor Fijo, Precio Fijo + - **Programación**: Fechas y horas de inicio/fin con estados automáticos + - **Badges Visuales**: Texto, color y preview en tiempo real + - **Selección de Productos**: Individual o aplicar a todos + +- **Protección de Rentabilidad** - Sistema de 4 capas para evitar vender con pérdida + - Capa 1: Precio nunca menor que CMV (costo) + - Capa 2: Respeta precio mínimo del producto + - Capa 3: Respeta precio mínimo de campaña + - Capa 4: Garantiza margen mínimo sobre CMV + - Preview con análisis de rentabilidad antes de crear campaña + +- **Precificación Estratégica** en Fichas Técnicas + - Precio del Competidor con comparación visual + - Precio Mínimo/Máximo para control de rango + - Multiplicador Premium (ajuste fino) + - Estrategias: Agresivo (-5%), Neutro, Premium (+10%) + - Margen Objetivo por producto (sobrescribe global) + - Redondeo Psicológico (€26.04 → €25.99) + +- **Soporte B2B/B2C** - Manejo diferenciado de IVA/VAT + - **B2C**: Precios finales incluyen IVA (venta al consumidor) + - **B2B**: Precios sin IVA (venta entre empresas) + - Campo `vat_rate` separado de otros impuestos + - Preview muestra: Markup Base × (1 + IVA) = Multiplicador Final + - El IVA NO se deduce del margen, se añade al precio final + +- **Documentación** - Guía completa del Módulo de Negocios + - `docs/MODULO_NEGOCIOS.txt`: Manual detallado de funcionamiento + - Casos de uso prácticos con ejemplos numéricos + - Explicación técnica del sistema de protección + +### Fixed +- **Error React #310** - Corregido hook `useMemo` después de return condicional +- **Scroll en Modales** - Agregado scroll para contenido largo + - ProductSheetModal: maxHeight 90vh con overflowY auto + - BusinessSettingModal: maxHeight 90vh con overflowY auto +- **Relación BelongsToMany** - Foreign key explícita en `PromotionalCampaign.products()` +- **Traducciones Duplicadas** - Eliminadas claves repetidas en i18n +- **Claves i18n Faltantes** - Agregadas traducciones para componentes de producto + +### Technical +- Nueva migración: `add_profitability_protection_to_campaigns` + - Campos: `min_margin_percent`, `protect_against_loss`, `margin_warning_threshold` + - Pivot: `promo_margin`, `promo_margin_percent`, `is_profitable` +- Nueva migración: `add_price_includes_tax_to_business_settings` + - Campos: `price_includes_tax` (boolean), `vat_rate` (decimal) +- Modelo PromotionalCampaign mejorado con métodos de protección +- Controller con análisis de rentabilidad en preview() y show() +- i18n: Traducciones completas en ES, PT-BR, EN para campañas + + ## [1.28.0] - 2025-12-14 ### Added diff --git a/VERSION b/VERSION index cfc7307..034552a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.28.0 +1.30.0 diff --git a/backend/app/Http/Controllers/Api/BusinessSettingController.php b/backend/app/Http/Controllers/Api/BusinessSettingController.php index aa09057..ca7d8b8 100644 --- a/backend/app/Http/Controllers/Api/BusinessSettingController.php +++ b/backend/app/Http/Controllers/Api/BusinessSettingController.php @@ -40,9 +40,17 @@ public function store(Request $request): JsonResponse $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', 'currency' => 'required|string|size:3', + 'business_type' => 'sometimes|in:products,services,both', + 'employees_count' => 'sometimes|integer|min:1', + 'hours_per_week' => 'sometimes|numeric|min:1|max:168', + 'working_days_per_week' => 'sometimes|integer|min:1|max:7', + 'working_days_per_month' => 'sometimes|integer|min:1|max:31', + 'productivity_rate' => 'sometimes|numeric|min:1|max:100', 'monthly_revenue' => 'required|numeric|min:0', 'fixed_expenses' => 'required|numeric|min:0', 'tax_rate' => 'required|numeric|min:0|max:100', + 'price_includes_tax' => 'sometimes|boolean', + 'vat_rate' => 'sometimes|numeric|min:0|max:100', 'sales_commission' => 'required|numeric|min:0|max:100', 'card_fee' => 'required|numeric|min:0|max:100', 'other_variable_costs' => 'nullable|numeric|min:0|max:100', @@ -99,9 +107,17 @@ public function update(Request $request, $id): JsonResponse $validator = Validator::make($request->all(), [ 'name' => 'sometimes|string|max:255', 'currency' => 'sometimes|string|size:3', + 'business_type' => 'sometimes|in:products,services,both', + 'employees_count' => 'sometimes|integer|min:1', + 'hours_per_week' => 'sometimes|numeric|min:1|max:168', + 'working_days_per_week' => 'sometimes|integer|min:1|max:7', + 'working_days_per_month' => 'sometimes|integer|min:1|max:31', + 'productivity_rate' => 'sometimes|numeric|min:1|max:100', 'monthly_revenue' => 'sometimes|numeric|min:0', 'fixed_expenses' => 'sometimes|numeric|min:0', 'tax_rate' => 'sometimes|numeric|min:0|max:100', + 'price_includes_tax' => 'sometimes|boolean', + 'vat_rate' => 'sometimes|numeric|min:0|max:100', 'sales_commission' => 'sometimes|numeric|min:0|max:100', 'card_fee' => 'sometimes|numeric|min:0|max:100', 'other_variable_costs' => 'sometimes|numeric|min:0|max:100', diff --git a/backend/app/Http/Controllers/Api/ProductSheetController.php b/backend/app/Http/Controllers/Api/ProductSheetController.php index f2cd142..a96a6c1 100644 --- a/backend/app/Http/Controllers/Api/ProductSheetController.php +++ b/backend/app/Http/Controllers/Api/ProductSheetController.php @@ -41,6 +41,7 @@ public function index(Request $request): JsonResponse $sheets = $query->get()->map(function ($sheet) { return array_merge($sheet->toArray(), [ 'contribution_margin_percent' => $sheet->contribution_margin_percent, + 'competitor_comparison' => $sheet->competitor_comparison, ]); }); @@ -162,6 +163,14 @@ public function update(Request $request, $id): JsonResponse 'currency' => 'sometimes|string|size:3', 'business_setting_id' => 'nullable|exists:business_settings,id', 'is_active' => 'sometimes|boolean', + // Strategic pricing fields + 'competitor_price' => 'nullable|numeric|min:0', + 'min_price' => 'nullable|numeric|min:0', + 'max_price' => 'nullable|numeric|min:0', + 'premium_multiplier' => 'nullable|numeric|min:0.1|max:5', + 'price_strategy' => 'nullable|in:aggressive,neutral,premium', + 'psychological_rounding' => 'nullable|boolean', + 'target_margin_percent' => 'nullable|numeric|min:0|max:99', ]); if ($validator->fails()) { @@ -183,10 +192,14 @@ public function update(Request $request, $id): JsonResponse $sheet->calculateSalePrice(); } + // Calcular preço final com estratégias + $sheet->calculateFinalPrice(); + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); return response()->json(array_merge($sheet->toArray(), [ 'contribution_margin_percent' => $sheet->contribution_margin_percent, + 'competitor_comparison' => $sheet->competitor_comparison, ])); } diff --git a/backend/app/Http/Controllers/Api/PromotionalCampaignController.php b/backend/app/Http/Controllers/Api/PromotionalCampaignController.php new file mode 100644 index 0000000..9d235e4 --- /dev/null +++ b/backend/app/Http/Controllers/Api/PromotionalCampaignController.php @@ -0,0 +1,557 @@ +user()->id; + + $query = PromotionalCampaign::ofUser($userId) + ->withCount('products'); + + // Filtro por status + if ($request->has('status')) { + switch ($request->status) { + case 'active': + $query->active()->current(); + break; + case 'scheduled': + $query->active()->upcoming(); + break; + case 'ended': + $query->where('end_date', '<', Carbon::now()); + break; + case 'inactive': + $query->where('is_active', false); + break; + } + } + + $campaigns = $query->orderBy('start_date', 'desc')->get()->map(function ($campaign) { + return array_merge($campaign->toArray(), [ + 'status' => $campaign->status, + 'days_remaining' => $campaign->days_remaining, + 'is_currently_active' => $campaign->isCurrentlyActive(), + ]); + }); + + return response()->json($campaigns); + } + + /** + * Cria uma nova campanha + */ + public function store(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:50', + 'description' => 'nullable|string', + 'discount_type' => 'required|in:percentage,fixed,price_override', + 'discount_value' => 'required|numeric|min:0', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_active' => 'boolean', + 'apply_to_all_products' => 'boolean', + 'min_price' => 'nullable|numeric|min:0', + 'show_original_price' => 'boolean', + 'show_discount_badge' => 'boolean', + 'badge_text' => 'nullable|string|max:50', + 'badge_color' => 'nullable|string|max:20', + 'priority' => 'integer|min:0', + 'product_ids' => 'nullable|array', + 'product_ids.*' => 'exists:product_sheets,id', + 'preset' => 'nullable|string', // Para usar presets + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $userId = $request->user()->id; + $data = $validator->validated(); + + // Aplicar preset se fornecido + if (!empty($data['preset']) && isset(PromotionalCampaign::PRESETS[$data['preset']])) { + $preset = PromotionalCampaign::PRESETS[$data['preset']]; + $data = array_merge($preset, $data); + } + + unset($data['product_ids'], $data['preset']); + $data['user_id'] = $userId; + + DB::beginTransaction(); + + try { + $campaign = PromotionalCampaign::create($data); + + // Adicionar produtos se fornecidos + if ($request->has('product_ids') && is_array($request->product_ids)) { + foreach ($request->product_ids as $productId) { + $product = ProductSheet::ofUser($userId)->find($productId); + if ($product) { + $promoPrice = $campaign->calculatePromotionalPrice($product); + $campaign->products()->attach($productId, [ + 'promotional_price' => $promoPrice, + ]); + } + } + } + + // Se apply_to_all_products, adicionar todos + if ($campaign->apply_to_all_products) { + $campaign->applyToAllProducts(); + } + + DB::commit(); + + $campaign->load('products'); + + return response()->json(array_merge($campaign->toArray(), [ + 'status' => $campaign->status, + 'products_count' => $campaign->products->count(), + ]), 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'Erro ao criar campanha: ' . $e->getMessage()], 500); + } + } + + /** + * Exibe uma campanha específica + */ + public function show(Request $request, $id): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id) + ->with(['products' => function ($query) { + $query->select('product_sheets.*') + ->withPivot(['discount_type', 'discount_value', 'promotional_price', 'promo_margin', 'promo_margin_percent', 'is_profitable']); + }]) + ->findOrFail($id); + + // Adicionar informações de cada produto COM RENTABILIDADE + $totalProfit = 0; + $unprofitableCount = 0; + + $productsWithInfo = $campaign->products->map(function ($product) use ($campaign, &$totalProfit, &$unprofitableCount) { + $originalPrice = (float) ($product->final_price ?? $product->sale_price); + $promoPrice = (float) $product->pivot->promotional_price; + $cmv = (float) ($product->cmv_total ?? 0); + + // Calcular rentabilidade + $marginInfo = $campaign->getPromotionalMargin($product); + + if (!$marginInfo['is_profitable']) { + $unprofitableCount++; + } + $totalProfit += $marginInfo['promo_margin']; + + return array_merge($product->toArray(), [ + 'original_price' => $originalPrice, + 'promotional_price' => $promoPrice, + 'cmv' => $cmv, + 'discount_percent' => $campaign->getDiscountPercentage($originalPrice, $promoPrice), + 'savings' => round($originalPrice - $promoPrice, 2), + 'badge' => $campaign->getBadgeInfo($originalPrice, $promoPrice), + // Dados de rentabilidade + 'promo_margin' => $marginInfo['promo_margin'], + 'promo_margin_percent' => $marginInfo['promo_margin_percent'], + 'original_margin' => $marginInfo['original_margin'], + 'original_margin_percent' => $marginInfo['original_margin_percent'], + 'margin_reduction' => $marginInfo['margin_reduction'], + 'is_profitable' => $marginInfo['is_profitable'], + 'is_protected' => $marginInfo['is_protected'], + ]); + }); + + return response()->json(array_merge($campaign->toArray(), [ + 'status' => $campaign->status, + 'days_remaining' => $campaign->days_remaining, + 'is_currently_active' => $campaign->isCurrentlyActive(), + 'products' => $productsWithInfo, + // Resumo de rentabilidade + 'profitability_summary' => [ + 'total_profit' => round($totalProfit, 2), + 'unprofitable_count' => $unprofitableCount, + 'all_profitable' => $unprofitableCount === 0, + ], + ])); + } + + /** + * Atualiza uma campanha + */ + public function update(Request $request, $id): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'code' => 'nullable|string|max:50', + 'description' => 'nullable|string', + 'discount_type' => 'sometimes|in:percentage,fixed,price_override', + 'discount_value' => 'sometimes|numeric|min:0', + 'start_date' => 'sometimes|date', + 'end_date' => 'sometimes|date|after_or_equal:start_date', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i', + 'is_active' => 'boolean', + 'apply_to_all_products' => 'boolean', + 'min_price' => 'nullable|numeric|min:0', + 'show_original_price' => 'boolean', + 'show_discount_badge' => 'boolean', + 'badge_text' => 'nullable|string|max:50', + 'badge_color' => 'nullable|string|max:20', + 'priority' => 'integer|min:0', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $campaign->update($validator->validated()); + + // Recalcular preços se desconto mudou + if ($request->hasAny(['discount_type', 'discount_value', 'min_price'])) { + $campaign->recalculateAllPrices(); + } + + // Se mudou para apply_to_all_products + if ($request->has('apply_to_all_products') && $campaign->apply_to_all_products) { + $campaign->applyToAllProducts(); + } + + $campaign->load('products'); + + return response()->json(array_merge($campaign->toArray(), [ + 'status' => $campaign->status, + 'products_count' => $campaign->products->count(), + ])); + } + + /** + * Remove uma campanha + */ + public function destroy(Request $request, $id): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id); + $campaign->delete(); + + return response()->json(['message' => 'Campanha excluída com sucesso']); + } + + /** + * Adiciona produtos à campanha + */ + public function addProducts(Request $request, $id): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'product_ids' => 'required|array', + 'product_ids.*' => 'exists:product_sheets,id', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $userId = $request->user()->id; + $added = 0; + + foreach ($request->product_ids as $productId) { + $product = ProductSheet::ofUser($userId)->find($productId); + if ($product && !$campaign->products->contains($productId)) { + $promoPrice = $campaign->calculatePromotionalPrice($product); + $campaign->products()->attach($productId, [ + 'promotional_price' => $promoPrice, + ]); + $added++; + } + } + + return response()->json([ + 'message' => "{$added} produto(s) adicionado(s) à campanha", + 'products_count' => $campaign->products()->count(), + ]); + } + + /** + * Remove produtos da campanha + */ + public function removeProducts(Request $request, $id): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'product_ids' => 'required|array', + 'product_ids.*' => 'exists:product_sheets,id', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $campaign->products()->detach($request->product_ids); + + return response()->json([ + 'message' => 'Produto(s) removido(s) da campanha', + 'products_count' => $campaign->products()->count(), + ]); + } + + /** + * Atualiza desconto específico de um produto na campanha + */ + public function updateProductDiscount(Request $request, $campaignId, $productId): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id)->findOrFail($campaignId); + + $validator = Validator::make($request->all(), [ + 'discount_type' => 'nullable|in:percentage,fixed,price_override', + 'discount_value' => 'nullable|numeric|min:0', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $product = $campaign->products()->where('product_sheet_id', $productId)->first(); + + if (!$product) { + return response()->json(['message' => 'Produto não encontrado na campanha'], 404); + } + + $override = null; + if ($request->discount_type) { + $override = [ + 'discount_type' => $request->discount_type, + 'discount_value' => $request->discount_value, + ]; + } + + $promoPrice = $campaign->calculatePromotionalPrice($product, $override); + + $campaign->products()->updateExistingPivot($productId, [ + 'discount_type' => $request->discount_type, + 'discount_value' => $request->discount_value, + 'promotional_price' => $promoPrice, + ]); + + return response()->json([ + 'promotional_price' => $promoPrice, + 'message' => 'Desconto atualizado', + ]); + } + + /** + * Retorna presets disponíveis + */ + public function presets(): JsonResponse + { + return response()->json(PromotionalCampaign::getPresets()); + } + + /** + * Duplica uma campanha + */ + public function duplicate(Request $request, $id): JsonResponse + { + $campaign = PromotionalCampaign::ofUser($request->user()->id) + ->with('products') + ->findOrFail($id); + + DB::beginTransaction(); + + try { + $newCampaign = $campaign->replicate(); + $newCampaign->name = $campaign->name . ' (Cópia)'; + $newCampaign->is_active = false; + $newCampaign->start_date = Carbon::now(); + $newCampaign->end_date = Carbon::now()->addDays(7); + $newCampaign->save(); + + // Copiar produtos + foreach ($campaign->products as $product) { + $newCampaign->products()->attach($product->id, [ + 'discount_type' => $product->pivot->discount_type, + 'discount_value' => $product->pivot->discount_value, + 'promotional_price' => $product->pivot->promotional_price, + ]); + } + + DB::commit(); + + return response()->json(array_merge($newCampaign->toArray(), [ + 'status' => $newCampaign->status, + 'products_count' => $newCampaign->products()->count(), + ]), 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'Erro ao duplicar: ' . $e->getMessage()], 500); + } + } + + /** + * Preview de preços para uma campanha COM ANÁLISE DE RENTABILIDADE + */ + public function preview(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'discount_type' => 'required|in:percentage,fixed,price_override', + 'discount_value' => 'required|numeric|min:0', + 'min_price' => 'nullable|numeric|min:0', + 'min_margin_percent' => 'nullable|numeric|min:0', + 'protect_against_loss' => 'nullable|boolean', + 'product_ids' => 'required|array|min:1', + 'product_ids.*' => 'exists:product_sheets,id', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $userId = $request->user()->id; + $products = ProductSheet::ofUser($userId) + ->whereIn('id', $request->product_ids) + ->get(); + + $protectAgainstLoss = $request->protect_against_loss !== false; // default true + $minMarginPercent = $request->min_margin_percent; + + $unprofitableCount = 0; + $protectedCount = 0; + + $preview = $products->map(function ($product) use ($request, $protectAgainstLoss, $minMarginPercent, &$unprofitableCount, &$protectedCount) { + $originalPrice = (float) ($product->final_price ?? $product->sale_price); + $cmv = (float) ($product->cmv_total ?? 0); + $promoPrice = $originalPrice; + + // Calcular desconto + switch ($request->discount_type) { + case 'percentage': + $promoPrice = $originalPrice * (1 - ($request->discount_value / 100)); + break; + case 'fixed': + $promoPrice = $originalPrice - $request->discount_value; + break; + case 'price_override': + $promoPrice = $request->discount_value; + break; + } + + $wasProtected = false; + $protectionReason = null; + + // Aplicar preço mínimo da campanha + if ($request->min_price && $promoPrice < $request->min_price) { + $promoPrice = $request->min_price; + $wasProtected = true; + $protectionReason = 'campaign_min_price'; + } + + // Aplicar preço mínimo do produto + if ($product->min_price && $promoPrice < (float) $product->min_price) { + $promoPrice = (float) $product->min_price; + $wasProtected = true; + $protectionReason = 'product_min_price'; + } + + // Proteção contra prejuízo (nunca vender abaixo do CMV) + if ($protectAgainstLoss && $cmv > 0 && $promoPrice < $cmv) { + $promoPrice = $cmv; + $wasProtected = true; + $protectionReason = 'cmv_protection'; + $protectedCount++; + } + + // Margem mínima obrigatória + if ($minMarginPercent && $cmv > 0) { + $minPriceWithMargin = $cmv * (1 + ($minMarginPercent / 100)); + if ($promoPrice < $minPriceWithMargin) { + $promoPrice = $minPriceWithMargin; + $wasProtected = true; + $protectionReason = 'min_margin'; + } + } + + $promoPrice = max(0, round($promoPrice, 2)); + + // Calcular margens + $discountPercent = $originalPrice > 0 + ? round((($originalPrice - $promoPrice) / $originalPrice) * 100, 1) + : 0; + + $promoMargin = $promoPrice - $cmv; + $promoMarginPercent = $promoPrice > 0 ? round(($promoMargin / $promoPrice) * 100, 1) : 0; + $originalMargin = $originalPrice - $cmv; + $originalMarginPercent = $originalPrice > 0 ? round(($originalMargin / $originalPrice) * 100, 1) : 0; + + $isProfitable = $promoMargin > 0; + if (!$isProfitable) { + $unprofitableCount++; + } + + return [ + 'id' => $product->id, + 'name' => $product->name, + 'sku' => $product->sku, + 'cmv' => $cmv, + 'original_price' => $originalPrice, + 'promotional_price' => $promoPrice, + 'discount_percent' => $discountPercent, + 'savings' => round($originalPrice - $promoPrice, 2), + // Rentabilidade + 'promo_margin' => round($promoMargin, 2), + 'promo_margin_percent' => $promoMarginPercent, + 'original_margin' => round($originalMargin, 2), + 'original_margin_percent' => $originalMarginPercent, + 'margin_reduction' => round($originalMarginPercent - $promoMarginPercent, 1), + 'is_profitable' => $isProfitable, + // Proteção + 'was_protected' => $wasProtected, + 'protection_reason' => $protectionReason, + ]; + }); + + $totals = [ + 'products_count' => $preview->count(), + 'total_original' => round($preview->sum('original_price'), 2), + 'total_promotional' => round($preview->sum('promotional_price'), 2), + 'total_savings' => round($preview->sum('savings'), 2), + 'avg_discount' => round($preview->avg('discount_percent'), 1), + // Rentabilidade + 'total_cmv' => round($preview->sum('cmv'), 2), + 'total_promo_margin' => round($preview->sum('promo_margin'), 2), + 'avg_promo_margin_percent' => round($preview->avg('promo_margin_percent'), 1), + 'avg_original_margin_percent' => round($preview->avg('original_margin_percent'), 1), + // Alertas + 'unprofitable_count' => $unprofitableCount, + 'protected_count' => $protectedCount, + 'all_profitable' => $unprofitableCount === 0, + ]; + + return response()->json([ + 'products' => $preview, + 'totals' => $totals, + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/ServiceSheetController.php b/backend/app/Http/Controllers/Api/ServiceSheetController.php new file mode 100644 index 0000000..eb95e63 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ServiceSheetController.php @@ -0,0 +1,431 @@ +user()->id; + + $query = ServiceSheet::ofUser($userId) + ->with(['items', 'businessSetting:id,name,currency,markup_factor,fixed_expenses,employees_count,hours_per_week,working_days_per_week,working_days_per_month,productivity_rate']); + + // Filtro por categoria + if ($request->has('category')) { + $query->where('category', $request->category); + } + + // Filtro por status + if ($request->has('active')) { + $query->where('is_active', $request->boolean('active')); + } + + // Ordenação + $sortBy = $request->get('sort_by', 'name'); + $sortDir = $request->get('sort_dir', 'asc'); + $query->orderBy($sortBy, $sortDir); + + $sheets = $query->get()->map(function ($sheet) { + return array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ]); + }); + + return response()->json($sheets); + } + + /** + * Cria uma nova ficha de serviço + */ + public function store(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'business_setting_id' => 'required|exists:business_settings,id', + 'duration_minutes' => 'required|numeric|min:1', + 'items' => 'nullable|array', + 'items.*.name' => 'required|string|max:255', + 'items.*.type' => 'required|in:supply,consumable,material,equipment_usage,other', + 'items.*.unit_cost' => 'required|numeric|min:0', + 'items.*.quantity_used' => 'nullable|numeric|min:0', + 'items.*.unit' => 'nullable|string|max:20', + // Strategic pricing fields + 'competitor_price' => 'nullable|numeric|min:0', + 'min_price' => 'nullable|numeric|min:0', + 'max_price' => 'nullable|numeric|min:0', + 'premium_multiplier' => 'nullable|numeric|min:0.1|max:5', + 'pricing_strategy' => 'nullable|in:aggressive,neutral,premium', + 'psychological_pricing' => 'nullable|boolean', + 'target_margin' => 'nullable|numeric|min:0|max:99', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $userId = $request->user()->id; + + // Verificar se business_setting_id pertence ao usuário + $setting = BusinessSetting::ofUser($userId)->find($request->business_setting_id); + if (!$setting) { + return response()->json(['message' => 'Configuração de negócio não encontrada'], 404); + } + + // Verificar se a configuração suporta serviços + if ($setting->business_type === 'products') { + return response()->json(['message' => 'Esta configuração é apenas para produtos. Altere para "Serviços" ou "Ambos".'], 422); + } + + DB::beginTransaction(); + + try { + // Criar a ficha de serviço + $sheet = ServiceSheet::create([ + 'user_id' => $userId, + 'business_setting_id' => $request->business_setting_id, + 'name' => $request->name, + 'code' => $request->code, + 'description' => $request->description, + 'category' => $request->category, + 'duration_minutes' => $request->duration_minutes, + 'competitor_price' => $request->competitor_price, + 'min_price' => $request->min_price, + 'max_price' => $request->max_price, + 'premium_multiplier' => $request->premium_multiplier ?? 1, + 'pricing_strategy' => $request->pricing_strategy ?? 'neutral', + 'psychological_pricing' => $request->psychological_pricing ?? false, + 'target_margin' => $request->target_margin, + ]); + + // Criar os itens (insumos) + if ($request->has('items') && is_array($request->items)) { + foreach ($request->items as $itemData) { + ServiceSheetItem::create([ + 'service_sheet_id' => $sheet->id, + 'name' => $itemData['name'], + 'type' => $itemData['type'], + 'unit_cost' => $itemData['unit_cost'], + 'quantity_used' => $itemData['quantity_used'] ?? 1, + 'unit' => $itemData['unit'] ?? null, + 'notes' => $itemData['notes'] ?? null, + ]); + } + } + + // Recalcular tudo + $sheet->recalculate(); + + DB::commit(); + + // Recarregar com relacionamentos + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ]), 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'Erro ao criar ficha de serviço: ' . $e->getMessage()], 500); + } + } + + /** + * Exibe uma ficha de serviço específica + */ + public function show(Request $request, $id): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id) + ->with(['items', 'businessSetting']) + ->findOrFail($id); + + return response()->json(array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ])); + } + + /** + * Atualiza uma ficha de serviço + */ + public function update(Request $request, $id): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'code' => 'nullable|string|max:100', + 'description' => 'nullable|string', + 'category' => 'nullable|string|max:100', + 'business_setting_id' => 'sometimes|exists:business_settings,id', + 'duration_minutes' => 'sometimes|numeric|min:1', + 'is_active' => 'sometimes|boolean', + // Strategic pricing fields + 'competitor_price' => 'nullable|numeric|min:0', + 'min_price' => 'nullable|numeric|min:0', + 'max_price' => 'nullable|numeric|min:0', + 'premium_multiplier' => 'nullable|numeric|min:0.1|max:5', + 'pricing_strategy' => 'nullable|in:aggressive,neutral,premium', + 'psychological_pricing' => 'nullable|boolean', + 'target_margin' => 'nullable|numeric|min:0|max:99', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + // Verificar se business_setting_id pertence ao usuário + if ($request->has('business_setting_id') && $request->business_setting_id) { + $setting = BusinessSetting::ofUser($request->user()->id)->find($request->business_setting_id); + if (!$setting) { + return response()->json(['message' => 'Configuração de negócio não encontrada'], 404); + } + // Verificar se suporta serviços + if ($setting->business_type === 'products') { + return response()->json(['message' => 'Esta configuração é apenas para produtos.'], 422); + } + } + + $sheet->update($validator->validated()); + + // Recalcular preços + $sheet->recalculate(); + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ])); + } + + /** + * Remove uma ficha de serviço + */ + public function destroy(Request $request, $id): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($id); + $sheet->delete(); + + return response()->json(['message' => 'Ficha de serviço excluída com sucesso']); + } + + /** + * Adiciona um item à ficha de serviço + */ + public function addItem(Request $request, $id): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'type' => 'required|in:supply,consumable,material,equipment_usage,other', + 'unit_cost' => 'required|numeric|min:0', + 'quantity_used' => 'nullable|numeric|min:0', + 'unit' => 'nullable|string|max:20', + 'notes' => 'nullable|string', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + ServiceSheetItem::create([ + 'service_sheet_id' => $sheet->id, + 'name' => $request->name, + 'type' => $request->type, + 'unit_cost' => $request->unit_cost, + 'quantity_used' => $request->quantity_used ?? 1, + 'unit' => $request->unit, + 'notes' => $request->notes, + ]); + + // Recalcular preço + $sheet->recalculate(); + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ])); + } + + /** + * Atualiza um item da ficha de serviço + */ + public function updateItem(Request $request, $sheetId, $itemId): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($sheetId); + $item = ServiceSheetItem::where('service_sheet_id', $sheet->id)->findOrFail($itemId); + + $validator = Validator::make($request->all(), [ + 'name' => 'sometimes|string|max:255', + 'type' => 'sometimes|in:supply,consumable,material,equipment_usage,other', + 'unit_cost' => 'sometimes|numeric|min:0', + 'quantity_used' => 'sometimes|numeric|min:0', + 'unit' => 'nullable|string|max:20', + 'notes' => 'nullable|string', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $item->update($validator->validated()); + + // Recalcular preço + $sheet->recalculate(); + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ])); + } + + /** + * Remove um item da ficha de serviço + */ + public function removeItem(Request $request, $sheetId, $itemId): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id)->findOrFail($sheetId); + $item = ServiceSheetItem::where('service_sheet_id', $sheet->id)->findOrFail($itemId); + + $item->delete(); + + // Recalcular preço + $sheet->recalculate(); + + $sheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($sheet->toArray(), [ + 'cost_breakdown' => $sheet->cost_breakdown, + ])); + } + + /** + * Lista as categorias de serviços do usuário + */ + public function categories(Request $request): JsonResponse + { + $categories = ServiceSheet::ofUser($request->user()->id) + ->whereNotNull('category') + ->distinct() + ->pluck('category'); + + return response()->json($categories); + } + + /** + * Lista os tipos de componentes disponíveis + */ + public function itemTypes(): JsonResponse + { + return response()->json(ServiceSheetItem::TYPES); + } + + /** + * Duplica uma ficha de serviço + */ + public function duplicate(Request $request, $id): JsonResponse + { + $sheet = ServiceSheet::ofUser($request->user()->id) + ->with('items') + ->findOrFail($id); + + DB::beginTransaction(); + + try { + // Criar cópia da ficha + $newSheet = $sheet->replicate(); + $newSheet->name = $sheet->name . ' (cópia)'; + $newSheet->save(); + + // Copiar os itens + foreach ($sheet->items as $item) { + $newItem = $item->replicate(); + $newItem->service_sheet_id = $newSheet->id; + $newItem->save(); + } + + // Recalcular + $newSheet->recalculate(); + + DB::commit(); + + $newSheet->load(['items', 'businessSetting:id,name,currency,markup_factor']); + + return response()->json(array_merge($newSheet->toArray(), [ + 'cost_breakdown' => $newSheet->cost_breakdown, + ]), 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['message' => 'Erro ao duplicar ficha de serviço: ' . $e->getMessage()], 500); + } + } + + /** + * Simula o preço de um serviço (para calculadora) + */ + public function simulate(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'business_setting_id' => 'required|exists:business_settings,id', + 'duration_minutes' => 'required|numeric|min:1', + 'csv' => 'required|numeric|min:0', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $setting = BusinessSetting::ofUser($request->user()->id)->findOrFail($request->business_setting_id); + + $price = $setting->calculateServicePrice( + (float) $request->duration_minutes, + (float) $request->csv + ); + + // Calcular breakdown + $fixedCostPortion = $setting->fixed_cost_per_minute * $request->duration_minutes; + $baseCost = $fixedCostPortion + $request->csv; + $markup = $setting->markup_factor ?? $setting->calculateMarkup(); + + $priceWithoutVat = $baseCost * $markup; + $vatAmount = 0; + + if ($setting->price_includes_tax) { + $vatAmount = $price - $priceWithoutVat; + } + + return response()->json([ + 'duration_minutes' => (float) $request->duration_minutes, + 'duration_hours' => round($request->duration_minutes / 60, 2), + 'csv' => (float) $request->csv, + 'fixed_cost_per_hour' => $setting->fixed_cost_per_hour, + 'fixed_cost_portion' => round($fixedCostPortion, 2), + 'base_cost' => round($baseCost, 2), + 'markup' => $markup, + 'price_without_vat' => round($priceWithoutVat, 2), + 'vat_rate' => $setting->price_includes_tax ? (float) $setting->vat_rate : 0, + 'vat_amount' => round($vatAmount, 2), + 'final_price' => $price, + 'productive_hours' => $setting->productive_hours, + ]); + } +} diff --git a/backend/app/Models/BusinessSetting.php b/backend/app/Models/BusinessSetting.php index 3082aa7..2bb1111 100644 --- a/backend/app/Models/BusinessSetting.php +++ b/backend/app/Models/BusinessSetting.php @@ -15,9 +15,17 @@ class BusinessSetting extends Model 'user_id', 'currency', 'name', + 'business_type', + 'employees_count', + 'hours_per_week', + 'working_days_per_week', + 'working_days_per_month', + 'productivity_rate', 'monthly_revenue', 'fixed_expenses', 'tax_rate', + 'price_includes_tax', + 'vat_rate', 'sales_commission', 'card_fee', 'other_variable_costs', @@ -28,9 +36,17 @@ class BusinessSetting extends Model ]; protected $casts = [ + 'business_type' => 'string', + 'employees_count' => 'integer', + 'hours_per_week' => 'decimal:2', + 'working_days_per_week' => 'integer', + 'working_days_per_month' => 'integer', + 'productivity_rate' => 'decimal:2', 'monthly_revenue' => 'decimal:2', 'fixed_expenses' => 'decimal:2', 'tax_rate' => 'decimal:2', + 'price_includes_tax' => 'boolean', + 'vat_rate' => 'decimal:2', 'sales_commission' => 'decimal:2', 'card_fee' => 'decimal:2', 'other_variable_costs' => 'decimal:2', @@ -40,6 +56,23 @@ class BusinessSetting extends Model 'is_active' => 'boolean', ]; + /** + * Derived attributes - calculated from hours_per_week + */ + protected $appends = ['hours_per_day', 'productive_hours', 'fixed_cost_per_hour']; + + /** + * Calcula horas por dia a partir de horas por semana + * hours_per_day = hours_per_week / working_days_per_week + */ + public function getHoursPerDayAttribute(): float + { + $hoursPerWeek = (float) ($this->hours_per_week ?? 40); + $daysPerWeek = (int) ($this->working_days_per_week ?? 5); + + return $daysPerWeek > 0 ? round($hoursPerWeek / $daysPerWeek, 2) : 8; + } + /** * Relacionamento com usuário */ @@ -49,13 +82,107 @@ public function user(): BelongsTo } /** - * Fichas técnicas que usam esta configuração + * Fichas técnicas de produtos que usam esta configuração */ public function productSheets(): HasMany { return $this->hasMany(ProductSheet::class); } + /** + * Fichas técnicas de serviços que usam esta configuração + */ + public function serviceSheets(): HasMany + { + return $this->hasMany(ServiceSheet::class); + } + + /** + * Calcula as horas produtivas mensais + * + * Formula baseada em horas por semana: + * Horas Produtivas = Funcionários × (Horas/Semana × 4.33 semanas/mês) × Produtividade% + * + * Ou usando dias do mês: + * Horas Produtivas = Funcionários × (Horas/Semana / Dias/Semana × Dias/Mês) × Produtividade% + * + * @return float + */ + public function getProductiveHoursAttribute(): float + { + $employees = (int) ($this->employees_count ?? 1); + $hoursPerWeek = (float) ($this->hours_per_week ?? 40); + $daysPerWeek = (int) ($this->working_days_per_week ?? 5); + $daysPerMonth = (int) ($this->working_days_per_month ?? 22); + $productivity = (float) ($this->productivity_rate ?? 80) / 100; + + // Calcula horas por dia a partir de horas por semana + $hoursPerDay = $daysPerWeek > 0 ? $hoursPerWeek / $daysPerWeek : 8; + + // Horas produtivas = funcionários × horas/dia × dias/mês × produtividade + return round($employees * $hoursPerDay * $daysPerMonth * $productivity, 2); + } + + /** + * Calcula o gasto fixo por hora + * Gasto Fixo/Hora = Despesas Fixas / Horas Produtivas + * + * @return float + */ + public function getFixedCostPerHourAttribute(): float + { + $productiveHours = $this->productive_hours; + + if ($productiveHours <= 0) { + return 0; + } + + return round((float) $this->fixed_expenses / $productiveHours, 2); + } + + /** + * Calcula o gasto fixo por minuto + * + * @return float + */ + public function getFixedCostPerMinuteAttribute(): float + { + return round($this->fixed_cost_per_hour / 60, 4); + } + + /** + * Calcula o preço de venda de um serviço + * Preço = (Gasto Fixo/Hora × Duração em Horas + CSV) × Markup + * + * Para B2C, ainda adiciona o IVA no final + * + * @param float $durationMinutes Duração do serviço em minutos + * @param float $csv Custo do Serviço Vendido (insumos) + * @return float + */ + public function calculateServicePrice(float $durationMinutes, float $csv): float + { + $markup = $this->markup_factor ?? $this->calculateMarkup(); + + if ($markup <= 0) { + return 0; + } + + // Gasto fixo proporcional ao tempo do serviço + $fixedCostPortion = $this->fixed_cost_per_minute * $durationMinutes; + + // Preço base = (Gasto Fixo + CSV) × Markup + $priceWithoutTax = ($fixedCostPortion + $csv) * $markup; + + // Se B2C, adiciona IVA + if ($this->price_includes_tax) { + $vatRate = (float) ($this->vat_rate ?? 0); + return round($priceWithoutTax * (1 + $vatRate / 100), 2); + } + + return round($priceWithoutTax, 2); + } + /** * Calcula o percentual de despesas fixas sobre a receita * @@ -72,15 +199,23 @@ public function getFixedExpensesRateAttribute(): float /** * Calcula o total de custos variáveis (sem CMV) + * Para B2C (price_includes_tax=true), o tax_rate NÃO entra aqui pois é adicionado ao final + * Para B2B (price_includes_tax=false), o tax_rate é uma dedução do preço * * @return float */ public function getTotalVariableCostsAttribute(): float { - return $this->tax_rate + - $this->sales_commission + - $this->card_fee + - $this->other_variable_costs; + $costs = $this->sales_commission + + $this->card_fee + + $this->other_variable_costs; + + // Se B2B (preço NÃO inclui imposto), adiciona tax_rate como custo + if (!$this->price_includes_tax) { + $costs += $this->tax_rate; + } + + return $costs; } /** @@ -127,6 +262,9 @@ public function recalculateMarkup(): float /** * Calcula o preço de venda para um CMV dado * + * B2B (price_includes_tax=false): Preço = CMV × Markup (imposto já está nas deduções) + * B2C (price_includes_tax=true): Preço = CMV × Markup × (1 + VAT%) (imposto adicionado ao final) + * * @param float $cmv Custo da Mercadoria Vendida * @return float */ @@ -138,9 +276,56 @@ public function calculateSalePrice(float $cmv): float return 0; } + $priceWithoutTax = $cmv * $markup; + + // Se B2C (preço inclui imposto), adiciona o IVA/VAT ao preço final + if ($this->price_includes_tax) { + $vatRate = (float) ($this->vat_rate ?? 0); + return round($priceWithoutTax * (1 + $vatRate / 100), 2); + } + + return round($priceWithoutTax, 2); + } + + /** + * Calcula o preço SEM impostos para um CMV dado + * Útil para mostrar o preço base antes do IVA + * + * @param float $cmv Custo da Mercadoria Vendida + * @return float + */ + public function calculatePriceWithoutTax(float $cmv): float + { + $markup = $this->markup_factor ?? $this->calculateMarkup(); + + if ($markup <= 0) { + return 0; + } + return round($cmv * $markup, 2); } + /** + * Extrai o valor do IVA de um preço final (B2C) + * + * @param float $finalPrice Preço final com IVA + * @return float + */ + public function extractVat(float $finalPrice): float + { + if (!$this->price_includes_tax) { + return 0; + } + + $vatRate = (float) ($this->vat_rate ?? 0); + if ($vatRate <= 0) { + return 0; + } + + $priceWithoutVat = $finalPrice / (1 + $vatRate / 100); + return round($finalPrice - $priceWithoutVat, 2); + } + /** * Retorna o breakdown do Markup para exibição * @@ -151,6 +336,8 @@ public function getMarkupBreakdownAttribute(): array return [ 'fixed_expenses_rate' => $this->fixed_expenses_rate, 'tax_rate' => (float) $this->tax_rate, + 'price_includes_tax' => (bool) $this->price_includes_tax, + 'vat_rate' => (float) ($this->vat_rate ?? 21), 'sales_commission' => (float) $this->sales_commission, 'card_fee' => (float) $this->card_fee, 'other_variable_costs' => (float) $this->other_variable_costs, diff --git a/backend/app/Models/ProductSheet.php b/backend/app/Models/ProductSheet.php index 80917f5..48bec1b 100644 --- a/backend/app/Models/ProductSheet.php +++ b/backend/app/Models/ProductSheet.php @@ -24,6 +24,16 @@ class ProductSheet extends Model 'markup_used', 'contribution_margin', 'is_active', + // Strategic pricing fields + 'competitor_price', + 'min_price', + 'max_price', + 'premium_multiplier', + 'price_strategy', + 'psychological_rounding', + 'target_margin_percent', + 'final_price', + 'real_margin_percent', ]; protected $casts = [ @@ -32,8 +42,22 @@ class ProductSheet extends Model 'markup_used' => 'decimal:4', 'contribution_margin' => 'decimal:2', 'is_active' => 'boolean', + // Strategic pricing casts + 'competitor_price' => 'decimal:2', + 'min_price' => 'decimal:2', + 'max_price' => 'decimal:2', + 'premium_multiplier' => 'decimal:2', + 'psychological_rounding' => 'boolean', + 'target_margin_percent' => 'decimal:2', + 'final_price' => 'decimal:2', + 'real_margin_percent' => 'decimal:2', ]; + // Price strategies + const STRATEGY_AGGRESSIVE = 'aggressive'; + const STRATEGY_NEUTRAL = 'neutral'; + const STRATEGY_PREMIUM = 'premium'; + /** * Relacionamento com usuário */ @@ -136,4 +160,168 @@ public function scopeByCategory($query, $category) { return $query->where('category', $category); } + + /** + * Calcula o preço final aplicando estratégias de precificação + * + * @return float + */ + public function calculateFinalPrice(): float + { + // Começar com o preço base (markup) + $basePrice = (float) $this->sale_price; + + if ($basePrice <= 0) { + return 0; + } + + $finalPrice = $basePrice; + + // 1. Aplicar multiplicador premium + $premiumMultiplier = (float) ($this->premium_multiplier ?? 1.0); + $finalPrice = $finalPrice * $premiumMultiplier; + + // 2. Aplicar estratégia de preço baseada no concorrente + if ($this->competitor_price && $this->competitor_price > 0) { + $competitorPrice = (float) $this->competitor_price; + + switch ($this->price_strategy) { + case self::STRATEGY_AGGRESSIVE: + // 5% abaixo do concorrente + $targetPrice = $competitorPrice * 0.95; + $finalPrice = min($finalPrice, $targetPrice); + break; + + case self::STRATEGY_PREMIUM: + // 10% acima do concorrente + $targetPrice = $competitorPrice * 1.10; + $finalPrice = max($finalPrice, $targetPrice); + break; + + case self::STRATEGY_NEUTRAL: + default: + // Manter preço calculado, mas não muito diferente do concorrente + break; + } + } + + // 3. Aplicar margem alvo específica se definida + if ($this->target_margin_percent !== null && $this->target_margin_percent > 0) { + $targetMargin = (float) $this->target_margin_percent / 100; + $cmv = (float) $this->cmv_total; + // Preço = CMV / (1 - margem) + $targetPrice = $cmv / (1 - $targetMargin); + $finalPrice = $targetPrice; + } + + // 4. Aplicar limites min/max + if ($this->min_price && $finalPrice < $this->min_price) { + $finalPrice = (float) $this->min_price; + } + if ($this->max_price && $finalPrice > $this->max_price) { + $finalPrice = (float) $this->max_price; + } + + // 5. Aplicar arredondamento psicológico + if ($this->psychological_rounding) { + $finalPrice = $this->applyPsychologicalRounding($finalPrice); + } + + // Salvar resultado + $this->final_price = round($finalPrice, 2); + $this->real_margin_percent = $this->calculateRealMargin($this->final_price); + $this->save(); + + return $this->final_price; + } + + /** + * Aplica arredondamento psicológico ao preço + * Ex: 26.04 -> 25.99, 135.41 -> 134.99 + * + * @param float $price + * @return float + */ + protected function applyPsychologicalRounding(float $price): float + { + if ($price < 10) { + // Preços baixos: arredondar para .99 + return floor($price) + 0.99; + } elseif ($price < 100) { + // Preços médios: arredondar para X9.99 + $tens = floor($price / 10) * 10; + $remainder = $price - $tens; + if ($remainder >= 5) { + return $tens + 9.99; + } else { + return $tens - 0.01; + } + } else { + // Preços altos: arredondar para XX9.99 ou XX4.99 + $hundreds = floor($price / 100) * 100; + $remainder = $price - $hundreds; + if ($remainder >= 75) { + return $hundreds + 99.99; + } elseif ($remainder >= 25) { + return $hundreds + 49.99; + } else { + return $hundreds - 0.01; + } + } + } + + /** + * Calcula a margem real baseada no preço final + * + * @param float $finalPrice + * @return float + */ + protected function calculateRealMargin(float $finalPrice): float + { + if ($finalPrice <= 0) { + return 0; + } + + $cmv = (float) $this->cmv_total; + $margin = (($finalPrice - $cmv) / $finalPrice) * 100; + + return round($margin, 2); + } + + /** + * Retorna comparação com concorrente + * + * @return array|null + */ + public function getCompetitorComparisonAttribute(): ?array + { + if (!$this->competitor_price || $this->competitor_price <= 0) { + return null; + } + + $finalPrice = (float) ($this->final_price ?? $this->sale_price); + $competitorPrice = (float) $this->competitor_price; + $difference = $finalPrice - $competitorPrice; + $percentDiff = (($finalPrice - $competitorPrice) / $competitorPrice) * 100; + + return [ + 'competitor_price' => $competitorPrice, + 'our_price' => $finalPrice, + 'difference' => round($difference, 2), + 'percent_difference' => round($percentDiff, 2), + 'position' => $difference > 0 ? 'above' : ($difference < 0 ? 'below' : 'equal'), + ]; + } + + /** + * Lista de estratégias disponíveis + */ + public static function getStrategies(): array + { + return [ + self::STRATEGY_AGGRESSIVE => 'Agressivo (5% abaixo do concorrente)', + self::STRATEGY_NEUTRAL => 'Neutro (preço calculado)', + self::STRATEGY_PREMIUM => 'Premium (10% acima do concorrente)', + ]; + } } diff --git a/backend/app/Models/PromotionalCampaign.php b/backend/app/Models/PromotionalCampaign.php new file mode 100644 index 0000000..784da32 --- /dev/null +++ b/backend/app/Models/PromotionalCampaign.php @@ -0,0 +1,419 @@ + 'date', + 'end_date' => 'date', + 'is_active' => 'boolean', + 'apply_to_all_products' => 'boolean', + 'protect_against_loss' => 'boolean', + 'show_original_price' => 'boolean', + 'show_discount_badge' => 'boolean', + 'discount_value' => 'decimal:2', + 'min_price' => 'decimal:2', + 'min_margin_percent' => 'decimal:2', + 'margin_warning_threshold' => 'decimal:2', + 'priority' => 'integer', + ]; + + // Tipos de desconto + const TYPE_PERCENTAGE = 'percentage'; + const TYPE_FIXED = 'fixed'; + const TYPE_PRICE_OVERRIDE = 'price_override'; + + // Presets de campanhas + const PRESETS = [ + 'black_friday' => [ + 'name' => 'Black Friday', + 'discount_type' => 'percentage', + 'discount_value' => 30, + 'badge_text' => 'BLACK FRIDAY', + 'badge_color' => '#000000', + 'show_discount_badge' => true, + ], + 'cyber_monday' => [ + 'name' => 'Cyber Monday', + 'discount_type' => 'percentage', + 'discount_value' => 25, + 'badge_text' => 'CYBER MONDAY', + 'badge_color' => '#3b82f6', + 'show_discount_badge' => true, + ], + 'christmas' => [ + 'name' => 'Natal', + 'discount_type' => 'percentage', + 'discount_value' => 20, + 'badge_text' => '🎄 NATAL', + 'badge_color' => '#22c55e', + 'show_discount_badge' => true, + ], + 'new_year' => [ + 'name' => 'Ano Novo', + 'discount_type' => 'percentage', + 'discount_value' => 15, + 'badge_text' => '🎆 ANO NOVO', + 'badge_color' => '#f59e0b', + 'show_discount_badge' => true, + ], + 'summer_sale' => [ + 'name' => 'Liquidação de Verão', + 'discount_type' => 'percentage', + 'discount_value' => 40, + 'badge_text' => 'LIQUIDAÇÃO', + 'badge_color' => '#ef4444', + 'show_discount_badge' => true, + ], + 'flash_sale' => [ + 'name' => 'Flash Sale', + 'discount_type' => 'percentage', + 'discount_value' => 50, + 'badge_text' => '⚡ FLASH SALE', + 'badge_color' => '#8b5cf6', + 'show_discount_badge' => true, + ], + ]; + + /** + * Usuário dono da campanha + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Produtos na campanha + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(ProductSheet::class, 'campaign_products', 'campaign_id', 'product_sheet_id') + ->withPivot(['discount_type', 'discount_value', 'promotional_price', 'promo_margin', 'promo_margin_percent', 'is_profitable']) + ->withTimestamps(); + } + + /** + * Verifica se a campanha está ativa agora + */ + public function isCurrentlyActive(): bool + { + if (!$this->is_active) { + return false; + } + + $now = Carbon::now(); + $today = $now->toDateString(); + + // Verificar data + if ($today < $this->start_date->toDateString() || $today > $this->end_date->toDateString()) { + return false; + } + + // Verificar hora se definida + if ($this->start_time && $this->end_time) { + $currentTime = $now->format('H:i:s'); + + // Se for o primeiro dia, verificar hora de início + if ($today === $this->start_date->toDateString() && $currentTime < $this->start_time) { + return false; + } + + // Se for o último dia, verificar hora de fim + if ($today === $this->end_date->toDateString() && $currentTime > $this->end_time) { + return false; + } + } + + return true; + } + + /** + * Calcula o preço promocional para um produto + * COM PROTEÇÃO DE RENTABILIDADE + */ + public function calculatePromotionalPrice(ProductSheet $product, ?array $override = null): float + { + $basePrice = (float) ($product->final_price ?? $product->sale_price); + + if ($basePrice <= 0) { + return 0; + } + + $discountType = $override['discount_type'] ?? $this->discount_type; + $discountValue = (float) ($override['discount_value'] ?? $this->discount_value); + + $promoPrice = $basePrice; + + switch ($discountType) { + case self::TYPE_PERCENTAGE: + $promoPrice = $basePrice * (1 - ($discountValue / 100)); + break; + + case self::TYPE_FIXED: + $promoPrice = $basePrice - $discountValue; + break; + + case self::TYPE_PRICE_OVERRIDE: + $promoPrice = $discountValue; + break; + } + + // === PROTEÇÃO DE RENTABILIDADE === + + // 1. Aplicar preço mínimo da CAMPANHA se definido + if ($this->min_price && $promoPrice < $this->min_price) { + $promoPrice = (float) $this->min_price; + } + + // 2. Aplicar preço mínimo do PRODUTO se definido (proteção individual) + if ($product->min_price && $promoPrice < (float) $product->min_price) { + $promoPrice = (float) $product->min_price; + } + + // 3. NUNCA vender abaixo do CMV (Custo) - Proteção contra prejuízo + $cmv = (float) ($product->cmv_total ?? 0); + if ($cmv > 0 && $promoPrice < $cmv) { + $promoPrice = $cmv; // Preço mínimo = custo (margem zero, sem prejuízo) + } + + // 4. Aplicar margem mínima de segurança se configurada na campanha + if ($this->min_margin_percent && $cmv > 0) { + $minPriceWithMargin = $cmv * (1 + ($this->min_margin_percent / 100)); + if ($promoPrice < $minPriceWithMargin) { + $promoPrice = $minPriceWithMargin; + } + } + + // Não pode ser negativo + return max(0, round($promoPrice, 2)); + } + + /** + * Verifica se o preço promocional gera lucro + */ + public function isProfitable(ProductSheet $product): bool + { + $promoPrice = $this->calculatePromotionalPrice($product); + $cmv = (float) ($product->cmv_total ?? 0); + return $promoPrice > $cmv; + } + + /** + * Calcula a margem real durante a promoção + */ + public function getPromotionalMargin(ProductSheet $product): array + { + $promoPrice = $this->calculatePromotionalPrice($product); + $cmv = (float) ($product->cmv_total ?? 0); + $originalPrice = (float) ($product->final_price ?? $product->sale_price); + + $promoMargin = $promoPrice - $cmv; + $promoMarginPercent = $promoPrice > 0 ? ($promoMargin / $promoPrice) * 100 : 0; + + $originalMargin = $originalPrice - $cmv; + $originalMarginPercent = $originalPrice > 0 ? ($originalMargin / $originalPrice) * 100 : 0; + + return [ + 'promo_price' => $promoPrice, + 'cmv' => $cmv, + 'promo_margin' => round($promoMargin, 2), + 'promo_margin_percent' => round($promoMarginPercent, 1), + 'original_margin' => round($originalMargin, 2), + 'original_margin_percent' => round($originalMarginPercent, 1), + 'margin_reduction' => round($originalMarginPercent - $promoMarginPercent, 1), + 'is_profitable' => $promoMargin > 0, + 'is_protected' => $promoPrice >= $cmv, + ]; + } + + /** + * Calcula o percentual de desconto + */ + public function getDiscountPercentage(float $originalPrice, float $promoPrice): float + { + if ($originalPrice <= 0) { + return 0; + } + + return round((($originalPrice - $promoPrice) / $originalPrice) * 100, 1); + } + + /** + * Retorna informações do badge para exibição + */ + public function getBadgeInfo(float $originalPrice, float $promoPrice): ?array + { + if (!$this->show_discount_badge) { + return null; + } + + $discountPercent = $this->getDiscountPercentage($originalPrice, $promoPrice); + + return [ + 'text' => $this->badge_text ?? "-{$discountPercent}%", + 'color' => $this->badge_color ?? '#ef4444', + 'discount_percent' => $discountPercent, + ]; + } + + /** + * Aplica a campanha a todos os produtos do usuário + */ + public function applyToAllProducts(): int + { + if (!$this->apply_to_all_products) { + return 0; + } + + $products = ProductSheet::where('user_id', $this->user_id) + ->where('is_active', true) + ->get(); + + $count = 0; + foreach ($products as $product) { + $promoPrice = $this->calculatePromotionalPrice($product); + + $this->products()->syncWithoutDetaching([ + $product->id => [ + 'promotional_price' => $promoPrice, + ] + ]); + + $count++; + } + + return $count; + } + + /** + * Atualiza preços promocionais de todos os produtos da campanha + */ + public function recalculateAllPrices(): void + { + foreach ($this->products as $product) { + $override = null; + if ($product->pivot->discount_type) { + $override = [ + 'discount_type' => $product->pivot->discount_type, + 'discount_value' => $product->pivot->discount_value, + ]; + } + + $promoPrice = $this->calculatePromotionalPrice($product, $override); + + $this->products()->updateExistingPivot($product->id, [ + 'promotional_price' => $promoPrice, + ]); + } + } + + /** + * Scope para campanhas do usuário + */ + public function scopeOfUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope para campanhas ativas + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope para campanhas atuais (dentro do período) + */ + public function scopeCurrent($query) + { + $now = Carbon::now(); + return $query->where('start_date', '<=', $now) + ->where('end_date', '>=', $now); + } + + /** + * Scope para campanhas futuras + */ + public function scopeUpcoming($query) + { + return $query->where('start_date', '>', Carbon::now()); + } + + /** + * Lista de presets disponíveis + */ + public static function getPresets(): array + { + return self::PRESETS; + } + + /** + * Dias restantes da campanha + */ + public function getDaysRemainingAttribute(): int + { + if (!$this->isCurrentlyActive()) { + return 0; + } + + return max(0, Carbon::now()->diffInDays($this->end_date, false)); + } + + /** + * Status da campanha + */ + public function getStatusAttribute(): string + { + if (!$this->is_active) { + return 'inactive'; + } + + $now = Carbon::now(); + + if ($now < $this->start_date) { + return 'scheduled'; + } + + if ($now > $this->end_date) { + return 'ended'; + } + + return 'active'; + } +} diff --git a/backend/app/Models/ServiceSheet.php b/backend/app/Models/ServiceSheet.php new file mode 100644 index 0000000..6b4c713 --- /dev/null +++ b/backend/app/Models/ServiceSheet.php @@ -0,0 +1,281 @@ + 'decimal:2', + 'total_csv' => 'decimal:2', + 'fixed_cost_portion' => 'decimal:2', + 'calculated_price' => 'decimal:2', + 'contribution_margin' => 'decimal:2', + 'margin_percentage' => 'decimal:2', + 'competitor_price' => 'decimal:2', + 'min_price' => 'decimal:2', + 'max_price' => 'decimal:2', + 'premium_multiplier' => 'decimal:4', + 'target_margin' => 'decimal:2', + 'psychological_pricing' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Relacionamento com usuário + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Relacionamento com configuração de negócio + */ + public function businessSetting(): BelongsTo + { + return $this->belongsTo(BusinessSetting::class); + } + + /** + * Itens/componentes do serviço (insumos) + */ + public function items(): HasMany + { + return $this->hasMany(ServiceSheetItem::class); + } + + /** + * Calcula o CSV (Custo do Serviço Vendido) somando todos os itens + * + * @return float + */ + public function calculateTotalCsv(): float + { + return $this->items->sum(function ($item) { + return $item->unit_cost * $item->quantity_used; + }); + } + + /** + * Calcula o gasto fixo proporcional ao tempo do serviço + * + * @return float + */ + public function calculateFixedCostPortion(): float + { + $setting = $this->businessSetting; + + if (!$setting) { + return 0; + } + + return round($setting->fixed_cost_per_minute * $this->duration_minutes, 2); + } + + /** + * Calcula o preço de venda usando a fórmula de serviços: + * Preço = (Gasto Fixo/Tempo + CSV) × Markup + * + * @return float + */ + public function calculatePrice(): float + { + $setting = $this->businessSetting; + + if (!$setting) { + return 0; + } + + $basePrice = $setting->calculateServicePrice( + (float) $this->duration_minutes, + (float) $this->total_csv + ); + + // Aplicar multiplicador premium + $price = $basePrice * (float) ($this->premium_multiplier ?? 1); + + // Aplicar estratégia de preço baseada no competidor + if ($this->competitor_price && $this->competitor_price > 0) { + switch ($this->pricing_strategy) { + case 'aggressive': + $strategicPrice = $this->competitor_price * 0.95; + $price = min($price, $strategicPrice); + break; + case 'premium': + $strategicPrice = $this->competitor_price * 1.10; + $price = max($price, $strategicPrice); + break; + } + } + + // Aplicar limites min/max + if ($this->min_price && $price < $this->min_price) { + $price = (float) $this->min_price; + } + if ($this->max_price && $price > $this->max_price) { + $price = (float) $this->max_price; + } + + // Aplicar redondeo psicológico + if ($this->psychological_pricing) { + $price = $this->applyPsychologicalPricing($price); + } + + return round($price, 2); + } + + /** + * Aplica redondeo psicológico (€26.04 → €25.99) + */ + protected function applyPsychologicalPricing(float $price): float + { + $intPart = floor($price); + $decimal = $price - $intPart; + + if ($decimal > 0 && $decimal <= 0.50) { + return $intPart - 0.01; + } elseif ($decimal > 0.50) { + return $intPart + 0.99; + } + + return $price - 0.01; + } + + /** + * Calcula a margem de contribuição + * Margem = Preço - CSV - Gasto Fixo Proporcional - Deduções + */ + public function calculateContributionMargin(): float + { + $price = (float) $this->calculated_price; + $csv = (float) $this->total_csv; + $fixedCost = (float) $this->fixed_cost_portion; + + $setting = $this->businessSetting; + if (!$setting) { + return 0; + } + + // Para B2C, remover IVA do preço antes de calcular margem + if ($setting->price_includes_tax) { + $vatRate = (float) ($setting->vat_rate ?? 0); + $price = $price / (1 + $vatRate / 100); + } + + // Deduções variáveis + $deductions = $price * ($setting->total_variable_costs / 100); + + return round($price - $csv - $fixedCost - $deductions, 2); + } + + /** + * Recalcula todos os valores e salva + */ + public function recalculate(): self + { + $this->total_csv = $this->calculateTotalCsv(); + $this->fixed_cost_portion = $this->calculateFixedCostPortion(); + $this->calculated_price = $this->calculatePrice(); + $this->contribution_margin = $this->calculateContributionMargin(); + + // Calcular margem percentual + if ($this->calculated_price > 0) { + $this->margin_percentage = round( + ($this->contribution_margin / $this->calculated_price) * 100, + 2 + ); + } else { + $this->margin_percentage = 0; + } + + $this->save(); + + return $this; + } + + /** + * Retorna o breakdown de custos para exibição + */ + public function getCostBreakdownAttribute(): array + { + $setting = $this->businessSetting; + $price = (float) $this->calculated_price; + $csv = (float) $this->total_csv; + $fixedCost = (float) $this->fixed_cost_portion; + + // Calcular valores + $vatAmount = 0; + $priceWithoutVat = $price; + + if ($setting && $setting->price_includes_tax) { + $vatRate = (float) ($setting->vat_rate ?? 0); + $priceWithoutVat = $price / (1 + $vatRate / 100); + $vatAmount = $price - $priceWithoutVat; + } + + $deductions = $setting ? $priceWithoutVat * ($setting->total_variable_costs / 100) : 0; + + return [ + 'duration_minutes' => (float) $this->duration_minutes, + 'duration_hours' => round($this->duration_minutes / 60, 2), + 'csv' => $csv, + 'fixed_cost_portion' => $fixedCost, + 'base_cost' => $csv + $fixedCost, + 'markup' => $setting ? (float) $setting->markup_factor : 0, + 'price_without_vat' => round($priceWithoutVat, 2), + 'vat_amount' => round($vatAmount, 2), + 'vat_rate' => $setting && $setting->price_includes_tax ? (float) $setting->vat_rate : 0, + 'final_price' => $price, + 'deductions' => round($deductions, 2), + 'contribution_margin' => (float) $this->contribution_margin, + 'margin_percentage' => (float) $this->margin_percentage, + ]; + } + + /** + * Scope para buscar serviços de um usuário + */ + public function scopeOfUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope para buscar serviços ativos + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/backend/app/Models/ServiceSheetItem.php b/backend/app/Models/ServiceSheetItem.php new file mode 100644 index 0000000..d41025c --- /dev/null +++ b/backend/app/Models/ServiceSheetItem.php @@ -0,0 +1,63 @@ + 'decimal:2', + 'quantity_used' => 'decimal:4', + ]; + + /** + * Tipos de item disponíveis + */ + public const TYPES = [ + 'supply' => 'Insumo', + 'consumable' => 'Descartável', + 'material' => 'Material', + 'equipment_usage' => 'Uso de Equipamento', + 'other' => 'Outro', + ]; + + /** + * Relacionamento com ficha de serviço + */ + public function serviceSheet(): BelongsTo + { + return $this->belongsTo(ServiceSheet::class); + } + + /** + * Calcula o custo total do item + * Custo Total = Custo Unitário × Quantidade Usada + */ + public function getTotalCostAttribute(): float + { + return round((float) $this->unit_cost * (float) $this->quantity_used, 2); + } + + /** + * Retorna o nome do tipo traduzido + */ + public function getTypeNameAttribute(): string + { + return self::TYPES[$this->type] ?? $this->type; + } +} diff --git a/backend/database/migrations/2025_12_14_100001_add_strategic_pricing_to_product_sheets.php b/backend/database/migrations/2025_12_14_100001_add_strategic_pricing_to_product_sheets.php new file mode 100644 index 0000000..82ed439 --- /dev/null +++ b/backend/database/migrations/2025_12_14_100001_add_strategic_pricing_to_product_sheets.php @@ -0,0 +1,55 @@ +decimal('competitor_price', 15, 2)->nullable()->after('sale_price'); + + // Limites de preço + $table->decimal('min_price', 15, 2)->nullable()->after('competitor_price'); + $table->decimal('max_price', 15, 2)->nullable()->after('min_price'); + + // Fator premium por produto (1.0 = neutro, >1 = premium, <1 = desconto) + $table->decimal('premium_multiplier', 5, 2)->default(1.00)->after('max_price'); + + // Estratégia de preço + $table->enum('price_strategy', ['aggressive', 'neutral', 'premium'])->default('neutral')->after('premium_multiplier'); + + // Arredondamento psicológico (ex: 25.99 em vez de 26.04) + $table->boolean('psychological_rounding')->default(false)->after('price_strategy'); + + // Margem alvo específica para este produto (override do global) + $table->decimal('target_margin_percent', 5, 2)->nullable()->after('psychological_rounding'); + + // Preço final calculado (após ajustes estratégicos) + $table->decimal('final_price', 15, 2)->nullable()->after('target_margin_percent'); + + // Margem real calculada + $table->decimal('real_margin_percent', 5, 2)->nullable()->after('final_price'); + }); + } + + public function down(): void + { + Schema::table('product_sheets', function (Blueprint $table) { + $table->dropColumn([ + 'competitor_price', + 'min_price', + 'max_price', + 'premium_multiplier', + 'price_strategy', + 'psychological_rounding', + 'target_margin_percent', + 'final_price', + 'real_margin_percent', + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_12_14_100002_create_promotional_campaigns_table.php b/backend/database/migrations/2025_12_14_100002_create_promotional_campaigns_table.php new file mode 100644 index 0000000..dd65180 --- /dev/null +++ b/backend/database/migrations/2025_12_14_100002_create_promotional_campaigns_table.php @@ -0,0 +1,69 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('code', 50)->nullable(); // Código da campanha (ex: BLACKFRIDAY2025) + $table->text('description')->nullable(); + $table->enum('discount_type', ['percentage', 'fixed', 'price_override'])->default('percentage'); + $table->decimal('discount_value', 10, 2)->default(0); // Valor ou percentual + $table->date('start_date'); + $table->date('end_date'); + $table->time('start_time')->nullable(); // Hora de início (ex: 00:00:00 para Black Friday) + $table->time('end_time')->nullable(); // Hora de fim + $table->boolean('is_active')->default(true); + $table->boolean('apply_to_all_products')->default(false); + $table->decimal('min_price', 15, 2)->nullable(); // Preço mínimo durante campanha + $table->boolean('show_original_price')->default(true); // Mostrar preço riscado + $table->boolean('show_discount_badge')->default(true); // Mostrar badge de desconto + $table->string('badge_text', 50)->nullable(); // Texto do badge (ex: "-30%", "BLACK FRIDAY") + $table->string('badge_color', 20)->default('#ef4444'); // Cor do badge + $table->integer('priority')->default(0); // Prioridade (maior = prevalece) + $table->timestamps(); + + $table->index(['user_id', 'is_active']); + $table->index(['start_date', 'end_date']); + }); + + // Tabela pivot para produtos na campanha + Schema::create('campaign_products', function (Blueprint $table) { + $table->id(); + $table->foreignId('campaign_id')->constrained('promotional_campaigns')->cascadeOnDelete(); + $table->foreignId('product_sheet_id')->constrained('product_sheets')->cascadeOnDelete(); + $table->enum('discount_type', ['percentage', 'fixed', 'price_override'])->nullable(); // Override do desconto + $table->decimal('discount_value', 10, 2)->nullable(); // Override do valor + $table->decimal('promotional_price', 15, 2)->nullable(); // Preço calculado + $table->timestamps(); + + $table->unique(['campaign_id', 'product_sheet_id']); + }); + + // Adicionar campo de preço promocional na product_sheets + Schema::table('product_sheets', function (Blueprint $table) { + $table->decimal('promotional_price', 15, 2)->nullable()->after('final_price'); + $table->foreignId('active_campaign_id')->nullable()->after('promotional_price') + ->constrained('promotional_campaigns')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('product_sheets', function (Blueprint $table) { + $table->dropForeign(['active_campaign_id']); + $table->dropColumn(['promotional_price', 'active_campaign_id']); + }); + + Schema::dropIfExists('campaign_products'); + Schema::dropIfExists('promotional_campaigns'); + } +}; diff --git a/backend/database/migrations/2025_12_14_100003_add_profitability_protection_to_campaigns.php b/backend/database/migrations/2025_12_14_100003_add_profitability_protection_to_campaigns.php new file mode 100644 index 0000000..f4fafdd --- /dev/null +++ b/backend/database/migrations/2025_12_14_100003_add_profitability_protection_to_campaigns.php @@ -0,0 +1,52 @@ +decimal('min_margin_percent', 5, 2)->nullable()->after('min_price') + ->comment('Margem mínima obrigatória sobre o CMV'); + + // Proteção automática contra prejuízo + $table->boolean('protect_against_loss')->default(true)->after('min_margin_percent') + ->comment('Nunca vender abaixo do CMV'); + + // Alertar quando margem ficar muito baixa + $table->decimal('margin_warning_threshold', 5, 2)->nullable()->after('protect_against_loss') + ->comment('Alertar quando margem cair abaixo deste %'); + }); + + // Adicionar campo na tabela pivot para armazenar margem real + Schema::table('campaign_products', function (Blueprint $table) { + $table->decimal('promo_margin', 15, 2)->nullable()->after('promotional_price') + ->comment('Margem real durante a promoção'); + $table->decimal('promo_margin_percent', 5, 2)->nullable()->after('promo_margin') + ->comment('% de margem durante promoção'); + $table->boolean('is_profitable')->default(true)->after('promo_margin_percent') + ->comment('Se o produto gera lucro na promoção'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('promotional_campaigns', function (Blueprint $table) { + $table->dropColumn(['min_margin_percent', 'protect_against_loss', 'margin_warning_threshold']); + }); + + Schema::table('campaign_products', function (Blueprint $table) { + $table->dropColumn(['promo_margin', 'promo_margin_percent', 'is_profitable']); + }); + } +}; diff --git a/backend/database/migrations/2025_12_14_110001_add_price_includes_tax_to_business_settings.php b/backend/database/migrations/2025_12_14_110001_add_price_includes_tax_to_business_settings.php new file mode 100644 index 0000000..d800cf5 --- /dev/null +++ b/backend/database/migrations/2025_12_14_110001_add_price_includes_tax_to_business_settings.php @@ -0,0 +1,37 @@ +boolean('price_includes_tax')->default(true)->after('tax_rate'); + + // Tax rate for inclusion (IVA/VAT) - separate from deduction tax + // This is the tax that gets ADDED to the price, not deducted from margin + $table->decimal('vat_rate', 5, 2)->default(21.00)->after('price_includes_tax'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('business_settings', function (Blueprint $table) { + $table->dropColumn(['price_includes_tax', 'vat_rate']); + }); + } +}; diff --git a/backend/database/migrations/2025_12_14_120001_add_service_pricing_to_business_settings.php b/backend/database/migrations/2025_12_14_120001_add_service_pricing_to_business_settings.php new file mode 100644 index 0000000..6bfb342 --- /dev/null +++ b/backend/database/migrations/2025_12_14_120001_add_service_pricing_to_business_settings.php @@ -0,0 +1,52 @@ +enum('business_type', ['products', 'services', 'both'])->default('products')->after('currency'); + + // Workforce configuration for service pricing + $table->integer('employees_count')->default(1)->after('business_type'); + $table->decimal('hours_per_day', 4, 2)->default(8.00)->after('employees_count'); + $table->integer('working_days_per_month')->default(22)->after('hours_per_day'); + $table->decimal('productivity_rate', 5, 2)->default(80.00)->after('working_days_per_month'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('business_settings', function (Blueprint $table) { + $table->dropColumn([ + 'business_type', + 'employees_count', + 'hours_per_day', + 'working_days_per_month', + 'productivity_rate', + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_12_14_120002_create_service_sheets_tables.php b/backend/database/migrations/2025_12_14_120002_create_service_sheets_tables.php new file mode 100644 index 0000000..35d5e28 --- /dev/null +++ b/backend/database/migrations/2025_12_14_120002_create_service_sheets_tables.php @@ -0,0 +1,90 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('business_setting_id')->constrained()->onDelete('cascade'); + + // Basic info + $table->string('name'); + $table->string('code')->nullable(); // Service code/SKU + $table->string('category')->nullable(); + $table->text('description')->nullable(); + + // Time configuration + $table->decimal('duration_minutes', 6, 2)->default(30.00); // Service duration in minutes + + // Calculated values (cached) + $table->decimal('total_csv', 12, 2)->default(0); // Cost of Service Vendido + $table->decimal('fixed_cost_portion', 12, 2)->default(0); // Gasto fijo por tiempo + $table->decimal('calculated_price', 12, 2)->default(0); + $table->decimal('contribution_margin', 12, 2)->default(0); + $table->decimal('margin_percentage', 5, 2)->default(0); + + // Strategic pricing (same as products) + $table->decimal('competitor_price', 12, 2)->nullable(); + $table->decimal('min_price', 12, 2)->nullable(); + $table->decimal('max_price', 12, 2)->nullable(); + $table->decimal('premium_multiplier', 5, 4)->default(1.0000); + $table->enum('pricing_strategy', ['aggressive', 'neutral', 'premium'])->default('neutral'); + $table->decimal('target_margin', 5, 2)->nullable(); + $table->boolean('psychological_pricing', )->default(false); + + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + // Indexes + $table->index(['user_id', 'business_setting_id']); + $table->index(['user_id', 'is_active']); + }); + + // Service sheet items (insumos/componentes del servicio) + Schema::create('service_sheet_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('service_sheet_id')->constrained()->onDelete('cascade'); + + $table->string('name'); // Nombre del insumo + $table->enum('type', [ + 'supply', // Insumos (champú, crema, etc.) + 'consumable', // Descartables (gillette, guantes, etc.) + 'material', // Materiales + 'equipment_usage', // Uso de equipo (amortización) + 'other' + ])->default('supply'); + + $table->decimal('unit_cost', 12, 2); // Costo por unidad + $table->decimal('quantity_used', 10, 4)->default(1); // Cantidad usada por servicio + $table->string('unit')->nullable(); // ml, g, unidad, etc. + $table->text('notes')->nullable(); + + $table->timestamps(); + + $table->index('service_sheet_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('service_sheet_items'); + Schema::dropIfExists('service_sheets'); + } +}; diff --git a/backend/database/migrations/2025_12_14_130001_change_hours_per_day_to_hours_per_week.php b/backend/database/migrations/2025_12_14_130001_change_hours_per_day_to_hours_per_week.php new file mode 100644 index 0000000..6890226 --- /dev/null +++ b/backend/database/migrations/2025_12_14_130001_change_hours_per_day_to_hours_per_week.php @@ -0,0 +1,57 @@ +decimal('hours_per_week', 5, 2)->default(40.00)->after('employees_count'); + // Add working_days_per_week (for calculation) + $table->integer('working_days_per_week')->default(5)->after('hours_per_week'); + }); + + // Convert existing hours_per_day to hours_per_week + // hours_per_week = hours_per_day * 5 (assuming 5-day week) + DB::table('business_settings')->update([ + 'hours_per_week' => DB::raw('hours_per_day * 5'), + 'working_days_per_week' => 5, + ]); + + // Remove old hours_per_day column + Schema::table('business_settings', function (Blueprint $table) { + $table->dropColumn('hours_per_day'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('business_settings', function (Blueprint $table) { + $table->decimal('hours_per_day', 4, 2)->default(8.00)->after('employees_count'); + }); + + // Convert back: hours_per_day = hours_per_week / 5 + DB::table('business_settings')->update([ + 'hours_per_day' => DB::raw('hours_per_week / 5'), + ]); + + Schema::table('business_settings', function (Blueprint $table) { + $table->dropColumn(['hours_per_week', 'working_days_per_week']); + }); + } +}; diff --git a/backend/database/seeders/BusinessExampleSeeder.php b/backend/database/seeders/BusinessExampleSeeder.php new file mode 100644 index 0000000..f4deb26 --- /dev/null +++ b/backend/database/seeders/BusinessExampleSeeder.php @@ -0,0 +1,288 @@ + $userId, + 'name' => 'TechStore - E-commerce', + 'currency' => 'EUR', + 'business_type' => 'products', + 'employees_count' => 3, + 'hours_per_week' => 40, + 'working_days_per_week' => 5, + 'working_days_per_month' => 22, + 'productivity_rate' => 85, + 'monthly_revenue' => 45000, + 'fixed_expenses' => 8500, + 'tax_rate' => 21, + 'price_includes_tax' => true, + 'vat_rate' => 21, + 'sales_commission' => 3, + 'card_fee' => 1.5, + 'other_variable_costs' => 2, + 'investment_rate' => 5, + 'profit_margin' => 15, + 'is_active' => true, + ]); + $ecommerce->recalculateMarkup(); + + // Produtos do E-commerce + $this->createProduct($ecommerce, 'Smartphone Samsung Galaxy S24', 'PHONE-001', 'Eletrônicos', [ + ['name' => 'Custo do produto', 'cost' => 650, 'quantity' => 1], + ['name' => 'Embalagem premium', 'cost' => 5, 'quantity' => 1], + ['name' => 'Frete de entrada', 'cost' => 8, 'quantity' => 1], + ]); + + $this->createProduct($ecommerce, 'Notebook Dell Inspiron 15', 'NOTE-001', 'Eletrônicos', [ + ['name' => 'Custo do notebook', 'cost' => 580, 'quantity' => 1], + ['name' => 'Embalagem', 'cost' => 12, 'quantity' => 1], + ['name' => 'Frete importação', 'cost' => 25, 'quantity' => 1], + ]); + + $this->createProduct($ecommerce, 'Fone Bluetooth JBL', 'AUDIO-001', 'Áudio', [ + ['name' => 'Custo do fone', 'cost' => 45, 'quantity' => 1], + ['name' => 'Embalagem', 'cost' => 2, 'quantity' => 1], + ]); + + $this->createProduct($ecommerce, 'Smartwatch Apple Watch SE', 'WATCH-001', 'Wearables', [ + ['name' => 'Custo do relógio', 'cost' => 220, 'quantity' => 1], + ['name' => 'Embalagem premium', 'cost' => 8, 'quantity' => 1], + ]); + + $this->createProduct($ecommerce, 'Carregador USB-C 65W', 'ACC-001', 'Acessórios', [ + ['name' => 'Custo do carregador', 'cost' => 18, 'quantity' => 1], + ['name' => 'Embalagem', 'cost' => 1, 'quantity' => 1], + ]); + + // ===================================================================== + // NEGÓCIO 2: CONSULTORIA DE TI (Apenas Serviços) + // ===================================================================== + $consultoria = BusinessSetting::create([ + 'user_id' => $userId, + 'name' => 'DevPro Consultoria', + 'currency' => 'EUR', + 'business_type' => 'services', + 'employees_count' => 2, + 'hours_per_week' => 40, + 'working_days_per_week' => 5, + 'working_days_per_month' => 22, + 'productivity_rate' => 75, + 'monthly_revenue' => 18000, + 'fixed_expenses' => 3500, + 'tax_rate' => 21, + 'price_includes_tax' => false, + 'vat_rate' => 21, + 'sales_commission' => 0, + 'card_fee' => 0, + 'other_variable_costs' => 1, + 'investment_rate' => 10, + 'profit_margin' => 25, + 'is_active' => true, + ]); + $consultoria->recalculateMarkup(); + + // Serviços da Consultoria (duração em minutos) + $this->createService($consultoria, 'Desenvolvimento Web - React', 'DEV-001', 'Desenvolvimento', [ + ['name' => 'Infraestrutura cloud', 'cost' => 5, 'quantity' => 1], + ['name' => 'Licenças software', 'cost' => 3, 'quantity' => 1], + ], 480); // 8 horas + + $this->createService($consultoria, 'Consultoria Arquitetura Cloud', 'CONS-001', 'Consultoria', [ + ['name' => 'Ferramentas de análise', 'cost' => 10, 'quantity' => 1], + ], 240); // 4 horas + + $this->createService($consultoria, 'Manutenção Mensal WordPress', 'MAINT-001', 'Manutenção', [ + ['name' => 'Hosting dedicado', 'cost' => 15, 'quantity' => 1], + ['name' => 'Backup automático', 'cost' => 5, 'quantity' => 1], + ], 180); // 3 horas + + $this->createService($consultoria, 'Setup E-commerce Shopify', 'SETUP-001', 'Implementação', [ + ['name' => 'Tema premium', 'cost' => 150, 'quantity' => 1], + ['name' => 'Apps necessários', 'cost' => 50, 'quantity' => 1], + ], 1200); // 20 horas + + $this->createService($consultoria, 'Auditoria de Segurança', 'SEC-001', 'Segurança', [ + ['name' => 'Ferramentas de scan', 'cost' => 25, 'quantity' => 1], + ['name' => 'Relatório detalhado', 'cost' => 10, 'quantity' => 1], + ], 600); // 10 horas + + // ===================================================================== + // NEGÓCIO 3: GRÁFICA E DESIGN (Produtos + Serviços) + // ===================================================================== + $grafica = BusinessSetting::create([ + 'user_id' => $userId, + 'name' => 'Print & Design Studio', + 'currency' => 'EUR', + 'business_type' => 'both', + 'employees_count' => 4, + 'hours_per_week' => 44, + 'working_days_per_week' => 6, + 'working_days_per_month' => 26, + 'productivity_rate' => 80, + 'monthly_revenue' => 28000, + 'fixed_expenses' => 6200, + 'tax_rate' => 21, + 'price_includes_tax' => true, + 'vat_rate' => 21, + 'sales_commission' => 2, + 'card_fee' => 1.2, + 'other_variable_costs' => 3, + 'investment_rate' => 8, + 'profit_margin' => 20, + 'is_active' => true, + ]); + $grafica->recalculateMarkup(); + + // Produtos da Gráfica + $this->createProduct($grafica, 'Cartões de Visita (500 unid)', 'CARD-001', 'Impressos', [ + ['name' => 'Papel couché 300g', 'cost' => 8, 'quantity' => 1], + ['name' => 'Tinta e impressão', 'cost' => 12, 'quantity' => 1], + ['name' => 'Corte e acabamento', 'cost' => 5, 'quantity' => 1], + ]); + + $this->createProduct($grafica, 'Banner Roll-up 85x200cm', 'BANNER-001', 'Grandes Formatos', [ + ['name' => 'Lona impressa', 'cost' => 25, 'quantity' => 1], + ['name' => 'Estrutura roll-up', 'cost' => 35, 'quantity' => 1], + ['name' => 'Bolsa de transporte', 'cost' => 8, 'quantity' => 1], + ]); + + $this->createProduct($grafica, 'Flyers A5 (1000 unid)', 'FLYER-001', 'Impressos', [ + ['name' => 'Papel couché 150g', 'cost' => 15, 'quantity' => 1], + ['name' => 'Impressão offset', 'cost' => 20, 'quantity' => 1], + ]); + + $this->createProduct($grafica, 'Catálogo 20 páginas', 'CAT-001', 'Impressos', [ + ['name' => 'Papel interior', 'cost' => 30, 'quantity' => 1], + ['name' => 'Capa dura', 'cost' => 15, 'quantity' => 1], + ['name' => 'Encadernação', 'cost' => 10, 'quantity' => 1], + ['name' => 'Impressão', 'cost' => 45, 'quantity' => 1], + ]); + + // Serviços da Gráfica + $this->createService($grafica, 'Design de Logotipo', 'LOGO-001', 'Design', [ + ['name' => 'Banco de imagens', 'cost' => 5, 'quantity' => 1], + ['name' => 'Fontes premium', 'cost' => 10, 'quantity' => 1], + ], 360); // 6 horas + + $this->createService($grafica, 'Design de Embalagem', 'PACK-001', 'Design', [ + ['name' => 'Mockups 3D', 'cost' => 15, 'quantity' => 1], + ], 720); // 12 horas + + $this->createService($grafica, 'Identidade Visual Completa', 'BRAND-001', 'Branding', [ + ['name' => 'Manual de marca', 'cost' => 20, 'quantity' => 1], + ['name' => 'Arquivos fonte', 'cost' => 10, 'quantity' => 1], + ['name' => 'Aplicações básicas', 'cost' => 15, 'quantity' => 1], + ], 1500); // 25 horas + + $this->createService($grafica, 'Design de Post Redes Sociais', 'SOCIAL-001', 'Marketing', [ + ['name' => 'Templates Canva Pro', 'cost' => 2, 'quantity' => 1], + ], 60); // 1 hora + + $this->command->info('Business examples seeded successfully!'); + $this->command->info('- TechStore (Products): ' . $ecommerce->productSheets()->count() . ' products'); + $this->command->info('- DevPro (Services): ' . $consultoria->serviceSheets()->count() . ' services'); + $this->command->info('- Print & Design (Both): ' . $grafica->productSheets()->count() . ' products, ' . $grafica->serviceSheets()->count() . ' services'); + } + + /** + * Create a product with items + */ + private function createProduct(BusinessSetting $setting, string $name, string $sku, string $category, array $items): ProductSheet + { + $product = ProductSheet::create([ + 'user_id' => $setting->user_id, + 'business_setting_id' => $setting->id, + 'name' => $name, + 'sku' => $sku, + 'category' => $category, + 'currency' => $setting->currency, + 'is_active' => true, + ]); + + foreach ($items as $item) { + ProductSheetItem::create([ + 'product_sheet_id' => $product->id, + 'name' => $item['name'], + 'amount' => $item['cost'], + 'quantity' => $item['quantity'], + 'unit_cost' => $item['cost'], + 'type' => 'product_cost', + ]); + } + + // Recalculate totals + $product->refresh(); + $product->cmv_total = $product->items->sum(fn($i) => ($i->amount ?? $i->unit_cost) * $i->quantity); + $product->sale_price = $product->calculateSalePrice($setting); + $product->final_price = $product->calculateFinalPrice(); + $product->save(); + + return $product; + } + + /** + * Create a service with items + */ + private function createService(BusinessSetting $setting, string $name, string $code, string $category, array $items, int $durationMinutes): ServiceSheet + { + // Calculate CSV + $totalCsv = collect($items)->sum(fn($i) => $i['cost'] * $i['quantity']); + + $service = ServiceSheet::create([ + 'user_id' => $setting->user_id, + 'business_setting_id' => $setting->id, + 'name' => $name, + 'code' => $code, + 'category' => $category, + 'duration_minutes' => $durationMinutes, + 'total_csv' => $totalCsv, + 'premium_multiplier' => 1.0, + 'psychological_pricing' => false, + 'is_active' => true, + ]); + + foreach ($items as $item) { + ServiceSheetItem::create([ + 'service_sheet_id' => $service->id, + 'name' => $item['name'], + 'type' => 'supply', + 'unit_cost' => $item['cost'], + 'quantity_used' => $item['quantity'], + ]); + } + + // Recalculate pricing + $service->refresh(); + $service->fixed_cost_portion = $service->calculateFixedCostPortion(); + $service->calculated_price = $service->calculatePrice(); + $service->contribution_margin = $service->calculateContributionMargin(); + $service->margin_percentage = $service->calculated_price > 0 + ? round(($service->contribution_margin / $service->calculated_price) * 100, 2) + : 0; + $service->save(); + + return $service; + } +} diff --git a/backend/database/seeders/BusinessSeeder.php b/backend/database/seeders/BusinessSeeder.php new file mode 100644 index 0000000..94e88a0 --- /dev/null +++ b/backend/database/seeders/BusinessSeeder.php @@ -0,0 +1,113 @@ +command->error('No user found!'); + return; + } + + // Criar configuração de markup + $setting = BusinessSetting::create([ + 'user_id' => $user->id, + 'name' => 'Loja Principal', + 'currency' => 'EUR', + 'monthly_revenue' => 50000, + 'fixed_expenses' => 6000, + 'tax_rate' => 10, + 'sales_commission' => 5, + 'card_fee' => 3, + 'other_variable_costs' => 2, + 'investment_rate' => 5, + 'profit_margin' => 15, + 'is_active' => true, + ]); + $setting->recalculateMarkup(); + + $this->command->info("Setting criado: {$setting->name} - Markup: {$setting->markup_factor}"); + + // Criar produtos de exemplo com fichas técnicas + $products = [ + ['name' => 'Camiseta Básica', 'sku' => 'CAM-001', 'cmv' => 12.50, 'category' => 'Vestuário'], + ['name' => 'Calça Jeans', 'sku' => 'CAL-001', 'cmv' => 35.00, 'category' => 'Vestuário'], + ['name' => 'Tênis Esportivo', 'sku' => 'TEN-001', 'cmv' => 65.00, 'category' => 'Calçados'], + ['name' => 'Mochila Escolar', 'sku' => 'MOC-001', 'cmv' => 28.00, 'category' => 'Acessórios'], + ['name' => 'Boné Trucker', 'sku' => 'BON-001', 'cmv' => 8.50, 'category' => 'Acessórios'], + ['name' => 'Relógio Digital', 'sku' => 'REL-001', 'cmv' => 45.00, 'category' => 'Acessórios'], + ['name' => 'Óculos de Sol', 'sku' => 'OCU-001', 'cmv' => 22.00, 'category' => 'Acessórios'], + ['name' => 'Jaqueta Corta-Vento', 'sku' => 'JAQ-001', 'cmv' => 55.00, 'category' => 'Vestuário'], + ['name' => 'Shorts Fitness', 'sku' => 'SHO-001', 'cmv' => 18.00, 'category' => 'Vestuário'], + ['name' => 'Meias Pack 3', 'sku' => 'MEI-001', 'cmv' => 6.00, 'category' => 'Vestuário'], + ]; + + foreach ($products as $productData) { + // Criar a ficha técnica + $sheet = ProductSheet::create([ + 'user_id' => $user->id, + 'business_setting_id' => $setting->id, + 'name' => $productData['name'], + 'sku' => $productData['sku'], + 'description' => 'Produto de exemplo', + 'category' => $productData['category'], + 'currency' => 'EUR', + 'cmv_total' => $productData['cmv'], + 'is_active' => true, + ]); + + // Criar item de custo principal + ProductSheetItem::create([ + 'product_sheet_id' => $sheet->id, + 'name' => 'Custo de Aquisição', + 'type' => ProductSheetItem::TYPE_PRODUCT_COST, + 'amount' => $productData['cmv'] * 0.8, + 'quantity' => 1, + 'unit' => 'un', + 'unit_cost' => $productData['cmv'] * 0.8, + 'sort_order' => 1, + ]); + + // Criar item de embalagem + ProductSheetItem::create([ + 'product_sheet_id' => $sheet->id, + 'name' => 'Embalagem', + 'type' => ProductSheetItem::TYPE_PACKAGING, + 'amount' => $productData['cmv'] * 0.15, + 'quantity' => 1, + 'unit' => 'un', + 'unit_cost' => $productData['cmv'] * 0.15, + 'sort_order' => 2, + ]); + + // Criar item de etiqueta + ProductSheetItem::create([ + 'product_sheet_id' => $sheet->id, + 'name' => 'Etiqueta', + 'type' => ProductSheetItem::TYPE_LABEL, + 'amount' => $productData['cmv'] * 0.05, + 'quantity' => 1, + 'unit' => 'un', + 'unit_cost' => $productData['cmv'] * 0.05, + 'sort_order' => 3, + ]); + + // Calcular preço de venda + $sheet->calculateSalePrice($setting); + + $this->command->info("Produto: {$sheet->name} - CMV: EUR {$sheet->cmv_total} - Venda: EUR {$sheet->sale_price}"); + } + + $this->command->info('Business seeder concluído!'); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 8b5ba5c..986ac2f 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -15,6 +15,8 @@ use App\Http\Controllers\Api\RecurringTemplateController; use App\Http\Controllers\Api\BusinessSettingController; use App\Http\Controllers\Api\ProductSheetController; +use App\Http\Controllers\Api\ServiceSheetController; +use App\Http\Controllers\Api\PromotionalCampaignController; // Public routes with rate limiting Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register'); @@ -211,5 +213,24 @@ Route::delete('product-sheets/{sheetId}/items/{itemId}', [ProductSheetController::class, 'removeItem']); Route::post('product-sheets/{id}/recalculate-price', [ProductSheetController::class, 'recalculatePrice']); Route::post('product-sheets/{id}/duplicate', [ProductSheetController::class, 'duplicate']); + + // Fichas Técnicas de Serviços (CSV - Custo do Serviço Vendido) + Route::get('service-sheets/categories', [ServiceSheetController::class, 'categories']); + Route::get('service-sheets/item-types', [ServiceSheetController::class, 'itemTypes']); + Route::post('service-sheets/simulate', [ServiceSheetController::class, 'simulate']); + Route::apiResource('service-sheets', ServiceSheetController::class); + Route::post('service-sheets/{id}/items', [ServiceSheetController::class, 'addItem']); + Route::put('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'updateItem']); + Route::delete('service-sheets/{sheetId}/items/{itemId}', [ServiceSheetController::class, 'removeItem']); + Route::post('service-sheets/{id}/duplicate', [ServiceSheetController::class, 'duplicate']); + + // Campanhas Promocionais (Black Friday, etc.) + Route::get('campaigns/presets', [PromotionalCampaignController::class, 'presets']); + Route::post('campaigns/preview', [PromotionalCampaignController::class, 'preview']); + Route::apiResource('campaigns', PromotionalCampaignController::class); + Route::post('campaigns/{id}/duplicate', [PromotionalCampaignController::class, 'duplicate']); + Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']); + Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']); + Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']); }); diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..df4d91b --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,219 @@ +# ============================================================================= +# WEBMoney - Script de Deploy Unificado +# ============================================================================= +# Este script faz deploy do frontend, backend ou ambos para o servidor +# +# Uso: +# .\deploy.ps1 # Deploy de ambos (frontend + backend) +# .\deploy.ps1 -Target frontend # Apenas frontend +# .\deploy.ps1 -Target backend # Apenas backend +# .\deploy.ps1 -Target both # Ambos (padrao) +# +# Requer: Node.js, npm, PuTTY (pscp, plink) instalados no PATH +# ============================================================================= + +param( + [ValidateSet('frontend', 'backend', 'both')] + [string]$Target = 'both' +) + +$ErrorActionPreference = "Stop" + +# Configuracoes +$SERVER_USER = "root" +$SERVER_HOST = "213.165.93.60" +$SERVER_PASS = "Master9354" +$PROJECT_ROOT = $PSScriptRoot + +# Paths +$FRONTEND_LOCAL = "$PROJECT_ROOT\frontend" +$FRONTEND_DIST = "$FRONTEND_LOCAL\dist" +$FRONTEND_REMOTE = "/var/www/webmoney/frontend/dist" + +$BACKEND_LOCAL = "$PROJECT_ROOT\backend" +$BACKEND_REMOTE = "/var/www/webmoney/backend" + +# Cores +function Write-Color { + param([string]$Text, [string]$Color = "White") + Write-Host $Text -ForegroundColor $Color +} + +function Write-Header { + param([string]$Text) + Write-Host "" + Write-Color "========================================" "Cyan" + Write-Color " $Text" "Cyan" + Write-Color "========================================" "Cyan" + Write-Host "" +} + +function Write-Step { + param([string]$Step, [string]$Text) + Write-Color "[$Step] $Text" "Yellow" +} + +function Write-OK { + param([string]$Text) + Write-Color "[OK] $Text" "Green" +} + +function Write-Fail { + param([string]$Text) + Write-Color "[ERRO] $Text" "Red" +} + +# ============================================================================= +# DEPLOY FRONTEND +# ============================================================================= +function Deploy-Frontend { + Write-Header "DEPLOY FRONTEND" + + # 1. Build + Write-Step "1/4" "Fazendo build do frontend..." + Set-Location $FRONTEND_LOCAL + + if (Test-Path $FRONTEND_DIST) { + Remove-Item -Recurse -Force $FRONTEND_DIST + } + + npm run build + + if (-not (Test-Path $FRONTEND_DIST)) { + Write-Fail "Build falhou - pasta dist nao encontrada" + exit 1 + } + Write-OK "Build concluido" + Write-Host "" + + # 2. Limpar diretorio remoto + Write-Step "2/4" "Limpando diretorio remoto..." + plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "rm -rf $FRONTEND_REMOTE/*" + Write-OK "Diretorio limpo" + Write-Host "" + + # 3. Enviar arquivos + Write-Step "3/4" "Enviando arquivos..." + pscp -r -batch -pw $SERVER_PASS "$FRONTEND_DIST\*" "${SERVER_USER}@${SERVER_HOST}:${FRONTEND_REMOTE}/" + Write-OK "Arquivos enviados" + Write-Host "" + + # 4. Verificar + Write-Step "4/4" "Verificando deploy..." + $result = plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "ls $FRONTEND_REMOTE/index.html 2>/dev/null" + + if (-not $result) { + Write-Fail "index.html nao encontrado" + exit 1 + } + Write-OK "Frontend deployado com sucesso" + Write-Host "" + + Set-Location $PROJECT_ROOT +} + +# ============================================================================= +# DEPLOY BACKEND +# ============================================================================= +function Deploy-Backend { + Write-Header "DEPLOY BACKEND" + + # 1. Enviar pastas + Write-Step "1/5" "Enviando pastas do backend..." + $folders = @("app", "bootstrap", "config", "database", "public", "resources", "routes") + + foreach ($folder in $folders) { + $folderPath = "$BACKEND_LOCAL\$folder" + if (Test-Path $folderPath) { + Write-Host " -> $folder" + pscp -r -batch -pw $SERVER_PASS "$folderPath" "${SERVER_USER}@${SERVER_HOST}:${BACKEND_REMOTE}/" + } + } + Write-OK "Pastas enviadas" + Write-Host "" + + # 2. Enviar arquivos + Write-Step "2/5" "Enviando arquivos..." + $files = @("artisan", "composer.json", "composer.lock") + + foreach ($file in $files) { + $filePath = "$BACKEND_LOCAL\$file" + if (Test-Path $filePath) { + Write-Host " -> $file" + pscp -batch -pw $SERVER_PASS "$filePath" "${SERVER_USER}@${SERVER_HOST}:${BACKEND_REMOTE}/" + } + } + Write-OK "Arquivos enviados" + Write-Host "" + + # 3. Composer install + Write-Step "3/5" "Instalando dependencias..." + plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "cd $BACKEND_REMOTE && composer install --no-dev --optimize-autoloader 2>&1 | tail -3" + Write-OK "Dependencias instaladas" + Write-Host "" + + # 4. Migracoes e cache + Write-Step "4/5" "Executando migracoes e otimizacoes..." + plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "cd $BACKEND_REMOTE && php artisan migrate --force && php artisan optimize:clear && php artisan config:cache && php artisan route:cache && php artisan view:cache" + Write-OK "Migracoes e caches OK" + Write-Host "" + + # 5. Permissoes e PHP-FPM + Write-Step "5/5" "Ajustando permissoes e reiniciando PHP-FPM..." + plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "chown -R www-data:www-data $BACKEND_REMOTE/storage $BACKEND_REMOTE/bootstrap/cache && chmod -R 775 $BACKEND_REMOTE/storage $BACKEND_REMOTE/bootstrap/cache && systemctl restart php8.4-fpm" + Write-OK "Backend deployado com sucesso" + Write-Host "" +} + +# ============================================================================= +# REINICIAR NGINX (sempre ao final) +# ============================================================================= +function Restart-Nginx { + Write-Step "FINAL" "Reiniciando Nginx para limpar cache..." + plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "systemctl restart nginx" + Write-OK "Nginx reiniciado" + Write-Host "" +} + +# ============================================================================= +# MAIN +# ============================================================================= +Write-Host "" +Write-Color "========================================" "Magenta" +Write-Color " WEBMoney - Deploy Unificado " "Magenta" +Write-Color " Target: $Target " "Magenta" +Write-Color "========================================" "Magenta" +Write-Host "" + +$startTime = Get-Date + +# Executar deploy conforme target +switch ($Target) { + 'frontend' { + Deploy-Frontend + } + 'backend' { + Deploy-Backend + } + 'both' { + Deploy-Backend + Deploy-Frontend + } +} + +# Sempre reiniciar Nginx ao final +Restart-Nginx + +$endTime = Get-Date +$duration = $endTime - $startTime + +Write-Color "========================================" "Green" +Write-Color " DEPLOY CONCLUIDO COM SUCESSO! " "Green" +Write-Color "========================================" "Green" +Write-Host "" +Write-Host " Target: $Target" +Write-Host " Duracao: $($duration.TotalSeconds.ToString('F1')) segundos" +Write-Host " URL: https://webmoney.cnxifly.com" +Write-Host "" +Write-Host " Dica: Use Ctrl+Shift+R no navegador para limpar cache local" +Write-Host "" diff --git a/docs/MODULO_NEGOCIOS.txt b/docs/MODULO_NEGOCIOS.txt new file mode 100644 index 0000000..7d35a49 --- /dev/null +++ b/docs/MODULO_NEGOCIOS.txt @@ -0,0 +1,418 @@ +================================================================================ + WEBMONEY - MÓDULO DE NEGOCIOS + Guía Completa de Funcionamiento +================================================================================ + +ÍNDICE +------ +1. Visión General +2. Pestaña: Configuraciones de Negocio +3. Pestaña: Fichas Técnicas de Productos +4. Pestaña: Campañas Promocionales +5. Pestaña: Calculadora de Precios +6. Flujo de Trabajo Recomendado +7. Protección de Rentabilidad +8. Casos de Uso Prácticos + +================================================================================ +1. VISIÓN GENERAL +================================================================================ + +El Módulo de Negocios es una herramienta integral para la gestión de precios, +costos y promociones de productos. Está diseñado para ayudar a emprendedores +y pequeñas empresas a: + + • Calcular precios de venta basados en costos reales + • Mantener márgenes de ganancia saludables + • Crear campañas promocionales sin perder dinero + • Comparar precios con la competencia + • Tomar decisiones informadas sobre precificación + • Trabajar con precios B2B (sin IVA) o B2C (con IVA incluido) + +El módulo se divide en 4 pestañas principales que trabajan de forma integrada. + +================================================================================ +2. PESTAÑA: CONFIGURACIONES DE NEGOCIO +================================================================================ + +¿PARA QUÉ SIRVE? +---------------- +Define los parámetros globales de markup y deducciones que se aplicarán a todos +los productos. Es la base del cálculo de precios. + +CAMPOS PRINCIPALES: +------------------- +• Nombre: Identificador de la configuración (ej: "Tienda Online", "Mayorista") +• Moneda: EUR, USD, BRL, GBP + +TIPO DE PRECIO (B2B vs B2C): +---------------------------- +El sistema soporta dos modos de cálculo de precios: + + B2C (Business to Consumer) - Venta al consumidor final: + ───────────────────────────────────────────────────────── + • El precio final INCLUYE el IVA/VAT + • El IVA NO se resta del margen, se AÑADE al precio final + • Fórmula: Precio = CMV × Markup × (1 + IVA%) + • Ideal para: Tiendas online, retail, venta directa + • Ejemplo: PVP €24.20 (incluye 21% IVA) + + B2B (Business to Business) - Venta entre empresas: + ───────────────────────────────────────────────────────── + • El precio final NO incluye IVA (se factura aparte) + • Los impuestos se tratan como deducción del margen + • Fórmula: Precio = CMV × Markup + • Ideal para: Mayoristas, distribuidores, proveedores + • Ejemplo: Precio neto €20.00 + IVA (factura separada) + +• Tasa de IVA/VAT (%): Solo visible en modo B2C (ej: 21% España, 23% Portugal) +• Otros Impuestos (%): Tasas especiales además del IVA + +DEDUCCIONES (se descuentan del precio de venta): +------------------------------------------------ +• Tarifa de Plataforma (%): Comisiones de marketplaces (Amazon, Mercado Libre) +• Tarifa de Pago (%): Comisiones de pasarelas (PayPal, Stripe) +• Envío (%): Costo de envío como porcentaje +• Marketing (%): Inversión en publicidad +• Otros (%): Costos adicionales + +EJEMPLO B2C (Precio incluye IVA): +--------------------------------- + CMV (Costo): €10.00 + Markup Base: 2.5× (150%) + IVA: 21% + Otras deducciones: 15% (plataforma + pago) + + Precio sin IVA = €10.00 × 2.5 = €25.00 + Precio con IVA = €25.00 × 1.21 = €30.25 (PVP) + Deducciones = €25.00 × 15% = €3.75 + Margen Neto = €25.00 - €10.00 - €3.75 = €11.25 (45% sobre CMV) + + El preview muestra: Markup Base (2.5) × (1 + IVA) = Multiplicador Final (3.025) + +EJEMPLO B2B (Precio sin IVA): +----------------------------- + CMV (Costo): €10.00 + Markup: 2.5× (150%) + Impuestos como deducción: 21% + Plataforma: 15% + + Precio Neto = €10.00 × 2.5 = €25.00 + Deducciones = €25.00 × 36% = €9.00 + Margen Neto = €25.00 - €10.00 - €9.00 = €6.00 (24% sobre CMV) + + El cliente recibe factura: €25.00 + 21% IVA = €30.25 + +================================================================================ +3. PESTAÑA: FICHAS TÉCNICAS DE PRODUCTOS +================================================================================ + +¿PARA QUÉ SIRVE? +---------------- +Registra cada producto con todos sus componentes de costo (CMV - Costo de +Mercancía Vendida) y calcula automáticamente el precio de venta sugerido. + +INFORMACIÓN BÁSICA: +------------------- +• Nombre del Producto +• SKU/Código +• Categoría +• Configuración de Negocio asociada +• Estado (Activo/Inactivo) + +COMPONENTES DE COSTO (CMV): +--------------------------- +Cada producto puede tener múltiples componentes: + + Tipo Ejemplo + ───────────────────────────────────────── + Costo del Producto Materia prima, producto al por mayor + Embalaje Cajas, bolsas, papel + Etiqueta Tags, stickers + Envío Flete del proveedor + Manipulación Mano de obra de preparación + Otros Cualquier costo adicional + +Cada componente tiene: Nombre, Tipo, Valor, Cantidad + +PRECIFICACIÓN ESTRATÉGICA (Avanzado): +------------------------------------- +• Precio del Competidor: Para comparar tu precio vs mercado +• Precio Mínimo: Nunca vender por debajo de este valor +• Precio Máximo: Límite superior +• Multiplicador Premium: Ajuste fino (1.0 = neutro) +• Estrategia de Precio: + - Agresivo: -5% del competidor + - Neutro: Precio calculado + - Premium: +10% del competidor +• Margen Objetivo (%): Sobrescribe el markup global para este producto +• Redondeo Psicológico: Convierte €26.04 en €25.99 + +CÁLCULOS AUTOMÁTICOS: +--------------------- + CMV Total = Suma de todos los componentes de costo + Precio de Venta = CMV × (1 + Markup%) ÷ (1 - Deducciones%) + Margen de Contribución = Precio - CMV - Deducciones + Margen Real (%) = (Margen de Contribución ÷ Precio) × 100 + +================================================================================ +4. PESTAÑA: CAMPAÑAS PROMOCIONALES +================================================================================ + +¿PARA QUÉ SIRVE? +---------------- +Crea y gestiona ofertas temporales con descuentos, manteniendo la rentabilidad +gracias al sistema de protección automática. + +PRESETS RÁPIDOS: +---------------- +• Black Friday (30% descuento, badge negro) +• Cyber Monday (25% descuento, badge azul) +• Navidad (20% descuento, badge verde) +• Año Nuevo (15% descuento, badge naranja) +• Liquidación de Verano (40% descuento, badge rojo) + +CONFIGURACIÓN DE CAMPAÑA: +------------------------- +Paso 1 - Detalles: + • Nombre y Código + • Tipo de Descuento: + - Porcentaje: 30% off + - Valor Fijo: €5 off + - Precio Fijo: Todo a €9.99 + • Período: Fecha/hora inicio y fin + • Badge visual (texto y color) + +Paso 2 - Productos: + • Seleccionar productos específicos + • O aplicar a todos los productos + +Paso 3 - Revisión: + • Vista previa del impacto en precios + • Análisis de rentabilidad + • Alertas de productos con pérdida + +PROTECCIÓN DE RENTABILIDAD: +--------------------------- +El sistema incluye protecciones automáticas para evitar vender con pérdida: + + ✓ Nunca vender con pérdida: El precio nunca será menor que el CMV + ✓ Margen Mínimo: Garantiza un % mínimo de ganancia + ✓ Precio Mínimo: Límite absoluto inferior + +Ejemplo con protección: + Producto: Camiseta + CMV: €15.00 + Precio Normal: €30.00 + Descuento Black Friday: 60% + Precio sin protección: €12.00 (PÉRDIDA de €3.00) + Precio con protección: €15.00 (CMV, sin pérdida) + +ESTADOS DE CAMPAÑA: +------------------- +• Activa: En ejecución ahora +• Programada: Iniciará en el futuro +• Finalizada: Ya terminó +• Inactiva: Desactivada manualmente + +================================================================================ +5. PESTAÑA: CALCULADORA DE PRECIOS +================================================================================ + +¿PARA QUÉ SIRVE? +---------------- +Herramienta rápida para simular precios sin necesidad de crear productos. +Ideal para cotizaciones y análisis rápidos. + +FUNCIONALIDADES: +---------------- +• Ingresa un CMV y ve el precio calculado instantáneamente +• Prueba diferentes configuraciones de negocio +• Compara márgenes entre escenarios +• Exporta simulaciones + +================================================================================ +6. FLUJO DE TRABAJO RECOMENDADO +================================================================================ + +CONFIGURACIÓN INICIAL: +---------------------- +1. Crear al menos una Configuración de Negocio + → Define tu markup estándar y deducciones + +2. Registrar tus Productos + → Agrega todos los componentes de costo + → Vincula con la configuración de negocio + +3. Revisar los Precios Calculados + → Verifica que los márgenes sean adecuados + → Ajusta el markup si es necesario + +PARA CREAR UNA PROMOCIÓN: +------------------------- +1. Ir a Campañas Promocionales +2. Usar un preset o crear desde cero +3. Configurar descuento y período +4. Activar "Protección de Rentabilidad" +5. Seleccionar productos +6. Revisar el impacto en la vista previa +7. Crear la campaña + +MANTENIMIENTO CONTINUO: +----------------------- +• Actualizar costos cuando cambien los proveedores +• Revisar márgenes mensualmente +• Ajustar markup según la competencia +• Analizar rendimiento de campañas pasadas + +================================================================================ +7. PROTECCIÓN DE RENTABILIDAD - DETALLE TÉCNICO +================================================================================ + +El sistema aplica 4 capas de protección al calcular precios promocionales: + +CAPA 1 - CMV (Costo): + → El precio nunca será menor que el costo del producto + → Evita pérdidas absolutas + +CAPA 2 - Precio Mínimo del Producto: + → Respeta el precio mínimo definido en la ficha técnica + → Útil para productos con valor de marca + +CAPA 3 - Precio Mínimo de Campaña: + → Límite global para toda la campaña + → Ej: "Ningún producto por menos de €5" + +CAPA 4 - Margen Mínimo: + → Garantiza un porcentaje de ganancia sobre el CMV + → Ej: "Siempre al menos 10% de margen" + +ORDEN DE APLICACIÓN: +-------------------- +El sistema calcula el precio promocional y luego aplica la protección +más restrictiva que corresponda: + + precio_final = MAX( + precio_promocional, + cmv, + precio_minimo_producto, + precio_minimo_campaña, + cmv × (1 + margen_minimo%) + ) + +================================================================================ +8. CASOS DE USO PRÁCTICOS +================================================================================ + +CASO 1: TIENDA DE ROPA ONLINE +----------------------------- +Configuración: + • Markup: 150% + • Impuestos: 21% + • Plataforma (Shopify): 2% + • Pago (Stripe): 3% + • Marketing: 10% + +Producto: Camiseta Básica + • Costo tela: €3.00 + • Costo confección: €2.00 + • Embalaje: €0.50 + • CMV Total: €5.50 + • Precio Calculado: €18.99 + +Black Friday: + • Descuento: 30% + • Precio Promo: €13.29 + • Margen: €7.79 (58% sobre CMV) ✓ Rentable + +───────────────────────────────────────────────────────────────────────────── + +CASO 2: VENTA DE ELECTRÓNICOS EN AMAZON +---------------------------------------- +Configuración: + • Markup: 80% + • Impuestos: 21% + • Plataforma (Amazon): 15% + • Pago: 0% (incluido en Amazon) + • Marketing: 5% + +Producto: Cable USB-C + • Costo producto: €2.00 + • Embalaje: €0.30 + • CMV Total: €2.30 + • Precio Calculado: €6.99 + +Cyber Monday: + • Descuento: 25% + • Precio sin protección: €5.24 + • Con margen mínimo 10%: €5.24 (mantiene porque CMV×1.1 = €2.53) + • Margen final: €2.94 (127% sobre CMV) ✓ Muy rentable + +───────────────────────────────────────────────────────────────────────────── + +CASO 3: PRODUCTO DE ALTO COSTO CON BAJO MARGEN +---------------------------------------------- +Configuración: + • Markup: 30% + • Deducciones totales: 25% + +Producto: Laptop + • CMV: €800.00 + • Precio Normal: €1,386.67 + • Margen Normal: €240.00 (30%) + +Liquidación 50%: + • Precio sin protección: €693.33 (PÉRDIDA de €106.67) + • Con protección CMV: €800.00 (sin pérdida) + • Con margen mínimo 5%: €840.00 (ganancia €40) + +El sistema automáticamente ajusta a €840.00 para mantener el 5% de margen. + +───────────────────────────────────────────────────────────────────────────── + +CASO 4: MISMO PRODUCTO EN B2B Y B2C +----------------------------------- +Una empresa vende tanto a consumidores (B2C) como a otras empresas (B2B). + +Producto: Auriculares Bluetooth + • CMV: €15.00 + +Configuración B2C (Tienda Online): + • Tipo: B2C (precio incluye IVA) + • IVA: 21% + • Markup Base: 2.0× + • Deducciones: 18% + • Precio = €15 × 2.0 × 1.21 = €36.30 (PVP con IVA) + • El IVA (€6.30) va al gobierno, no afecta tu margen + +Configuración B2B (Distribuidora): + • Tipo: B2B (precio sin IVA) + • Markup: 1.5× + • Deducciones: 10% + • Precio = €15 × 1.5 = €22.50 (neto) + • Factura al cliente: €22.50 + €4.73 IVA = €27.23 + +Ventaja: Puedes tener dos configuraciones diferentes para el mismo producto, +una para cada canal de ventas. + +================================================================================ + +RESUMEN +------- +El Módulo de Negocios de WebMoney te permite: + + ✓ Calcular precios basados en costos reales + ✓ Mantener márgenes de ganancia consistentes + ✓ Crear promociones atractivas sin perder dinero + ✓ Comparar con la competencia + ✓ Tomar decisiones de precio informadas + ✓ Trabajar en modo B2C (IVA incluido) o B2B (IVA separado) + ✓ Gestionar múltiples canales de venta con diferentes configuraciones + +La clave está en configurar correctamente los costos y el tipo de precio +(B2B o B2C) para que el sistema calcule los precios óptimos mientras +protege tu rentabilidad. + +================================================================================ + FIN DEL DOCUMENTO +================================================================================ diff --git a/fix_migration.sh b/fix_migration.sh new file mode 100644 index 0000000..c862b42 --- /dev/null +++ b/fix_migration.sh @@ -0,0 +1,2 @@ +mysql -u webmoney -p'M@ster9354' webmoney -e "INSERT INTO migrations (migration, batch) VALUES ('2025_12_08_000004_create_transactions_table', 1)" +php artisan migrate --force \ No newline at end of file diff --git a/frontend/deploy.ps1 b/frontend/deploy.ps1 index 4ec3e43..37e87d6 100644 --- a/frontend/deploy.ps1 +++ b/frontend/deploy.ps1 @@ -59,22 +59,32 @@ Write-Color "Arquivos enviados" "Green" Write-Host "" # 4. Verificar deploy -Write-Color "[4/4] Verificando deploy..." "Yellow" +Write-Color "[4/5] Verificando deploy..." "Yellow" $result = plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "ls $REMOTE_PATH/index.html 2>/dev/null" -if ($result) { - Write-Host "" - Write-Color "========================================" "Green" - Write-Color " Deploy concluido com sucesso! " "Green" - Write-Color "========================================" "Green" - Write-Host "" - Write-Host "Acesse: https://webmoney.cnxifly.com" - Write-Host "" - Write-Host "Dica: Use Ctrl+Shift+R no navegador para limpar o cache" - Write-Host "" -} else { +if (-not $result) { Write-Color "========================================" "Red" Write-Color " ERRO: index.html nao encontrado " "Red" Write-Color "========================================" "Red" exit 1 } + +Write-Color "Arquivos verificados" "Green" +Write-Host "" + +# 5. Reiniciar Nginx para limpar cache +Write-Color "[5/5] Reiniciando Nginx para limpar cache..." "Yellow" +plink -batch -pw $SERVER_PASS "$SERVER_USER@$SERVER_HOST" "systemctl restart nginx" + +Write-Color "Nginx reiniciado" "Green" +Write-Host "" + +Write-Host "" +Write-Color "========================================" "Green" +Write-Color " Deploy concluido com sucesso! " "Green" +Write-Color "========================================" "Green" +Write-Host "" +Write-Host "Acesse: https://webmoney.cnxifly.com" +Write-Host "" +Write-Host "Dica: Use Ctrl+Shift+R no navegador para limpar o cache local" +Write-Host "" diff --git a/frontend/src/components/business/BusinessSettingModal.jsx b/frontend/src/components/business/BusinessSettingModal.jsx index 40a45e3..804bbb4 100644 --- a/frontend/src/components/business/BusinessSettingModal.jsx +++ b/frontend/src/components/business/BusinessSettingModal.jsx @@ -9,9 +9,17 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { const [formData, setFormData] = useState({ name: '', currency: 'EUR', + business_type: 'products', + employees_count: '1', + hours_per_week: '40', + working_days_per_week: '5', + working_days_per_month: '22', + productivity_rate: '80', monthly_revenue: '', fixed_expenses: '', tax_rate: '', + price_includes_tax: true, + vat_rate: '21', sales_commission: '', card_fee: '', other_variable_costs: '', @@ -29,14 +37,22 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { setFormData({ name: setting.name || '', currency: setting.currency || 'EUR', - monthly_revenue: setting.monthly_revenue || '', - fixed_expenses: setting.fixed_expenses || '', - tax_rate: setting.tax_rate || '', - sales_commission: setting.sales_commission || '', - card_fee: setting.card_fee || '', - other_variable_costs: setting.other_variable_costs || '', - investment_rate: setting.investment_rate || '', - profit_margin: setting.profit_margin || '', + business_type: setting.business_type || 'products', + employees_count: setting.employees_count?.toString() || '1', + hours_per_week: setting.hours_per_week?.toString() || '40', + working_days_per_week: setting.working_days_per_week?.toString() || '5', + working_days_per_month: setting.working_days_per_month?.toString() || '22', + productivity_rate: setting.productivity_rate?.toString() || '80', + monthly_revenue: setting.monthly_revenue?.toString() || '', + fixed_expenses: setting.fixed_expenses?.toString() || '', + tax_rate: setting.tax_rate?.toString() || '', + price_includes_tax: setting.price_includes_tax ?? true, + vat_rate: setting.vat_rate?.toString() || '21', + sales_commission: setting.sales_commission?.toString() || '', + card_fee: setting.card_fee?.toString() || '', + other_variable_costs: setting.other_variable_costs?.toString() || '', + investment_rate: setting.investment_rate?.toString() || '', + profit_margin: setting.profit_margin?.toString() || '', is_active: setting.is_active ?? true, }); } @@ -47,33 +63,61 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { const monthlyRevenue = parseFloat(formData.monthly_revenue) || 0; const fixedExpenses = parseFloat(formData.fixed_expenses) || 0; const taxRate = parseFloat(formData.tax_rate) || 0; + const priceIncludesTax = formData.price_includes_tax; + const vatRate = parseFloat(formData.vat_rate) || 0; const salesCommission = parseFloat(formData.sales_commission) || 0; const cardFee = parseFloat(formData.card_fee) || 0; const otherVariableCosts = parseFloat(formData.other_variable_costs) || 0; const investmentRate = parseFloat(formData.investment_rate) || 0; const profitMargin = parseFloat(formData.profit_margin) || 0; + + // Calcular horas produtivas (para serviços) + // Chave: horas por semana - as outras são derivadas + const employees = parseInt(formData.employees_count) || 1; + const hoursPerWeek = parseFloat(formData.hours_per_week) || 40; + const daysPerWeek = parseInt(formData.working_days_per_week) || 5; + const daysPerMonth = parseInt(formData.working_days_per_month) || 22; + const productivity = parseFloat(formData.productivity_rate) || 80; + + // Derivar horas por dia a partir de horas por semana + const hoursPerDay = daysPerWeek > 0 ? hoursPerWeek / daysPerWeek : 8; + + // Horas produtivas = funcionários × horas/dia × dias/mês × produtividade + const productiveHours = employees * hoursPerDay * daysPerMonth * (productivity / 100); + const fixedCostPerHour = productiveHours > 0 ? fixedExpenses / productiveHours : 0; - // Calcular % despesas fixas + // Calcular % despesas fixas (para produtos) const fixedExpensesRate = monthlyRevenue > 0 ? (fixedExpenses / monthlyRevenue) * 100 : 0; - // Total custos variáveis - const totalVariableCosts = taxRate + salesCommission + cardFee + otherVariableCosts; + // Total custos variáveis - se B2C, tax_rate NÃO entra nas deduções + const totalVariableCosts = (priceIncludesTax ? 0 : taxRate) + salesCommission + cardFee + otherVariableCosts; // Total deduções (em decimal) const totalDeductions = (fixedExpensesRate + totalVariableCosts + investmentRate + profitMargin) / 100; - // Markup + // Markup base (sem IVA) let markup = 0; if (totalDeductions < 1) { markup = 1 / (1 - totalDeductions); } + + // Se B2C, o preço final inclui IVA + const finalMultiplier = priceIncludesTax ? markup * (1 + vatRate / 100) : markup; setPreview({ fixedExpensesRate: fixedExpensesRate.toFixed(2), totalVariableCosts: totalVariableCosts.toFixed(2), totalDeductions: (totalDeductions * 100).toFixed(2), markup: markup.toFixed(4), + finalMultiplier: finalMultiplier.toFixed(4), + vatRate: vatRate.toFixed(2), + priceIncludesTax, isValid: totalDeductions < 1, + // Para serviços - derivados de horas por semana + hoursPerWeek: hoursPerWeek.toFixed(1), + hoursPerDay: hoursPerDay.toFixed(2), + productiveHours: productiveHours.toFixed(1), + fixedCostPerHour: fixedCostPerHour.toFixed(2), }); }, [formData]); @@ -93,9 +137,17 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { try { const dataToSend = { ...formData, + business_type: formData.business_type, + employees_count: parseInt(formData.employees_count) || 1, + hours_per_week: parseFloat(formData.hours_per_week) || 40, + working_days_per_week: parseInt(formData.working_days_per_week) || 5, + working_days_per_month: parseInt(formData.working_days_per_month) || 22, + productivity_rate: parseFloat(formData.productivity_rate) || 80, monthly_revenue: parseFloat(formData.monthly_revenue) || 0, fixed_expenses: parseFloat(formData.fixed_expenses) || 0, tax_rate: parseFloat(formData.tax_rate) || 0, + price_includes_tax: formData.price_includes_tax, + vat_rate: parseFloat(formData.vat_rate) || 0, sales_commission: parseFloat(formData.sales_commission) || 0, card_fee: parseFloat(formData.card_fee) || 0, other_variable_costs: parseFloat(formData.other_variable_costs) || 0, @@ -122,8 +174,8 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { return (
-
-
+
+
@@ -133,7 +185,7 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
-
+
{error && (
{typeof error === 'string' ? error : JSON.stringify(error)}
)} @@ -167,6 +219,161 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => {
+ {/* Tipo de Negócio */} +
+
+
+ + {t('business.settings.businessType')} +
+
+
+
+
+
setFormData(prev => ({ ...prev, business_type: 'products' }))} + style={{ cursor: 'pointer' }} + > + +
+ {t('business.settings.typeProducts')} +
+
+
+
+
setFormData(prev => ({ ...prev, business_type: 'services' }))} + style={{ cursor: 'pointer' }} + > + +
+ {t('business.settings.typeServices')} +
+
+
+
+
setFormData(prev => ({ ...prev, business_type: 'both' }))} + style={{ cursor: 'pointer' }} + > + +
+ {t('business.settings.typeBoth')} +
+
+
+
+
+ + {/* Configuração de Horas Produtivas - Solo para servicios */} + {(formData.business_type === 'services' || formData.business_type === 'both') && ( + <> +
+
+
+
+ + {t('business.settings.productiveHours')} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + % +
+
+
+
+
+ + +
+
+ + +
+
+ {preview && ( +
+
+
+ {t('business.settings.totalProductiveHours')}: + {preview.productiveHours} h/{t('common.month')} +
+
+ {t('business.settings.fixedCostPerHour')}: + {formData.currency} {preview.fixedCostPerHour}{t('business.common.perHourUnit')} +
+
+
+ )} +
+
+
+ + )} + {/* Receita e Despesas Fixas */}

@@ -175,6 +382,9 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { {t('business.settings.revenueAndExpenses')}
+ + {/* Receita mensal - Solo para productos */} + {(formData.business_type === 'products' || formData.business_type === 'both') && (
{ value={formData.monthly_revenue} onChange={handleChange} placeholder="40000.00" - required + required={formData.business_type === 'products'} /> {t('business.settings.monthlyRevenueHelp')}
-
+ )} +
{ {t('business.settings.variableCosts')} (%)
+ + {/* B2B vs B2C - Tipo de Precio */} +
+
+
+
+ +
+
{t('business.settings.priceType')}
+
+
+ setFormData(prev => ({ ...prev, price_includes_tax: true }))} + /> + +
+
+ setFormData(prev => ({ ...prev, price_includes_tax: false }))} + /> + +
+
+
+
+
+
+
+ + {/* IVA/VAT Rate - Solo visible en B2C */} + {formData.price_includes_tax && ( +
+ +
+ + % +
+ {t('business.settings.vatRateExample')} +
+ )} +
- +
{ name="tax_rate" value={formData.tax_rate} onChange={handleChange} - placeholder="9" + placeholder={formData.price_includes_tax ? "0" : "9"} required /> %
+ {formData.price_includes_tax && ( + {t('business.settings.otherTaxesHelp')} + )}
@@ -342,12 +626,45 @@ const BusinessSettingModal = ({ setting, onSave, onClose }) => { {t('business.settings.totalDeductions')}: {preview.totalDeductions}%
+ {preview.priceIncludesTax && ( +
+ {t('business.settings.vatRate')}: + {preview.vatRate}% +
+ )}
{preview.isValid ? (
- {t('business.settings.markupFactor')}: -

{preview.markup}

+ {preview.priceIncludesTax ? ( + <> +
+
+ {t('business.settings.baseMarkup')} + {preview.markup} +
+ × +
+ {t('business.common.plusVat')} + {(1 + parseFloat(preview.vatRate) / 100).toFixed(2)} +
+ = +
+ {t('business.settings.finalMultiplier')} + {preview.finalMultiplier} +
+
+ + + {t('business.settings.vatIncluded')} + + + ) : ( + <> + {t('business.settings.markupFactor')}: +

{preview.markup}

+ + )}
) : (
diff --git a/frontend/src/components/business/BusinessSettingsTab.jsx b/frontend/src/components/business/BusinessSettingsTab.jsx index d50e5cf..882d970 100644 --- a/frontend/src/components/business/BusinessSettingsTab.jsx +++ b/frontend/src/components/business/BusinessSettingsTab.jsx @@ -23,7 +23,7 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => { }; const handleDelete = async (setting) => { - if (!window.confirm(t('business.confirmDelete'))) return; + if (!window.confirm(t('business.settings.confirmDelete'))) return; setDeleting(setting.id); try { @@ -124,7 +124,7 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => { {/* Markup Factor - Destaque */}
{t('business.settings.markupFactor')} -

{setting.markup_factor?.toFixed(2) || '0.00'}

+

{parseFloat(setting.markup_factor || 0).toFixed(2)}

{/* Breakdown */} @@ -144,13 +144,13 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
{t('business.settings.fixedExpensesRate')} - {setting.fixed_expenses_rate?.toFixed(2)}% + {parseFloat(setting.fixed_expenses_rate || 0).toFixed(2)}%
{t('business.settings.totalVariableCosts')} - {setting.total_variable_costs?.toFixed(2)}% + {parseFloat(setting.total_variable_costs || 0).toFixed(2)}%
diff --git a/frontend/src/components/business/CampaignModal.jsx b/frontend/src/components/business/CampaignModal.jsx new file mode 100644 index 0000000..40f5dfe --- /dev/null +++ b/frontend/src/components/business/CampaignModal.jsx @@ -0,0 +1,933 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { campaignService } from '../../services/api'; +import useFormatters from '../../hooks/useFormatters'; + +const CampaignModal = ({ isOpen, onClose, onSaved, campaign, presets, sheets }) => { + const { t } = useTranslation(); + const { currency } = useFormatters(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeStep, setActiveStep] = useState(1); + const [preview, setPreview] = useState(null); + + const [formData, setFormData] = useState({ + name: '', + code: '', + description: '', + discount_type: 'percentage', + discount_value: 10, + start_date: '', + end_date: '', + start_time: '', + end_time: '', + is_active: true, + apply_to_all_products: false, + min_price: '', + min_margin_percent: '', + protect_against_loss: true, + show_original_price: true, + show_discount_badge: true, + badge_text: '', + badge_color: '#ef4444', + priority: 0, + product_ids: [], + }); + + // Preset colors for badges + const badgeColors = [ + { name: 'Red', value: '#ef4444' }, + { name: 'Orange', value: '#f97316' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Green', value: '#22c55e' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Purple', value: '#8b5cf6' }, + { name: 'Pink', value: '#ec4899' }, + { name: 'Black', value: '#000000' }, + ]; + + // Initialize form when campaign changes + useEffect(() => { + if (isOpen) { + if (campaign?.id) { + // Editing existing campaign + setFormData({ + name: campaign.name || '', + code: campaign.code || '', + description: campaign.description || '', + discount_type: campaign.discount_type || 'percentage', + discount_value: parseFloat(campaign.discount_value) || 10, + start_date: campaign.start_date?.split('T')[0] || '', + end_date: campaign.end_date?.split('T')[0] || '', + start_time: campaign.start_time || '', + end_time: campaign.end_time || '', + is_active: campaign.is_active !== false, + apply_to_all_products: campaign.apply_to_all_products || false, + min_price: campaign.min_price || '', + min_margin_percent: campaign.min_margin_percent || '', + protect_against_loss: campaign.protect_against_loss !== false, + show_original_price: campaign.show_original_price !== false, + show_discount_badge: campaign.show_discount_badge !== false, + badge_text: campaign.badge_text || '', + badge_color: campaign.badge_color || '#ef4444', + priority: campaign.priority || 0, + product_ids: campaign.products?.map(p => p.id) || [], + }); + setActiveStep(1); + } else if (campaign?.preset && presets[campaign.preset]) { + // Creating from preset + const preset = presets[campaign.preset]; + const today = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 7); + + setFormData({ + name: preset.name || '', + code: preset.code || '', + description: preset.description || '', + discount_type: preset.discount_type || 'percentage', + discount_value: parseFloat(preset.discount_value) || 10, + start_date: today.toISOString().split('T')[0], + end_date: endDate.toISOString().split('T')[0], + start_time: preset.start_time || '', + end_time: preset.end_time || '', + is_active: true, + apply_to_all_products: false, + min_price: preset.min_price || '', + min_margin_percent: '', + protect_against_loss: true, + show_original_price: preset.show_original_price !== false, + show_discount_badge: preset.show_discount_badge !== false, + badge_text: preset.badge_text || '', + badge_color: preset.badge_color || '#ef4444', + priority: preset.priority || 0, + product_ids: [], + }); + setActiveStep(1); + } else { + // New campaign + const today = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 7); + + setFormData({ + name: '', + code: '', + description: '', + discount_type: 'percentage', + discount_value: 10, + start_date: today.toISOString().split('T')[0], + end_date: endDate.toISOString().split('T')[0], + start_time: '', + end_time: '', + is_active: true, + apply_to_all_products: false, + min_price: '', + min_margin_percent: '', + protect_against_loss: true, + show_original_price: true, + show_discount_badge: true, + badge_text: '', + badge_color: '#ef4444', + priority: 0, + product_ids: [], + }); + setActiveStep(1); + } + setError(null); + setPreview(null); + } + }, [isOpen, campaign, presets]); + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const handleProductToggle = (productId) => { + setFormData(prev => ({ + ...prev, + product_ids: prev.product_ids.includes(productId) + ? prev.product_ids.filter(id => id !== productId) + : [...prev.product_ids, productId], + })); + }; + + const handleSelectAllProducts = () => { + const allIds = sheets.map(s => s.id); + setFormData(prev => ({ + ...prev, + product_ids: prev.product_ids.length === allIds.length ? [] : allIds, + })); + }; + + // Preview calculation + const loadPreview = async () => { + if (formData.product_ids.length === 0 && !formData.apply_to_all_products) return; + + try { + const productIds = formData.apply_to_all_products + ? sheets.map(s => s.id) + : formData.product_ids; + + const result = await campaignService.preview({ + discount_type: formData.discount_type, + discount_value: parseFloat(formData.discount_value) || 0, + min_price: formData.min_price ? parseFloat(formData.min_price) : null, + min_margin_percent: formData.min_margin_percent ? parseFloat(formData.min_margin_percent) : null, + protect_against_loss: formData.protect_against_loss, + product_ids: productIds, + }); + setPreview(result); + } catch (err) { + console.error('Preview error:', err); + } + }; + + useEffect(() => { + if (activeStep === 3) { + loadPreview(); + } + }, [activeStep, formData.discount_type, formData.discount_value, formData.min_price, formData.min_margin_percent, formData.protect_against_loss, formData.product_ids]); + + // Form validation + const isStepValid = useMemo(() => { + switch (activeStep) { + case 1: + return formData.name && formData.discount_value > 0 && formData.start_date && formData.end_date; + case 2: + return formData.apply_to_all_products || formData.product_ids.length > 0; + case 3: + return true; + default: + return false; + } + }, [activeStep, formData]); + + const handleSubmit = async () => { + setLoading(true); + setError(null); + + try { + const data = { + ...formData, + discount_value: parseFloat(formData.discount_value) || 0, + min_price: formData.min_price ? parseFloat(formData.min_price) : null, + priority: parseInt(formData.priority) || 0, + }; + + let result; + if (campaign?.id) { + result = await campaignService.update(campaign.id, data); + } else { + result = await campaignService.create(data); + } + + onSaved(result); + } catch (err) { + setError(err.response?.data?.message || err.response?.data?.errors || t('common.error')); + } finally { + setLoading(false); + } + }; + + // Moved BEFORE conditional return to respect React hooks rules + const discountPreview = useMemo(() => { + const basePrice = 100; + let promoPrice = basePrice; + + switch (formData.discount_type) { + case 'percentage': + promoPrice = basePrice * (1 - (parseFloat(formData.discount_value) || 0) / 100); + break; + case 'fixed': + promoPrice = basePrice - (parseFloat(formData.discount_value) || 0); + break; + case 'price_override': + promoPrice = parseFloat(formData.discount_value) || 0; + break; + } + + return { original: basePrice, promo: Math.max(0, promoPrice) }; + }, [formData.discount_type, formData.discount_value]); + + if (!isOpen) return null; + + return ( +
+
+
+ {/* Header */} +
+
+ + {campaign?.id ? t('campaigns.edit') : t('campaigns.create')} +
+ +
+ + {/* Progress Steps */} +
+
+ {[1, 2, 3].map(step => ( +
+ + {step < activeStep ? : step} + + + {step === 1 && t('campaigns.steps.details')} + {step === 2 && t('campaigns.steps.products')} + {step === 3 && t('campaigns.steps.review')} + +
+ ))} +
+
+ + {/* Body */} +
+ {error && ( +
+ + {typeof error === 'object' ? JSON.stringify(error) : error} +
+ )} + + {/* Step 1: Campaign Details */} + {activeStep === 1 && ( +
+ {/* Nome e Código */} +
+
+ + +
+
+ + +
+
+ + {/* Descrição */} +
+ + +
+ + {/* Tipo e Valor do Desconto */} +
+
+ + +
+
+ +
+ {formData.discount_type !== 'percentage' && ( + $ + )} + + {formData.discount_type === 'percentage' && ( + % + )} +
+
+
+ + {/* Preview do Desconto */} +
+ {t('campaigns.form.example')}: + {currency(discountPreview.original)} + + {currency(discountPreview.promo)} +
+ + {/* PROTEÇÃO DE RENTABILIDADE */} +
+
+
+ + {t('campaigns.form.profitProtection')} +
+ +
+ + +
+ +
+
+ + + {t('campaigns.form.minPriceHelp')} +
+
+ +
+ + % +
+ {t('campaigns.form.minMarginHelp')} +
+
+
+
+ + {/* Datas */} +
+
+ + +
+
+ + +
+
+ + {/* Horas */} +
+
+ + +
+
+ + +
+
+ + {/* Badge Settings */} +
+
+
+ + {t('campaigns.form.badgeSettings')} +
+ +
+ + +
+ +
+ + +
+ + {formData.show_discount_badge && ( + <> +
+ + +
+ +
+ +
+ {badgeColors.map(color => ( + + ))} +
+
+ + {/* Badge Preview */} +
+ {t('campaigns.form.preview')}: + + {formData.badge_text || `-${formData.discount_value}%`} + +
+ + )} +
+
+ + {/* Ativo */} +
+ + +
+
+ )} + + {/* Step 2: Select Products */} + {activeStep === 2 && ( +
+
+ + +
+ + {!formData.apply_to_all_products && ( + <> +
+ + {formData.product_ids.length} {t('campaigns.productsSelected')} + + +
+ +
+ {sheets.map(sheet => ( +
handleProductToggle(sheet.id)} + style={{ cursor: 'pointer' }} + > +
+ {}} + /> +
+
{sheet.name}
+ {sheet.category} +
+
+
+
{currency(sheet.final_price || sheet.sale_price)}
+ + {currency(parseFloat(sheet.final_price || sheet.sale_price) * (1 - parseFloat(formData.discount_value) / 100))} + +
+
+ ))} +
+ + )} +
+ )} + + {/* Step 3: Review */} + {activeStep === 3 && ( +
+
+
+
{t('campaigns.review.summary')}
+ +
+
+ {t('campaigns.form.name')} +
{formData.name}
+
+
+ {t('campaigns.discount')} +
+ {formData.discount_type === 'percentage' && `-${formData.discount_value}%`} + {formData.discount_type === 'fixed' && `-${currency(formData.discount_value)}`} + {formData.discount_type === 'price_override' && currency(formData.discount_value)} +
+
+
+ {t('campaigns.period')} +
{formData.start_date} - {formData.end_date}
+
+
+ {t('campaigns.products')} +
+ {formData.apply_to_all_products + ? t('campaigns.allProducts') + : `${formData.product_ids.length} ${t('campaigns.productsSelected')}`} +
+
+
+
+
+ + {/* Preview Results */} + {preview && ( + <> + {/* Alertas de Rentabilidade */} + {preview.totals?.unprofitable_count > 0 && ( +
+ +
+ {t('campaigns.review.profitWarning')} +
+ {t('campaigns.review.unprofitableProducts', { count: preview.totals.unprofitable_count })} +
+
+
+ )} + + {preview.totals?.protected_count > 0 && ( +
+ +
+ {t('campaigns.review.pricesProtected')} +
+ {t('campaigns.review.protectedProducts', { count: preview.totals.protected_count })} +
+
+
+ )} + +
+
+
{t('campaigns.review.priceImpact')}
+ +
+
+
+ {t('campaigns.review.totalOriginal')} +
{currency(preview.totals?.total_original || 0)}
+
+
+
+
+ {t('campaigns.review.totalPromo')} +
{currency(preview.totals?.total_promotional || 0)}
+
+
+
+
+ {t('campaigns.review.avgDiscount')} +
-{(preview.totals?.avg_discount || 0).toFixed(1)}%
+
+
+
+
+ {t('campaigns.review.totalCmv')} +
{currency(preview.totals?.total_cmv || 0)}
+
+
+
+
+
+ + {/* Análise de Rentabilidade */} +
+
+
+ + {t('campaigns.review.profitAnalysis')} +
+ +
+
+
+ {t('campaigns.review.totalProfit')} +
= 0 ? 'text-success' : 'text-danger'}`}> + {currency(preview.totals?.total_promo_margin || 0)} +
+
+
+
+
+ {t('campaigns.review.avgPromoMargin')} +
= 10 ? 'text-success' : 'text-warning'}`}> + {(preview.totals?.avg_promo_margin_percent || 0).toFixed(1)}% +
+
+
+
+
+ {t('campaigns.review.originalMargin')} +
+ {(preview.totals?.avg_original_margin_percent || 0).toFixed(1)}% +
+
+
+
+
+
+ + {preview.products && preview.products.length > 0 && ( +
+
+
{t('campaigns.review.productDetails')}
+
+ + + + + + + + + + + + + {preview.products.slice(0, 15).map(p => ( + + + + + + + + + ))} + +
{t('campaigns.review.product')}{t('campaigns.review.cmv')}{t('campaigns.review.original')}{t('campaigns.review.promo')}{t('campaigns.review.margin')}{t('campaigns.review.status')}
+ {p.name} + {p.was_protected && ( + + )} + {currency(p.cmv)} + {currency(p.original_price)} + {currency(p.promotional_price)}= 10 ? 'text-success' : p.promo_margin_percent >= 0 ? 'text-warning' : 'text-danger'}`}> + {p.promo_margin_percent.toFixed(1)}% + + {p.is_profitable ? ( + + + + ) : ( + + + + )} +
+ {preview.products.length > 15 && ( + + {t('campaigns.review.andMore', { count: preview.products.length - 15 })} + + )} +
+
+
+ )} + + )} +
+ )} +
+ + {/* Footer */} +
+ + + {activeStep > 1 && ( + + )} + + {activeStep < 3 ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +export default CampaignModal; diff --git a/frontend/src/components/business/CampaignsTab.jsx b/frontend/src/components/business/CampaignsTab.jsx new file mode 100644 index 0000000..083f169 --- /dev/null +++ b/frontend/src/components/business/CampaignsTab.jsx @@ -0,0 +1,327 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { campaignService } from '../../services/api'; +import useFormatters from '../../hooks/useFormatters'; +import CampaignModal from './CampaignModal'; + +const CampaignsTab = ({ sheets }) => { + const { t } = useTranslation(); + const { currency, date: formatDate } = useFormatters(); + + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [presets, setPresets] = useState({}); + const [modalOpen, setModalOpen] = useState(false); + const [editingCampaign, setEditingCampaign] = useState(null); + const [statusFilter, setStatusFilter] = useState('all'); + + // Carregar dados + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [campaignsData, presetsData] = await Promise.all([ + campaignService.getAll(statusFilter !== 'all' ? { status: statusFilter } : {}), + campaignService.getPresets(), + ]); + setCampaigns(campaignsData); + setPresets(presetsData); + } catch (err) { + console.error('Error loading campaigns:', err); + setError(err.response?.data?.message || t('common.error')); + } finally { + setLoading(false); + } + }, [t, statusFilter]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleCreate = () => { + setEditingCampaign(null); + setModalOpen(true); + }; + + const handleEdit = (campaign) => { + setEditingCampaign(campaign); + setModalOpen(true); + }; + + const handleDuplicate = async (campaign) => { + try { + const duplicated = await campaignService.duplicate(campaign.id); + setCampaigns(prev => [duplicated, ...prev]); + } catch (err) { + setError(err.response?.data?.message || t('common.error')); + } + }; + + const handleDelete = async (campaign) => { + if (!window.confirm(t('campaigns.deleteConfirm', { name: campaign.name }))) { + return; + } + try { + await campaignService.delete(campaign.id); + setCampaigns(prev => prev.filter(c => c.id !== campaign.id)); + } catch (err) { + setError(err.response?.data?.message || t('common.error')); + } + }; + + const handleSaved = (campaign) => { + if (editingCampaign) { + setCampaigns(prev => prev.map(c => c.id === campaign.id ? campaign : c)); + } else { + setCampaigns(prev => [campaign, ...prev]); + } + setModalOpen(false); + setEditingCampaign(null); + }; + + const getStatusBadge = (campaign) => { + const statusConfig = { + active: { class: 'bg-success', icon: 'bi-check-circle', label: t('campaigns.status.active') }, + scheduled: { class: 'bg-info', icon: 'bi-clock', label: t('campaigns.status.scheduled') }, + ended: { class: 'bg-secondary', icon: 'bi-x-circle', label: t('campaigns.status.ended') }, + inactive: { class: 'bg-warning', icon: 'bi-pause-circle', label: t('campaigns.status.inactive') }, + }; + const config = statusConfig[campaign.status] || statusConfig.inactive; + return ( + + + {config.label} + + ); + }; + + const getDiscountDisplay = (campaign) => { + switch (campaign.discount_type) { + case 'percentage': + return `-${parseFloat(campaign.discount_value).toFixed(0)}%`; + case 'fixed': + return `-${currency(campaign.discount_value)}`; + case 'price_override': + return currency(campaign.discount_value); + default: + return '-'; + } + }; + + // Quick preset buttons + const quickPresets = [ + { key: 'black_friday', icon: 'bi-tag-fill', color: '#000000' }, + { key: 'cyber_monday', icon: 'bi-laptop', color: '#0ea5e9' }, + { key: 'christmas', icon: 'bi-gift', color: '#dc2626' }, + { key: 'flash_sale', icon: 'bi-lightning-charge-fill', color: '#eab308' }, + ]; + + return ( +
+ {/* Header */} +
+
+
+ + {t('campaigns.title')} +
+ {campaigns.length} +
+
+ {/* Status Filter */} + + +
+
+ + {/* Error */} + {error && ( +
+ + {error} + +
+ )} + + {/* Quick Presets */} +
+
+
+ + {t('campaigns.quickStart')} +
+
+ {quickPresets.map(preset => ( + + ))} +
+
+
+ + {/* Campaigns List */} + {loading ? ( +
+
+ Loading... +
+
+ ) : campaigns.length === 0 ? ( +
+ +

{t('campaigns.empty')}

+ +
+ ) : ( +
+ {campaigns.map(campaign => ( +
+
+
+
+ {campaign.show_discount_badge && ( + + {campaign.badge_text || getDiscountDisplay(campaign)} + + )} + {getStatusBadge(campaign)} +
+
+ +
    +
  • + +
  • +
  • + +
  • +

  • +
  • + +
  • +
+
+
+
+
{campaign.name}
+ {campaign.description && ( +

{campaign.description}

+ )} + + {/* Desconto */} +
+ {t('campaigns.discount')}: + {getDiscountDisplay(campaign)} +
+ + {/* Período */} +
+ {t('campaigns.period')}: + + {formatDate(campaign.start_date)} - {formatDate(campaign.end_date)} + +
+ + {/* Produtos */} +
+ {t('campaigns.products')}: + + {campaign.products_count || 0} + {campaign.apply_to_all_products && ( + {t('campaigns.allProducts')} + )} + +
+ + {/* Dias restantes */} + {campaign.is_currently_active && campaign.days_remaining !== null && ( +
+
+ {t('campaigns.daysRemaining')}: + + {campaign.days_remaining} {t('campaigns.days')} + +
+
+ )} +
+
+
+ ))} +
+ )} + + {/* Modal */} + { + setModalOpen(false); + setEditingCampaign(null); + }} + onSaved={handleSaved} + campaign={editingCampaign} + presets={presets} + sheets={sheets} + /> +
+ ); +}; + +export default CampaignsTab; diff --git a/frontend/src/components/business/PriceCalculatorTab.jsx b/frontend/src/components/business/PriceCalculatorTab.jsx index d9cc6b9..91bd6cb 100644 --- a/frontend/src/components/business/PriceCalculatorTab.jsx +++ b/frontend/src/components/business/PriceCalculatorTab.jsx @@ -99,7 +99,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => { > {settings.map(s => ( ))} @@ -138,7 +138,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
- × Markup + {t('business.common.timesMarkup')} {localResult.markup.toFixed(2)}
@@ -170,7 +170,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
{t('business.calculator.formula')} - {t('business.calculator.salePrice')} = CMV × Markup + {t('business.calculator.salePrice')} = {t('business.common.cmvLabel')} × {t('business.common.markupLabel')}
@@ -195,7 +195,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => { {/* Markup Factor */}
{t('business.settings.markupFactor')} -

{selectedSetting.markup_factor?.toFixed(4)}

+

{parseFloat(selectedSetting.markup_factor || 0).toFixed(4)}

{/* Detalhes */} @@ -210,7 +210,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
{t('business.settings.fixedExpensesRate')} - {selectedSetting.fixed_expenses_rate?.toFixed(2)}% + {parseFloat(selectedSetting.fixed_expenses_rate || 0).toFixed(2)}%
@@ -236,7 +236,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => { )}
{t('business.settings.totalVariableCosts')} - {selectedSetting.total_variable_costs?.toFixed(2)}% + {parseFloat(selectedSetting.total_variable_costs || 0).toFixed(2)}%
@@ -256,7 +256,7 @@ const PriceCalculatorTab = ({ settings, sheets }) => {
{t('business.settings.totalDeductions')} - {(selectedSetting.fixed_expenses_rate + selectedSetting.total_variable_costs + selectedSetting.investment_rate + selectedSetting.profit_margin).toFixed(2)}% + {(parseFloat(selectedSetting.fixed_expenses_rate || 0) + parseFloat(selectedSetting.total_variable_costs || 0) + parseFloat(selectedSetting.investment_rate || 0) + parseFloat(selectedSetting.profit_margin || 0)).toFixed(2)}%
diff --git a/frontend/src/components/business/ProductSheetModal.jsx b/frontend/src/components/business/ProductSheetModal.jsx index 88c1ba5..494b615 100644 --- a/frontend/src/components/business/ProductSheetModal.jsx +++ b/frontend/src/components/business/ProductSheetModal.jsx @@ -16,11 +16,20 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { currency: 'EUR', business_setting_id: '', is_active: true, + // Strategic pricing fields + competitor_price: '', + min_price: '', + max_price: '', + premium_multiplier: '1.00', + price_strategy: 'neutral', + psychological_rounding: false, + target_margin_percent: '', }); const [items, setItems] = useState([]); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [showStrategicPricing, setShowStrategicPricing] = useState(false); const itemTypes = [ { value: 'product_cost', label: t('business.products.itemTypes.productCost') }, @@ -41,6 +50,14 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { currency: sheet.currency || 'EUR', business_setting_id: sheet.business_setting_id || '', is_active: sheet.is_active ?? true, + // Strategic pricing fields + competitor_price: sheet.competitor_price || '', + min_price: sheet.min_price || '', + max_price: sheet.max_price || '', + premium_multiplier: sheet.premium_multiplier || '1.00', + price_strategy: sheet.price_strategy || 'neutral', + psychological_rounding: sheet.psychological_rounding ?? false, + target_margin_percent: sheet.target_margin_percent || '', }); setItems(sheet.items?.map(item => ({ id: item.id, @@ -50,6 +67,11 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { quantity: item.quantity || 1, unit: item.unit || '', })) || []); + // Show strategic pricing if any field is set + if (sheet.competitor_price || sheet.min_price || sheet.max_price || + sheet.target_margin_percent || sheet.price_strategy !== 'neutral') { + setShowStrategicPricing(true); + } } }, [sheet]); @@ -130,6 +152,14 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { currency: formData.currency, business_setting_id: formData.business_setting_id || null, is_active: formData.is_active, + // Strategic pricing fields + competitor_price: formData.competitor_price ? parseFloat(formData.competitor_price) : null, + min_price: formData.min_price ? parseFloat(formData.min_price) : null, + max_price: formData.max_price ? parseFloat(formData.max_price) : null, + premium_multiplier: parseFloat(formData.premium_multiplier) || 1.0, + price_strategy: formData.price_strategy, + psychological_rounding: formData.psychological_rounding, + target_margin_percent: formData.target_margin_percent ? parseFloat(formData.target_margin_percent) : null, }); // Remover itens que não existem mais @@ -185,8 +215,8 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { return (
-
-
+
+
@@ -196,7 +226,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
-
+
{error && (
{typeof error === 'string' ? error : JSON.stringify(error)}
)} @@ -223,7 +253,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { name="sku" value={formData.sku} onChange={handleChange} - placeholder="ABC-001" + placeholder={t('business.products.skuPlaceholder')} />
@@ -263,7 +293,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { {settings.filter(s => s.is_active).map(s => ( ))} @@ -280,6 +310,141 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => { />
+ {/* Strategic Pricing Toggle */} +
+
+ +
+ + {/* Strategic Pricing Fields */} + {showStrategicPricing && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + + 1.0 = {t('business.products.neutral')} +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + )} + {/* Componentes de Custo (CMV) */}

@@ -383,7 +548,7 @@ const ProductSheetModal = ({ sheet, settings, onSave, onClose }) => {
- CMV Total + {t('business.products.cmvTotalLabel')}

{currency(cmvTotal, formData.currency)}

diff --git a/frontend/src/components/business/ProductSheetsTab.jsx b/frontend/src/components/business/ProductSheetsTab.jsx index d6faccd..8a42c2d 100644 --- a/frontend/src/components/business/ProductSheetsTab.jsx +++ b/frontend/src/components/business/ProductSheetsTab.jsx @@ -147,7 +147,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) {t('common.inactive')} )} - {sheet.sku && SKU: {sheet.sku}} + {sheet.sku && {t('business.common.skuLabel')}: {sheet.sku}} {sheet.category && ( {sheet.category} )} @@ -189,7 +189,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
- CMV + {t('business.common.cmvLabel')} {currency(sheet.cmv_total, sheet.currency)} @@ -203,6 +203,17 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
+ {/* Final Price (if strategic pricing applied) */} + {sheet.final_price && parseFloat(sheet.final_price) !== parseFloat(sheet.sale_price) && ( +
+
+ {t('business.products.finalPrice')} + + {currency(sheet.final_price, sheet.currency)} + +
+
+ )}
{/* Margem de Contribuição */} @@ -212,9 +223,57 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) {currency(sheet.contribution_margin, sheet.currency)} ({sheet.contribution_margin_percent}%) + {sheet.real_margin_percent && parseFloat(sheet.real_margin_percent) !== parseFloat(sheet.contribution_margin_percent) && ( + + → {parseFloat(sheet.real_margin_percent).toFixed(1)}% + + )}
)} + + {/* Competitor Comparison */} + {sheet.competitor_comparison && ( +
+ + + {t('business.products.competitorComparison')} + + + {currency(sheet.competitor_comparison.our_price, sheet.currency)} + + ({sheet.competitor_comparison.percent_difference > 0 ? '+' : ''}{sheet.competitor_comparison.percent_difference.toFixed(1)}%) + + +
+ )} + + {/* Strategic Badges */} + {(sheet.price_strategy !== 'neutral' || sheet.psychological_rounding || sheet.target_margin_percent) && ( +
+ {sheet.price_strategy === 'aggressive' && ( + + {t('business.products.strategyAggressiveLabel')} + + )} + {sheet.price_strategy === 'premium' && ( + + {t('business.products.strategyPremiumLabel')} + + )} + {sheet.psychological_rounding && ( + + {t('business.products.psychologicalBadge')} + + )} + {sheet.target_margin_percent && ( + + {sheet.target_margin_percent}% + + )} +
+ )} {/* Itens/Componentes */} {sheet.items && sheet.items.length > 0 && ( @@ -239,7 +298,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) {sheet.business_setting.name} {sheet.markup_used && ( - (Markup: {sheet.markup_used.toFixed(2)}) + ({t('business.common.markupLabel')}: {parseFloat(sheet.markup_used).toFixed(2)}) )} diff --git a/frontend/src/components/business/ServiceSheetModal.jsx b/frontend/src/components/business/ServiceSheetModal.jsx new file mode 100644 index 0000000..2423530 --- /dev/null +++ b/frontend/src/components/business/ServiceSheetModal.jsx @@ -0,0 +1,713 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { serviceSheetService } from '../../services/api'; +import useFormatters from '../../hooks/useFormatters'; + +const ServiceSheetModal = ({ sheet, settings, onSave, onClose }) => { + const { t } = useTranslation(); + const { currency } = useFormatters(); + const isEditing = !!sheet; + + const [formData, setFormData] = useState({ + name: '', + code: '', + description: '', + category: '', + business_setting_id: '', + duration_minutes: '30', + is_active: true, + // Strategic pricing fields + competitor_price: '', + min_price: '', + max_price: '', + premium_multiplier: '1.00', + pricing_strategy: 'neutral', + psychological_pricing: false, + target_margin: '', + }); + + const [items, setItems] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [showStrategicPricing, setShowStrategicPricing] = useState(false); + + const itemTypes = [ + { value: 'supply', label: t('business.services.itemTypes.supply') }, + { value: 'consumable', label: t('business.services.itemTypes.consumable') }, + { value: 'material', label: t('business.services.itemTypes.material') }, + { value: 'equipment_usage', label: t('business.services.itemTypes.equipmentUsage') }, + { value: 'other', label: t('business.services.itemTypes.other') }, + ]; + + useEffect(() => { + if (sheet) { + setFormData({ + name: sheet.name || '', + code: sheet.code || '', + description: sheet.description || '', + category: sheet.category || '', + business_setting_id: sheet.business_setting_id || '', + duration_minutes: sheet.duration_minutes || '30', + is_active: sheet.is_active ?? true, + // Strategic pricing fields + competitor_price: sheet.competitor_price || '', + min_price: sheet.min_price || '', + max_price: sheet.max_price || '', + premium_multiplier: sheet.premium_multiplier || '1.00', + pricing_strategy: sheet.pricing_strategy || 'neutral', + psychological_pricing: sheet.psychological_pricing ?? false, + target_margin: sheet.target_margin || '', + }); + setItems(sheet.items?.map(item => ({ + id: item.id, + name: item.name, + type: item.type, + unit_cost: item.unit_cost, + quantity_used: item.quantity_used || 1, + unit: item.unit || '', + })) || []); + // Show strategic pricing if any field is set + if (sheet.competitor_price || sheet.min_price || sheet.max_price || + sheet.target_margin || sheet.pricing_strategy !== 'neutral') { + setShowStrategicPricing(true); + } + } else if (settings.length > 0) { + // Auto-select first service-enabled setting + setFormData(prev => ({ + ...prev, + business_setting_id: settings[0].id.toString(), + })); + } + }, [sheet, settings]); + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const handleItemChange = (index, field, value) => { + setItems(prev => prev.map((item, i) => + i === index ? { ...item, [field]: value } : item + )); + }; + + const addItem = () => { + setItems(prev => [...prev, { + name: '', + type: 'supply', + unit_cost: '', + quantity_used: 1, + unit: '', + }]); + }; + + const removeItem = (index) => { + setItems(prev => prev.filter((_, i) => i !== index)); + }; + + // Calcular CSV total (Custo do Serviço Vendido) + const calculateCsv = () => { + return items.reduce((sum, item) => { + const cost = parseFloat(item.unit_cost) || 0; + const quantity = parseFloat(item.quantity_used) || 1; + return sum + (cost * quantity); + }, 0); + }; + + // Calcular preview del precio + const calculatePreview = () => { + if (!formData.business_setting_id) return null; + const setting = settings.find(s => s.id === parseInt(formData.business_setting_id)); + if (!setting) return null; + + const duration = parseFloat(formData.duration_minutes) || 30; + const csv = calculateCsv(); + + // Gasto fijo por minuto + const employees = parseInt(setting.employees_count) || 1; + const hoursPerDay = parseFloat(setting.hours_per_day) || 8; + const workingDays = parseInt(setting.working_days_per_month) || 22; + const productivity = parseFloat(setting.productivity_rate) || 80; + const productiveHours = employees * hoursPerDay * workingDays * (productivity / 100); + const fixedExpenses = parseFloat(setting.fixed_expenses) || 0; + const fixedCostPerHour = productiveHours > 0 ? fixedExpenses / productiveHours : 0; + const fixedCostPerMinute = fixedCostPerHour / 60; + + // Porção de custo fixo proporcional ao tempo + const fixedCostPortion = fixedCostPerMinute * duration; + + // Preço base = (Gasto Fixo + CSV) × Markup + const markup = parseFloat(setting.markup_factor) || 1; + const priceWithoutTax = (fixedCostPortion + csv) * markup; + + // Se B2C, adiciona IVA + let finalPrice = priceWithoutTax; + let vatAmount = 0; + if (setting.price_includes_tax) { + const vatRate = parseFloat(setting.vat_rate) || 0; + finalPrice = priceWithoutTax * (1 + vatRate / 100); + vatAmount = finalPrice - priceWithoutTax; + } + + return { + duration, + csv, + fixedCostPortion, + baseCost: fixedCostPortion + csv, + markup, + priceWithoutTax, + vatRate: setting.price_includes_tax ? parseFloat(setting.vat_rate) : 0, + vatAmount, + finalPrice, + fixedCostPerHour, + currency: setting.currency, + }; + }; + + const preview = calculatePreview(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(null); + + // Validar itens + const validItems = items.filter(item => item.name && item.unit_cost); + + try { + const dataToSend = { + ...formData, + business_setting_id: parseInt(formData.business_setting_id), + duration_minutes: parseFloat(formData.duration_minutes) || 30, + competitor_price: formData.competitor_price ? parseFloat(formData.competitor_price) : null, + min_price: formData.min_price ? parseFloat(formData.min_price) : null, + max_price: formData.max_price ? parseFloat(formData.max_price) : null, + premium_multiplier: parseFloat(formData.premium_multiplier) || 1, + target_margin: formData.target_margin ? parseFloat(formData.target_margin) : null, + items: validItems.map(item => ({ + name: item.name, + type: item.type, + unit_cost: parseFloat(item.unit_cost) || 0, + quantity_used: parseFloat(item.quantity_used) || 1, + unit: item.unit || null, + })), + }; + + let result; + if (isEditing) { + // Para edição, atualizar ficha e depois itens + result = await serviceSheetService.update(sheet.id, { + name: formData.name, + code: formData.code, + description: formData.description, + category: formData.category, + business_setting_id: dataToSend.business_setting_id, + duration_minutes: dataToSend.duration_minutes, + is_active: formData.is_active, + competitor_price: dataToSend.competitor_price, + min_price: dataToSend.min_price, + max_price: dataToSend.max_price, + premium_multiplier: dataToSend.premium_multiplier, + pricing_strategy: formData.pricing_strategy, + psychological_pricing: formData.psychological_pricing, + target_margin: dataToSend.target_margin, + }); + + // Gerenciar itens + const existingIds = items.filter(i => i.id).map(i => i.id); + const itemsToRemove = sheet.items?.filter(i => !existingIds.includes(i.id)) || []; + + for (const item of itemsToRemove) { + await serviceSheetService.removeItem(sheet.id, item.id); + } + + for (const item of validItems) { + if (item.id) { + await serviceSheetService.updateItem(sheet.id, item.id, { + name: item.name, + type: item.type, + unit_cost: parseFloat(item.unit_cost) || 0, + quantity_used: parseFloat(item.quantity_used) || 1, + unit: item.unit || null, + }); + } else { + await serviceSheetService.addItem(sheet.id, { + name: item.name, + type: item.type, + unit_cost: parseFloat(item.unit_cost) || 0, + quantity_used: parseFloat(item.quantity_used) || 1, + unit: item.unit || null, + }); + } + } + + // Reload to get updated data + result = await serviceSheetService.get(sheet.id); + } else { + result = await serviceSheetService.create(dataToSend); + } + + onSave(result); + } catch (err) { + setError(err.response?.data?.message || err.response?.data?.errors || t('common.error')); + } finally { + setSaving(false); + } + }; + + const selectedSetting = settings.find(s => s.id === parseInt(formData.business_setting_id)); + + return ( +
+
+
+
+
+ + {isEditing ? t('business.services.edit') : t('business.services.add')} +
+ +
+ + +
+ {error && ( +
{typeof error === 'string' ? error : JSON.stringify(error)}
+ )} + +
+ {/* Información básica */} +
+ + +
+
+ + +
+
+ +
+ + {t('business.common.minuteShort')} +
+
+ +
+ + +
+
+ + +
+ +
+ +