commit14
This commit is contained in:
@@ -2,10 +2,15 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Requests\StoreRequisitionRequest;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Equipment;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\WarehouseRequest;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -17,23 +22,70 @@ use Illuminate\Validation\ValidationException;
|
||||
/** Event-driven draft preparation. Never submits, decides, orders, reserves budget or moves stock. */
|
||||
class AutomaticPurchases
|
||||
{
|
||||
private function independentReviewer(User $actor, SupplyRecord $rule): bool
|
||||
{
|
||||
return ! in_array($actor->id, $rule->snapshot['contributors'] ?? [$rule->created_by], true);
|
||||
}
|
||||
|
||||
public function canReview(User $actor, SupplyRecord $rule, string $action = 'approve'): bool
|
||||
{
|
||||
if ($actor->tenant_id !== $rule->tenant_id || $rule->kind !== 'auto_rule' || ! in_array($action, ['approve', 'suspend'], true)
|
||||
|| ! app(SupplyPlanning::class)->allows($actor, 'supply_review')) {
|
||||
return false;
|
||||
}
|
||||
$latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
|
||||
|
||||
return $latest?->id === $rule->id && $rule->state === ($action === 'approve' ? 'draft' : 'approved')
|
||||
&& ($action !== 'approve' || $this->independentReviewer($actor, $rule));
|
||||
}
|
||||
|
||||
public function eligibleRequester(User $user): bool
|
||||
{
|
||||
return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && !app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
return in_array('requester', app(OrganizationAccess::class)->permissions($user, app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null), true) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return Schema::hasTable('supply_records') && app(WarehouseReservations::class)->ready();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function origin(Requisition $requisition): ?array
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
return null;
|
||||
}
|
||||
$record = SupplyRecord::query()->where('kind', 'auto_need')->whereJsonContains('data->requisition_ids', $requisition->id)->oldest('id')->first();
|
||||
|
||||
return $record === null ? null : ['need_id' => $record->id, 'rule_id' => $record->snapshot['rule_id'], 'item' => $record->snapshot['item'], 'warehouse' => $record->snapshot['warehouse'], 'origins' => $record->snapshot['origins'], 'created_at' => $record->created_at->toDateTimeString()];
|
||||
}
|
||||
|
||||
public function requisitionChanged(User $actor, Requisition $requisition): void
|
||||
{
|
||||
$origin = $this->origin($requisition);
|
||||
if ($origin !== null) {
|
||||
$this->signal($actor, $origin['item']['id'], $origin['warehouse']['id']);
|
||||
}
|
||||
}
|
||||
|
||||
public function requester(User $executor, SupplyRecord $rule): User
|
||||
{
|
||||
$executor = app(CurrentActor::class)->lock($executor);
|
||||
$executor = $this->lockExecutor($executor);
|
||||
$current = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
|
||||
$this->check($current !== null && $current->id === $rule->id && $current->tenant_id === $executor->tenant_id && $current->state === 'approved', 'A parametrização automática deixou de estar activa.');
|
||||
$this->check(! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
|
||||
$user = User::query()->where('tenant_id', $executor->tenant_id)->lockForUpdate()->find($rule->data['requester_id']);
|
||||
$this->check($user !== null && $this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
|
||||
$this->check(app(ReplenishmentRun::class)->tenantId() !== null || ! app(OrganizationAccess::class)->maintenanceOnly($executor), 'A manutenção não pode executar compras automáticas.');
|
||||
|
||||
return $this->configuredRequester($rule, true);
|
||||
}
|
||||
|
||||
private function configuredRequester(SupplyRecord $rule, bool $lock): User
|
||||
{
|
||||
$query = User::query()->where('tenant_id', $rule->tenant_id)->whereKey($rule->data['requester_id']);
|
||||
if ($lock) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
$user = $query->firstOrFail();
|
||||
$this->check($this->eligibleRequester($user), 'Requisitante sem competência vigente no contexto de compras internas.');
|
||||
$this->check(CostCenter::query()->whereKey($rule->data['cost_center_id'])->where('department_id', $user->department_id)->exists(), 'Centro de custo incompatível com o departamento actual do requisitante.');
|
||||
$this->check($user->only(['id', 'name', 'department_id']) === $rule->snapshot['requester'], 'O requisitante mudou; reveja a parametrização.');
|
||||
$center = CostCenter::query()->findOrFail($rule->data['cost_center_id']);
|
||||
@@ -42,6 +94,30 @@ class AutomaticPurchases
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** Inspect the same sources used by generation, without actor substitution, locks or commands.
|
||||
* @return array{valid:bool,issues:list<string>}
|
||||
*/
|
||||
public function inspectRule(User $viewer, SupplyRecord $rule): array
|
||||
{
|
||||
abort_unless(auth()->id() === $viewer->id && $viewer->tenant_id === $rule->tenant_id && $viewer->hasFunctions(['admin']), 403);
|
||||
try {
|
||||
$latest = app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $rule->record_key)->first();
|
||||
$this->check($latest !== null && $latest->id === $rule->id && $latest->state === 'approved', 'A regra já não está activa.');
|
||||
$requester = $this->configuredRequester($rule, false);
|
||||
$form = new StoreRequisitionRequest;
|
||||
$form->setUserResolver(fn () => $requester);
|
||||
Validator::make(['items' => [['unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]], array_intersect_key($form->rules(), array_flip(['items.*.unit_price', 'items.*.tax_rate'])), $form->messages())->validate();
|
||||
$this->basis($rule);
|
||||
|
||||
return ['valid' => true, 'issues' => []];
|
||||
} catch (ValidationException $error) {
|
||||
return ['valid' => false, 'issues' => array_values(array_merge(...array_values($error->errors())))];
|
||||
} catch (ModelNotFoundException) {
|
||||
return ['valid' => false, 'issues' => ['Uma origem deixou de estar disponível; reveja a parametrização.']];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function save(User $actor, array $input): SupplyRecord
|
||||
{
|
||||
abort_unless($this->ready(), 503, 'Instalação da reserva e reposição pendente.');
|
||||
@@ -59,24 +135,37 @@ class AutomaticPurchases
|
||||
return $retry;
|
||||
}
|
||||
$old = empty($data['record_key']) ? null : app(SupplyPlanning::class)->latest('auto_rule')->where('record_key', $data['record_key'])->firstOrFail();
|
||||
$this->check($data['version'] === ($old?->version ?? 0), 'A parametrização mudou. Actualize a página.');
|
||||
$snapshot = $old?->snapshot ?? [];
|
||||
$values = $old?->data ?? [];
|
||||
$this->check($data['version'] === ($old->version ?? 0), 'A parametrização mudou. Actualize a página.');
|
||||
$snapshot = $old->snapshot ?? [];
|
||||
$values = $old->data ?? [];
|
||||
if ($data['action'] === 'save') {
|
||||
$values = Validator::make($data['data'], ['policy_key' => 'required|uuid', 'requester_id' => 'required|integer|min:1', 'cost_center_id' => 'required|integer|min:1',
|
||||
'unit_price' => 'nullable|string|regex:/^[0-9]{1,8}(\.[0-9]{1,2})?$/D|numeric|min:0.01', 'tax_rate' => 'nullable|string|numeric|min:0|max:100|regex:/^[0-9]{1,3}(\.[0-9]{1,2})?$/D',
|
||||
'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate();
|
||||
'equipment_id' => 'nullable|integer|min:1', 'needed_days' => 'required|integer|min:0|max:366', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today'])->validate();
|
||||
$basis = app(SupplySuggestions::class)->basis($values['policy_key'], today()->toDateString());
|
||||
$values['unit_price'] = $values['unit_price'] ?? $basis['unit_price'];
|
||||
$values['tax_rate'] = $values['tax_rate'] ?? (string) $basis['tax_rate'];
|
||||
$values['tax_rate'] = $values['tax_rate'] ?? $basis['tax_rate'];
|
||||
$this->check($values['unit_price'] !== null, 'O acordo não fornece um preço em MZN. Registe uma estimativa válida.');
|
||||
$this->check($values['tax_rate'] !== null, 'A empresa não tem imposto configurado. Registe uma estimativa fundamentada.');
|
||||
$values['tax_rate'] = (string) $values['tax_rate'];
|
||||
$this->check($basis['policy']['purpose'] === 'internal', 'Esta parametrização destina-se às compras internas do SGC.');
|
||||
$this->check($basis['catalogue']['purchase_type'] === 'consumable', 'Seleccione um consumível publicado; equipamento/peças exigem o seu destino técnico.');
|
||||
$user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($values['requester_id']);
|
||||
$this->check(in_array($basis['catalogue']['purchase_type'], ['consumable', 'other', 'spare_part'], true), 'Seleccione um artigo de stock publicado. Equipamento novo e serviços seguem o seu processo próprio.');
|
||||
$user = User::query()->where('tenant_id', $actor->tenant_id)->whereKey($values['requester_id'])->firstOrFail();
|
||||
$this->check($this->eligibleRequester($user), 'Seleccione um requisitante com competência vigente em compras internas.');
|
||||
$center = CostCenter::query()->where('department_id', $user->department_id)->findOrFail($values['cost_center_id']);
|
||||
$snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id,
|
||||
$snapshot = ['requester' => $user->only(['id', 'name', 'department_id']), 'cost_center' => $center->only(['id', 'code', 'name', 'department_id']), 'prepared_by' => $actor->id, 'contributors' => array_values(array_unique([...($old?->snapshot['contributors'] ?? []), $actor->id])),
|
||||
'item' => $basis['item'], 'warehouse' => $basis['warehouse'], 'policy_version' => $basis['policy_version'], 'catalogue' => $basis['catalogue']];
|
||||
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
|
||||
$this->check(! empty($values['equipment_id']), 'Seleccione o equipamento de destino da peça.');
|
||||
$part = SparePart::query()->where('stock_item_id', $basis['stock_item_id'])->firstOrFail();
|
||||
app(EquipmentWorkflow::class)->linkedPart($part->id, (int) $values['equipment_id'], $user->department_id);
|
||||
$equipment = Equipment::query()->whereKey($values['equipment_id'])->firstOrFail();
|
||||
$values['spare_part_id'] = $part->id;
|
||||
$snapshot['equipment'] = $equipment->only(['id', 'code', 'description', 'department_id']);
|
||||
$snapshot['spare_part'] = $part->toArray();
|
||||
} else {
|
||||
$this->check(empty($values['equipment_id']), 'O equipamento na parametrização aplica-se apenas a peças. Os destinos dos pedidos são preservados na origem.');
|
||||
}
|
||||
$duplicates = app(SupplyPlanning::class)->latest('auto_rule')->where('stock_item_id', $basis['stock_item_id'])->where('warehouse_id', $basis['warehouse_id'])->whereIn('state', ['draft', 'approved']);
|
||||
if ($old !== null) {
|
||||
$duplicates->where('record_key', '!=', $old->record_key);
|
||||
@@ -84,7 +173,7 @@ class AutomaticPurchases
|
||||
$this->check(! $duplicates->exists(), 'Já existe uma parametrização neste artigo/armazém. Reveja a existente.');
|
||||
} else {
|
||||
$this->check($old !== null && $old->state === ($data['action'] === 'approve' ? 'draft' : 'approved'), 'Estado incompatível com a intervenção.');
|
||||
$this->check($data['action'] !== 'approve' || $snapshot['prepared_by'] !== $actor->id, 'Quem preparou não pode activar a própria parametrização.');
|
||||
$this->check($data['action'] !== 'approve' || $this->independentReviewer($actor, $old), 'Quem preparou não pode activar a própria parametrização.');
|
||||
}
|
||||
$row = $this->append($actor, 'auto_rule', $old, $data['action'] === 'save' ? 'draft' : ($data['action'] === 'approve' ? 'approved' : 'suspended'), $values, $snapshot, $data['reason'], $data['operation_key'], $hash);
|
||||
if ($row->state === 'approved') {
|
||||
@@ -97,11 +186,17 @@ class AutomaticPurchases
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function basis(SupplyRecord $rule): array
|
||||
{
|
||||
$this->check($rule->data['valid_until'] >= today()->toDateString(), 'A estimativa/parametrização automática expirou.');
|
||||
$basis = app(SupplySuggestions::class)->basis($rule->data['policy_key'], today()->toDateString());
|
||||
$this->check($basis['policy_version'] === $rule->snapshot['policy_version'] && $basis['item'] === $rule->snapshot['item'] && $basis['warehouse'] === $rule->snapshot['warehouse'] && $basis['catalogue'] === $rule->snapshot['catalogue'], 'A política, o catálogo ou o armazém mudou. Reveja a parametrização automática.');
|
||||
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
|
||||
$part = app(EquipmentWorkflow::class)->linkedPart($rule->data['spare_part_id'], (int) $rule->data['equipment_id'], $rule->snapshot['requester']['department_id']);
|
||||
$equipment = Equipment::query()->whereKey($rule->data['equipment_id'])->firstOrFail();
|
||||
$this->check($part->toArray() === $rule->snapshot['spare_part'] && $equipment->only(['id', 'code', 'description', 'department_id']) === $rule->snapshot['equipment'], 'O destino técnico mudou. Reveja a parametrização da peça.');
|
||||
}
|
||||
|
||||
return $basis;
|
||||
}
|
||||
@@ -112,9 +207,20 @@ class AutomaticPurchases
|
||||
if (! $this->ready()) {
|
||||
return;
|
||||
}
|
||||
if (app(ReplenishmentQueue::class)->ready()) {
|
||||
$id = app(ReplenishmentQueue::class)->enqueue($actor, $item, $warehouse);
|
||||
DB::afterCommit(fn () => app(ReplenishmentQueue::class)->interactive($actor, $id));
|
||||
|
||||
return;
|
||||
}
|
||||
DB::afterCommit(fn () => $this->evaluate($actor, $item, $warehouse));
|
||||
}
|
||||
|
||||
public function lockExecutor(User $actor): User
|
||||
{
|
||||
return app(ReplenishmentRun::class)->tenantId() === null ? app(CurrentActor::class)->lock($actor) : app(ReplenishmentRun::class)->lockResponsible($actor);
|
||||
}
|
||||
|
||||
public function evaluate(User $actor, int $item, int $warehouse): void
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
@@ -122,15 +228,19 @@ class AutomaticPurchases
|
||||
}
|
||||
DB::transaction(function () use ($actor, $item, $warehouse) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$actor = $this->lockExecutor($actor);
|
||||
abort_if(app(ReplenishmentRun::class)->tenantId() === null && app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$planning = app(SupplyPlanning::class);
|
||||
$policy = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
|
||||
$rule = $planning->latest('auto_rule')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->first();
|
||||
$policies = $planning->latest('policy')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'approved')->where('data->purpose', 'internal');
|
||||
if ($rule !== null) {
|
||||
$policies->where('record_key', $rule->data['policy_key']);
|
||||
}
|
||||
$policy = $policies->latest('id')->first();
|
||||
$old = $planning->latest('auto_need')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
|
||||
$origins = [];
|
||||
$unreserved = 0;
|
||||
foreach (WarehouseRequest::query()->where('warehouse_id', $warehouse)->get() as $request) {
|
||||
foreach (WarehouseRequest::query()->where('work_context_key', 'procurement.internal')->where('warehouse_id', $warehouse)->get() as $request) {
|
||||
$p = app(WarehouseFulfilment::class)->progress($request);
|
||||
if (! in_array($p['state'], ['submitted', 'authorized', 'partial'], true)) {
|
||||
continue;
|
||||
@@ -142,14 +252,14 @@ class AutomaticPurchases
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($policy === null && $unreserved === 0 && $old === null) {
|
||||
if ($policy === null && $origins === [] && $old === null) {
|
||||
return;
|
||||
}
|
||||
$purchases = $old?->data['purchases'] ?? [];
|
||||
$snapshot = ['item' => ['id' => $item], 'warehouse' => ['id' => $warehouse], 'origins' => $origins, 'rule_id' => $rule?->id];
|
||||
$snapshot = ['item' => StockItem::query()->whereKey($item)->firstOrFail()->only(['id', 'code', 'name', 'unit']), 'warehouse' => Warehouse::query()->whereKey($warehouse)->firstOrFail()->only(['id', 'code', 'name']), 'origins' => $origins, 'rule_id' => $rule?->id];
|
||||
$state = 'pending';
|
||||
$message = 'Parametrize requisitante, centro de custo e reposição por artigo/armazém.';
|
||||
$values = ['purchases' => $purchases, 'message' => $message];
|
||||
$values = ['purchases' => $purchases, 'requisition_ids' => array_column($purchases, 'id'), 'message' => $message];
|
||||
try {
|
||||
$free = app(WarehouseReservations::class)->free($item, $warehouse);
|
||||
$minimum = $policy?->data['minimum_stock_milli'] ?? 0;
|
||||
@@ -162,20 +272,31 @@ class AutomaticPurchases
|
||||
$this->check($rule !== null, $message);
|
||||
$requester = $this->requester($actor, $rule);
|
||||
$basis = $this->basis($rule);
|
||||
if ($basis['catalogue']['purchase_type'] === 'spare_part') {
|
||||
foreach ($origins as $origin) {
|
||||
$this->check(($origin['equipment']['id'] ?? null) === (int) $rule->data['equipment_id'], 'O equipamento do pedido não coincide com o destino técnico da parametrização. Reconciliar os destinos antes de gerar a compra.');
|
||||
}
|
||||
}
|
||||
$coverage = 0;
|
||||
$blocked = false;
|
||||
foreach ($purchases as $purchase) {
|
||||
$r = Requisition::query()->find($purchase['id']);
|
||||
$this->check($r !== null, 'A requisição de reposição anterior requer reconciliação.');
|
||||
$tracked = $purchases;
|
||||
foreach ($planning->latest('suggestion')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->whereIn('state', ['converted', 'closed'])->get() as $suggestion) {
|
||||
if (! in_array($suggestion->data['requisition_id'], array_column($tracked, 'id'), true)) {
|
||||
$tracked[] = ['id' => $suggestion->data['requisition_id'], 'rule_id' => null];
|
||||
}
|
||||
}
|
||||
foreach ($tracked as $purchase) {
|
||||
$r = Requisition::query()->whereKey($purchase['id'])->firstOrFail();
|
||||
if ($r->status === 'rejected') {
|
||||
if ($purchase['rule_id'] === $rule->id) {
|
||||
$blocked = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
$this->check(in_array($r->status, ['draft', 'pending', 'approved'], true), 'Rever o estado da reposição anterior antes de gerar nova compra.');
|
||||
$r->load('items');
|
||||
$this->check($r->items->count() === 1, 'Uma reposição anterior foi repartida ou alterada; reconcilie as linhas antes de gerar nova compra.');
|
||||
$line = $r->items->sole();
|
||||
$this->check($line->catalogue_entry_id === $basis['catalogue']['id'] && ($line->catalogue_snapshot['version'] ?? null) === $basis['catalogue']['version'], 'O rascunho automático anterior mudou de artigo; reconcilie a necessidade.');
|
||||
$received = 0;
|
||||
@@ -185,11 +306,13 @@ continue;
|
||||
$received += (int) $receipt->accepted_milli;
|
||||
}
|
||||
}
|
||||
$coverage += max(0, ReceiptWorkflow::milli($line->quantity) - $received);
|
||||
$orderLines = DB::table('purchase_order_items')->where('tenant_id', $actor->tenant_id)->where('requisition_item_id', $line->id)->get(['quantity']);
|
||||
$expected = $orderLines->isEmpty() ? ReceiptWorkflow::milli($line->quantity) : $orderLines->sum(fn ($ordered) => ReceiptWorkflow::milli($ordered->quantity));
|
||||
$coverage += max(0, $expected - $received);
|
||||
}
|
||||
$this->check(! $blocked, 'A reposição foi rejeitada. Rever e activar nova versão da parametrização antes de tentar novamente.');
|
||||
// A confirmed outside delivery also covers the need; never double-count an automatic order.
|
||||
$automaticIds = array_column($purchases, 'id');
|
||||
$automaticIds = array_column($tracked, 'id');
|
||||
foreach ($planning->latest('delivery')->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->where('state', 'active')->get() as $delivery) {
|
||||
$order = DB::table('purchase_orders')->where('id', $delivery->purchase_order_id)->first();
|
||||
if ($order !== null && ! in_array($order->requisition_id, $automaticIds, true)) {
|
||||
@@ -203,13 +326,19 @@ continue;
|
||||
$values['message'] = 'Reposição efectiva já em curso; não foi duplicada.';
|
||||
} else {
|
||||
$pack = $basis['policy']['pack_milli'];
|
||||
$quantity = (int) (ceil(max($demand, $basis['policy']['minimum_milli']) / $pack) * $pack);
|
||||
$quantity = intdiv(max($demand, $basis['policy']['minimum_milli']) + $pack - 1, $pack) * $pack;
|
||||
$this->check($quantity <= 1000000000 && $quantity + $free - $unreserved + $coverage <= $basis['policy']['maximum_milli'], 'A embalagem ou quantidade mínima excede o máximo aprovado. Rever a política.');
|
||||
$decimal = intdiv($quantity, 1000).'.'.str_pad((string) ($quantity % 1000), 3, '0', STR_PAD_LEFT);
|
||||
$r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(),
|
||||
$payload = ['cost_center_id' => $rule->data['cost_center_id'], 'priority' => 'normal', 'needed_by' => today()->addDays($rule->data['needed_days'])->toDateString(),
|
||||
'justification' => 'Reposição automática do armazém '.$basis['warehouse']['code'].', artigo '.$basis['item']['code'].'. Origem e utilização preservadas na necessidade de reposição.',
|
||||
'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate']]]]);
|
||||
'items' => [['catalogue_entry_id' => $basis['catalogue']['id'], 'catalogue_version' => $basis['catalogue']['version'], 'purchase_type' => $basis['catalogue']['purchase_type'], 'quantity' => $decimal, 'unit_price' => $rule->data['unit_price'], 'tax_rate' => $rule->data['tax_rate'],
|
||||
...($basis['catalogue']['purchase_type'] === 'spare_part' ? ['equipment_id' => $rule->data['equipment_id'], 'spare_part_id' => $rule->data['spare_part_id']] : [])]]];
|
||||
$form = new StoreRequisitionRequest;
|
||||
$form->setUserResolver(fn () => $requester);
|
||||
$payload = Validator::make($payload, $form->rules(), $form->messages())->validate();
|
||||
$r = app(RequisitionWorkflow::class)->automaticDraft($actor, $rule, $payload);
|
||||
$values['purchases'][] = ['id' => $r->id, 'number' => $r->number, 'quantity_milli' => $quantity, 'rule_id' => $rule->id];
|
||||
$values['requisition_ids'][] = $r->id;
|
||||
$state = 'created';
|
||||
$values['message'] = 'Rascunho criado; o requisitante deve conferir e submeter às aprovações habituais.';
|
||||
}
|
||||
@@ -222,21 +351,24 @@ continue;
|
||||
if ($old !== null && $old->state === $state && $old->data === $values && $old->snapshot === $snapshot) {
|
||||
return;
|
||||
}
|
||||
$this->append($actor, 'auto_need', $old, $state, $values, $snapshot, 'Avaliação automática após alteração da disponibilidade ou parametrização.');
|
||||
$this->append($actor, 'auto_need', $old, $state, $values, $snapshot, app(ReplenishmentRun::class)->tenantId() === null ? 'Avaliação automática após alteração da disponibilidade ou parametrização.' : 'Reavaliação periódica ou recuperação da reposição, sem decisão automática de compra.');
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @param array<string,mixed> $snapshot
|
||||
*/
|
||||
private function append(User $actor, string $kind, ?SupplyRecord $old, string $state, array $data, array $snapshot, string $reason, ?string $key = null, ?string $hash = null): SupplyRecord
|
||||
{
|
||||
$row = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old?->record_key ?? (string) Str::uuid(), 'version' => ($old?->version ?? 0) + 1, 'state' => $state,
|
||||
$row = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => $kind, 'record_key' => $old->record_key ?? (string) Str::uuid(), 'version' => ($old->version ?? 0) + 1, 'state' => $state,
|
||||
'stock_item_id' => $snapshot['item']['id'], 'warehouse_id' => $snapshot['warehouse']['id'], 'created_by' => $actor->id, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason,
|
||||
'operation_key' => $key ?? (string) Str::uuid(), 'input_hash' => $hash ?? hash('sha256', json_encode([$state, $data, $snapshot], JSON_THROW_ON_ERROR)), 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor,$kind.'.'.$state,null,null,['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, $kind.'.'.$state, null, null, ['record_id' => $row->id, 'record_key' => $row->record_key, 'version' => $row->version, 'data' => $data, 'snapshot' => $snapshot, 'reason' => $reason]);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function check(bool $valid,string $message): void
|
||||
private function check(bool $valid, string $message): void
|
||||
{
|
||||
if (! $valid) {
|
||||
throw ValidationException::withMessages(['replenishment' => $message]);
|
||||
|
||||
@@ -24,6 +24,7 @@ class DocumentaryQualificationWorkflow
|
||||
/** Call after the company/actor locks; coordinates with award and quote transactions. */
|
||||
private function process(User $actor, int $requisitionId): Requisition
|
||||
{
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($requisitionId);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
|
||||
$this->invalid('qualification', 'Registe a análise num pedido aprovado, antes da adjudicação. As versões anteriores continuam disponíveis.');
|
||||
@@ -36,6 +37,36 @@ class DocumentaryQualificationWorkflow
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function canParticipate(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
|
||||
&& ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
||||
}
|
||||
|
||||
public function canDefine(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->hasFunctions([PurchasingRole::Procurement]) && $this->canParticipate($actor, $request)
|
||||
&& Quotation::query()->where('requisition_id', $request->id)->exists();
|
||||
}
|
||||
|
||||
public function currentSet(Requisition $request, QualificationSet $set): bool
|
||||
{
|
||||
$latest = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->first();
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
|
||||
return $set->tenant_id === $request->tenant_id && $set->requisition_id === $request->id && $latest?->id === $set->id
|
||||
&& $set->basis_hash === $opening->basis($opening->proposals($request));
|
||||
}
|
||||
|
||||
public function canAssess(User $actor, Requisition $request, QualificationSet $set): bool
|
||||
{
|
||||
return $actor->id !== $request->requester_id && app(ProposalComparison::class)->eligibleRole($actor, $request)
|
||||
&& $this->canParticipate($actor, $request) && $this->currentSet($request, $set);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function define(User $actor, Requisition $request, array $data): QualificationSet
|
||||
{
|
||||
@@ -80,11 +111,10 @@ class DocumentaryQualificationWorkflow
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
$request = $this->process($actor, $set->requisition_id);
|
||||
abort_unless(app(ProposalComparison::class)->eligibleRole($actor, $request), 403);
|
||||
abort_if($actor->id === $request->requester_id, 403);
|
||||
$set = QualificationSet::query()->lockForUpdate()->findOrFail($set->id);
|
||||
$latestSet = QualificationSet::query()->where('requisition_id', $request->id)->latest('revision')->firstOrFail();
|
||||
$opening = app(ProposalOpeningWorkflow::class);
|
||||
if ($set->id !== $latestSet->id || $set->basis_hash !== $opening->basis($opening->proposals($request))) {
|
||||
if (! $this->currentSet($request, $set)) {
|
||||
$this->invalid('qualification', 'Os requisitos ou a base mudaram. As análises anteriores são históricas; registe uma versão actual dos requisitos.');
|
||||
}
|
||||
$quote = Quotation::query()->where('requisition_id', $request->id)->whereKey($data['quotation_id'])->firstOrFail();
|
||||
|
||||
@@ -15,12 +15,47 @@ use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
class ExternalPaymentWorkflow
|
||||
{
|
||||
private function actor(User $user, int $tenantId): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
}
|
||||
|
||||
private function reviewerBaseEligible(User $user, ExternalPayment $payment): bool
|
||||
{
|
||||
return $user->tenant_id === $payment->tenant_id && $payment->created_by !== $user->id
|
||||
&& $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
}
|
||||
|
||||
/** The confirmation still rechecks bank, balance, author and evidence in its transaction. */
|
||||
public function reviewerEligible(User $user, ExternalPayment $payment): bool
|
||||
{
|
||||
if ($payment->status !== 'pending' || ! $this->reviewerBaseEligible($user, $payment)) {
|
||||
return false;
|
||||
}
|
||||
$invoice = Invoice::query()->whereKey($payment->invoice_id)->first();
|
||||
$order = $invoice === null ? null : PurchaseOrder::query()->whereKey($invoice->purchase_order_id)->first();
|
||||
if ($invoice === null || $order === null) {
|
||||
return false;
|
||||
}
|
||||
$request = Requisition::query()->visibleTo($user)->whereKey($order->requisition_id)->first();
|
||||
if ($request === null || app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $user)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
app(SupplierPayables::class)->assertHistoricalReview($user, $invoice);
|
||||
} catch (HttpExceptionInterface $error) {
|
||||
if (! in_array($error->getStatusCode(), [403, 409], true)) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
@@ -100,7 +135,7 @@ class ExternalPaymentWorkflow
|
||||
public function review(User $user, ExternalPayment $payment, string $decision, string $reason): void
|
||||
{
|
||||
$this->actor($user, $payment->tenant_id);
|
||||
abort_if($payment->created_by === $user->id, 403);
|
||||
abort_unless($this->reviewerBaseEligible($user, $payment), 403);
|
||||
if (! in_array($decision, ['confirmed', 'rejected']) || trim($reason) === '') {
|
||||
$this->invalid('Decisão e fundamento obrigatórios.');
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class FormalAdmission
|
||||
|
||||
private function process(User $actor, int $id): Requisition
|
||||
{
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
|
||||
$this->invalid('O regime e as decisões só podem ser registados num pedido aprovado, antes da adjudicação.');
|
||||
@@ -174,7 +175,40 @@ class FormalAdmission
|
||||
|
||||
public function eligible(User $actor, Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $decision->tenant_id === $request->tenant_id
|
||||
&& $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $decision->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function canParticipate(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
|
||||
&& ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
||||
}
|
||||
|
||||
/** Same immutable basis check used before a decision is recorded. */
|
||||
public function currentReviewBasis(Requisition $request, AdmissionAdoption $adoption, AdmissionDecision $decision): bool
|
||||
{
|
||||
if ($adoption->requisition_id !== $request->id || $decision->admission_adoption_id !== $adoption->id) {
|
||||
return false;
|
||||
}
|
||||
$latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
|
||||
$basis = $this->basis($request, $decision->quotation_id);
|
||||
|
||||
return $latest?->id === $decision->id && $basis !== null && $basis['hash'] === $decision->basis_hash;
|
||||
}
|
||||
|
||||
public function canReview(User $actor, Requisition $request, AdmissionDecision $decision): bool
|
||||
{
|
||||
$adoption = $this->adoption($request);
|
||||
|
||||
return $adoption !== null && $this->eligible($actor, $request, $adoption, $decision) && $this->canParticipate($actor, $request)
|
||||
&& $this->currentReviewBasis($request, $adoption, $decision) && $this->status($decision, $adoption) === 'pending'
|
||||
&& ! AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->exists();
|
||||
}
|
||||
|
||||
public function review(User $actor, AdmissionDecision $decision, string $verdict, string $reason): void
|
||||
@@ -189,9 +223,7 @@ class FormalAdmission
|
||||
$request = $this->process($actor, $original->requisition_id);
|
||||
$adoption = $this->adoption($request);
|
||||
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $decision), 403);
|
||||
$latest = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->where('quotation_id', $decision->quotation_id)->latest('revision')->first();
|
||||
$basis = $this->basis($request, $decision->quotation_id);
|
||||
if ($decision->admission_adoption_id !== $adoption->id || $latest?->id !== $decision->id || ! $basis || $basis['hash'] !== $decision->basis_hash) {
|
||||
if (! $this->currentReviewBasis($request, $adoption, $decision)) {
|
||||
$this->invalid('A política do processo, tentativa ou base mudou. Registe outra tentativa e novas conferências.');
|
||||
}
|
||||
$existing = AdmissionReview::query()->where('admission_decision_id', $decision->id)->where('reviewed_by', $actor->id)->first();
|
||||
|
||||
@@ -34,6 +34,7 @@ class FormalRecommendation
|
||||
|
||||
private function process(User $actor, int $id): Requisition
|
||||
{
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403);
|
||||
$request = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($id);
|
||||
if ($request->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $id)->exists() || PurchaseOrder::query()->where('requisition_id', $id)->exists()) {
|
||||
$this->invalid('A recomendação só pode ser preparada num pedido aprovado, antes de adjudicar.');
|
||||
@@ -152,7 +153,40 @@ class FormalRecommendation
|
||||
|
||||
public function eligible(User $actor, Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
return $actor->tenant_id === $request->tenant_id && $adoption->tenant_id === $request->tenant_id && $attempt->tenant_id === $request->tenant_id
|
||||
&& $adoption->requisition_id === $request->id && ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& $actor->hasFunctions($adoption->snapshot['roles']) && ! in_array($actor->id, [$request->requester_id, $attempt->created_by], true) && (! $actor->departmentScoped() || $actor->department_id === $request->department_id);
|
||||
}
|
||||
|
||||
public function canParticipate(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && $request->status === 'approved'
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists()
|
||||
&& ! PurchaseAward::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! PurchaseOrder::query()->where('requisition_id', $request->id)->exists()
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($actor, $request);
|
||||
}
|
||||
|
||||
/** Same immutable basis check used before a decision is recorded. */
|
||||
public function currentReviewBasis(Requisition $request, RecommendationAdoption $adoption, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
if ($adoption->requisition_id !== $request->id || $attempt->recommendation_adoption_id !== $adoption->id) {
|
||||
return false;
|
||||
}
|
||||
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||
$basis = $this->basis($request);
|
||||
|
||||
return $latest?->id === $attempt->id && $basis['ready'] && $basis['hash'] === $attempt->basis_hash;
|
||||
}
|
||||
|
||||
public function canReview(User $actor, Requisition $request, RecommendationAttempt $attempt): bool
|
||||
{
|
||||
$adoption = $this->adoption($request);
|
||||
|
||||
return $adoption !== null && $this->eligible($actor, $request, $adoption, $attempt) && $this->canParticipate($actor, $request)
|
||||
&& $this->currentReviewBasis($request, $adoption, $attempt) && $this->status($attempt, $adoption) === 'pending'
|
||||
&& ! RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->exists();
|
||||
}
|
||||
|
||||
public function review(User $actor, RecommendationAttempt $attempt, string $verdict, string $reason): void
|
||||
@@ -167,9 +201,7 @@ class FormalRecommendation
|
||||
$request = $this->process($actor, $original->requisition_id);
|
||||
$adoption = $this->adoption($request);
|
||||
abort_unless($adoption && $this->eligible($actor, $request, $adoption, $attempt), 403);
|
||||
$latest = RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||
$basis = $this->basis($request);
|
||||
if ($attempt->recommendation_adoption_id !== $adoption->id || $latest?->id !== $attempt->id || ! $basis['ready'] || $basis['hash'] !== $attempt->basis_hash) {
|
||||
if (! $this->currentReviewBasis($request, $adoption, $attempt)) {
|
||||
$this->invalid('A base, adopção ou tentativa mudou. Prepare outra recomendação e novas conferências.');
|
||||
}
|
||||
$existing = RecommendationReview::query()->where('recommendation_attempt_id', $attempt->id)->where('reviewed_by', $actor->id)->first();
|
||||
|
||||
@@ -30,6 +30,12 @@ final class InternalRequisitionPort implements RequisitionPort
|
||||
{
|
||||
return DB::transaction(function () use ($work) {
|
||||
app(ConflictGuard::class)->lockCompany($this->actor);
|
||||
if (app(ReplenishmentRun::class)->tenantId() !== null) {
|
||||
abort_unless($this->automaticRule !== null, 403);
|
||||
$this->actor = app(ReplenishmentRun::class)->lockResponsible($this->actor);
|
||||
|
||||
return $work();
|
||||
}
|
||||
$this->actor = app(CurrentActor::class)->lock($this->actor);
|
||||
abort_unless(app(OrganizationAccess::class)->permissions($this->actor) !== [], 403);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($this->actor), 403);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Internal links over authoritative SGC documents; no copy or workflow transition. */
|
||||
class InternalSupplyTrace
|
||||
{
|
||||
/** @param array<string,mixed> $progress
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function forRequest(User $actor, WarehouseRequest $request, array $progress): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && app(WarehouseWorkdesk::class)->canView($actor, $request), 403);
|
||||
if (! Schema::hasTable('supply_records')) {
|
||||
return ['groups' => [], 'ready' => false];
|
||||
}
|
||||
// Last version that actually mentioned this request: future shared purchases
|
||||
// must not be retroactively attributed to a request already fulfilled.
|
||||
$matched = SupplyRecord::query()->where('kind', 'auto_need')->where('warehouse_id', $request->warehouse_id);
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
$matched->whereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(supply_records.snapshot::jsonb->'origins') o WHERE o->>'warehouse_request_id' = ?)", [(string) $request->id]);
|
||||
} else {
|
||||
$matched->whereRaw("EXISTS (SELECT 1 FROM json_each(supply_records.snapshot, '$.origins') o WHERE json_extract(o.value, '$.warehouse_request_id') = ?)", [$request->id]);
|
||||
}
|
||||
$records = SupplyRecord::query()->whereIn('id', $matched->selectRaw('MAX(id)')->groupBy('record_key'))->orderBy('stock_item_id')->get();
|
||||
$current = app(SupplyPlanning::class)->latest('auto_need')->whereIn('record_key', $records->pluck('record_key'))->get()->keyBy('record_key');
|
||||
$corrections = app(InventoryReversals::class)->corrections('receipt');
|
||||
$groups = [];
|
||||
foreach ($records as $record) {
|
||||
$indexes = [];
|
||||
foreach ($record->snapshot['origins'] as $origin) {
|
||||
$index = $origin['line_index'];
|
||||
if ($origin['warehouse_request_id'] === $request->id && isset($request->snapshot['lines'][$index])
|
||||
&& $request->snapshot['lines'][$index]['item']['id'] === $record->stock_item_id) {
|
||||
$indexes[] = $index;
|
||||
}
|
||||
}
|
||||
if ($indexes === []) {
|
||||
continue;
|
||||
}
|
||||
$latest = $current->get($record->record_key) ?? $record;
|
||||
$ids = array_values(array_unique($record->data['requisition_ids'] ?? []));
|
||||
$purchases = Requisition::query()->visibleTo($actor)->whereIn('id', $ids)->with('steps')->orderBy('id')->get();
|
||||
$rows = [];
|
||||
foreach ($purchases as $purchase) {
|
||||
$orders = [];
|
||||
foreach (PurchaseOrder::query()->visibleTo($actor)->where('requisition_id', $purchase->id)->orderBy('id')->get() as $order) {
|
||||
$receipts = [];
|
||||
foreach (DB::table('goods_receipt_items as i')->join('goods_receipts as g', 'g.id', '=', 'i.goods_receipt_id')
|
||||
->where('i.tenant_id', $actor->tenant_id)->where('g.tenant_id', $actor->tenant_id)->where('g.purchase_order_id', $order->id)
|
||||
->where('g.warehouse_id', $request->warehouse_id)->where('i.stock_item_id', $record->stock_item_id)
|
||||
->orderBy('i.id')->get(['i.id', 'g.id as receipt_id', 'g.received_on', 'i.accepted_milli']) as $receipt) {
|
||||
$receipts[] = ['id' => $receipt->receipt_id, 'item_id' => $receipt->id, 'received_on' => $receipt->received_on,
|
||||
'accepted_milli' => (int) $receipt->accepted_milli, 'reversed' => isset($corrections[$receipt->id])];
|
||||
}
|
||||
$orders[] = ['id' => $order->id, 'number' => $order->number,
|
||||
'href' => $actor->hasFunctions(['admin', 'procurement', 'finance', 'department_head']) ? '/ordens/'.$order->id : null,
|
||||
'receipts' => $receipts, 'effective_received_milli' => array_sum(array_map(fn ($row) => $row['reversed'] ? 0 : $row['accepted_milli'], $receipts))];
|
||||
}
|
||||
$next = $purchase->status === 'pending' ? $purchase->steps->firstWhere('decision', 'pending') : null;
|
||||
$rows[] = ['id' => $purchase->id, 'number' => $purchase->number, 'status' => $purchase->status, 'href' => '/requisicoes/'.$purchase->id,
|
||||
'next_task' => $next === null ? null : (OrganizationAccess::PERMISSIONS[$next->role] ?? $next->role), 'orders' => $orders];
|
||||
}
|
||||
$unreserved = array_sum(array_map(fn ($index) => max(0, $progress['remaining'][$index] - $progress['reserved'][$index]), $indexes));
|
||||
$groups[] = ['item' => $request->snapshot['lines'][$indexes[0]]['item'], 'line_indexes' => array_values(array_unique($indexes)),
|
||||
'origin_need_id' => $record->id, 'current_need_id' => $latest->id, 'need_state' => $latest->state,
|
||||
'message' => $latest->data['message'] ?? '', 'unreserved_milli' => $unreserved,
|
||||
'purchases' => $rows, 'restricted' => $purchases->count() !== count($ids)];
|
||||
}
|
||||
|
||||
return ['ready' => true, 'groups' => $groups];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function purchaseOrigin(User $actor, Requisition $purchase): ?array
|
||||
{
|
||||
$origin = app(AutomaticPurchases::class)->origin($purchase);
|
||||
if ($origin === null) {
|
||||
return null;
|
||||
}
|
||||
$sources = WarehouseRequest::query()->whereIn('id', array_column($origin['origins'], 'warehouse_request_id'))->get()->keyBy('id');
|
||||
$origin['origins'] = array_map(function ($row) use ($sources, $actor) {
|
||||
$request = $sources->get($row['warehouse_request_id']);
|
||||
|
||||
return [...$row, 'href' => $request !== null && app(WarehouseWorkdesk::class)->canView($actor, $request) ? '/stock/requisicoes/'.$request->id : null];
|
||||
}, $origin['origins']);
|
||||
|
||||
return $origin;
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,8 @@ class InventoryReversals
|
||||
$receiptSource = null;
|
||||
if ($data['source_kind'] === 'operation') {
|
||||
$operation = StockOperation::query()->whereKey($data['source_operation_id'])->firstOrFail();
|
||||
$sourceMovementIds=StockMovement::query()->where('stock_operation_id',$operation->id)->pluck('id');
|
||||
$this->check(!StockMovement::query()->where('movement_type','workshop_return_in')->whereIn('snapshot->delivery_movement_id',$sourceMovementIds)->exists(),'action','A entrega já tem devolução física de excedente. Preserve o original e confira a correcção própria.');
|
||||
$this->check(in_array($operation->kind, ['issue', 'transfer', 'adjustment', 'supplier_return', 'write_off'], true), 'data.source_operation_id', 'Seleccione uma operação física; contagens e outros estornos não são estornados por este percurso.');
|
||||
$this->check(! $this->operationReversed($operation->id), 'action', 'Esta operação já tem estorno integral.');
|
||||
$movements = StockMovement::query()->where('stock_operation_id', $operation->id)->orderBy('id')->get();
|
||||
|
||||
@@ -19,7 +19,28 @@ class InvoiceWorkflow
|
||||
{
|
||||
private function access(User $user): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]), 403);
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
}
|
||||
|
||||
private function reviewerBaseEligible(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $user->tenant_id === $invoice->tenant_id && $invoice->created_by !== $user->id
|
||||
&& $user->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($user);
|
||||
}
|
||||
|
||||
/** Pending intervention, not a promise of a matching invoice or budget availability. */
|
||||
public function reviewerEligible(User $user, Invoice $invoice): bool
|
||||
{
|
||||
if ($invoice->status !== 'pending' || ! $this->reviewerBaseEligible($user, $invoice)) {
|
||||
return false;
|
||||
}
|
||||
$order = PurchaseOrder::query()->whereKey($invoice->purchase_order_id)->first();
|
||||
if ($order === null) {
|
||||
return false;
|
||||
}
|
||||
$request = Requisition::query()->visibleTo($user)->whereKey($order->requisition_id)->first();
|
||||
|
||||
return $request !== null && ! app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $user);
|
||||
}
|
||||
|
||||
private function invalid(string $message): never
|
||||
@@ -49,7 +70,10 @@ class InvoiceWorkflow
|
||||
if ($quantity <= 0 || (int) $previous + $quantity > (int) $accepted || (int) $previous + $quantity > ReceiptWorkflow::milli($source->quantity)) {
|
||||
$this->invalid('Quantidade superior à recepção aceite ainda não facturada. Serviços também exigem aceitação.');
|
||||
}
|
||||
if (BigDecimal::of((string) $input['unit_price'])->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $input['tax_rate'])->compareTo((string) $source->tax_rate) !== 0 || trim((string) $input['unit']) !== $source->unit) {
|
||||
$unit = array_key_exists('unit', $input) ? $input['unit'] : $source->unit;
|
||||
$unitPrice = array_key_exists('unit_price', $input) ? $input['unit_price'] : (string) BigDecimal::of($source->unit_price_minor)->dividedBy(100, 2);
|
||||
$taxRate = array_key_exists('tax_rate', $input) ? $input['tax_rate'] : $source->tax_rate;
|
||||
if (BigDecimal::of((string) $unitPrice)->multipliedBy(100)->compareTo($source->unit_price_minor) !== 0 || BigDecimal::of((string) $taxRate)->compareTo((string) $source->tax_rate) !== 0 || trim((string) $unit) !== $source->unit) {
|
||||
$this->invalid('Preço, unidade e IVA devem coincidir exactamente com a ordem. Não há tolerância ou excepção configurada.');
|
||||
}
|
||||
// Cumulative rounding ensures partial invoices sum exactly to the frozen order.
|
||||
@@ -75,6 +99,7 @@ class InvoiceWorkflow
|
||||
return DB::transaction(function () use ($user, $order, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||
$this->access($user);
|
||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
||||
$request = Requisition::query()->findOrFail($order->requisition_id);
|
||||
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
|
||||
@@ -104,10 +129,11 @@ class InvoiceWorkflow
|
||||
public function validate(User $user, Invoice $invoice): void
|
||||
{
|
||||
$this->access($user);
|
||||
abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403);
|
||||
abort_unless($this->reviewerBaseEligible($user, $invoice), 403);
|
||||
DB::transaction(function () use ($user, $invoice) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||
$this->access($user);
|
||||
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
|
||||
if ($invoice->status === 'validated') {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,9 @@ use Illuminate\Support\Facades\Schema;
|
||||
class OrganizationAccess
|
||||
{
|
||||
public const PERMISSIONS = [
|
||||
'warehouse_return_read' => 'Consultar devoluções de excedentes de entregas internas',
|
||||
'warehouse_return_prepare' => 'Declarar excedente não consumido de entrega interna própria',
|
||||
'warehouse_return_confirm' => 'Conferir ou recusar recepção independente do excedente no armazém (exige função de armazém)',
|
||||
'warehouse_request_review' => 'Autorizar ou rejeitar requisições internas ao armazém do departamento, de forma independente',
|
||||
'warehouse_request_deliver' => 'Entregar material de requisições internas autorizadas (exige função de armazém)',
|
||||
'warehouse_request_prepare' => 'Preparar requisições próprias ao armazém com destino de utilização',
|
||||
@@ -70,6 +73,18 @@ class OrganizationAccess
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return $user->purchasing_role === null || $user->purchasing_role === PurchasingRole::Configured ? [] : [$user->purchasing_role->value];
|
||||
}
|
||||
|
||||
return $this->configuredPermissions($user, $context);
|
||||
}
|
||||
|
||||
/** Project the saved profiles without activating them or granting authority.
|
||||
* @return list<string>
|
||||
*/
|
||||
public function configuredPermissions(User $user, ?string $context = null): array
|
||||
{
|
||||
if ($user->tenant_id === null || ! Schema::hasTable('organization_profiles') || ! Schema::hasTable('organization_memberships')) {
|
||||
return [];
|
||||
}
|
||||
$contextKey = $context;
|
||||
if ($contextKey === null && app()->bound('request') && request()->hasSession() && WorkContext::area(request()->path()) !== null && app(WorkContext::class)->enforced($user)) {
|
||||
$contextKey = app(WorkContext::class)->active(request());
|
||||
@@ -108,6 +123,15 @@ class OrganizationAccess
|
||||
if (! $this->active($user->tenant_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->configuredMaintenanceOnly($user);
|
||||
}
|
||||
|
||||
public function configuredMaintenanceOnly(User $user): bool
|
||||
{
|
||||
if ($user->tenant_id === null || ! Schema::hasTable('organization_memberships')) {
|
||||
return false;
|
||||
}
|
||||
$valid = DB::table('organization_memberships')->where('tenant_id', $user->tenant_id)->where('user_id', $user->id)->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
|
||||
|
||||
return (clone $valid)->where('maintenance', true)->exists() && ! (clone $valid)->where('maintenance', false)->exists();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Diagnostic only: reads exactly the authority sources used by activation. */
|
||||
class OrganizationActivationPreview
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function build(User $actor): array
|
||||
{
|
||||
$configuration = app(OrganizationConfiguration::class);
|
||||
$actor = $configuration->administrator($actor);
|
||||
$ready = Schema::hasTable('organization_access') && Schema::hasTable('organization_profiles') && Schema::hasTable('organization_memberships');
|
||||
if (! $ready) {
|
||||
return ['ready' => false, 'users' => []];
|
||||
}
|
||||
$access = app(OrganizationAccess::class);
|
||||
$context = app(WorkContext::class);
|
||||
$company = Tenant::query()->findOrFail($actor->tenant_id);
|
||||
$admins = $configuration->permanentAdministrators($actor->tenant_id);
|
||||
$active = $access->active($actor->tenant_id);
|
||||
$users = [];
|
||||
foreach (User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->orderBy('id')->get() as $user) {
|
||||
$current = $access->permissions($user);
|
||||
$configured = $access->configuredPermissions($user);
|
||||
$enforced = $context->enforced($user);
|
||||
$users[] = ['id' => $user->id, 'name' => $user->name, 'current_permissions' => $current, 'configured_permissions' => $configured,
|
||||
'added' => array_values(array_diff($configured, $current)), 'removed' => array_values(array_diff($current, $configured)),
|
||||
'current_can_request' => $access->canRequest($user), 'configured_can_request' => in_array('requester', $configured, true),
|
||||
'configured_maintenance_only' => $access->configuredMaintenanceOnly($user), 'permanent_admin' => in_array($user->id, $admins, true),
|
||||
'current_contexts' => $enforced ? $context->available($user) : null,
|
||||
'configured_contexts' => $enforced ? $context->configuredAvailable($user) : null,
|
||||
'loses_access' => $current !== [] && $configured === []];
|
||||
}
|
||||
|
||||
return ['ready' => true, 'active' => $active, 'version' => DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->value('version') ?? 1,
|
||||
'checked_at' => now()->toIso8601String(), 'users' => $users, 'permanent_administrators' => count($admins),
|
||||
'provisional_rules' => $company->rules_provisional, 'can_activate' => ! $active && count($admins) >= 2 && ! $company->rules_provisional,
|
||||
'losing_access' => count(array_filter($users, fn ($row) => $row['loses_access'])),
|
||||
'context_labels' => array_map(fn ($option) => $option['areaLabel'].' · '.$option['label'], WorkContext::OPTIONS)];
|
||||
}
|
||||
}
|
||||
@@ -161,16 +161,25 @@ class OrganizationConfiguration
|
||||
|
||||
private function twoAdministrators(int $tenant): void
|
||||
{
|
||||
$count = 0;
|
||||
if (count($this->permanentAdministrators($tenant)) < 2) {
|
||||
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** Same permanent administrator rule for activation, updates and their preview.
|
||||
* @return list<int>
|
||||
*/
|
||||
public function permanentAdministrators(int $tenant): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach (User::query()->where('tenant_id', $tenant)->get() as $user) {
|
||||
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $tenant)->where('p.tenant_id', $tenant)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions');
|
||||
if ($profiles->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
|
||||
$count++;
|
||||
$ids[] = $user->id;
|
||||
}
|
||||
}
|
||||
if ($count < 2) {
|
||||
throw ValidationException::withMessages(['administrators' => 'Mantenha pelo menos dois administradores permanentes da empresa; manutenção não conta para esse mínimo.']);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public function initialise(User $first, User $second): void
|
||||
|
||||
@@ -28,6 +28,7 @@ class PurchaseWorkflow
|
||||
return DB::transaction(function () use ($user, $requisition, $data) {
|
||||
$this->guard->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]) && $user->id !== $requisition->requester_id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
@@ -64,7 +65,8 @@ class PurchaseWorkflow
|
||||
|
||||
public function canDecide(User $user, PurchaseAward $award, Requisition $requisition): bool
|
||||
{
|
||||
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || in_array($user->id, [$requisition->requester_id, $award->created_by])) {
|
||||
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || $requisition->tenant_id !== $award->tenant_id || $requisition->id !== $award->requisition_id
|
||||
|| in_array($user->id, [$requisition->requester_id, $award->created_by]) || app(OrganizationAccess::class)->maintenanceOnly($user) || $this->pendingCancellation($award)) {
|
||||
return false;
|
||||
}
|
||||
$steps = $award->approvals()->get();
|
||||
@@ -75,6 +77,17 @@ class PurchaseWorkflow
|
||||
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
/** Eligibility to confer issuance; currentBasis and all final checks still run on issue. */
|
||||
public function canIssue(User $user, PurchaseAward $award, Requisition $requisition): bool
|
||||
{
|
||||
return $user->tenant_id === $award->tenant_id && $requisition->tenant_id === $award->tenant_id && $requisition->id === $award->requisition_id
|
||||
&& $award->status === 'approved' && $user->hasFunctions([PurchasingRole::Procurement])
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($user) && ! $this->pendingCancellation($award)
|
||||
&& ! PurchaseOrder::query()->where('requisition_id', $requisition->id)->exists()
|
||||
&& Requisition::query()->visibleTo($user)->whereKey($requisition->id)->exists()
|
||||
&& ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
public function decide(User $user, PurchaseAward $award, string $decision, ?string $comment): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $award, $decision, $comment) {
|
||||
@@ -103,6 +116,7 @@ class PurchaseWorkflow
|
||||
return DB::transaction(function () use ($user, $award) {
|
||||
$this->guard->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||
abort_if(app(OrganizationAccess::class)->maintenanceOnly($user), 403);
|
||||
abort_unless($user->hasFunctions([PurchasingRole::Procurement]), 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
@@ -313,8 +327,13 @@ class PurchaseWorkflow
|
||||
|
||||
private function assertNoPendingCancellation(PurchaseAward $award): void
|
||||
{
|
||||
if (AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists()) {
|
||||
if ($this->pendingCancellation($award)) {
|
||||
throw ValidationException::withMessages(['cancellation' => 'Existe pedido de cancelamento pendente. Aguarde a decisão antes de aprovar ou emitir.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function pendingCancellation(PurchaseAward $award): bool
|
||||
{
|
||||
return AwardCancellation::query()->where('purchase_award_id', $award->id)->where('status', 'pending')->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/** Shared read-only authority for quotation forms, tasks and commands. */
|
||||
class QuotationParticipation
|
||||
{
|
||||
private function assertActor(User $actor, Requisition $request, bool $evaluation): void
|
||||
{
|
||||
$roles = $evaluation ? [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead] : [PurchasingRole::Procurement];
|
||||
abort_unless($actor->tenant_id === $request->tenant_id && $request->status === 'approved' && $actor->hasFunctions($roles)
|
||||
&& ! app(OrganizationAccess::class)->maintenanceOnly($actor)
|
||||
&& (! $evaluation || $actor->id !== $request->requester_id)
|
||||
&& Requisition::query()->visibleTo($actor)->whereKey($request->id)->exists(), 403);
|
||||
}
|
||||
|
||||
private function assertOpen(Requisition $request, bool $evaluation): void
|
||||
{
|
||||
if (PurchaseAward::query()->where('requisition_id', $request->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => $evaluation
|
||||
? 'As avaliações estão fechadas pela adjudicação deste processo.'
|
||||
: 'As propostas estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertPrepare(User $actor, Requisition $request): void
|
||||
{
|
||||
$this->assertActor($actor, $request, false);
|
||||
$this->assertOpen($request, false);
|
||||
app(ConflictGuard::class)->assertParticipation($actor, $request);
|
||||
}
|
||||
|
||||
public function assertEvaluateProcess(User $actor, Requisition $request): void
|
||||
{
|
||||
$this->assertActor($actor, $request, true);
|
||||
$this->assertOpen($request, true);
|
||||
app(ConflictGuard::class)->assertParticipation($actor, $request);
|
||||
}
|
||||
|
||||
public function assertEvaluate(User $actor, Requisition $request, Quotation $quote): void
|
||||
{
|
||||
$this->assertEvaluateProcess($actor, $request);
|
||||
abort_unless($quote->tenant_id === $request->tenant_id && $quote->requisition_id === $request->id, 403);
|
||||
app(ConflictGuard::class)->assertProcess($request, $quote->supplier_id, $actor);
|
||||
if ($quote->valid_until < today() || ! $quote->supplier?->eligible()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
|
||||
}
|
||||
if ($quote->evaluations()->where('evaluator_id', $actor->id)->exists()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param callable():void $check */
|
||||
private function eligible(callable $check): bool
|
||||
{
|
||||
try {
|
||||
$check();
|
||||
|
||||
return true;
|
||||
} catch (ValidationException) {
|
||||
return false;
|
||||
} catch (HttpException $error) {
|
||||
if ($error->getStatusCode() !== 403) {
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function canPrepare(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $this->eligible(fn () => $this->assertPrepare($actor, $request));
|
||||
}
|
||||
|
||||
public function canEvaluateProcess(User $actor, Requisition $request): bool
|
||||
{
|
||||
return $this->eligible(fn () => $this->assertEvaluateProcess($actor, $request));
|
||||
}
|
||||
|
||||
public function canEvaluate(User $actor, Requisition $request, Quotation $quote): bool
|
||||
{
|
||||
return $this->eligible(fn () => $this->assertEvaluate($actor, $request, $quote));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Called only within ReceiptWorkflow's company-locked transaction. */
|
||||
final class ReceiptStockMovement
|
||||
{
|
||||
public function for(User $actor): \Tempo\Inventory\ReceiptMovementWriter
|
||||
{
|
||||
$root=base_path('packages/inventory-write/releases/0.1.0');
|
||||
if(!is_file($root.'/manifest.json')||!hash_equals('f0b99e591f9a4df135e685f64ceffad77bed259222debe0f34c20f95e3cad800',hash_file('sha256',$root.'/manifest.json')))
|
||||
throw new \LogicException('Pacote de recepção incompatível; não executar outro escritor.');
|
||||
$manifest=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
foreach(['bootstrap.php','src/ReceiptMovementWriter.php'] as $file)
|
||||
if(!is_file($root.'/'.$file)||!hash_equals($manifest['files'][$file]??'',hash_file('sha256',$root.'/'.$file)))throw new \LogicException('Pacote de recepção alterado.');
|
||||
require_once $root.'/bootstrap.php';
|
||||
$schema=match(DB::connection()->getDriverName()){
|
||||
'pgsql'=>'public','sqlite'=>'main','mysql','mariadb'=>DB::connection()->getDatabaseName(),
|
||||
default=>throw new \LogicException('Driver de inventário não suportado.'),
|
||||
};
|
||||
return new \Tempo\Inventory\ReceiptMovementWriter(DB::connection(),$schema,(int)$actor->tenant_id,(int)$actor->id,
|
||||
function () use ($actor) {app(CurrentActor::class)->lock($actor,[PurchasingRole::Warehouse]);});
|
||||
}
|
||||
}
|
||||
@@ -216,12 +216,10 @@ class ReceiptWorkflow
|
||||
foreach ($lines as $line) {
|
||||
$item = $receipt->items()->create($line);
|
||||
if ($line['accepted_milli'] > 0 && $line['snapshot']['physical']) {
|
||||
$movement = 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],
|
||||
]);
|
||||
$writer = app(ReceiptStockMovement::class)->for($user);
|
||||
$basis = $writer->preview($item->id);
|
||||
$result = $writer->write($item->id, $basis['source_hash']);
|
||||
$movement = StockMovement::query()->findOrFail($result['movement_id']);
|
||||
$sourceInput = collect($inputs)->firstWhere('purchase_order_item_id', $line['purchase_order_item_id']);
|
||||
app(InventoryLots::class)->receive($user, $movement, $sourceInput['lots'] ?? [], $data['received_on']);
|
||||
app(AutomaticPurchases::class)->signal($user, $line['stock_item_id'], $warehouse->id);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
/** One durable, coalescing work item per tenant/article/warehouse. No leased human identity. */
|
||||
class ReplenishmentQueue
|
||||
{
|
||||
/** Read-only shared status for the workdesk and pilot diagnostics.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function status(User $actor): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id, 403);
|
||||
$scheduler = null;
|
||||
$recent = false;
|
||||
$path = storage_path('framework/replenishment-status.json');
|
||||
if (app()->environment('payable-testing') && is_file($path)) {
|
||||
$text = file_get_contents($path);
|
||||
$data = $text === false ? null : json_decode($text, true);
|
||||
if (is_array($data) && is_string($data['finished_at'] ?? null) && in_array($data['result'] ?? null, ['passed', 'retry_pending'], true)) {
|
||||
try {
|
||||
$finished = CarbonImmutable::parse($data['finished_at']);
|
||||
$recent = $finished->greaterThanOrEqualTo(now()->subMinutes(3)) && $finished->lessThanOrEqualTo(now()->addMinute());
|
||||
$scheduler = array_intersect_key($data, array_flip(['started_at', 'finished_at', 'pid', 'scope', 'result', 'exit_code']));
|
||||
} catch (InvalidFormatException) {
|
||||
} // Optional telemetry must not prevent operational consultation.
|
||||
}
|
||||
}
|
||||
|
||||
return ['recoveryReady' => $this->ready(), 'scanMinutes' => config('replenishment.scan_minutes'), 'schedulerStatus' => $scheduler, 'schedulerRecent' => $recent,
|
||||
'work' => $this->ready() ? DB::table('replenishment_work')->where('tenant_id', $actor->tenant_id)->selectRaw('COUNT(*) as total, SUM(CASE WHEN generation > processed_generation THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN failures > 0 THEN 1 ELSE 0 END) as failed, MAX(last_run_at) as last_run_at')->first() : null];
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return config('work_context.composition') === 'sgc' && Schema::hasTable('replenishment_work');
|
||||
}
|
||||
|
||||
public function enqueue(User $actor, int $item, int $warehouse): int
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $item, $warehouse) {
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
StockItem::query()->whereKey($item)->firstOrFail();
|
||||
Warehouse::query()->whereKey($warehouse)->firstOrFail();
|
||||
|
||||
return $this->touch($actor->tenant_id, $item, $warehouse, $actor->id, true);
|
||||
});
|
||||
}
|
||||
|
||||
private function touch(int $tenant, int $item, int $warehouse, int $responsible, bool $signal): int
|
||||
{
|
||||
$row = DB::table('replenishment_work')->where('tenant_id', $tenant)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->first();
|
||||
if ($row === null) {
|
||||
return DB::table('replenishment_work')->insertGetId(['tenant_id' => $tenant, 'stock_item_id' => $item, 'warehouse_id' => $warehouse, 'responsible_id' => $responsible, 'generation' => 1, 'processed_generation' => 0, 'due_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
if ($signal) {
|
||||
DB::table('replenishment_work')->where('id', $row->id)->update(['generation' => $row->generation + 1, 'responsible_id' => $responsible, 'due_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
return $row->id;
|
||||
}
|
||||
|
||||
/** CLI discovery recovers pre-installation interruptions and adds approved policies without stock events. */
|
||||
public function discover(): void
|
||||
{
|
||||
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
||||
if (! $this->ready()) {
|
||||
return;
|
||||
}
|
||||
$latest = DB::table('supply_records')->selectRaw('MAX(id)')->whereIn('kind', ['policy', 'auto_rule', 'auto_need'])->groupBy('tenant_id', 'kind', 'record_key');
|
||||
DB::table('supply_records')->whereIn('id', $latest)->whereNotNull('stock_item_id')->whereNotNull('warehouse_id')->orderBy('id')->chunkById(100, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$data = json_decode($row->data, true, 64, JSON_THROW_ON_ERROR);
|
||||
if ($row->kind === 'policy' && ($row->state !== 'approved' || ($data['purpose'] ?? null) !== 'internal')) {
|
||||
continue;
|
||||
}
|
||||
if ($row->kind === 'auto_rule' && $row->state !== 'approved') {
|
||||
continue;
|
||||
}
|
||||
$this->discoverPair($row->tenant_id, $row->stock_item_id, $row->warehouse_id, $row->created_by);
|
||||
}
|
||||
});
|
||||
DB::table('warehouse_requests')->where('work_context_key', 'procurement.internal')->orderBy('id')->chunkById(100, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
foreach (json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)['lines'] as $line) {
|
||||
$this->discoverPair($row->tenant_id, $line['item']['id'], $row->warehouse_id, $row->created_by);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function enabled(int $tenant): bool
|
||||
{
|
||||
$company = Tenant::query()->findOrFail($tenant);
|
||||
|
||||
return $company->work_context_policy === null || (in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$tenant, []), true));
|
||||
}
|
||||
|
||||
private function discoverPair(int $tenant, int $item, int $warehouse, int $responsible): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $item, $warehouse, $responsible) {
|
||||
Tenant::query()->whereKey($tenant)->lockForUpdate()->firstOrFail();
|
||||
if (! $this->enabled($tenant)) {
|
||||
return;
|
||||
}
|
||||
if (! DB::table('users')->where('tenant_id', $tenant)->where('id', $responsible)->exists() || ! DB::table('stock_items')->where('tenant_id', $tenant)->where('id', $item)->exists() || ! DB::table('warehouses')->where('tenant_id', $tenant)->where('id', $warehouse)->exists()) {
|
||||
return;
|
||||
}
|
||||
$this->touch($tenant, $item, $warehouse, $responsible, false);
|
||||
});
|
||||
}
|
||||
|
||||
public function interactive(User $actor, int $id): bool
|
||||
{
|
||||
return $this->process($id, $actor);
|
||||
}
|
||||
|
||||
public function scheduled(int $id): bool
|
||||
{
|
||||
return app(ReplenishmentRun::class)->execute($id, fn () => $this->process($id, null));
|
||||
}
|
||||
|
||||
private function process(int $id, ?User $actor): bool
|
||||
{
|
||||
$work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
||||
if ($actor !== null) {
|
||||
abort_unless($actor->tenant_id === $work->tenant_id && auth()->id() === $actor->id, 403);
|
||||
}
|
||||
$generation = null;
|
||||
try {
|
||||
DB::transaction(function () use ($id, $actor, &$generation) {
|
||||
$first = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
||||
Tenant::query()->whereKey($first->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
if (CarbonImmutable::parse($row->due_at)->isFuture() && $row->generation === $row->processed_generation) {
|
||||
return;
|
||||
}
|
||||
$generation = $row->generation;
|
||||
if ($actor === null) {
|
||||
$actor = User::query()->where('tenant_id', $row->tenant_id)->whereKey($row->responsible_id)->firstOrFail();
|
||||
}
|
||||
if ($this->enabled($row->tenant_id)) {
|
||||
app(AutomaticPurchases::class)->evaluate($actor, $row->stock_item_id, $row->warehouse_id);
|
||||
}
|
||||
$latest = app(SupplyPlanning::class)->latest('auto_need')->where('stock_item_id', $row->stock_item_id)->where('warehouse_id', $row->warehouse_id)->first();
|
||||
DB::table('replenishment_work')->where('id', $id)->update(['processed_generation' => $generation, 'attempts' => $row->attempts + 1, 'failures' => 0, 'last_outcome' => $this->enabled($row->tenant_id) ? ($latest->state ?? 'no_need') : 'context_disabled', 'last_error' => null, 'last_run_at' => now(), 'due_at' => now()->addMinutes(config('replenishment.scan_minutes')), 'updated_at' => now()]);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (Throwable $error) {
|
||||
report($error);
|
||||
// The failed transaction rolled back both draft and checkpoint. Keep the durable signal.
|
||||
DB::transaction(function () use ($id, $work, $generation) {
|
||||
Tenant::query()->whereKey($work->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$row = DB::table('replenishment_work')->where('id', $id)->lockForUpdate()->firstOrFail();
|
||||
$fields = ['attempts' => $row->attempts + 1, 'failures' => $row->failures + 1, 'last_outcome' => 'error', 'last_error' => 'Avaliação interrompida. Recuperação automática pendente; consulte o registo técnico.', 'updated_at' => now()];
|
||||
if ($generation !== null && $generation === $row->generation) {
|
||||
$fields['due_at'] = now()->addSeconds(min(3600, 60 * (2 ** min($row->failures, 6))));
|
||||
}
|
||||
DB::table('replenishment_work')->where('id', $id)->update($fields);
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{processed:int,failed:int} */
|
||||
public function drain(int $limit): array
|
||||
{
|
||||
abort_unless(app()->runningInConsole() && ! auth()->check() && ! request()->hasSession(), 403);
|
||||
$result = ['processed' => 0, 'failed' => 0];
|
||||
foreach (DB::table('replenishment_work')->where('due_at', '<=', now())->orderBy('due_at')->orderBy('id')->limit($limit)->pluck('id') as $id) {
|
||||
$result[$this->scheduled($id) ? 'processed' : 'failed']++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** A narrow CLI execution scope, never a session, account or general authority bypass. */
|
||||
final class ReplenishmentRun
|
||||
{
|
||||
private ?int $tenant = null;
|
||||
|
||||
private ?int $work = null;
|
||||
|
||||
private ?int $responsible = null;
|
||||
|
||||
public function tenantId(): ?int
|
||||
{
|
||||
return $this->tenant;
|
||||
}
|
||||
|
||||
public function lockResponsible(User $user): User
|
||||
{
|
||||
abort_unless($this->tenant !== null && $user->tenant_id === $this->tenant && DB::transactionLevel() > 0 && ! auth()->check(), 403);
|
||||
// A stock writer may have updated the signal while this worker waited for the company lock.
|
||||
$responsible = DB::table('replenishment_work')->where('tenant_id', $this->tenant)->where('id', $this->work)->value('responsible_id');
|
||||
abort_unless($user->id === $responsible, 403);
|
||||
$this->responsible = $responsible;
|
||||
|
||||
return User::query()->where('tenant_id', $this->tenant)->whereKey($user->id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function evidence(): array
|
||||
{
|
||||
return ['mode' => 'scheduled_replenishment', 'work_id' => $this->work, 'tenant_id' => $this->tenant, 'responsible_user_id' => $this->responsible, 'attribution' => 'responsible_for_trigger_or_rule; not_an_interactive_action'];
|
||||
}
|
||||
|
||||
public function execute(int $id, callable $callback): mixed
|
||||
{
|
||||
abort_unless(config('work_context.composition') === 'sgc' && app()->runningInConsole() && ! auth()->check() && $this->tenant === null && (! request()->hasSession()), 403);
|
||||
$work = DB::table('replenishment_work')->where('id', $id)->firstOrFail();
|
||||
$this->tenant = $work->tenant_id;
|
||||
$this->work = $work->id;
|
||||
$this->responsible = $work->responsible_id;
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->tenant = null;
|
||||
$this->work = null;
|
||||
$this->responsible = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\RequisitionItem;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -37,8 +38,11 @@ class RequisitionCatalogue
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
abort_unless($this->canManage($actor), 403);
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'stock_basis' => 'required|string|size:64', 'category' => 'required|string|max:100',
|
||||
'purchase_type' => 'required|in:consumable,other', 'published' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
'purchase_type' => 'required|in:consumable,other,spare_part', 'published' => 'required|boolean', 'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
$stock = StockItem::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($stockId);
|
||||
if ($data['purchase_type'] === 'spare_part' && ! SparePart::query()->where('stock_item_id', $stock->id)->exists()) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'Registe a peça e os equipamentos compatíveis antes de a publicar como peça.']);
|
||||
}
|
||||
$entry = DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('stock_item_id', $stock->id)->lockForUpdate()->first();
|
||||
if ((int) $data['version'] !== (int) ($entry->version ?? 0) || $data['stock_basis'] !== $this->basis($stock)) {
|
||||
throw ValidationException::withMessages(['version' => 'O artigo ou a publicação mudou. Actualize e confira antes de guardar.']);
|
||||
@@ -119,6 +123,10 @@ class RequisitionCatalogue
|
||||
$snapshot = ['entry_id' => $entry->id, 'version' => $entry->version, ...$this->sourceDescriptor($entry)];
|
||||
}
|
||||
|
||||
if ($snapshot['purchase_type'] === 'spare_part' && ! SparePart::query()->whereKey($line['spare_part_id'] ?? 0)->where('stock_item_id', $snapshot['stock_item_id'])->exists()) {
|
||||
throw ValidationException::withMessages(['items' => 'A peça seleccionada não corresponde ao artigo publicado no catálogo.']);
|
||||
}
|
||||
|
||||
return [...$line, 'catalogue_entry_id' => $snapshot['entry_id'], 'catalogue_snapshot' => $snapshot,
|
||||
'description' => $snapshot['description'], 'unit' => $snapshot['unit'], 'category' => $snapshot['category'], 'purchase_type' => $snapshot['purchase_type']];
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ class RequisitionWorkflow
|
||||
unset($data['operation_key'], $data['process_version']);
|
||||
$result = $this->execute($user, 'save', $existing?->id, $version, $key, $data);
|
||||
|
||||
return Requisition::query()->whereKey($result['id'])->firstOrFail();
|
||||
$record = Requisition::query()->whereKey($result['id'])->firstOrFail();
|
||||
if ($existing !== null) {
|
||||
app(AutomaticPurchases::class)->requisitionChanged($user, $record);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +56,7 @@ class RequisitionWorkflow
|
||||
{
|
||||
return DB::transaction(function () use ($user, $data, $existing, $automaticRule) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = app(CurrentActor::class)->lock($user);
|
||||
$user = $automaticRule === null ? app(CurrentActor::class)->lock($user) : app(AutomaticPurchases::class)->lockExecutor($user);
|
||||
$executor = $user;
|
||||
if ($automaticRule !== null) {
|
||||
abort_unless($existing === null, 403);
|
||||
@@ -116,23 +121,27 @@ class RequisitionWorkflow
|
||||
});
|
||||
}
|
||||
|
||||
/** Only an independently activated rule may create a draft on behalf of its designated requester. */
|
||||
/** Only an independently activated rule may create a draft on behalf of its designated requester.
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public function automaticDraft(User $executor, SupplyRecord $rule, array $data): Requisition
|
||||
{
|
||||
$process = new InternalRequisitions(new InternalRequisitionPort($executor, fn (array $input, ?Requisition $existing) => $this->persistDraft($executor, $input, $existing, $rule), $rule));
|
||||
$result = $process->command('save', null, null, null, $data);
|
||||
|
||||
return Requisition::query()->findOrFail($result['id']);
|
||||
return Requisition::query()->whereKey($result['id'])->firstOrFail();
|
||||
}
|
||||
|
||||
public function submit(User $user, Requisition $requisition, ?string $operationKey = null, ?int $version = null): void
|
||||
{
|
||||
$this->execute($user, 'submit', $requisition->id, $version, $operationKey);
|
||||
app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
|
||||
}
|
||||
|
||||
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment, ?string $operationKey = null, ?int $version = null): void
|
||||
{
|
||||
$this->execute($user, 'decide', $requisition->id, $version, $operationKey, ['decision' => $decision, 'comment' => $comment]);
|
||||
app(AutomaticPurchases::class)->requisitionChanged($user, $requisition);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $before
|
||||
@@ -143,7 +152,7 @@ class RequisitionWorkflow
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'user_id' => $user->id,
|
||||
'requisition_id' => $requisition?->id, 'action' => $action,
|
||||
'ip_address' => request()->ip(), 'before' => $before, 'after' => $after,
|
||||
'ip_address' => app(ReplenishmentRun::class)->tenantId() === null ? request()->ip() : null, 'before' => $before, 'after' => $after + (app(ReplenishmentRun::class)->tenantId() === null ? [] : ['execution' => app(ReplenishmentRun::class)->evidence()]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class StockIssueMovement
|
||||
{
|
||||
public function for(User $actor): \Tempo\Inventory\IssueMovementWriter
|
||||
{
|
||||
$root=base_path('packages/inventory-issue/releases/0.1.0');
|
||||
if(!is_file($root.'/manifest.json')||!hash_equals('3c24d9e9fb99bfbb274077495c3a42ddb7eccab06b5c6a956cd739b2bc9b56f0',hash_file('sha256',$root.'/manifest.json')))throw new \LogicException('Pacote de saída incompatível.');
|
||||
$m=json_decode(file_get_contents($root.'/manifest.json'),true,512,JSON_THROW_ON_ERROR);
|
||||
foreach(['bootstrap.php','src/IssueMovementWriter.php'] as $f)if(!is_file($root.'/'.$f)||!hash_equals($m['files'][$f]??'',hash_file('sha256',$root.'/'.$f)))throw new \LogicException('Pacote de saída alterado.');
|
||||
require_once $root.'/bootstrap.php';
|
||||
$schema=match(DB::connection()->getDriverName()){'pgsql'=>'public','sqlite'=>'main','mysql','mariadb'=>DB::connection()->getDatabaseName(),default=>throw new \LogicException('Driver não suportado.')};
|
||||
return new \Tempo\Inventory\IssueMovementWriter(DB::connection(),$schema,(int)$actor->tenant_id,(int)$actor->id,function()use($actor){app(CurrentActor::class)->lock($actor,[PurchasingRole::Warehouse]);});
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,10 @@ class StockWorkflow
|
||||
|
||||
private function movement(User $actor, StockOperation $operation, int $warehouse, int $quantity, string $type): StockMovement
|
||||
{
|
||||
if($type==='issue'&&$operation->kind==='issue'){
|
||||
$writer=app(StockIssueMovement::class)->for($actor);$result=$writer->write($operation->id,$writer->preview($operation->id));
|
||||
return StockMovement::query()->findOrFail($result['movement_id']);
|
||||
}
|
||||
return 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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,24 @@ class SupplyPlanning
|
||||
return $actor->hasFunctions($functions) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
}
|
||||
|
||||
public function independentReviewer(User $actor, SupplyRecord $record): bool
|
||||
{
|
||||
return $actor->tenant_id === $record->tenant_id && $this->allows($actor, 'supply_review')
|
||||
&& ! in_array($actor->id, $record->snapshot['contributors'] ?? [$record->created_by], true);
|
||||
}
|
||||
|
||||
/** Eligibility to confer, not a promise that changed sources permit approval. */
|
||||
public function canReview(User $actor, SupplyRecord $record, string $action = 'approve'): bool
|
||||
{
|
||||
if (! in_array($record->kind, ['agreement', 'policy'], true) || ! in_array($action, ['approve', 'return', 'suspend'], true)
|
||||
|| ! $this->independentReviewer($actor, $record)) {
|
||||
return false;
|
||||
}
|
||||
$latest = $this->latest($record->kind)->where('record_key', $record->record_key)->first();
|
||||
|
||||
return $latest?->id === $record->id && ($action === 'suspend' ? $record->state === 'approved' : in_array($record->state, ['draft', 'returned'], true));
|
||||
}
|
||||
|
||||
/** @return Builder<SupplyRecord> */
|
||||
public function latest(string $kind)
|
||||
{
|
||||
@@ -115,7 +133,7 @@ class SupplyPlanning
|
||||
$data = $old->data;
|
||||
if ($kind !== 'import') {
|
||||
$this->check(in_array($action, ['approve', 'return', 'suspend'], true), 'action', 'Acção incompatível com este registo.');
|
||||
$this->check(! in_array($actor->id, $old->snapshot['contributors'] ?? [$old->created_by], true), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.');
|
||||
$this->check($this->independentReviewer($actor, $old), 'action', 'A conferência exige uma pessoa que não tenha preparado esta versão ou as anteriores.');
|
||||
$this->check($action === 'suspend' ? $old->state === 'approved' : in_array($old->state, ['draft', 'returned'], true), 'action', 'Estado incompatível.');
|
||||
$state = ['approve' => 'approved', 'return' => 'returned', 'suspend' => 'suspended'][$action];
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** Pull boundary over committed facts; never posts another stock movement. */
|
||||
class WarehouseExchange
|
||||
{
|
||||
public const SCHEMA = 'sgc.warehouse.fulfilment-feed.v1';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function page(User $actor, ?string $cursor = null, int $limit = 50): array
|
||||
{
|
||||
abort_unless(auth('web')->id() === $actor->id && $actor->tenant_id !== null
|
||||
&& $actor->hasFunctions(['admin', 'warehouse_request_read', 'warehouse_request_prepare', 'warehouse_request_review', 'warehouse_request_deliver']), 403);
|
||||
$contexts = app(WorkContext::class);
|
||||
$enforced = $contexts->enforced($actor);
|
||||
abort_unless(! $enforced || $contexts->active(request()) === 'procurement.internal', 403);
|
||||
abort_unless(Schema::hasTable('warehouse_request_events'), 503, 'Instalação das entregas pendente.');
|
||||
abort_unless($limit >= 1 && $limit <= 100, 422);
|
||||
$permissions = app(OrganizationAccess::class)->permissions($actor);
|
||||
sort($permissions);
|
||||
$permissionFingerprint = hash('sha256', json_encode($permissions, JSON_THROW_ON_ERROR));
|
||||
$scope = [self::SCHEMA, $actor->tenant_id, $actor->id, $enforced ? 'procurement.internal' : 'legacy', $actor->department_id, $permissionFingerprint];
|
||||
$after = 0;
|
||||
if ($cursor !== null) {
|
||||
try {
|
||||
$decoded = json_decode(Crypt::decryptString($cursor), true, 8, JSON_THROW_ON_ERROR);
|
||||
} catch (DecryptException|\JsonException $e) {
|
||||
throw ValidationException::withMessages(['cursor' => 'Cursor inválido. Reinicie a leitura.']);
|
||||
}
|
||||
if (! is_array($decoded) || ($decoded['scope'] ?? null) !== $scope
|
||||
|| ! is_int($decoded['after'] ?? null) || $decoded['after'] < 0) {
|
||||
throw ValidationException::withMessages(['cursor' => 'O cursor pertence a outro âmbito. Reinicie a leitura.']);
|
||||
}
|
||||
$after = $decoded['after'];
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $enforced, $scope, $after, $limit) {
|
||||
if (DB::getDriverName() === 'pgsql' && DB::transactionLevel() === 1) {
|
||||
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
}
|
||||
$requests = app(WarehouseWorkdesk::class)->visible($actor)->where('tenant_id', $actor->tenant_id);
|
||||
$requests->where(fn ($q) => $q->where('work_context_key', 'procurement.internal')
|
||||
->when(! $enforced, fn ($q) => $q->orWhereNull('work_context_key')));
|
||||
$events = WarehouseRequestEvent::query()->where('tenant_id', $actor->tenant_id)
|
||||
->whereIn('warehouse_request_id', (clone $requests)->select('id'))
|
||||
->whereIn('action', ['deliver', 'receive'])->where('id', '>', $after)->orderBy('id')->limit($limit + 1)->get();
|
||||
$more = $events->count() > $limit;
|
||||
$events = $events->take($limit);
|
||||
$sources = $requests->whereIn('id', $events->pluck('warehouse_request_id'))->get()->keyBy('id');
|
||||
$items = [];
|
||||
foreach ($events as $event) {
|
||||
$source = $sources->get($event->getAttribute('warehouse_request_id'));
|
||||
abort_unless($source instanceof WarehouseRequest, 409, 'Origem da entrega indisponível.');
|
||||
$items[] = $this->envelope($source, $event);
|
||||
}
|
||||
|
||||
return ['schema' => self::SCHEMA, 'items' => $items, 'has_more' => $more,
|
||||
'next_cursor' => Crypt::encryptString(json_encode(['scope' => $scope, 'after' => $events->max('id') ?? $after], JSON_THROW_ON_ERROR))];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function envelope(WarehouseRequest $request, WarehouseRequestEvent $event): array
|
||||
{
|
||||
$key = 'sgc:'.$request->tenant_id.':warehouse-event:'.$event->id;
|
||||
$snapshot = $request->snapshot;
|
||||
$lines = [];
|
||||
if ($event->getAttribute('action') === 'deliver') {
|
||||
foreach ($event->data['lines'] as $line) {
|
||||
$source = $snapshot['lines'][$line['index']];
|
||||
$lines[] = ['request_line_index' => $line['index'], 'item' => $source['item'],
|
||||
'quantity_milli' => $line['quantity_milli'], 'use' => $source['use'],
|
||||
'destination' => $source['destination'], 'equipment' => $source['equipment'],
|
||||
'stock_operation_id' => $line['stock_operation_id']];
|
||||
}
|
||||
}
|
||||
$payload = ['schema' => 'sgc.warehouse.fulfilment-event.v1', 'idempotency_key' => $key,
|
||||
'tenant_id' => $request->tenant_id, 'event_id' => $event->id,
|
||||
'event_version' => $event->getAttribute('version'), 'action' => $event->getAttribute('action'),
|
||||
'occurred_at' => CarbonImmutable::parse($event->getAttribute('created_at'))->utc()->toIso8601String(), 'actor' => $event->data['actor'],
|
||||
'request' => ['id' => $request->id, 'reference' => 'RA-'.$request->id, 'document_type' => 'internal_warehouse_request',
|
||||
'work_context_key' => $request->getAttribute('work_context_key'),
|
||||
'requester' => $snapshot['requester'], 'warehouse' => $snapshot['warehouse'],
|
||||
'cost_center' => $snapshot['cost_center'], 'destination' => $snapshot['destination']],
|
||||
'lines' => $lines, 'delivery_event_key' => $event->getAttribute('action') === 'receive'
|
||||
? 'sgc:'.$request->tenant_id.':warehouse-event:'.$event->data['delivery_event_id'] : null];
|
||||
|
||||
return [...$payload, 'payload_sha256' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))];
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ class WarehouseFulfilment
|
||||
$event = WarehouseRequestEvent::query()->create(['tenant_id' => $actor->tenant_id, 'warehouse_request_id' => $id, 'actor_id' => $actor->id, 'version' => $p['version'] + 1,
|
||||
'operation_key' => $data['operation_key'], 'input_hash' => $hash, 'action' => $action, 'reason' => $data['reason'], 'data' => $eventData, 'created_at' => now()]);
|
||||
if ($action === 'authorize') {
|
||||
app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $eventData['partial_allowed'], $data['operation_key']);
|
||||
app(WarehouseReservations::class)->reserve($actor, $r, $event, $p['remaining'], $data['partial_allowed'] ?? false, $data['operation_key']);
|
||||
}
|
||||
if ($action === 'reserve') {
|
||||
$authorization = WarehouseRequestEvent::query()->where('warehouse_request_id', $id)->where('action', 'authorize')->firstOrFail();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** A diagnostic, never an activation, grant, business command or production certificate. */
|
||||
class WarehousePilotReadiness
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public function inspect(User $actor): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && $actor->hasFunctions(['admin']), 403);
|
||||
$users = User::query()->where('tenant_id', $actor->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']);
|
||||
$centers = CostCenter::query()->get(['id', 'department_id']);
|
||||
$accessReady = Schema::hasTable('organization_memberships') && Schema::hasTable('organization_profiles');
|
||||
$groups = ['prepare' => [], 'review' => [], 'deliver' => [], 'purchase_requester' => [], 'supply_prepare' => [], 'supply_review' => []];
|
||||
$permanentAdmins = 0;
|
||||
foreach ($users as $user) {
|
||||
$grants = $accessReady ? DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')->where('m.tenant_id', $actor->tenant_id)->where('p.tenant_id', $actor->tenant_id)->where('m.user_id', $user->id)->where('m.maintenance', false)->whereNull('m.expires_at')->pluck('p.permissions') : collect();
|
||||
if ($grants->contains(fn ($json) => in_array('admin', json_decode($json, true, 32, JSON_THROW_ON_ERROR), true))) {
|
||||
$permanentAdmins++;
|
||||
}
|
||||
if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
|
||||
continue;
|
||||
}
|
||||
$context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
|
||||
if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
|
||||
continue;
|
||||
}
|
||||
$permissions = app(OrganizationAccess::class)->permissions($user, $context);
|
||||
$person = $user->only(['id', 'name', 'department_id']) + ['has_cost_center' => $user->department_id !== null && $centers->contains('department_id', $user->department_id)];
|
||||
foreach (['prepare' => 'warehouse_request_prepare', 'review' => 'warehouse_request_review', 'purchase_requester' => 'requester', 'supply_prepare' => 'supply_prepare', 'supply_review' => 'supply_review'] as $key => $permission) {
|
||||
if (in_array($permission, $permissions, true)) {
|
||||
$groups[$key][] = $person;
|
||||
}
|
||||
}
|
||||
if (in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
|
||||
$groups['deliver'][] = $person;
|
||||
}
|
||||
}
|
||||
$independent = false;
|
||||
$deliveryIds = array_fill_keys(array_column($groups['deliver'], 'id'), true);
|
||||
foreach ($groups['prepare'] as $requester) {
|
||||
foreach ($groups['review'] as $reviewer) {
|
||||
if ($requester['id'] === $reviewer['id'] || $requester['department_id'] === null || $requester['department_id'] !== $reviewer['department_id'] || ! $requester['has_cost_center']) {
|
||||
continue;
|
||||
}
|
||||
if (count($deliveryIds) > (isset($deliveryIds[$requester['id']]) ? 1 : 0) + (isset($deliveryIds[$reviewer['id']]) ? 1 : 0)) {
|
||||
$independent = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$tables = [];
|
||||
foreach (['warehouse_requests', 'warehouse_request_events', 'warehouse_reservations', 'supply_records', 'replenishment_work', 'requisition_catalogue'] as $table) {
|
||||
$tables[$table] = Schema::hasTable($table);
|
||||
}
|
||||
$warehouses = Warehouse::query()->count();
|
||||
$items = StockItem::query()->get(['id', 'code', 'name', 'unit'])->keyBy('id');
|
||||
$published = 0;
|
||||
$stale = 0;
|
||||
if ($tables['requisition_catalogue']) {
|
||||
foreach (DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true)->get(['stock_item_id', 'source_snapshot']) as $entry) {
|
||||
$item = $items->get($entry->stock_item_id);
|
||||
if ($item !== null && app(RequisitionCatalogue::class)->source($item) === json_decode($entry->source_snapshot, true, 32, JSON_THROW_ON_ERROR)) {
|
||||
$published++;
|
||||
} else {
|
||||
$stale++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$planning = app(SupplyPlanning::class);
|
||||
$policies = $tables['supply_records'] ? $planning->latest('policy')->where('state', 'approved')->where('data->purpose', 'internal')->count() : 0;
|
||||
$validRules = 0;
|
||||
$invalidRules = 0;
|
||||
if (! in_array(false, $tables, true)) {
|
||||
foreach ($planning->latest('auto_rule')->where('state', 'approved')->cursor() as $rule) {
|
||||
$status = app(AutomaticPurchases::class)->inspectRule($actor, $rule);
|
||||
if ($status['valid']) {
|
||||
$validRules++;
|
||||
} else {
|
||||
$invalidRules++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$runtime = app(ReplenishmentQueue::class)->status($actor);
|
||||
$pending = (int) ($runtime['work']->pending ?? 0);
|
||||
$failed = (int) ($runtime['work']->failed ?? 0);
|
||||
$recoveryClear = $runtime['recoveryReady'] && $pending === 0 && $failed === 0;
|
||||
$active = app(OrganizationAccess::class)->active($actor->tenant_id);
|
||||
$company = Tenant::query()->findOrFail($actor->tenant_id);
|
||||
$internalConfigured = $company->work_context_policy !== null && in_array('procurement.internal', $company->work_context_policy['enabled'] ?? [], true) && in_array('procurement.internal', config('work_context.entitlements.'.$actor->tenant_id, []), true);
|
||||
$checks = [
|
||||
['key' => 'installation', 'label' => 'Instalação dos percursos', 'ok' => ! in_array(false, $tables, true), 'detail' => in_array(false, $tables, true) ? 'Existem componentes por instalar.' : 'Requisições, reservas, catálogo e recuperação durável instalados.', 'path' => null],
|
||||
['key' => 'participants', 'label' => 'Tarefas e independência no armazém', 'ok' => $independent, 'detail' => $independent ? 'Há combinação elegível de requisitante, autorizador e entregador distintos, com centro de custo do departamento.' : 'Faltam tarefas, centro de custo ou uma combinação de três intervenientes independentes. Não são atribuídas competências nesta consulta.', 'path' => '/administracao/parametrizacao'],
|
||||
['key' => 'catalogue', 'label' => 'Artigos e armazéns', 'ok' => $warehouses > 0 && $published > 0, 'detail' => $warehouses.' armazém(ns), '.$published.' artigo(s) publicado(s) com origem actual e '.$stale.' publicação(ões) a rever.', 'path' => '/stock/catalogo'],
|
||||
['key' => 'replenishment', 'label' => 'Reposição parametrizada e válida', 'ok' => $validRules > 0, 'detail' => $policies.' política(s) interna(s) aprovada(s), '.$validRules.' regra(s) activa(s) válida(s) e '.$invalidRules.' a rever. Falta de stock não é, por si, erro de configuração.', 'path' => '/stock/reposicao'],
|
||||
['key' => 'scheduler', 'label' => 'Verificação periódica observada', 'ok' => $runtime['recoveryReady'] && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed', 'detail' => $runtime['schedulerRecent'] ? 'Há uma passagem recente conhecida do serviço.' : 'Sem passagem recente confirmada nesta interface; instalação não prova execução.', 'path' => '/stock/reposicao'],
|
||||
['key' => 'administration', 'label' => 'Configuração própria da empresa', 'ok' => $active && $permanentAdmins >= 2 && $internalConfigured, 'detail' => $active ? 'Perfis próprios activos; '.$permanentAdmins.' administrador(es) permanente(s). Conferir também o contexto interno contratado/activado.' : 'Acesso ainda em compatibilidade com funções de desenvolvimento. Isto não é configuração de cliente concluída.', 'path' => '/administracao/parametrizacao'],
|
||||
['key' => 'recovery', 'label' => 'Trabalhos técnicos de reposição', 'ok' => $recoveryClear, 'detail' => $pending.' sinal(is) por processar e '.$failed.' trabalho(s) com recuperação pendente. Necessidades funcionais sem configuração válida continuam visíveis na reposição.', 'path' => '/stock/reposicao'],
|
||||
];
|
||||
|
||||
return ['checked_at' => now()->toIso8601String(), 'checks' => $checks, 'participants' => $groups, 'registered' => $users->map(fn ($u) => $u->only(['id', 'name', 'department_id']))->all(), 'tables' => $tables, 'runtime' => $runtime,
|
||||
'warehouse_scenario_ready' => ! in_array(false, $tables, true) && $independent && $warehouses > 0 && $published > 0,
|
||||
'replenishment_scenario_ready' => ! in_array(false, $tables, true) && $recoveryClear && $validRules > 0 && $runtime['schedulerRecent'] && ($runtime['schedulerStatus']['result'] ?? null) === 'passed',
|
||||
'client_configuration_complete' => $active && $permanentAdmins >= 2 && $internalConfigured];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class WarehouseReservations
|
||||
|
||||
public function committed(int $item, int $warehouse): int
|
||||
{
|
||||
return $this->ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id)->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0;
|
||||
return $this->ready() ? (int) DB::table('warehouse_reservations')->where('tenant_id', auth()->user()?->tenant_id ?? app(ReplenishmentRun::class)->tenantId())->where('stock_item_id', $item)->where('warehouse_id', $warehouse)->sum('quantity_milli') : 0;
|
||||
}
|
||||
|
||||
/** @return array<int,int> */
|
||||
@@ -114,10 +114,10 @@ class WarehouseReservations
|
||||
/** Refuse worsening a shortage of usable reserved material; quality holds remain independent. */
|
||||
public function protect(int $item, int $warehouse, int $before): void
|
||||
{
|
||||
if (! $this->ready() || $this->committed($item,$warehouse) === 0) {
|
||||
if (! $this->ready() || $this->committed($item, $warehouse) === 0) {
|
||||
return;
|
||||
}
|
||||
if ($this->free($item,$warehouse) < min(0,$before)) {
|
||||
if ($this->free($item, $warehouse) < min(0, $before)) {
|
||||
throw ValidationException::withMessages(['quantity' => 'Esta operação consumiria material reservado. Liberte ou regularize a reserva autorizadamente.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tempo\Inventory\DeliveryReturnWriter;
|
||||
|
||||
final class WarehouseReturnMovement
|
||||
{
|
||||
public const MANIFEST_SHA256='2850a4f5cb678acfe3a9dbc7a7d62eb233bc72722ad0de374681c2679c90557c';
|
||||
private function fileHash(string $file): string
|
||||
{
|
||||
$hash = is_file($file) ? hash_file('sha256', $file) : false;
|
||||
abort_unless(is_string($hash), 503, 'Ficheiro do pacote de devolução indisponível.');
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
public function for(User $actor): DeliveryReturnWriter
|
||||
{
|
||||
$root = base_path('packages/inventory-return/releases/0.1.0');
|
||||
if (! hash_equals(self::MANIFEST_SHA256, $this->fileHash($root.'/manifest.json'))) {
|
||||
abort(503, 'Pacote de devolução incompatível.');
|
||||
}
|
||||
$raw = file_get_contents($root.'/manifest.json');
|
||||
if ($raw === false) {
|
||||
abort(503, 'Manifesto de devolução indisponível.');
|
||||
}
|
||||
$manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach (['bootstrap.php', 'src/DeliveryReturnWriter.php'] as $file) {
|
||||
if (! hash_equals($manifest['files'][$file] ?? '', $this->fileHash($root.'/'.$file))) {
|
||||
abort(503, 'Pacote de devolução alterado.');
|
||||
}
|
||||
}
|
||||
require_once $root.'/bootstrap.php';
|
||||
|
||||
return new DeliveryReturnWriter(DB::connection(), 'public', (int) $actor->tenant_id, (int) $actor->id, function () use ($actor) {
|
||||
app(WarehouseReturns::class)->authorize($actor, 'confirm');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\StockItem;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\StockOperation;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\Warehouse;
|
||||
use App\Models\WarehouseRequest;
|
||||
use App\Models\WarehouseRequestEvent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Native internal issue -> unused material declaration -> independently received physical return. */
|
||||
final class WarehouseReturns
|
||||
{
|
||||
public function allows(User $actor, string $action): bool
|
||||
{
|
||||
$access = app(OrganizationAccess::class);
|
||||
$scope = app(WorkContext::class)->enforced($actor) ? $this->context() : null;
|
||||
$permissions = $access->permissions($actor, $scope);
|
||||
|
||||
return ! $access->maintenanceOnly($actor) && in_array('warehouse_return_'.$action, $permissions, true) && ($action !== 'confirm' || in_array('warehouse', $permissions, true));
|
||||
}
|
||||
|
||||
public function authorize(User $actor, string $action): User
|
||||
{
|
||||
abort_unless(config('stock_returns.enabled'), 503, 'Devoluções de excedentes ainda não activadas.');
|
||||
abort_unless(DB::connection()->getDriverName() === 'pgsql', 503, 'Percurso de devoluções ainda não validado neste driver.');
|
||||
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
|
||||
$fresh = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($actor->id);
|
||||
abort_unless($this->allows($fresh, $action), 403);
|
||||
abort_unless(Schema::hasTable('supply_records'), 503, 'Histórico administrativo ainda não instalado.');
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
/** @param array<array-key,mixed> $v */
|
||||
private function hash(array $v): string
|
||||
{
|
||||
return hash('sha256', json_encode($v, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function lock(User $actor, string $action): User
|
||||
{
|
||||
$timeout=config('stock_returns.lock_timeout_ms',2000);
|
||||
abort_unless(is_int($timeout)&&$timeout>=100&&$timeout<=10000,503,'Tempo de espera de conferência inválido.');
|
||||
DB::select("SELECT set_config('lock_timeout',?,true)",[$timeout.'ms']);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$fresh = app(CurrentActor::class)->lock($actor);
|
||||
|
||||
return $this->authorize($fresh, $action);
|
||||
}
|
||||
|
||||
private function context(): string
|
||||
{
|
||||
$actor = auth()->user();
|
||||
abort_unless($actor instanceof User, 403);
|
||||
if (! app(WorkContext::class)->enforced($actor)) {
|
||||
return 'procurement.internal';
|
||||
}
|
||||
abort_unless(request()->hasSession(), 403);
|
||||
$key = app(WorkContext::class)->active(request());
|
||||
abort_unless($key === 'procurement.internal', 403);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function request(int $id): WarehouseRequest
|
||||
{
|
||||
$r = WarehouseRequest::query()->findOrFail($id);
|
||||
abort_unless(($r->work_context_key ?? 'procurement.internal') === $this->context(), 403);
|
||||
|
||||
return $r;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function availableDeliveries(User $actor, int $requestId, int $page): array
|
||||
{
|
||||
$this->authorize($actor, 'read');
|
||||
$r = $this->request($requestId);
|
||||
abort_unless(app(WarehouseWorkdesk::class)->canView($actor, $r), 403);
|
||||
$events = WarehouseRequestEvent::query()->where('warehouse_request_id', $r->id)->where('action', 'deliver')->whereExists(function ($q) use ($actor, $r) {
|
||||
$q->selectRaw('1')->from('warehouse_request_events AS ack')->where('ack.tenant_id', $actor->tenant_id)->where('ack.warehouse_request_id', $r->id)->where('ack.action', 'receive')->whereRaw("(ack.data->>'delivery_event_id')::bigint=warehouse_request_events.id");
|
||||
})->orderByDesc('id')->paginate(25, ['id', 'data'], 'source_page', $page);
|
||||
$options = [];
|
||||
foreach ($events as $event) {
|
||||
foreach ($event->data['lines'] as $line) {
|
||||
$basis = $r->snapshot['lines'][$line['index']] ?? null;
|
||||
if ($basis && $basis['use'] === 'internal' && $basis['equipment'] === null) {
|
||||
$options[] = ['delivery_event_id' => $event->id, 'line_index' => $line['index'], 'item' => $basis['item'], 'delivered_milli' => (string) $line['quantity_milli']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['request_id' => $r->id, 'can_prepare' => $r->created_by === $actor->id && $this->allows($actor, 'prepare'), 'options' => $options, 'pagination' => ['page' => $events->currentPage(), 'last_page' => $events->lastPage(), 'total' => $events->total()]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $lines
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function deliveryLine(array $lines, int $index): ?array
|
||||
{
|
||||
foreach ($lines as $line) {
|
||||
if ($line['index'] === $index) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function source(User $actor, array $d): array
|
||||
{
|
||||
$event = WarehouseRequestEvent::query()->where('action', 'deliver')->whereKey($d['delivery_event_id'])->lockForUpdate()->firstOrFail();
|
||||
$request = $this->request($event->warehouse_request_id);
|
||||
$line = $this->deliveryLine($event->data['lines'], $d['line_index']);
|
||||
$basis = $request->snapshot['lines'][$d['line_index']] ?? null;
|
||||
abort_unless($line && $basis && $basis['use'] === 'internal' && $basis['equipment'] === null, 409, 'Apenas excedente de entrega interna simples, sem equipamento ou venda.');
|
||||
$acknowledgement = WarehouseRequestEvent::query()->where('warehouse_request_id', $request->id)->where('action', 'receive')->where('data->delivery_event_id', $event->id)->lockForUpdate()->first();
|
||||
abort_unless($acknowledgement !== null, 409, 'A entrega original ainda não foi recebida pelo destinatário.');
|
||||
$operation = DB::table('stock_operations')->where(['tenant_id' => $actor->tenant_id, 'id' => $line['stock_operation_id'], 'kind' => 'issue'])->lockForUpdate()->first();
|
||||
abort_unless($operation && (int) $operation->stock_item_id === $basis['item']['id'] && (int) $operation->warehouse_id === $request->warehouse_id && $operation->equipment_id === null && $operation->spare_part_id === null, 409, 'Entrega incompatível com o documento interno.');
|
||||
$movement = DB::table('stock_movements')->where(['tenant_id' => $actor->tenant_id, 'stock_operation_id' => $operation->id, 'movement_type' => 'issue'])->lockForUpdate()->first();
|
||||
abort_unless($movement && (int) $movement->quantity_milli === -(int) $line['quantity_milli'], 409, 'Quantidade da entrega incompatível.');
|
||||
$snapshot = json_decode($movement->snapshot, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(($snapshot['inventory_operation_hash'] ?? null) === $this->hash((array) $operation), 409, 'Entrega sem fotografia original íntegra.');
|
||||
abort_if(SupplyRecord::query()->where('kind', 'stock_reversal')->where('state', 'executed')->where('data->source_kind', 'operation')->where('data->source_operation_id', $operation->id)->exists(), 409, 'Entrega estornada; exige reconciliação própria.');
|
||||
abort_if(app(InventoryLots::class)->setup($operation->stock_item_id, $operation->warehouse_id) !== null || app(WarehouseReservations::class)->committed($operation->stock_item_id, $operation->warehouse_id) > 0, 409, 'Lotes ou reservas exigem percurso próprio.');
|
||||
$item = StockItem::query()->whereKey($operation->stock_item_id)->firstOrFail();
|
||||
$warehouse = Warehouse::query()->whereKey($operation->warehouse_id)->firstOrFail();
|
||||
abort_unless($item->unit === $basis['item']['unit'], 409, 'Unidade do artigo mudou; exige reconciliação própria, sem conversão automática.');
|
||||
$returned = (int) StockMovement::query()->where('movement_type', 'workshop_return_in')->where('snapshot->delivery_movement_id', $movement->id)->sum('quantity_milli');
|
||||
$remaining = (int) $line['quantity_milli'] - $returned;
|
||||
abort_unless($d['quantity_milli'] > 0 && $d['quantity_milli'] <= $remaining, 409, 'Quantidade excede a entrega ainda não devolvida.');
|
||||
$book = app(StockWorkflow::class)->balance($operation->stock_item_id, $operation->warehouse_id);
|
||||
|
||||
return ['contract_version' => 'WAREHOUSE-UNUSED-RETURN-v1', 'writer_manifest_sha256' => WarehouseReturnMovement::MANIFEST_SHA256, 'context_revision' => app(WorkContext::class)->policy($actor)['revision'] ?? null, 'request_id' => $request->id, 'requester_id' => $request->created_by, 'work_context_key' => $request->work_context_key ?? 'procurement.internal', 'delivery' => (array) $event->getAttributes(), 'acknowledgement' => (array) $acknowledgement->getAttributes(), 'line_index' => $d['line_index'], 'operation' => (array) $operation, 'movement' => (array) $movement, 'item' => $item->only(['id', 'code', 'name', 'unit']), 'original_item' => $basis['item'], 'warehouse' => $warehouse->only(['id', 'code', 'name']), 'quantity_milli' => $d['quantity_milli'], 'remaining_milli' => $remaining, 'book' => $book];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $s
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function impact(array $s): array
|
||||
{
|
||||
return ['physical_before' => (string) $s['book']['quantity'], 'physical_after' => (string) ($s['book']['quantity'] + $s['quantity_milli']), 'return_quantity_milli' => (string) $s['quantity_milli'], 'not_returned_before' => (string) $s['remaining_milli'], 'not_returned_after' => (string) ($s['remaining_milli'] - $s['quantity_milli']), 'original_movement_id' => (int) $s['movement']['id'], 'item' => $s['item'], 'warehouse' => $s['warehouse']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function prepare(User $actor, array $d): array
|
||||
{
|
||||
abort_unless(($d['declared_unused'] ?? false) === true, 422, 'Declare expressamente que o material não foi consumido.');
|
||||
$this->authorize($actor, 'prepare');
|
||||
|
||||
return DB::transaction(function () use ($actor, $d) {
|
||||
$actor = $this->lock($actor, 'prepare');
|
||||
$hash = $this->hash($d);
|
||||
$old = SupplyRecord::query()->where('kind', 'warehouse_return')->where('operation_key', $d['operation_key'])->first();
|
||||
if ($old) {
|
||||
abort_unless($old->created_by === $actor->id && hash_equals($old->input_hash, $hash), 409, 'Chave utilizada com outros dados.');
|
||||
$this->request($old->snapshot['source']['request_id']);
|
||||
|
||||
$state=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$old->record_key)->orderByDesc('version')->value('state');
|
||||
return ['id' => $old->id, 'state'=>$state, 'impact' => $this->impact($old->snapshot['source']), 'replayed' => true];
|
||||
}
|
||||
$source = $this->source($actor, $d);
|
||||
abort_unless($source['requester_id'] === $actor->id, 403, 'A declaração de excedente pertence ao destinatário original.');
|
||||
$record = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $d['operation_key'], 'version' => 1, 'state' => 'prepared', 'stock_item_id' => $source['item']['id'], 'warehouse_id' => $source['warehouse']['id'], 'created_by' => $actor->id, 'data' => $d, 'snapshot' => ['source' => $source, 'source_hash' => $this->hash($source), 'declared_unused' => true], 'reason' => $d['reason'], 'operation_key' => $d['operation_key'], 'input_hash' => $hash, 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.prepared', null, null, ['record_id' => $record->id, 'request_id' => $source['request_id'], 'impact' => $this->impact($source), 'declared_unused' => true]);
|
||||
|
||||
return ['id' => $record->id, 'state'=>'prepared', 'impact' => $this->impact($source), 'replayed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function confirm(User $actor, int $id, array $d): array
|
||||
{
|
||||
$this->authorize($actor, 'confirm');
|
||||
|
||||
return DB::transaction(function () use ($actor, $id, $d) {
|
||||
$actor = $this->lock($actor, 'confirm');
|
||||
$p = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->lockForUpdate()->findOrFail($id);
|
||||
$this->request($p->snapshot['source']['request_id']);
|
||||
abort_if($p->created_by === $actor->id, 403, 'Recepção deve ser independente da declaração.');
|
||||
$done = SupplyRecord::query()->where('kind', 'warehouse_return')->where('record_key', $p->record_key)->where('version', 2)->first();
|
||||
if ($done) {
|
||||
abort_unless($done->state==='confirmed',409,'Proposta encerrada sem movimento. Prepare uma nova proposta se necessário.');
|
||||
return ['id' => $done->id, 'movement_id' => $done->data['movement_id'], 'replayed' => true];
|
||||
}
|
||||
abort_unless($d['version'] === 1 && $d['received_in_warehouse'] === true, 422, 'Confirme a recepção física da versão conferida.');
|
||||
$source = $this->source($actor, $p->data);
|
||||
abort_unless(hash_equals($p->snapshot['source_hash'], $this->hash($source)), 409, 'A origem, configuração ou livro mudou. Prepare nova conferência.');
|
||||
$operation = StockOperation::query()->create(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'responsible_id' => $actor->id, 'warehouse_id' => $source['warehouse']['id'], 'stock_item_id' => $source['item']['id'], 'kind' => 'workshop_return', 'reference' => 'EXCEDENTE-'.$p->record_key, 'idempotency_key' => $p->record_key, 'payload_hash' => $p->snapshot['source_hash'], 'destination' => 'Devolução interna RA-'.$source['request_id'], 'reason' => $d['reason'], 'quantity_milli' => $source['quantity_milli'], 'theoretical_milli' => $source['book']['quantity'], 'ledger_version' => $source['book']['version'], 'snapshot' => ['actor' => $actor->only(['id', 'name']), 'responsible' => $actor->only(['id', 'name']), 'stock_item' => $source['item'], 'warehouse' => $source['warehouse'], 'destination_warehouse' => null, 'equipment' => null, 'spare_part' => null, 'difference_milli' => $source['quantity_milli'], 'budget_checked' => false, 'delivery_movement_id' => (int) $source['movement']['id'], 'delivery_movement_hash' => $this->hash($source['movement']), 'received_in_warehouse' => true, 'native_request_id' => $source['request_id'], 'declaration_id' => $p->id, 'declared_unused_by' => $p->created_by, 'evidence' => $p->data['evidence']], 'recorded_at' => now()]);
|
||||
$writer = app(WarehouseReturnMovement::class)->for($actor);
|
||||
$out = $writer->write($operation->id, $writer->preview($operation->id));
|
||||
$done = SupplyRecord::query()->create(['tenant_id' => $actor->tenant_id, 'kind' => 'warehouse_return', 'record_key' => $p->record_key, 'version' => 2, 'state' => 'confirmed', 'stock_item_id' => $p->stock_item_id, 'warehouse_id' => $p->warehouse_id, 'created_by' => $actor->id, 'data' => ['movement_id' => $out['movement_id'], 'operation_id' => $operation->id, 'prepared_id' => $p->id, 'received_in_warehouse' => true, 'reason' => $d['reason']], 'snapshot' => ['source' => $source, 'before' => $source['book']['quantity'], 'after' => $source['book']['quantity'] + $source['quantity_milli'], 'preparer_id' => $p->created_by], 'reason' => $d['reason'], 'operation_key' => (string) Str::uuid(), 'input_hash' => $this->hash($d), 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'warehouse_return.confirmed', null, null, ['record_id' => $done->id, 'declaration_id' => $p->id, 'movement_id' => $out['movement_id'], 'impact' => $this->impact($source), 'reason' => $d['reason']]);
|
||||
// Reuse the existing configured replenishment queue; replay never signals again.
|
||||
app(AutomaticPurchases::class)->signal($actor, $source['item']['id'], $source['warehouse']['id']);
|
||||
|
||||
return ['id' => $done->id, 'movement_id' => $out['movement_id'], 'replayed' => false];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $d
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function close(User $actor,int $id,array $d,string $state): array
|
||||
{
|
||||
abort_unless(in_array($state,['withdrawn','rejected'],true),422);
|
||||
$action=$state==='withdrawn'?'prepare':'confirm';$this->authorize($actor,$action);
|
||||
return DB::transaction(function()use($actor,$id,$d,$state,$action){
|
||||
$actor=$this->lock($actor,$action);$p=SupplyRecord::query()->where('kind','warehouse_return')->where('version',1)->lockForUpdate()->findOrFail($id);$this->request($p->snapshot['source']['request_id']);
|
||||
abort_unless($state==='withdrawn'?$p->created_by===$actor->id:$p->created_by!==$actor->id,403,'Retirada pertence ao preparador; recusa exige conferência independente.');
|
||||
$done=SupplyRecord::query()->where('kind','warehouse_return')->where('record_key',$p->record_key)->where('version',2)->first();
|
||||
if($done){abort_unless($done->state===$state,409,'A proposta já tem outra decisão terminal.');return ['id'=>$done->id,'state'=>$done->state,'replayed'=>true];}
|
||||
abort_unless($d['version']===1,409,'Versão de proposta desactualizada.');$book=app(StockWorkflow::class)->balance($p->stock_item_id,$p->warehouse_id);
|
||||
$closed=SupplyRecord::query()->create(['tenant_id'=>$actor->tenant_id,'kind'=>'warehouse_return','record_key'=>$p->record_key,'version'=>2,'state'=>$state,'stock_item_id'=>$p->stock_item_id,'warehouse_id'=>$p->warehouse_id,'created_by'=>$actor->id,'data'=>['prepared_id'=>$p->id,'action'=>$state,'reason'=>$d['reason']],'snapshot'=>['source'=>$p->snapshot['source'],'before'=>$book['quantity'],'after'=>$book['quantity'],'preparer_id'=>$p->created_by],'reason'=>$d['reason'],'operation_key'=>(string)Str::uuid(),'input_hash'=>$this->hash($d+['action'=>$state]),'created_at'=>now()]);
|
||||
app(RequisitionWorkflow::class)->audit($actor,'warehouse_return.'.$state,null,null,['record_id'=>$closed->id,'declaration_id'=>$p->id,'reason'=>$d['reason'],'before'=>$book['quantity'],'after'=>$book['quantity'],'physical_effect'=>0]);
|
||||
return ['id'=>$closed->id,'state'=>$state,'replayed'=>false];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function listing(User $actor, int $page, string $state): array
|
||||
{
|
||||
$this->authorize($actor, 'read');
|
||||
$query = app(SupplyPlanning::class)->latest('warehouse_return');
|
||||
if ($state !== 'all') {
|
||||
$query->where('state', $state);
|
||||
}
|
||||
// Legacy and explicitly selected contexts are never mixed in this new history.
|
||||
$context = $this->context();
|
||||
$query->where('snapshot->source->work_context_key', $context);
|
||||
$rows = $query->orderByDesc('id')->paginate(25, ['id', 'record_key', 'version', 'state', 'reason', 'created_by', 'created_at', 'snapshot']);
|
||||
$prepared = SupplyRecord::query()->where('kind', 'warehouse_return')->where('version', 1)->whereIn('record_key', $rows->getCollection()->pluck('record_key'))->get()->keyBy('record_key');
|
||||
$items = $rows->getCollection()->map(function ($r) use ($prepared) {
|
||||
$p = $prepared->get($r->record_key);
|
||||
|
||||
return ['id' => $r->id, 'prepared_id' => $p->id, 'version' => $r->version, 'state' => $r->state, 'reason' => $r->reason, 'evidence' => $p->data['evidence'], 'preparer_id' => $p->created_by, 'author_id' => $r->created_by, 'created_at' => $r->created_at->toIso8601String(), 'impact' => $this->impact($r->snapshot['source'])];
|
||||
});
|
||||
|
||||
return ['rows' => $items, 'pagination' => ['page' => $rows->currentPage(), 'last_page' => $rows->lastPage(), 'total' => $rows->total()]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Equipment;
|
||||
use App\Models\EquipmentSparePart;
|
||||
use App\Models\SparePart;
|
||||
use App\Models\StockItem;
|
||||
use App\Models\User;
|
||||
use App\Models\WarehouseRequest;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Read-only workdesk over the existing request, event and reservation journals. */
|
||||
class WarehouseWorkdesk
|
||||
{
|
||||
public function canView(User $actor, WarehouseRequest $request): bool
|
||||
{
|
||||
return $actor->tenant_id === $request->tenant_id && (
|
||||
($request->created_by === $actor->id && $actor->hasFunctions(['warehouse_request_prepare']))
|
||||
|| ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $request->snapshot['requester']['department_id'])
|
||||
|| ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))
|
||||
|| $actor->hasFunctions(['admin', 'warehouse_request_read']));
|
||||
}
|
||||
|
||||
/** @return Builder<WarehouseRequest> */
|
||||
public function visible(User $actor): Builder
|
||||
{
|
||||
$query = WarehouseRequest::query();
|
||||
if (! $actor->hasFunctions(['admin', 'warehouse_request_read']) && ! ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']))) {
|
||||
$query->where(function ($q) use ($actor) {
|
||||
$q->where('created_by', $actor->id);
|
||||
if ($actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null) {
|
||||
$q->orWhere('snapshot->requester->department_id', $actor->department_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param literal-string $action
|
||||
* @return literal-string
|
||||
*/
|
||||
private function eventCount(string $action): string
|
||||
{
|
||||
if (! Schema::hasTable('warehouse_request_events')) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return "(SELECT COUNT(*) FROM warehouse_request_events e WHERE e.tenant_id=warehouse_requests.tenant_id AND e.warehouse_request_id=warehouse_requests.id AND e.action='$action')";
|
||||
}
|
||||
|
||||
/** SQL built solely from fixed identifiers/actions; filters use bound parameters.
|
||||
* @return literal-string
|
||||
*/
|
||||
public function stateExpression(): string
|
||||
{
|
||||
if (! Schema::hasTable('warehouse_request_events')) {
|
||||
return "'draft'";
|
||||
}
|
||||
$requested = $this->quantityExpression(false);
|
||||
$delivered = $this->quantityExpression(true);
|
||||
$deliveries = $this->eventCount('deliver');
|
||||
$receipts = $this->eventCount('receive');
|
||||
|
||||
return "CASE WHEN {$this->eventCount('cancel')}>0 THEN 'cancelled' WHEN {$this->eventCount('reject')}>0 THEN 'rejected' WHEN {$this->eventCount('authorize')}>0 THEN CASE WHEN $delivered >= $requested THEN CASE WHEN $deliveries=$receipts THEN 'closed' ELSE 'delivered' END WHEN $deliveries>0 THEN 'partial' ELSE 'authorized' END WHEN {$this->eventCount('submit')}>0 THEN 'submitted' ELSE 'draft' END";
|
||||
}
|
||||
|
||||
/** @return literal-string */
|
||||
private function quantityExpression(bool $delivery): string
|
||||
{
|
||||
$source = $delivery ? 'e.data' : 'warehouse_requests.snapshot';
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
$json = "json_array_elements($source->'lines') j(value)";
|
||||
$quantity = "CAST(j.value->>'quantity_milli' AS BIGINT)";
|
||||
} elseif (DB::getDriverName() === 'sqlite') {
|
||||
$json = "json_each(json_extract($source, '$.lines')) j";
|
||||
$quantity = "CAST(json_extract(j.value, '$.quantity_milli') AS INTEGER)";
|
||||
} else {
|
||||
throw new \LogicException('A projecção de requisições suporta PostgreSQL e SQLite.');
|
||||
}
|
||||
|
||||
return $delivery ? "(SELECT COALESCE(SUM($quantity),0) FROM warehouse_request_events e CROSS JOIN $json WHERE e.tenant_id=warehouse_requests.tenant_id AND e.warehouse_request_id=warehouse_requests.id AND e.action='deliver')" : "(SELECT COALESCE(SUM($quantity),0) FROM $json)";
|
||||
}
|
||||
|
||||
/** @param Builder<WarehouseRequest> $query
|
||||
* @return Builder<WarehouseRequest>
|
||||
*/
|
||||
public function forView(Builder $query, User $actor, string $view): Builder
|
||||
{
|
||||
if ($view === 'all') {
|
||||
return $query;
|
||||
}
|
||||
if (! Schema::hasTable('warehouse_request_events')) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
$maintenance = app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
if ($maintenance) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
$state = $this->stateExpression();
|
||||
if (in_array($view, ['review', 'reserve'], true)) {
|
||||
if (! $actor->hasFunctions(['warehouse_request_review']) || $actor->department_id === null) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
|
||||
return $query->where('created_by', '!=', $actor->id)->where('snapshot->requester->department_id', $actor->department_id)->whereRaw($state.($view === 'review' ? " = 'submitted'" : " IN ('authorized','partial')"));
|
||||
}
|
||||
if ($view === 'delivery') {
|
||||
if (! $actor->hasFunctions(['warehouse_request_deliver']) || ! $actor->hasFunctions(['warehouse'])) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
|
||||
return $query->where('created_by', '!=', $actor->id)->whereRaw("$state IN ('authorized','partial')")->whereNotExists(fn ($q) => $q->selectRaw('1')->from('warehouse_request_events as decision')->whereColumn('decision.tenant_id', 'warehouse_requests.tenant_id')->whereColumn('decision.warehouse_request_id', 'warehouse_requests.id')->where('decision.action', 'authorize')->where('decision.actor_id', $actor->id));
|
||||
}
|
||||
if (! $actor->hasFunctions(['warehouse_request_prepare'])) {
|
||||
return $query->whereRaw('1=0');
|
||||
}
|
||||
|
||||
return $query->where('created_by', $actor->id)->whereRaw($this->eventCount('deliver').'>'.$this->eventCount('receive'));
|
||||
}
|
||||
|
||||
/** @param Builder<WarehouseRequest> $base
|
||||
* @return list<array{key:string,label:string,total:int}>
|
||||
*/
|
||||
public function views(Builder $base, User $actor): array
|
||||
{
|
||||
$views = ['all' => 'Todos no meu âmbito'];
|
||||
if (! app(OrganizationAccess::class)->maintenanceOnly($actor)) {
|
||||
if ($actor->hasFunctions(['warehouse_request_review'])) {
|
||||
$views += ['review' => 'Para autorizar', 'reserve' => 'Reservas a acompanhar'];
|
||||
}
|
||||
if ($actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse'])) {
|
||||
$views['delivery'] = 'Entregas pendentes';
|
||||
}
|
||||
if ($actor->hasFunctions(['warehouse_request_prepare'])) {
|
||||
$views['receipt'] = 'Recepções por confirmar';
|
||||
}
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($views as $key => $label) {
|
||||
$rows[] = ['key' => $key, 'label' => $label, 'total' => $this->forView(clone $base, $actor, $key)->count()];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function actions(User $actor, WarehouseRequest $r, array $p): array
|
||||
{
|
||||
$active = ! app(OrganizationAccess::class)->maintenanceOnly($actor);
|
||||
$owner = $r->created_by === $actor->id;
|
||||
$prepare = $actor->hasFunctions(['warehouse_request_prepare']);
|
||||
$review = $actor->hasFunctions(['warehouse_request_review']) && $actor->department_id !== null && $actor->department_id === $r->snapshot['requester']['department_id'] && ! $owner;
|
||||
$open = in_array($p['state'], ['authorized', 'partial'], true);
|
||||
|
||||
return ['canSubmit' => $active && $owner && $prepare && $p['state'] === 'draft', 'canReview' => $active && $review && $p['state'] === 'submitted',
|
||||
'canDeliver' => $active && ! $owner && $p['reviewer_id'] !== $actor->id && $actor->hasFunctions(['warehouse_request_deliver']) && $actor->hasFunctions(['warehouse']) && $open,
|
||||
'canManageReservation' => $active && $review && $open, 'canReceive' => $active && $owner && $prepare];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $p
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function health(WarehouseRequest $r, array $p): array
|
||||
{
|
||||
$rows = [];
|
||||
$needsReserve = false;
|
||||
$needsCheck = false;
|
||||
$readyLines = 0;
|
||||
$open = in_array($p['state'], ['authorized', 'partial'], true);
|
||||
foreach ($r->snapshot['lines'] as $index => $line) {
|
||||
$item = StockItem::query()->whereKey($line['item']['id'])->first();
|
||||
$sourceChanged = $item === null || $item->only(['id', 'code', 'name', 'unit']) !== $line['item'];
|
||||
if ($line['equipment'] !== null) {
|
||||
$equipment = Equipment::query()->whereKey($line['equipment']['id'])->first();
|
||||
$sourceChanged = $sourceChanged || $equipment === null || $equipment->only(['id', 'code', 'description']) !== $line['equipment'] || $equipment->department_id !== $r->snapshot['requester']['department_id'];
|
||||
}
|
||||
$part = SparePart::query()->where('stock_item_id', $line['item']['id'])->first();
|
||||
if ($part !== null && ($line['equipment'] === null || ! EquipmentSparePart::query()->where('spare_part_id', $part->id)->where('equipment_id', $line['equipment']['id'])->exists())) {
|
||||
$sourceChanged = true;
|
||||
}
|
||||
$gap = $open ? max(0, $p['remaining'][$index] - $p['reserved'][$index]) : 0;
|
||||
$issue = null;
|
||||
$ready = false;
|
||||
if ($open) {
|
||||
$lots = app(InventoryLots::class)->context($line['item']['id'], $r->warehouse_id);
|
||||
$usable = $lots === null ? app(StockWorkflow::class)->balance($line['item']['id'], $r->warehouse_id)['quantity'] : ($lots['reconciled'] ? $lots['usable_milli'] : null);
|
||||
$commitment = app(WarehouseReservations::class)->committed($line['item']['id'], $r->warehouse_id);
|
||||
if ($sourceChanged) {
|
||||
$issue = 'O cadastro ou destino mudou; é necessária revisão da origem.';
|
||||
} elseif ($usable === null) {
|
||||
$issue = 'Reconciliar lotes antes de actualizar a reserva ou entregar.';
|
||||
} elseif ($p['reserved'][$index] > 0 && $usable < $commitment) {
|
||||
$issue = 'O artigo tem material comprometido indisponível; conferir qualidade, validade e reservas.';
|
||||
} elseif (! $p['reservation_ready']) {
|
||||
$issue = 'Reserva física por instalar.';
|
||||
} elseif ($gap > 0) {
|
||||
$issue = 'Saldo ainda sem reserva; rever reposição e actualizar a reserva autorizadamente.';
|
||||
}
|
||||
$ready = ! $sourceChanged && $usable !== null && $usable >= $commitment && $p['reserved'][$index] > 0;
|
||||
$needsReserve = $needsReserve || $gap > 0;
|
||||
$needsCheck = $needsCheck || $sourceChanged || $usable === null || ($p['reserved'][$index] > 0 && $usable < $commitment);
|
||||
}
|
||||
if ($ready) {
|
||||
$readyLines++;
|
||||
}
|
||||
$rows[] = ['index' => $index, 'unreserved_milli' => $gap, 'attention' => $issue, 'source_changed' => $sourceChanged, 'ready' => $ready];
|
||||
}
|
||||
$unreceived = count(array_filter($p['events'], fn ($e) => $e['action'] === 'deliver' && ! in_array($e['id'], $p['received_deliveries'], true)));
|
||||
|
||||
return ['lines' => $rows, 'needs_reservation' => $needsReserve, 'needs_check' => $needsCheck, 'unconfirmed_deliveries' => $unreceived,
|
||||
'delivery_ready' => $open && ! in_array(true, array_column($rows, 'source_changed'), true) && ($p['partial_allowed'] ? $readyLines > 0 : ($readyLines === count($rows) && ! $needsReserve))];
|
||||
}
|
||||
|
||||
/** Current eligible people, not assignments or new grants.
|
||||
* @param array<string,mixed> $p
|
||||
* @return array<string,list<array{id:int,name:string}>>
|
||||
*/
|
||||
public function people(WarehouseRequest $r, array $p): array
|
||||
{
|
||||
$result = ['requester' => [], 'review' => [], 'delivery' => []];
|
||||
foreach (User::query()->where('tenant_id', $r->tenant_id)->orderBy('name')->get(['id', 'tenant_id', 'name', 'department_id', 'purchasing_role']) as $user) {
|
||||
if (app(OrganizationAccess::class)->maintenanceOnly($user)) {
|
||||
continue;
|
||||
}
|
||||
$context = app(WorkContext::class)->enforced($user) ? 'procurement.internal' : null;
|
||||
$permissions = app(OrganizationAccess::class)->permissions($user, $context);
|
||||
if ($context !== null && ! in_array($context, app(WorkContext::class)->available($user), true)) {
|
||||
continue;
|
||||
}
|
||||
$person = $user->only(['id', 'name']);
|
||||
if ($user->id === $r->created_by && in_array('warehouse_request_prepare', $permissions, true)) {
|
||||
$result['requester'][] = $person;
|
||||
}
|
||||
if ($user->id !== $r->created_by && $user->department_id === $r->snapshot['requester']['department_id'] && in_array('warehouse_request_review', $permissions, true)) {
|
||||
$result['review'][] = $person;
|
||||
}
|
||||
if ($user->id !== $r->created_by && $user->id !== $p['reviewer_id'] && in_array('warehouse_request_deliver', $permissions, true) && in_array('warehouse', $permissions, true)) {
|
||||
$result['delivery'][] = $person;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -63,21 +63,11 @@ class WorkContext
|
||||
/** @return list<string> */
|
||||
public function available(User $user): array
|
||||
{
|
||||
$enabled = $this->policy($user)['enabled'] ?? [];
|
||||
$granted = [];
|
||||
if (app(OrganizationAccess::class)->active($user->tenant_id)) {
|
||||
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
|
||||
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
|
||||
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.work_contexts', 'p.permissions']);
|
||||
foreach ($profiles as $profile) {
|
||||
$permissions = json_decode($profile->permissions, true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach (json_decode($profile->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR) as $key) {
|
||||
if ($this->functional($permissions, $key)) {
|
||||
$granted[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif (app()->environment(['testing', 'payable-testing'])) {
|
||||
return $this->configuredAvailable($user);
|
||||
}
|
||||
$granted = [];
|
||||
if (app()->environment(['testing', 'payable-testing'])) {
|
||||
// Development roles remain usable locally; never become customer grants.
|
||||
foreach (array_keys(self::OPTIONS) as $key) {
|
||||
if ($this->functional(app(OrganizationAccess::class)->permissions($user), $key)) {
|
||||
@@ -86,7 +76,31 @@ class WorkContext
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_intersect($this->purchased($user), $enabled, array_unique($granted)));
|
||||
return array_values(array_intersect($this->purchased($user), $this->policy($user)['enabled'] ?? [], array_unique($granted)));
|
||||
}
|
||||
|
||||
/** Same configured intersection used after activation, without enabling it.
|
||||
* @return list<string>
|
||||
*/
|
||||
public function configuredAvailable(User $user): array
|
||||
{
|
||||
if (! Schema::hasTable('organization_memberships') || ! Schema::hasColumn('organization_profiles', 'work_contexts')) {
|
||||
return [];
|
||||
}
|
||||
$granted = [];
|
||||
$profiles = DB::table('organization_memberships as m')->join('organization_profiles as p', 'p.id', '=', 'm.profile_id')
|
||||
->where('m.tenant_id', $user->tenant_id)->where('p.tenant_id', $user->tenant_id)->where('m.user_id', $user->id)
|
||||
->where(fn ($q) => $q->whereNull('m.expires_at')->orWhere('m.expires_at', '>', now()))->get(['p.work_contexts', 'p.permissions']);
|
||||
foreach ($profiles as $profile) {
|
||||
$permissions = json_decode($profile->permissions, true, 32, JSON_THROW_ON_ERROR);
|
||||
foreach (json_decode($profile->work_contexts ?? '[]', true, 32, JSON_THROW_ON_ERROR) as $key) {
|
||||
if ($this->functional($permissions, $key)) {
|
||||
$granted[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_intersect($this->purchased($user), $this->policy($user)['enabled'] ?? [], array_unique($granted)));
|
||||
}
|
||||
|
||||
/** @param list<string> $permissions */
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AdmissionDecision;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\QualificationAssessment;
|
||||
use App\Models\QualificationSet;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\RecommendationAttempt;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\SupplyRecord;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** Current tasks delegated to the existing module authorities, never another workflow. */
|
||||
class WorkflowTaskInbox
|
||||
{
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function build(User $actor, string $area): array
|
||||
{
|
||||
abort_unless(auth()->id() === $actor->id && in_array($area, ['procurement', 'people'], true), 403);
|
||||
abort_unless($area !== 'people' || app(PeopleHiring::class)->kinds($actor) !== [], 403);
|
||||
if (app(OrganizationAccess::class)->maintenanceOnly($actor)) {
|
||||
return [];
|
||||
}
|
||||
$rows = $area === 'people' ? $this->people($actor) : [...$this->purchases($actor), ...$this->quotations($actor), ...$this->documentary($actor), ...$this->formalReviews($actor), ...$this->supply($actor), ...$this->awards($actor), ...$this->warehouse($actor), ...$this->finance($actor)];
|
||||
usort($rows, fn ($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '') ?: strnatcmp($a['id'], $b['id']));
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function purchases(User $actor): array
|
||||
{
|
||||
$rows = [];
|
||||
$prepare = app(OrganizationAccess::class)->canRequest($actor);
|
||||
$query = Requisition::query()->visibleTo($actor)->where(function ($q) use ($actor, $prepare) {
|
||||
$q->where('status', 'pending');
|
||||
if ($prepare) {
|
||||
$q->orWhere(fn ($draft) => $draft->where('status', 'draft')->where('requester_id', $actor->id));
|
||||
}
|
||||
});
|
||||
foreach ($query->with('steps')->lazyById(100) as $record) {
|
||||
$draft = $record->status === 'draft' && $prepare && $record->requester_id === $actor->id;
|
||||
if (! $draft && ! $record->canDecide($actor)) {
|
||||
continue;
|
||||
}
|
||||
$step = $draft ? null : $record->steps->firstWhere('decision', 'pending');
|
||||
$rows[] = ['id' => 'purchase:'.$record->id.':'.($step->id ?? 'draft'), 'module' => 'purchases',
|
||||
'reference' => $record->number, 'label' => $draft ? 'Conferir e submeter requisição de compra' : 'Decidir a etapa de aprovação',
|
||||
'task' => $draft ? 'prepare' : 'approve', 'status' => $record->status, 'blocked' => false,
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$record->id];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function warehouse(User $actor): array
|
||||
{
|
||||
if (! Schema::hasTable('warehouse_requests') || ! Schema::hasTable('warehouse_request_events')) {
|
||||
return [];
|
||||
}
|
||||
$desk = app(WarehouseWorkdesk::class);
|
||||
$rows = [];
|
||||
foreach (['review', 'reserve', 'delivery', 'receipt'] as $view) {
|
||||
$query = $desk->forView($desk->visible($actor), $actor, $view);
|
||||
foreach ($query->lazyById(100) as $record) {
|
||||
$progress = app(WarehouseFulfilment::class)->progress($record);
|
||||
$health = in_array($view, ['reserve', 'delivery'], true) ? $desk->health($record, $progress) : null;
|
||||
if ($view === 'reserve' && ! $health['needs_reservation'] && ! $health['needs_check']) {
|
||||
continue;
|
||||
}
|
||||
$base = ['module' => 'warehouse', 'reference' => 'RA-'.$record->id, 'status' => $progress['state'],
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null];
|
||||
if ($view === 'receipt') {
|
||||
foreach ($progress['events'] as $event) {
|
||||
if ($event['action'] === 'deliver' && ! in_array($event['id'], $progress['received_deliveries'], true)) {
|
||||
$rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':receive:'.$event['id'], 'task' => 'receive',
|
||||
'label' => 'Confirmar recepção da entrega #'.$event['id'], 'blocked' => false,
|
||||
'created_at' => $this->date($event['created_at']), 'href' => '/stock/requisicoes/'.$record->id.'#warehouse-command-receive-'.$event['id']];
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
$action = $view === 'review' ? 'authorize' : ($view === 'delivery' ? 'deliver' : 'reserve');
|
||||
$blocked = $view === 'delivery' && ! $health['delivery_ready'];
|
||||
$rows[] = [...$base, 'id' => 'warehouse:'.$record->id.':'.$view, 'task' => $view,
|
||||
'label' => match ($view) {
|
||||
'review' => 'Autorizar ou recusar requisição interna',
|
||||
'reserve' => 'Conferir reposição e actualizar reserva',
|
||||
default => $blocked ? 'Conferir stock antes da entrega' : 'Confirmar a entrega física',
|
||||
}, 'blocked' => $blocked,
|
||||
'href' => '/stock/requisicoes/'.$record->id.($blocked ? '' : '#warehouse-command-'.$action)];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function people(User $actor): array
|
||||
{
|
||||
$kinds = app(PeopleHiring::class)->kinds($actor);
|
||||
abort_unless($kinds !== [], 403);
|
||||
if (! Schema::hasTable('people_processes') || ! Schema::hasTable('people_process_access')) {
|
||||
return [];
|
||||
}
|
||||
$query = app(PeopleAccess::class)->visible(PeopleProcess::query()->whereIn('kind', $kinds), $actor);
|
||||
$desk = app(PeopleWorkdesk::class)->build(clone $query, $actor);
|
||||
$dates = (clone $query)->pluck('created_at', 'id');
|
||||
$rows = [];
|
||||
foreach ([...$desk['tasks'], ...$desk['approvals']] as $task) {
|
||||
$rows[] = ['id' => 'people:'.$task['id'], 'module' => 'people', 'reference' => $task['reference'], 'label' => $task['label'],
|
||||
'task' => $task['action'], 'status' => $task['status'], 'blocked' => false,
|
||||
'created_at' => $this->date($dates->get($task['process_id'])), 'due_on' => $task['due_on'], 'href' => $task['href']];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function finance(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['finance'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
foreach (Invoice::query()->where('status', 'pending')->lazyById(100) as $invoice) {
|
||||
if (! app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['id' => 'invoice:'.$invoice->id, 'module' => 'finance', 'reference' => $invoice->number,
|
||||
'label' => 'Conferir factura de fornecedor', 'task' => 'invoice_review', 'status' => $invoice->status, 'blocked' => false,
|
||||
'created_at' => $this->date($invoice->created_at), 'due_on' => null, 'href' => '/facturas?invoice_id='.$invoice->id.'#invoice-'.$invoice->id];
|
||||
}
|
||||
foreach (ExternalPayment::query()->where('status', 'pending')->lazyById(100) as $payment) {
|
||||
if (! app(ExternalPaymentWorkflow::class)->reviewerEligible($actor, $payment)) {
|
||||
continue;
|
||||
}
|
||||
$invoice = Invoice::query()->whereKey($payment->invoice_id)->firstOrFail();
|
||||
$rows[] = ['id' => 'payment:'.$payment->id, 'module' => 'finance', 'reference' => $invoice->number,
|
||||
'label' => 'Conferir registo de pagamento externo', 'task' => 'payment_review', 'status' => $payment->status, 'blocked' => false,
|
||||
'created_at' => $this->date($payment->created_at), 'due_on' => null, 'href' => '/pagamentos?payment_id='.$payment->id.'#payment-'.$payment->id];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function awards(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$workflow = app(PurchaseWorkflow::class);
|
||||
$rows = [];
|
||||
$requests = Requisition::query()->visibleTo($actor);
|
||||
$query = PurchaseAward::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereIn('status', ['pending', 'approved']);
|
||||
foreach ($query->lazyById(100) as $award) {
|
||||
$request = (clone $requests)->whereKey($award->requisition_id)->firstOrFail();
|
||||
$decide = $workflow->canDecide($actor, $award, $request);
|
||||
if (! $decide && ! $workflow->canIssue($actor, $award, $request)) {
|
||||
continue;
|
||||
}
|
||||
$step = $decide ? $award->approvals()->where('decision', 'pending')->orderBy('position')->first() : null;
|
||||
$rows[] = ['id' => 'award:'.$award->id.':'.($step->id ?? 'issue'), 'module' => 'purchases', 'reference' => $request->number,
|
||||
'label' => $decide ? 'Decidir a etapa da adjudicação' : 'Conferir adjudicação para emitir ordem de compra',
|
||||
'task' => $decide ? 'award_approve' : 'order_issue_review', 'status' => $award->status, 'blocked' => false,
|
||||
'created_at' => $this->date($award->created_at), 'due_on' => null, 'href' => '/requisicoes/'.$request->id.'/adjudicacao'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
return $value === null ? null : CarbonImmutable::parse($value)->utc()->toIso8601String();
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function formalReviews(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
$admission = app(FormalAdmission::class);
|
||||
$recommendation = app(FormalRecommendation::class);
|
||||
$requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
|
||||
foreach ($requests->lazyById(100) as $request) {
|
||||
$base = ['module' => 'purchases', 'reference' => $request->number, 'blocked' => false, 'status' => 'pending', 'due_on' => null];
|
||||
$adoption = $admission->adoption($request);
|
||||
if ($adoption !== null) {
|
||||
$decisions = AdmissionDecision::query()->where('admission_adoption_id', $adoption->id)->whereNotExists(fn ($query) => $query->selectRaw('1')->from('admission_decisions as newer')
|
||||
->whereColumn('newer.tenant_id', 'admission_decisions.tenant_id')->whereColumn('newer.admission_adoption_id', 'admission_decisions.admission_adoption_id')
|
||||
->whereColumn('newer.quotation_id', 'admission_decisions.quotation_id')->whereColumn('newer.revision', '>', 'admission_decisions.revision'));
|
||||
foreach ($decisions->lazyById(100) as $decision) {
|
||||
if ($admission->canReview($actor, $request, $decision)) {
|
||||
$rows[] = [...$base, 'id' => 'admission:'.$decision->id.':review', 'task' => 'admission_review',
|
||||
'label' => 'Conferir proposta de admissão formal', 'created_at' => $this->date($decision->created_at),
|
||||
'href' => '/requisicoes/'.$request->id.'/admissao?version='.$adoption->id.'&decision_id='.$decision->id.'#admission-'.$decision->id];
|
||||
}
|
||||
}
|
||||
}
|
||||
$adoption = $recommendation->adoption($request);
|
||||
$attempt = $adoption === null ? null : RecommendationAttempt::query()->where('recommendation_adoption_id', $adoption->id)->latest('revision')->first();
|
||||
if ($adoption !== null && $attempt !== null && $recommendation->canReview($actor, $request, $attempt)) {
|
||||
$rows[] = [...$base, 'id' => 'recommendation:'.$attempt->id.':review', 'task' => 'recommendation_review',
|
||||
'label' => 'Conferir recomendação formal de adjudicação', 'created_at' => $this->date($attempt->created_at),
|
||||
'href' => '/requisicoes/'.$request->id.'/recomendacao?version='.$adoption->id.'&attempt_id='.$attempt->id.'#recommendation-'.$attempt->id];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** Only a person's own unfinished analysis, never a new mandatory analyst.
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function documentary(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
$workflow = app(DocumentaryQualificationWorkflow::class);
|
||||
$requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
|
||||
$query = QualificationSet::query()->whereIn('requisition_id', (clone $requests)->select('id'))->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_sets as newer')
|
||||
->whereColumn('newer.tenant_id', 'qualification_sets.tenant_id')->whereColumn('newer.requisition_id', 'qualification_sets.requisition_id')->whereColumn('newer.revision', '>', 'qualification_sets.revision'));
|
||||
foreach ($query->lazyById(100) as $set) {
|
||||
$request = (clone $requests)->whereKey($set->requisition_id)->firstOrFail();
|
||||
if (! $workflow->canAssess($actor, $request, $set)) {
|
||||
continue;
|
||||
}
|
||||
$records = QualificationAssessment::query()->where('qualification_set_id', $set->id)->where('analyst_id', $actor->id)->whereNotExists(fn ($newer) => $newer->selectRaw('1')->from('qualification_assessments as newer')
|
||||
->whereColumn('newer.tenant_id', 'qualification_assessments.tenant_id')->whereColumn('newer.qualification_set_id', 'qualification_assessments.qualification_set_id')
|
||||
->whereColumn('newer.analyst_id', 'qualification_assessments.analyst_id')->whereColumn('newer.quotation_id', 'qualification_assessments.quotation_id')->whereColumn('newer.revision', '>', 'qualification_assessments.revision'));
|
||||
foreach ($records->lazyById(100) as $record) {
|
||||
if (! collect($record->answers)->contains('result', 'pending')) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['id' => 'qualification:'.$record->id.':complete', 'module' => 'purchases', 'reference' => $request->number,
|
||||
'label' => 'Completar a minha análise documental', 'task' => 'qualification_complete', 'status' => 'pending', 'blocked' => false,
|
||||
'created_at' => $this->date($record->created_at), 'due_on' => null,
|
||||
'href' => '/requisicoes/'.$request->id.'/qualificacao?version='.$set->id.'"ation_id='.$record->quotation_id.'#qualification-assess'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function supply(User $actor): array
|
||||
{
|
||||
$planning = app(SupplyPlanning::class);
|
||||
if (! Schema::hasTable('supply_records') || ! $planning->allows($actor, 'supply_review')) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
foreach (['agreement', 'policy', 'auto_rule'] as $kind) {
|
||||
foreach ($planning->latest($kind)->whereIn('state', ['draft', 'returned'])->lazyById(100) as $record) {
|
||||
$eligible = $kind === 'auto_rule' ? app(AutomaticPurchases::class)->canReview($actor, $record) : $planning->canReview($actor, $record);
|
||||
if (! $eligible) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['id' => 'supply:'.$record->id.':review', 'module' => 'supply',
|
||||
'reference' => trim(($record->snapshot['item']['code'] ?? 'Fornecimento').' '.($record->snapshot['warehouse']['code'] ?? '')),
|
||||
'label' => match ($kind) {
|
||||
'agreement' => 'Conferir acordo de fornecimento', 'policy' => 'Conferir política de reposição', default => 'Conferir parametrização de compra automática',
|
||||
}, 'task' => $kind.'_review', 'status' => $record->state, 'blocked' => false, 'created_at' => $this->date($record->created_at), 'due_on' => null,
|
||||
'href' => $kind === 'auto_rule' ? '/stock/reposicao?rule_id='.$record->id.'#supply-rule-'.$record->id
|
||||
: '/compras/fornecimento?view='.($kind === 'agreement' ? 'agreements' : 'calendar').'&record_id='.$record->id.'#supply-record-'.$record->id];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function quotations(User $actor): array
|
||||
{
|
||||
if (! $actor->hasFunctions(['procurement', 'finance', 'department_head'])) {
|
||||
return [];
|
||||
}
|
||||
$access = app(QuotationParticipation::class);
|
||||
$rows = [];
|
||||
$requests = Requisition::query()->visibleTo($actor)->where('status', 'approved')
|
||||
->whereNotIn('id', PurchaseAward::query()->select('requisition_id'));
|
||||
foreach ($requests->lazyById(100) as $request) {
|
||||
$prepare = $access->canPrepare($actor, $request);
|
||||
if (! $prepare && ! $access->canEvaluateProcess($actor, $request)) {
|
||||
continue;
|
||||
}
|
||||
$comparison = app(ProposalComparison::class)->inspect($request);
|
||||
$path = '/requisicoes/'.$request->id.'/cotacoes';
|
||||
$base = ['module' => 'purchases', 'reference' => $request->number, 'status' => $request->status, 'blocked' => false,
|
||||
'created_at' => $this->date($request->created_at), 'due_on' => null];
|
||||
if ($prepare) {
|
||||
if (count($comparison['rows']) < $comparison['policy']['minimum_proposals']) {
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$request->id.':prepare', 'task' => 'quotation_prepare',
|
||||
'label' => 'Completar consulta ao mercado', 'href' => $path.'?prepare=1#quotation-create'];
|
||||
} elseif (! $comparison['ready'] && collect($comparison['rows'])->contains('eligible', false)) {
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$request->id.':check', 'task' => 'quotation_check',
|
||||
'label' => 'Conferir propostas ou impedimentos da comparação', 'href' => $path];
|
||||
} elseif ($comparison['ready'] && $actor->id !== $request->requester_id) {
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$request->id.':result', 'task' => 'comparison_review',
|
||||
'label' => 'Conferir comparação e requisitos da adjudicação', 'href' => '/requisicoes/'.$request->id.'/adjudicacao'];
|
||||
}
|
||||
}
|
||||
$quotes = Quotation::query()->where('requisition_id', $request->id)->with('supplier')->get()->keyBy('id');
|
||||
foreach ($comparison['rows'] as $row) {
|
||||
$quote = $quotes->get($row['id']);
|
||||
if (! $row['eligible'] || $row['evaluation_count'] >= $comparison['policy']['minimum_evaluators'] || $quote === null || ! $access->canEvaluate($actor, $request, $quote)) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [...$base, 'id' => 'quotation:'.$quote->id.':evaluate', 'task' => 'quotation_evaluate',
|
||||
'label' => 'Avaliar proposta '.$quote->reference, 'created_at' => $this->date($quote->created_at),
|
||||
'href' => $path.'?quotation_id='.$quote->id.'#quotation-'.$quote->id];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,15 @@ class WorkflowTasks
|
||||
public function catalogue(): array
|
||||
{
|
||||
$specific = [
|
||||
'supply_prepare' => ['supply.prepare' => 'Preparar acordos, políticas e parametrização da compra automática; reavaliar necessidades'],
|
||||
'supply_review' => ['supply.review' => 'Conferir políticas e activar ou suspender parametrizações preparadas por outra pessoa'],
|
||||
'warehouse_request_review' => ['warehouse.request.review' => 'Decidir requisições internas do departamento, actualizar reservas e cancelar saldos pendentes'],
|
||||
'warehouse_request_prepare' => ['warehouse.request.prepare' => 'Preparar e submeter requisições internas próprias; acompanhar reservas e confirmar a recepção das entregas'],
|
||||
'warehouse_request_deliver' => ['warehouse.request.deliver' => 'Acompanhar entregas pendentes e confirmar a saída efectiva, em conjunto com a tarefa de armazém e com independência do requisitante e autorizador'],
|
||||
'requester' => ['requisition.prepare' => 'Preparar e submeter requisições próprias; acompanhar decisões'],
|
||||
'department_head' => ['requisition.approve.department_head' => 'Decidir a etapa de aprovação do próprio departamento'],
|
||||
'procurement' => ['requisition.approve.procurement' => 'Decidir a etapa de Compras quando exigida pelo percurso', 'procurement.prepare' => 'Preparar e acompanhar consulta ao mercado e aquisição'],
|
||||
'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Exercer as conferências e operações financeiras permitidas pelo perfil'],
|
||||
'department_head' => ['requisition.approve.department_head' => 'Decidir as etapas previstas de requisição e adjudicação do próprio departamento; analisar requisitos documentais e conferir admissão/recomendação quando previsto no regime adoptado, com independência'],
|
||||
'procurement' => ['requisition.approve.procurement' => 'Decidir as etapas previstas de Compras na requisição e adjudicação', 'procurement.prepare' => 'Preparar consulta, requisitos documentais e propostas formais conforme os regimes adoptados; avaliar propostas elegíveis e conferir a emissão da ordem após as aprovações exigidas'],
|
||||
'finance' => ['requisition.approve.finance' => 'Decidir a etapa de Finanças quando exigida pelo percurso', 'finance.operations' => 'Analisar requisitos documentais e conferir admissão/recomendação conforme o regime adoptado; conferir facturas e registos de pagamentos externos de outra pessoa, com revalidação dos conflitos e competências específicas'],
|
||||
'people_service_prepare' => ['people.service.prepare' => 'Preparar processo, candidaturas, proposta de selecção e minuta de prestador individual'],
|
||||
'people_staff_prepare' => ['people.staff.prepare' => 'Preparar processo, candidaturas, proposta de selecção e minuta de recrutamento'],
|
||||
'people_service_review' => ['people.service.review' => 'Conferir abertura, avaliar e decidir selecção de prestador individual conforme a equipa atribuída'],
|
||||
|
||||
Reference in New Issue
Block a user