Consolida recepcoes, stock, equipamentos e identidade SGC

This commit is contained in:
2026-10-28 13:54:53 +02:00
parent eb1c8b8074
commit 75ffdb395c
76 changed files with 6933 additions and 152 deletions
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\CriticalityLevel;
use App\Models\Department;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\StockOperation;
use App\Services\ConflictGuard;
use App\Services\ReceiptWorkflow;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class EquipmentController extends Controller
{
public function index(Request $request): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
$assets = Equipment::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
$assets->where('department_id', $request->user()->department_id);
}
$equipment = $assets->orderBy('code')->get();
$links = EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->get();
$parts = SparePart::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
$parts->whereIn('id', $links->pluck('spare_part_id'));
}
$parts = $parts->get()->map(function (SparePart $part) {
$sum = StockMovement::query()->where('stock_item_id', $part->stock_item_id)->sum('quantity_milli');
$balance = BigDecimal::of((string) $sum)->toInt();
return [...$part->toArray(), 'balance_milli' => $balance, 'below_minimum' => $balance < $part->minimum_milli];
});
$items = StockItem::query();
$departments = Department::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
$items->whereIn('id', $parts->pluck('stock_item_id')->merge($equipment->pluck('stock_item_id')->filter()));
$departments->whereKey($request->user()->department_id);
}
return Inertia::render('equipment/index', [
'equipment' => $equipment, 'parts' => $parts, 'links' => $links, 'levels' => CriticalityLevel::query()->orderBy('code')->get(),
'items' => $items->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'departments' => $departments->orderBy('name')->get(['id', 'name']),
'history' => StockOperation::query()->whereNotNull('equipment_id')->whereIn('equipment_id', $equipment->pluck('id'))->latest('id')->paginate(15),
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]),
'canConfigure' => $request->user()->purchasing_role === PurchasingRole::Admin,
]);
}
public function store(Request $request, string $kind): RedirectResponse
{
$user = $request->user();
abort_unless(in_array($kind, ['categorias', 'activos', 'pecas', 'associacoes']), 404);
abort_unless($kind === 'categorias' ? $user->purchasing_role === PurchasingRole::Admin : in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement]), 403);
$tenant = $user->tenant_id;
$exists = fn (string $table) => Rule::exists($table, 'id')->where('tenant_id', $tenant);
$request->merge(['code' => Str::upper(Str::squish($request->string('code')->toString()))]);
$rules = match ($kind) {
'categorias' => ['code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('criticality_levels')->where('tenant_id', $tenant)], 'name' => 'required|string|max:150', 'criteria' => 'required|string|max:4000', 'is_critical' => 'required|boolean'],
'activos' => ['id' => ['nullable', 'integer', $exists('equipment')], 'version' => 'nullable|required_with:id|integer|min:1', 'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('equipment')->where('tenant_id', $tenant)->ignore($request->input('id'))], 'description' => 'required|string|max:255', 'area' => 'required|string|max:200', 'serial_number' => 'nullable|string|max:100', 'status' => ['required', Rule::in(['active', 'stopped', 'retired'])], 'department_id' => ['required', 'integer', $exists('departments')], 'stock_item_id' => ['nullable', 'integer', $exists('stock_items')], 'criticality_level_id' => ['required', 'integer', $exists('criticality_levels')]],
'pecas' => ['id' => ['nullable', 'integer', $exists('spare_parts')], 'version' => 'nullable|required_with:id|integer|min:1', 'stock_item_id' => ['required', 'integer', $exists('stock_items'), Rule::unique('spare_parts')->ignore($request->input('id'))], 'criticality_level_id' => ['required', 'integer', $exists('criticality_levels')], 'minimum_quantity' => ['required', 'regex:/^\d{1,9}(\.\d{1,3})?$/']],
'associacoes' => ['equipment_id' => ['required', 'integer', $exists('equipment')], 'spare_part_id' => ['required', 'integer', $exists('spare_parts')]],
};
$data = $request->validate($rules);
DB::transaction(function () use ($data, $kind, $user) {
app(ConflictGuard::class)->lockCompany($user);
$model = match ($kind) {
'categorias' => new CriticalityLevel,'activos' => new Equipment,'pecas' => new SparePart,'associacoes' => new EquipmentSparePart
};
$id = $data['id'] ?? null;
$before = null;
$input = $data;
unset($input['id'],$input['version']);
if ($id) {
$model = $model->newQuery()->whereKey($id)->lockForUpdate()->firstOrFail();
if (! $model instanceof Equipment && ! $model instanceof SparePart) {
throw new \LogicException('Este cadastro é apenas de acréscimo.');
}
$before = $model->toArray();
if ((int) $data['version'] !== $model->version) {
throw ValidationException::withMessages(['version' => 'Ficha alterada. Actualize antes de guardar.']);
}if ($kind === 'pecas' && (int) $input['stock_item_id'] !== $model->stock_item_id) {
throw ValidationException::withMessages(['stock_item_id' => 'O artigo de uma peça não pode ser substituído; preserve a rastreabilidade.']);
}$input['version'] = $model->version + 1;
}
if ($kind === 'pecas') {
$input['minimum_milli'] = ReceiptWorkflow::milli($input['minimum_quantity']);
unset($input['minimum_quantity']);
}
if ($kind === 'associacoes' && EquipmentSparePart::query()->where('equipment_id', $input['equipment_id'])->where('spare_part_id', $input['spare_part_id'])->exists()) {
throw ValidationException::withMessages(['spare_part_id' => 'Esta associação já existe e permanece no histórico.']);
}
if (! $id) {
$input['tenant_id'] = $user->tenant_id;
$input['created_by'] = $user->id;
}
if (in_array($kind, ['categorias', 'associacoes'])) {
$input['created_at'] = now();
}
$model->fill($input)->save();
app(RequisitionWorkflow::class)->audit($user, 'equipment.'.$kind.($id ? '_updated' : '_created'), null, $before, $model->toArray());
});
return back();
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Http\Requests\ConfirmReceiptRequest;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\Warehouse;
use App\Services\ReceiptWorkflow;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
class GoodsReceiptController extends Controller
{
/** @return Builder<PurchaseOrder> */
private function visibleOrders(Request $request): Builder
{
Gate::authorize('viewAny', GoodsReceipt::class);
$orders = PurchaseOrder::query();
if ($request->user()->purchasing_role === PurchasingRole::DepartmentHead) {
$orders->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'));
}
return $orders;
}
public function index(Request $request, ReceiptWorkflow $workflow): Response
{
$orders = $this->visibleOrders($request);
return Inertia::render('receipts/index', [
'orders' => (clone $orders)->latest('id')->paginate(15)->through(fn (PurchaseOrder $order) => [
'id' => $order->id, 'number' => $order->number, 'supplier_name' => $order->snapshot['supplier']['name'], 'progress' => $workflow->progress($order),
]),
'receipts' => GoodsReceipt::query()->whereIn('purchase_order_id', (clone $orders)->select('id'))->latest('id')->paginate(15, ['*'], 'receipts_page'),
]);
}
public function order(Request $request, PurchaseOrder $order, ReceiptWorkflow $workflow): Response
{
Gate::authorize('viewOrder', [GoodsReceipt::class, $order]);
return Inertia::render('receipts/order', [
'order' => $order->only(['id', 'number', 'issued_by']), 'supplier_name' => $order->snapshot['supplier']['name'],
'progress' => $workflow->progress($order),
'receipts' => GoodsReceipt::query()->where('purchase_order_id', $order->id)->latest('id')->get(),
'warehouses' => Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']),
'stockItems' => StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']),
'canConfirm' => $request->user()->can('create', [GoodsReceipt::class, $order]),
]);
}
public function store(ConfirmReceiptRequest $request, PurchaseOrder $order, ReceiptWorkflow $workflow): JsonResponse|RedirectResponse
{
$receipt = $workflow->confirm($request->user(), $order, $request->validated());
if ($request->expectsJson()) {
return response()->json(['id' => $receipt->id, 'number' => $receipt->number, 'created' => $receipt->wasRecentlyCreated, 'status' => $receipt->status], $receipt->wasRecentlyCreated ? 201 : 200);
}
return to_route('receipts.show', $receipt);
}
public function show(GoodsReceipt $receipt): Response
{
Gate::authorize('view', $receipt);
return Inertia::render('receipts/show', [
'receipt' => $receipt->load('items'),
'movements' => StockMovement::query()->whereIn('goods_receipt_item_id', $receipt->items()->select('id'))->orderBy('id')->get(),
]);
}
}
@@ -5,9 +5,15 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreRequisitionRequest;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Equipment;
use App\Models\EquipmentSparePart;
use App\Models\Requisition;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\TechnicalReview;
use App\Models\Tenant;
use App\Services\ConflictGuard;
use App\Services\EquipmentWorkflow;
use App\Services\RequisitionWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -39,6 +45,7 @@ class RequisitionController extends Controller
return Inertia::render('requisitions/form', [
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
'requisition' => null,
...$this->equipmentOptions($request),
]);
}
@@ -60,6 +67,11 @@ class RequisitionController extends Controller
'canDecide' => $requisition->canDecide($request->user()),
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
'technical' => [
'canReview' => app(EquipmentWorkflow::class)->canReview($request->user(), $requisition),
'basis_hash' => app(EquipmentWorkflow::class)->basis($requisition),
'reviews' => TechnicalReview::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(),
],
]);
}
@@ -70,9 +82,27 @@ class RequisitionController extends Controller
return Inertia::render('requisitions/form', [
'requisition' => $requisition->load('items'),
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
...$this->equipmentOptions($request),
]);
}
/** @return array<string,mixed> */
private function equipmentOptions(Request $request): array
{
$equipment = Equipment::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(['id', 'code', 'description']);
$parts = SparePart::query()->whereIn('id', EquipmentSparePart::query()->whereIn('equipment_id', $equipment->pluck('id'))->select('spare_part_id'))->get(['id', 'stock_item_id']);
return ['equipment' => $equipment, 'spareParts' => $parts, 'stockItems' => StockItem::query()->whereIn('id', $parts->pluck('stock_item_id'))->get(['id', 'code', 'name', 'unit'])];
}
public function technical(Request $request, Requisition $requisition): RedirectResponse
{
$data = $request->validate(['basis_hash' => 'required|string|size:64', 'decision' => ['required', Rule::in(['approved', 'rejected'])], 'reason' => 'required|string|max:4000']);
app(EquipmentWorkflow::class)->review($request->user(), $requisition, $data['basis_hash'], $data['decision'], $data['reason']);
return back();
}
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$workflow->save($request->user(), $request->validated(), $requisition);
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\StockItem;
use App\Models\StockMovement;
use App\Models\Warehouse;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class StockController extends Controller
{
private function access(Request $request, bool $manage = false): void
{
$roles = $manage ? [PurchasingRole::Admin, PurchasingRole::Warehouse] : [PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Finance];
abort_unless(in_array($request->user()->purchasing_role, $roles), 403);
}
public function index(Request $request): Response
{
$this->access($request);
$filters = $request->validate([
'search' => 'nullable|string|max:100',
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $request->user()->tenant_id)],
]);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$selected = StockItem::query();
if (! empty($filters['search'])) {
$selected->where(fn ($q) => $q->where('code', 'like', '%'.$filters['search'].'%')->orWhere('name', 'like', '%'.$filters['search'].'%'));
}
$movements = StockMovement::query()->whereIn('stock_item_id', $selected->select('id'));
if (! empty($filters['warehouse_id'])) {
$movements->where('warehouse_id', $filters['warehouse_id']);
}
$balances = (clone $movements)->selectRaw('stock_item_id, warehouse_id, SUM(quantity_milli) as quantity_milli')->groupBy('stock_item_id', 'warehouse_id')->get()->map(fn ($balance) => [
'stock_item_id' => $balance->stock_item_id, 'warehouse_id' => $balance->warehouse_id,
'quantity_milli' => BigDecimal::of($balance->quantity_milli)->toInt(),
]);
return Inertia::render('stock/index', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'movements' => $movements->latest('id')->paginate(20)->withQueryString(), 'filters' => $filters,
'canManage' => in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Warehouse]),
]);
}
public function item(Request $request, RequisitionWorkflow $audit): RedirectResponse
{
$this->access($request, true);
$request->merge(['code' => Str::upper(trim($request->string('code')->toString())), 'unit' => Str::lower(trim($request->string('unit')->toString()))]);
$data = $request->validate([
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('stock_items')->where('tenant_id', $request->user()->tenant_id)],
'name' => 'required|string|max:200', 'unit' => 'required|string|max:30',
]);
DB::transaction(function () use ($request, $audit, $data) {
$item = StockItem::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]);
$audit->audit($request->user(), 'stock.item_created', null, null, $item->toArray());
});
return back();
}
public function warehouse(Request $request, RequisitionWorkflow $audit): RedirectResponse
{
$this->access($request, true);
$request->merge(['code' => Str::upper(trim($request->string('code')->toString()))]);
$data = $request->validate([
'code' => ['required', 'regex:/^[A-Z0-9][A-Z0-9._-]{0,29}$/', Rule::unique('warehouses')->where('tenant_id', $request->user()->tenant_id)],
'name' => 'required|string|max:200',
]);
DB::transaction(function () use ($request, $audit, $data) {
$warehouse = Warehouse::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id]);
$audit->audit($request->user(), 'stock.warehouse_created', null, null, $warehouse->toArray());
});
return back();
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Equipment;
use App\Models\StockItem;
use App\Models\StockOperation;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\StockWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class StockOperationController extends Controller
{
public function index(Request $request, StockWorkflow $workflow): Response
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Warehouse, PurchasingRole::Admin, PurchasingRole::Finance, PurchasingRole::Procurement]), 403);
$items = StockItem::query()->orderBy('code')->get(['id', 'code', 'name', 'unit']);
$warehouses = Warehouse::query()->orderBy('code')->get(['id', 'code', 'name']);
$balances = [];
foreach ($items as $item) {
foreach ($warehouses as $warehouse) {
$balances[] = ['stock_item_id' => $item->id, 'warehouse_id' => $warehouse->id, ...$workflow->balance($item->id, $warehouse->id)];
}
}
return Inertia::render('stock/operations', [
'items' => $items, 'warehouses' => $warehouses, 'balances' => $balances,
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name']),
'operations' => StockOperation::query()->latest('id')->paginate(20),
'counts' => StockOperation::query()->where('kind', 'count')->whereNotIn('id', StockOperation::query()->whereNotNull('count_id')->select('count_id'))->latest('id')->get(),
'canOperate' => $request->user()->purchasing_role === PurchasingRole::Warehouse,
'equipment' => Equipment::query()->orderBy('code')->get(['id', 'code', 'description']),
]);
}
public function store(Request $request, StockWorkflow $workflow): JsonResponse|RedirectResponse
{
abort_unless($request->user()->purchasing_role === PurchasingRole::Warehouse, 403);
$request->merge(['reference' => Str::upper(Str::squish($request->string('reference')->toString())), 'reason' => trim($request->string('reason')->toString()), 'destination' => trim($request->string('destination')->toString())]);
$tenant = $request->user()->tenant_id;
$data = $request->validate([
'kind' => ['required', Rule::in(['issue', 'transfer', 'count', 'adjustment'])],
'reference' => 'required|string|max:100', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|max:4000',
'responsible_id' => ['required', 'integer', Rule::exists('users', 'id')->where('tenant_id', $tenant)],
'warehouse_id' => ['required', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $tenant)],
'stock_item_id' => ['required', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $tenant)],
'destination_warehouse_id' => ['exclude_unless:kind,transfer', 'required', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $tenant)],
'destination' => 'exclude_unless:kind,issue|required|string|max:200',
'equipment_id' => ['exclude_unless:kind,issue', 'nullable', 'integer', Rule::exists('equipment', 'id')->where('tenant_id', $tenant)],
'quantity' => ['exclude_if:kind,adjustment', 'required', 'regex:/^\d{1,9}(\.\d{1,3})?$/', $request->input('kind') === 'count' ? 'gte:0' : 'gt:0'],
'ledger_version' => 'exclude_unless:kind,count|required|integer|min:0',
'count_id' => ['exclude_unless:kind,adjustment', 'required', 'integer', Rule::exists('stock_operations', 'id')->where('tenant_id', $tenant)->where('kind', 'count')],
]);
if (empty($data['equipment_id'])) {
unset($data['equipment_id']);
}
foreach (['responsible_id', 'warehouse_id', 'stock_item_id', 'destination_warehouse_id', 'ledger_version', 'count_id', 'equipment_id'] as $key) {
if (isset($data[$key])) {
$data[$key] = (int) $data[$key];
}
}
$data['quantity'] = isset($data['quantity']) ? (string) BigDecimal::of($data['quantity'])->toScale(3) : '0.000';
ksort($data);
$operation = $workflow->record($request->user(), $data);
return $request->expectsJson() ? response()->json(['id' => $operation->id, 'created' => $operation->wasRecentlyCreated], $operation->wasRecentlyCreated ? 201 : 200) : back();
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests;
use App\Models\GoodsReceipt;
use App\Models\PurchaseOrder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ConfirmReceiptRequest extends FormRequest
{
public function authorize(): bool
{
$order = $this->route('order');
return $order instanceof PurchaseOrder && ($this->user()?->can('create', [GoodsReceipt::class, $order]) ?? false);
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'idempotency_key' => 'required|uuid', 'delivery_note' => 'required|string|max:100',
'received_on' => 'required|date_format:Y-m-d|before_or_equal:today',
'warehouse_id' => ['nullable', 'integer', Rule::exists('warehouses', 'id')->where('tenant_id', $this->user()?->tenant_id)],
'items' => 'required|array|min:1|max:100', 'items.*.purchase_order_item_id' => 'required|integer|distinct',
'items.*.received_quantity' => ['required', 'string', 'numeric', 'gt:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
'items.*.accepted_quantity' => ['required', 'string', 'numeric', 'min:0', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
'items.*.quality_status' => ['required', Rule::in(['compliant', 'reservations', 'nonconforming'])],
'items.*.notes' => 'nullable|string|max:3000',
'items.*.stock_item_id' => ['nullable', 'integer', Rule::exists('stock_items', 'id')->where('tenant_id', $this->user()?->tenant_id)],
'tenant_id' => 'prohibited', 'received_by' => 'prohibited', 'status' => 'prohibited', 'snapshot' => 'prohibited',
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'delivery_note.required' => 'Indique a guia de remessa do fornecedor.',
'received_on.before_or_equal' => 'A recepção não pode ser registada numa data futura.',
'items.required' => 'Seleccione pelo menos uma linha para conferir.',
'items.min' => 'Seleccione pelo menos uma linha para conferir.',
'items.*.received_quantity.regex' => 'Use quantidades com até três casas decimais.',
'items.*.accepted_quantity.regex' => 'Use quantidades com até três casas decimais.',
'items.*.received_quantity.string' => 'Envie a quantidade recebida como texto decimal para manter a precisão.',
'items.*.accepted_quantity.string' => 'Envie a quantidade aceite como texto decimal para manter a precisão.',
'warehouse_id.exists' => 'Seleccione um armazém da sua empresa.',
'items.*.stock_item_id.exists' => 'Seleccione um artigo de stock da sua empresa.',
'idempotency_key.uuid' => 'A chave de confirmação é inválida. Reabra o formulário.',
];
}
}
@@ -25,7 +25,11 @@ class StoreRequisitionRequest extends FormRequest
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.description' => ['required', 'string', 'max:255'],
'items.*.category' => ['required', 'string', 'max:100'],
'items.*.purchase_type' => ['required', Rule::in(['consumable', 'service', 'other'])],
'items.*.purchase_type' => ['required', Rule::in(['consumable', 'service', 'other', 'equipment', 'spare_part'])],
'items.*.equipment_id' => ['exclude_unless:items.*.purchase_type,spare_part', 'required', 'integer', Rule::exists('equipment', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)],
'items.*.spare_part_id' => ['exclude_unless:items.*.purchase_type,spare_part', 'required', 'integer', Rule::exists('spare_parts', 'id')->where('tenant_id', $this->user()?->tenant_id)],
'items.*.production_area' => 'exclude_unless:items.*.purchase_type,equipment|required|string|max:200',
'items.*.downtime_started_at' => 'exclude_unless:items.*.purchase_type,spare_part|nullable|date_format:Y-m-d\TH:i|before_or_equal:now',
'items.*.quantity' => ['required', 'numeric', 'min:0.001', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
'items.*.unit' => ['required', 'string', 'max:30'],
'items.*.unit_price' => ['required', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'],