Consolida recepcoes, stock, equipamentos e identidade SGC
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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})?$/'],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use LogicException;
|
||||
|
||||
trait AppendOnlyInventoryRecord
|
||||
{
|
||||
protected static function bootAppendOnlyInventoryRecord(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('O histórico de recepção e stock não pode ser reescrito.'));
|
||||
static::deleting(fn () => throw new LogicException('O histórico de recepção e stock não pode ser eliminado.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CriticalityLevel extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord;
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/** @return array<string,string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_critical' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Equipment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EquipmentSparePart extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord;
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $purchase_order_id
|
||||
* @property int $received_by
|
||||
* @property string $payload_hash
|
||||
* @property array<string, mixed> $snapshot
|
||||
*/
|
||||
class GoodsReceipt extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'received_on' => 'date:Y-m-d', 'confirmed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
/** @return HasMany<GoodsReceiptItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptItem::class)->orderBy('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GoodsReceiptItem extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $quantity
|
||||
* @property int|null $equipment_id
|
||||
* @property int|null $spare_part_id
|
||||
*/
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
use BelongsToTenant, ImmutablePurchaseRecord;
|
||||
|
||||
@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*
|
||||
* @property string $quantity
|
||||
* @property string $tax_rate
|
||||
* @property int|null $equipment_id
|
||||
* @property int|null $spare_part_id
|
||||
*/
|
||||
class RequisitionItem extends Model
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SparePart extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockMovement extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'moved_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array<string, mixed> $snapshot
|
||||
* @property int|null $equipment_id
|
||||
* @property int|null $spare_part_id
|
||||
*/
|
||||
class StockOperation extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord, BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'recorded_at' => 'datetime', 'quantity_milli' => 'integer', 'theoretical_milli' => 'integer', 'ledger_version' => 'integer'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\AppendOnlyInventoryRecord;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TechnicalReview extends Model
|
||||
{
|
||||
use AppendOnlyInventoryRecord;
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/** @return array<string,string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'reviewed_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
|
||||
class GoodsReceiptPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::Warehouse, PurchasingRole::DepartmentHead]);
|
||||
}
|
||||
|
||||
public function viewOrder(User $user, PurchaseOrder $order): bool
|
||||
{
|
||||
return $this->viewAny($user) && $user->tenant_id === $order->tenant_id &&
|
||||
($user->purchasing_role !== PurchasingRole::DepartmentHead || Requisition::query()->whereKey($order->requisition_id)->where('department_id', $user->department_id)->exists());
|
||||
}
|
||||
|
||||
public function view(User $user, GoodsReceipt $receipt): bool
|
||||
{
|
||||
$order = PurchaseOrder::query()->find($receipt->purchase_order_id);
|
||||
|
||||
return $order !== null && $this->viewOrder($user, $order);
|
||||
}
|
||||
|
||||
public function create(User $user, PurchaseOrder $order): bool
|
||||
{
|
||||
return $user->tenant_id === $order->tenant_id && $user->purchasing_role === PurchasingRole::Warehouse && $user->id !== $order->issued_by;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ class ConflictGuard
|
||||
'requisition.created', 'requisition.updated', 'requisition.submitted',
|
||||
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
|
||||
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
|
||||
'equipment.technical_approved', 'equipment.technical_rejected',
|
||||
])->pluck('user_id'))
|
||||
->unique()->values()->all();
|
||||
$ids = [];
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CriticalityLevel;
|
||||
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\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EquipmentWorkflow
|
||||
{
|
||||
public function linkedPart(int $partId, int $equipmentId, ?int $departmentId = null): SparePart
|
||||
{
|
||||
$part = SparePart::query()->findOrFail($partId);
|
||||
$asset = Equipment::query()->findOrFail($equipmentId);
|
||||
if (($departmentId !== null && $asset->department_id !== $departmentId) || ! EquipmentSparePart::query()->where('equipment_id', $asset->id)->where('spare_part_id', $part->id)->exists()) {
|
||||
throw ValidationException::withMessages(['equipment_id' => 'A peça deve estar associada a um equipamento da área de destino e da mesma empresa.']);
|
||||
}
|
||||
|
||||
return $part;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $line */
|
||||
public function line(array $line, int $departmentId, string $priority): void
|
||||
{
|
||||
if ($line['purchase_type'] === 'equipment' && empty(trim($line['production_area'] ?? ''))) {
|
||||
throw ValidationException::withMessages(['items' => 'Indique a área ou linha de produção do equipamento novo.']);
|
||||
}
|
||||
if ($line['purchase_type'] !== 'spare_part') {
|
||||
return;
|
||||
}
|
||||
$part = $this->linkedPart((int) ($line['spare_part_id'] ?? 0), (int) ($line['equipment_id'] ?? 0), $departmentId);
|
||||
$stock = StockItem::query()->findOrFail($part->stock_item_id);
|
||||
if (mb_strtolower(trim($stock->unit)) !== mb_strtolower(trim($line['unit']))) {
|
||||
throw ValidationException::withMessages(['items' => 'A unidade da peça deve coincidir com o artigo de stock, sem conversões automáticas.']);
|
||||
}
|
||||
$level = CriticalityLevel::query()->findOrFail($part->criticality_level_id);
|
||||
if ($priority === 'urgent' && $level->is_critical && empty($line['downtime_started_at'])) {
|
||||
throw ValidationException::withMessages(['items' => 'Peça crítica urgente: indique o início da paragem do equipamento (RF-AF-07).']);
|
||||
}
|
||||
}
|
||||
|
||||
public function basis(Requisition $requisition): string
|
||||
{
|
||||
return hash('sha256', json_encode([$requisition->only(['id', 'tenant_id', 'department_id', 'requester_id', 'priority', 'needed_by', 'justification', 'total_minor']), $requisition->items()->orderBy('id')->get()->toArray()], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function canReview(User $user, Requisition $requisition): bool
|
||||
{
|
||||
return $requisition->status === 'draft' && $requisition->items()->where('purchase_type', 'equipment')->exists() && $this->eligible($user, $requisition);
|
||||
}
|
||||
|
||||
private function eligible(User $user, Requisition $requisition): bool
|
||||
{
|
||||
return $requisition->tenant_id === $user->tenant_id && $user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id && $user->id !== $requisition->requester_id && ! app(ConflictGuard::class)->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
public function review(User $user, Requisition $requisition, string $basis, string $decision, string $reason): TechnicalReview
|
||||
{
|
||||
return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($this->canReview($user, $requisition), 403);
|
||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||
if ($basis !== $this->basis($requisition)) {
|
||||
throw ValidationException::withMessages(['basis_hash' => 'O pedido mudou. Reveja a versão actual antes de decidir.']);
|
||||
}
|
||||
if (TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $basis)->exists()) {
|
||||
throw ValidationException::withMessages(['technical' => 'Esta versão já tem uma decisão técnica. A decisão anterior permanece no histórico.']);
|
||||
}
|
||||
$review = TechnicalReview::query()->create(['tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'reviewed_by' => $user->id, 'basis_hash' => $basis, 'decision' => $decision, 'reason' => $reason, 'reviewed_at' => now(), 'snapshot' => ['reviewer' => $user->only(['id', 'name', 'department_id']), 'items' => $requisition->items()->get()->toArray(), 'development_only' => true, 'budget_checked' => false]]);
|
||||
app(RequisitionWorkflow::class)->audit($user, 'equipment.technical_'.$decision, $requisition, null, $review->toArray());
|
||||
|
||||
return $review;
|
||||
});
|
||||
}
|
||||
|
||||
public function assertTechnical(Requisition $requisition): void
|
||||
{
|
||||
if (! $requisition->items()->where('purchase_type', 'equipment')->exists()) {
|
||||
return;
|
||||
}
|
||||
$review = TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $this->basis($requisition))->first();
|
||||
$user = $review ? User::query()->where('tenant_id', $requisition->tenant_id)->find($review->reviewed_by) : null;
|
||||
if (! $review || $review->decision !== 'approved' || ! $user || ! $this->eligible($user, $requisition)) {
|
||||
throw ValidationException::withMessages(['technical' => 'É necessária validação técnica válida do responsável da área, distinto do solicitante, para a versão actual.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\QuotationItem;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -30,8 +32,8 @@ class PurchaseWorkflow
|
||||
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
|
||||
}
|
||||
$rules = $this->rules($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$this->supportedItems($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
|
||||
$result = $this->checkComparison($user, $requisition, $quote);
|
||||
$award = PurchaseAward::query()->create([
|
||||
@@ -113,6 +115,7 @@ class PurchaseWorkflow
|
||||
$lines[] = [
|
||||
...$item->only(['requisition_item_id', 'description', 'quantity', 'unit_price_minor', 'tax_rate', 'subtotal_minor', 'tax_minor', 'total_minor']),
|
||||
...$source->only(['unit', 'category', 'purchase_type']), 'tenant_id' => $user->tenant_id,
|
||||
'equipment_id' => $source->equipment_id, 'spare_part_id' => $source->spare_part_id,
|
||||
];
|
||||
}
|
||||
$validUntil = $quote->attributesToArray()['valid_until'] ?? null;
|
||||
@@ -134,6 +137,7 @@ class PurchaseWorkflow
|
||||
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
||||
'final_approvals' => $award->approvals()->get()->toArray(),
|
||||
'decision_matrix' => $this->comparison->inspect($requisition)['rows'], 'items' => $lines,
|
||||
'equipment_context' => $requisition->items()->whereNotNull('equipment_id')->get()->map(fn ($line) => ['requisition_item_id' => $line->id, 'equipment' => Equipment::query()->findOrFail($line->equipment_id)->toArray(), 'spare_part' => SparePart::query()->findOrFail($line->spare_part_id)->toArray(), 'downtime_started_at' => $line->downtime_started_at])->all(),
|
||||
];
|
||||
$order = PurchaseOrder::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id,
|
||||
@@ -154,8 +158,8 @@ class PurchaseWorkflow
|
||||
private function currentBasis(User $user, PurchaseAward $award, Requisition $requisition): Quotation
|
||||
{
|
||||
$rules = $this->rules($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$this->supportedItems($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id);
|
||||
$result = $this->checkComparison($user, $requisition, $quote);
|
||||
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
|
||||
@@ -258,8 +262,14 @@ class PurchaseWorkflow
|
||||
|
||||
private function supportedItems(Requisition $requisition): void
|
||||
{
|
||||
if ($requisition->items()->whereNotIn('purchase_type', ['consumable', 'service', 'other'])->exists()) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'Equipamento e peças exigem regras adicionais ainda não suportadas. A emissão para estes tipos está bloqueada.']);
|
||||
if ($requisition->items()->where('purchase_type', 'equipment')->exists()) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'O limite próprio de investimento e a sua regra de aplicação ainda não foram definidos pela empresa. A validação técnica e Finanças não dispensam essa decisão; adjudicação e emissão de equipamento bloqueadas.']);
|
||||
}
|
||||
foreach ($requisition->items()->get() as $line) {
|
||||
if (! in_array($line->purchase_type, ['consumable', 'service', 'other', 'spare_part']) || ($line->purchase_type === 'spare_part' && (! $line->equipment_id || ! $line->spare_part_id))) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'Tipo não suportado ou peça sem equipamento/artigo associado. Complete o cadastro antes da compra.']);
|
||||
}
|
||||
app(EquipmentWorkflow::class)->line($line->toArray(), $requisition->department_id, $requisition->priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\GoodsReceipt;
|
||||
use App\Models\GoodsReceiptItem;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ReceiptWorkflow
|
||||
{
|
||||
public function __construct(private ConflictGuard $conflicts, private RequisitionWorkflow $audit) {}
|
||||
|
||||
public static function milli(string $quantity): int
|
||||
{
|
||||
return BigDecimal::of($quantity)->multipliedBy(1000)->toScale(0)->toInt();
|
||||
}
|
||||
|
||||
private function recordedQuantity(int $lineId, string $column): int
|
||||
{
|
||||
$sum = GoodsReceiptItem::query()->where('purchase_order_item_id', $lineId)->sum($column);
|
||||
if (! is_int($sum) && ! is_string($sum)) {
|
||||
throw new \LogicException('A soma das quantidades deve ser um inteiro exacto.');
|
||||
}
|
||||
|
||||
return BigDecimal::of($sum)->toInt();
|
||||
}
|
||||
|
||||
/** @return array{lines: list<array<string, mixed>>, state: string, has_divergences: bool} */
|
||||
public function progress(PurchaseOrder $order): array
|
||||
{
|
||||
$lines = [];
|
||||
$complete = true;
|
||||
$delivered = true;
|
||||
$started = false;
|
||||
$divergences = false;
|
||||
foreach ($order->items()->orderBy('id')->get() as $item) {
|
||||
$ordered = self::milli($item->quantity);
|
||||
$received = $this->recordedQuantity($item->id, 'received_milli');
|
||||
$accepted = $this->recordedQuantity($item->id, 'accepted_milli');
|
||||
$lines[] = [
|
||||
...$item->only(['id', 'description', 'unit', 'purchase_type']),
|
||||
'ordered_milli' => $ordered, 'received_milli' => $received, 'accepted_milli' => $accepted,
|
||||
'rejected_milli' => $received - $accepted, 'delivery_pending_milli' => $ordered - $received, 'acceptance_pending_milli' => $ordered - $accepted,
|
||||
];
|
||||
$complete = $complete && $accepted === $ordered;
|
||||
$delivered = $delivered && $received === $ordered;
|
||||
$started = $started || $received > 0;
|
||||
$divergences = $divergences || GoodsReceiptItem::query()->where('purchase_order_item_id', $item->id)->where('quality_status', '!=', 'compliant')->exists();
|
||||
}
|
||||
|
||||
return ['lines' => $lines, 'state' => ! $started ? 'not_received' : ($complete ? 'complete' : ($delivered ? 'quality_pending' : 'partial')), 'has_divergences' => $divergences];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function confirm(User $user, PurchaseOrder $order, array $data): GoodsReceipt
|
||||
{
|
||||
return DB::transaction(function () use ($user, $order, $data) {
|
||||
$this->conflicts->lockCompany($user);
|
||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
||||
Gate::authorize('create', [GoodsReceipt::class, $order]);
|
||||
$deliveryNote = Str::squish(Str::upper($data['delivery_note']));
|
||||
if ($deliveryNote === '') {
|
||||
throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']);
|
||||
}
|
||||
$inputs = [];
|
||||
/** @var list<array{purchase_order_item_id: int, received_quantity: string, accepted_quantity: string, quality_status: string, stock_item_id?: int|null, notes?: string|null}> $requested */
|
||||
$requested = $data['items'];
|
||||
foreach ($requested as $line) {
|
||||
$inputs[] = [
|
||||
'purchase_order_item_id' => $line['purchase_order_item_id'],
|
||||
'received_milli' => self::milli($line['received_quantity']), 'accepted_milli' => self::milli($line['accepted_quantity']),
|
||||
'quality_status' => $line['quality_status'], 'stock_item_id' => $line['stock_item_id'] ?? null,
|
||||
'notes' => isset($line['notes']) ? trim($line['notes']) : null,
|
||||
];
|
||||
}
|
||||
usort($inputs, fn ($a, $b) => $a['purchase_order_item_id'] <=> $b['purchase_order_item_id']);
|
||||
$canonical = ['delivery_note' => $deliveryNote, 'received_on' => $data['received_on'], 'warehouse_id' => $data['warehouse_id'] ?? null, 'items' => $inputs];
|
||||
// Normalize form-encoded identifiers to the same representation used by JSON requests.
|
||||
$canonical['warehouse_id'] = $canonical['warehouse_id'] === null ? null : (string) $canonical['warehouse_id'];
|
||||
foreach ($canonical['items'] as &$line) {
|
||||
$line['purchase_order_item_id'] = (string) $line['purchase_order_item_id'];
|
||||
$line['stock_item_id'] = $line['stock_item_id'] === null ? null : (string) $line['stock_item_id'];
|
||||
}
|
||||
unset($line);
|
||||
$hash = hash('sha256', json_encode($canonical, JSON_THROW_ON_ERROR));
|
||||
$existing = GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('idempotency_key', $data['idempotency_key'])->first();
|
||||
if ($existing) {
|
||||
if ($existing->payload_hash !== $hash) {
|
||||
throw ValidationException::withMessages(['idempotency_key' => 'Esta chave já confirmou uma recepção com dados diferentes.']);
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
if (GoodsReceipt::query()->where('purchase_order_id', $order->id)->where('delivery_note', $deliveryNote)->exists()) {
|
||||
throw ValidationException::withMessages(['delivery_note' => 'Esta guia já foi confirmada nesta ordem. Consulte o histórico em vez de repetir a recepção.']);
|
||||
}
|
||||
if ($order->status !== 'issued' || $data['received_on'] < substr((string) $order->getRawOriginal('issued_at'), 0, 10)) {
|
||||
throw ValidationException::withMessages(['received_on' => 'A recepção deve referir uma ordem emitida e não pode anteceder a sua emissão.']);
|
||||
}
|
||||
$requisition = Requisition::query()->findOrFail($order->requisition_id);
|
||||
$this->conflicts->assertParticipation($user, $requisition);
|
||||
$this->conflicts->assertProcess($requisition, $order->supplier_id, $user);
|
||||
$warehouse = ! empty($data['warehouse_id']) ? Warehouse::query()->whereKey($data['warehouse_id'])->lockForUpdate()->firstOrFail() : null;
|
||||
$ordered = $order->items()->orderBy('id')->lockForUpdate()->get()->keyBy('id');
|
||||
$lines = [];
|
||||
$seen = [];
|
||||
$acceptedTotal = 0;
|
||||
foreach ($inputs as $input) {
|
||||
$source = $ordered->get($input['purchase_order_item_id']);
|
||||
if (! $source || in_array($source->id, $seen, true)) {
|
||||
throw ValidationException::withMessages(['items' => 'As linhas devem ser distintas e pertencer à ordem de compra.']);
|
||||
}
|
||||
$seen[] = $source->id;
|
||||
$received = $input['received_milli'];
|
||||
$accepted = $input['accepted_milli'];
|
||||
$previous = $this->recordedQuantity($source->id, 'received_milli');
|
||||
if ($received <= 0 || $accepted < 0 || $accepted > $received || $previous + $received > self::milli($source->quantity)) {
|
||||
throw ValidationException::withMessages(['items' => 'Quantidade inválida ou superior à quantidade pendente autorizada na ordem.']);
|
||||
}
|
||||
if (($input['quality_status'] === 'compliant' && $accepted !== $received) || ($input['quality_status'] === 'nonconforming' && $accepted !== 0)) {
|
||||
throw ValidationException::withMessages(['items' => 'Conforme exige aceitar a quantidade recebida; Não conforme exige rejeitá-la integralmente. Use Conforme com reservas para aceitação parcial.']);
|
||||
}
|
||||
if ($input['quality_status'] !== 'compliant' && empty($input['notes'])) {
|
||||
throw ValidationException::withMessages(['items' => 'Descreva as reservas, danos ou divergências de qualidade.']);
|
||||
}
|
||||
$stockItem = ! empty($input['stock_item_id']) ? StockItem::query()->lockForUpdate()->findOrFail($input['stock_item_id']) : null;
|
||||
$physical = in_array($source->purchase_type, ['consumable', 'other', 'spare_part']);
|
||||
if ($source->purchase_type === 'spare_part') {
|
||||
if (! $source->spare_part_id || ! $source->equipment_id) {
|
||||
throw ValidationException::withMessages(['items' => 'Peça sem associação autorizada na ordem. A recepção está bloqueada.']);
|
||||
}
|
||||
$part = app(EquipmentWorkflow::class)->linkedPart((int) $source->spare_part_id, (int) $source->equipment_id, $requisition->department_id);
|
||||
if ($accepted > 0 && $stockItem?->id !== $part->stock_item_id) {
|
||||
throw ValidationException::withMessages(['items' => 'A peça recebida deve usar o artigo de stock autorizado na ordem.']);
|
||||
}
|
||||
}
|
||||
if (! $physical && $source->purchase_type !== 'service') {
|
||||
throw ValidationException::withMessages(['items' => 'Este tipo de artigo exige controlos adicionais ainda não implementados.']);
|
||||
}
|
||||
if ($physical && $accepted > 0 && (! $warehouse || ! $stockItem || Str::lower(trim($stockItem->unit)) !== Str::lower(trim($source->unit)))) {
|
||||
throw ValidationException::withMessages(['items' => 'Para bens aceites, indique armazém e artigo de stock com a mesma unidade da ordem. Não há conversões automáticas.']);
|
||||
}
|
||||
if (! $physical && $stockItem !== null) {
|
||||
throw ValidationException::withMessages(['items' => 'Serviços não geram existências nem recebem artigo de stock.']);
|
||||
}
|
||||
$acceptedTotal += $accepted;
|
||||
$lines[] = [
|
||||
'tenant_id' => $user->tenant_id, 'purchase_order_item_id' => $source->id,
|
||||
'stock_item_id' => $stockItem?->id, 'received_milli' => $received, 'accepted_milli' => $accepted,
|
||||
'rejected_milli' => $received - $accepted, 'quality_status' => $input['quality_status'], 'notes' => $input['notes'],
|
||||
'snapshot' => [
|
||||
'order_item' => $source->only(['id', 'description', 'unit', 'purchase_type', 'quantity']),
|
||||
'stock_item' => $stockItem?->only(['id', 'code', 'name', 'unit']),
|
||||
'warehouse' => $warehouse?->only(['id', 'code', 'name']),
|
||||
'physical' => $physical,
|
||||
'equipment_id' => $source->equipment_id, 'spare_part_id' => $source->spare_part_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
$receipt = GoodsReceipt::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'purchase_order_id' => $order->id, 'received_by' => $user->id, 'warehouse_id' => $warehouse?->id,
|
||||
'idempotency_key' => $data['idempotency_key'], 'payload_hash' => $hash, 'delivery_note' => $deliveryNote,
|
||||
'received_on' => $data['received_on'], 'status' => 'partial', 'confirmed_at' => now(),
|
||||
'snapshot' => ['order' => $order->only(['id', 'number']), 'supplier' => $order->snapshot['supplier'], 'receiver' => $user->only(['id', 'name']), 'warehouse' => $warehouse?->only(['id', 'code', 'name']), 'development_only' => true, 'budget_checked' => false],
|
||||
]);
|
||||
foreach ($lines as $line) {
|
||||
$item = $receipt->items()->create($line);
|
||||
if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) {
|
||||
StockMovement::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'goods_receipt_item_id' => $item->id, 'stock_item_id' => $line['stock_item_id'],
|
||||
'warehouse_id' => $warehouse?->id, 'moved_by' => $user->id, 'quantity_milli' => $line['accepted_milli'], 'moved_at' => now(),
|
||||
'equipment_id' => $line['snapshot']['equipment_id'], 'spare_part_id' => $line['snapshot']['spare_part_id'],
|
||||
'snapshot' => [...$line['snapshot'], 'receipt_id' => $receipt->id, 'order_number' => $order->number, 'delivery_note' => $deliveryNote],
|
||||
]);
|
||||
}
|
||||
}
|
||||
$progress = $this->progress($order);
|
||||
$status = $acceptedTotal === 0 ? 'rejected' : ($progress['state'] === 'complete' ? 'complete' : 'partial');
|
||||
// Complete the reference/status before exposing this append-only confirmation.
|
||||
DB::table('goods_receipts')->where('id', $receipt->id)->update(['number' => sprintf('REC-%s-%06d', now()->format('Y'), $receipt->id), 'status' => $status]);
|
||||
$receipt->refresh();
|
||||
$receipt->load('items');
|
||||
$this->audit->audit($user, 'receipt.confirmed', $requisition, null, ['receipt' => $receipt->toArray(), 'order_progress' => $progress]);
|
||||
|
||||
return $receipt;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,10 @@ class RequisitionWorkflow
|
||||
$items = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string}> $lines */
|
||||
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string, equipment_id?: int, spare_part_id?: int, production_area?: string, downtime_started_at?: string|null}> $lines */
|
||||
$lines = $data['items'];
|
||||
foreach ($lines as $line) {
|
||||
app(EquipmentWorkflow::class)->line($line, (int) $user->department_id, $data['priority']);
|
||||
$price = BigDecimal::of($line['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$lineSubtotal = BigDecimal::of($line['quantity'])->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$lineTax = BigDecimal::of($lineSubtotal)->multipliedBy($line['tax_rate'])->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
@@ -37,6 +38,7 @@ class RequisitionWorkflow
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'description' => $line['description'], 'category' => $line['category'],
|
||||
'purchase_type' => $line['purchase_type'], 'quantity' => $line['quantity'], 'unit' => $line['unit'],
|
||||
'equipment_id' => $line['equipment_id'] ?? null, 'spare_part_id' => $line['spare_part_id'] ?? null, 'production_area' => $line['production_area'] ?? null, 'downtime_started_at' => $line['downtime_started_at'] ?? null,
|
||||
'unit_price_minor' => $price, 'tax_rate' => $line['tax_rate'],
|
||||
'subtotal_minor' => $lineSubtotal, 'tax_minor' => $lineTax, 'total_minor' => $lineSubtotal + $lineTax,
|
||||
];
|
||||
@@ -71,6 +73,10 @@ class RequisitionWorkflow
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||
foreach ($requisition->items()->get() as $item) {
|
||||
app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority);
|
||||
}
|
||||
app(EquipmentWorkflow::class)->assertTechnical($requisition);
|
||||
if ($requisition->needed_by < today()) {
|
||||
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
|
||||
}
|
||||
@@ -80,7 +86,7 @@ class RequisitionWorkflow
|
||||
if ($amount > $tenant->first_limit_minor) {
|
||||
$roles[] = 'procurement';
|
||||
}
|
||||
if ($amount > $tenant->second_limit_minor) {
|
||||
if ($amount > $tenant->second_limit_minor || $requisition->items()->where('purchase_type', 'equipment')->exists()) {
|
||||
$roles[] = 'finance';
|
||||
}
|
||||
foreach ($roles as $position => $role) {
|
||||
@@ -99,6 +105,9 @@ class RequisitionWorkflow
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
$requisition->load('steps');
|
||||
abort_unless($requisition->canDecide($user), 403);
|
||||
if ($decision === 'approved') {
|
||||
app(EquipmentWorkflow::class)->assertTechnical($requisition);
|
||||
}
|
||||
$step = $requisition->steps->firstWhere('decision', 'pending');
|
||||
abort_if($step === null, 409);
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StockWorkflow
|
||||
{
|
||||
/** @return array{quantity: int, version: int} */
|
||||
public function balance(int $item, int $warehouse): array
|
||||
{
|
||||
$query = StockMovement::query()->where('stock_item_id', $item)->where('warehouse_id', $warehouse);
|
||||
|
||||
return ['quantity' => BigDecimal::of((string) (clone $query)->sum('quantity_milli'))->toInt(), 'version' => (int) $query->max('id')];
|
||||
}
|
||||
|
||||
/** @param array{kind: string, reference: string, idempotency_key: string, responsible_id: int, warehouse_id: int, stock_item_id: int, destination_warehouse_id?: int, quantity: string, reason: string, destination?: string, count_id?: int, ledger_version?: int, equipment_id?: int} $data */
|
||||
public function record(User $actor, array $data): StockOperation
|
||||
{
|
||||
abort_unless($actor->tenant_id !== null && $actor->purchasing_role === PurchasingRole::Warehouse && auth()->id() === $actor->id, 403);
|
||||
|
||||
return DB::transaction(function () use ($actor, $data) {
|
||||
// Same first lock as receipts: includes empty balances and concurrent receipts.
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
$existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first();
|
||||
if ($existing) {
|
||||
if ($existing->payload_hash !== $hash) {
|
||||
throw ValidationException::withMessages(['idempotency_key' => 'Chave já utilizada com dados diferentes.']);
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
if (StockOperation::query()->where('reference', $data['reference'])->exists()) {
|
||||
throw ValidationException::withMessages(['reference' => 'Referência já registada. Consulte o histórico.']);
|
||||
}
|
||||
$responsible = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($data['responsible_id']);
|
||||
$warehouse = Warehouse::query()->findOrFail($data['warehouse_id']);
|
||||
$item = StockItem::query()->findOrFail($data['stock_item_id']);
|
||||
$equipment = $data['kind'] === 'issue' && isset($data['equipment_id']) ? Equipment::query()->findOrFail($data['equipment_id']) : null;
|
||||
$part = $data['kind'] === 'issue' ? SparePart::query()->where('stock_item_id', $item->id)->first() : null;
|
||||
if ($part) {
|
||||
if (! $equipment) {
|
||||
throw ValidationException::withMessages(['equipment_id' => 'Seleccione o equipamento de destino associado à peça.']);
|
||||
}
|
||||
app(EquipmentWorkflow::class)->linkedPart($part->id, $equipment->id);
|
||||
}
|
||||
$destination = $data['kind'] === 'transfer' ? Warehouse::query()->findOrFail($data['destination_warehouse_id'] ?? 0) : null;
|
||||
if ($destination?->id === $warehouse->id) {
|
||||
throw ValidationException::withMessages(['destination_warehouse_id' => 'Escolha outro armazém da mesma empresa.']);
|
||||
}
|
||||
$balance = $this->balance($item->id, $warehouse->id);
|
||||
$quantity = ReceiptWorkflow::milli($data['quantity']);
|
||||
$delta = -$quantity;
|
||||
$count = null;
|
||||
if ($data['kind'] === 'count') {
|
||||
if (($data['ledger_version'] ?? -1) !== $balance['version']) {
|
||||
throw ValidationException::withMessages(['ledger_version' => 'Existem movimentos posteriores. Actualize o saldo e repita a contagem física.']);
|
||||
}
|
||||
$delta = 0;
|
||||
} elseif ($data['kind'] === 'adjustment') {
|
||||
$count = StockOperation::query()->where('kind', 'count')->findOrFail($data['count_id'] ?? 0);
|
||||
if ($count->stock_item_id !== $item->id || $count->warehouse_id !== $warehouse->id || $count->ledger_version !== $balance['version'] || StockOperation::query()->where('count_id', $count->id)->exists()) {
|
||||
throw ValidationException::withMessages(['count_id' => 'Contagem já ajustada, incompatível ou desactualizada. Faça nova contagem após movimentos.']);
|
||||
}
|
||||
$quantity = $count->quantity_milli;
|
||||
$delta = $quantity - $balance['quantity'];
|
||||
if ($delta === 0) {
|
||||
throw ValidationException::withMessages(['count_id' => 'Contagem sem divergência; não há ajuste a efectuar.']);
|
||||
}
|
||||
}
|
||||
if ($balance['quantity'] + $delta < 0) {
|
||||
throw ValidationException::withMessages(['quantity' => 'Stock insuficiente. A operação não pode produzir saldo negativo.']);
|
||||
}
|
||||
$operation = StockOperation::query()->create([
|
||||
'tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $responsible->id,
|
||||
'warehouse_id' => $warehouse->id, 'destination_warehouse_id' => $destination?->id, 'stock_item_id' => $item->id,
|
||||
'count_id' => $count?->id, 'kind' => $data['kind'], 'reference' => $data['reference'], 'idempotency_key' => $data['idempotency_key'],
|
||||
'equipment_id' => $equipment?->id, 'spare_part_id' => $part?->id,
|
||||
'payload_hash' => $hash, 'destination' => $data['destination'] ?? null, 'reason' => $data['reason'],
|
||||
'quantity_milli' => $quantity, 'theoretical_milli' => $balance['quantity'], 'ledger_version' => $balance['version'], 'recorded_at' => now(),
|
||||
'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $responsible->only(['id', 'name']), 'stock_item' => $item->only(['id', 'code', 'name', 'unit']), 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'destination_warehouse' => $destination?->only(['id', 'code', 'name']), 'equipment' => $equipment?->toArray(), 'spare_part' => $part?->toArray(), 'difference_milli' => $quantity - $balance['quantity'], 'budget_checked' => false, 'development_only' => true],
|
||||
]);
|
||||
if ($delta !== 0) {
|
||||
$this->movement($actor, $operation, $warehouse->id, $delta, $data['kind'] === 'transfer' ? 'transfer_out' : $data['kind']);
|
||||
if ($destination) {
|
||||
$this->movement($actor, $operation, $destination->id, $quantity, 'transfer_in');
|
||||
}
|
||||
}
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'stock.'.$data['kind'], null, null, $operation->toArray());
|
||||
|
||||
return $operation;
|
||||
});
|
||||
}
|
||||
|
||||
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): void
|
||||
{
|
||||
StockMovement::query()->create(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'stock_item_id' => $operation->stock_item_id, 'warehouse_id' => $warehouse, 'equipment_id' => $operation->equipment_id, 'spare_part_id' => $operation->spare_part_id, 'moved_by' => $actor->id, 'movement_type' => $type, 'quantity_milli' => $quantity, 'snapshot' => [...$operation->snapshot, 'warehouse' => Warehouse::query()->findOrFail($warehouse)->only(['id', 'code', 'name']), 'reference' => $operation->reference, 'reason' => $operation->reason], 'moved_at' => now()]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user