- Removido README.md padrão do Laravel (backend) - Removidos scripts de deploy (não mais necessários) - Atualizado copilot-instructions.md para novo fluxo - Adicionada documentação de auditoria do servidor - Sincronizado código de produção com repositório Novo workflow: - Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney) - Mudanças PHP são instantâneas - Mudanças React requerem 'npm run build' - Commit após validação funcional
432 lines
16 KiB
PHP
Executable File
432 lines
16 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\ServiceSheet;
|
|
use App\Models\ServiceSheetItem;
|
|
use App\Models\BusinessSetting;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
class ServiceSheetController extends Controller
|
|
{
|
|
/**
|
|
* Lista todas as fichas de serviço do usuário
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$userId = $request->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,
|
|
]);
|
|
}
|
|
}
|