Files
compras/app/Services/RequisitionCatalogue.php
2026-11-02 20:27:11 +02:00

134 lines
7.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\RequisitionItem;
use App\Models\SparePart;
use App\Models\StockItem;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use stdClass;
class RequisitionCatalogue
{
public function canManage(User $actor): bool
{
return $actor->hasFunctions(['admin', 'warehouse']) && ! app(OrganizationAccess::class)->maintenanceOnly($actor);
}
/** @return array<string,mixed> */
public function source(StockItem $stock): array
{
return $stock->only(['id', 'code', 'name', 'unit']);
}
public function basis(StockItem $stock): string
{
return hash('sha256', json_encode($this->source($stock), JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $input */
public function save(User $actor, int $stockId, array $input): void
{
DB::transaction(function () use ($actor, $stockId, $input) {
app(ConflictGuard::class)->lockCompany($actor);
$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,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.']);
}
$values = ['category' => $data['category'], 'purchase_type' => $data['purchase_type'], 'published' => $data['published'],
'source_snapshot' => json_encode($this->source($stock), JSON_THROW_ON_ERROR), 'version' => ($entry->version ?? 0) + 1, 'updated_at' => now()];
if ($entry === null) {
$id = DB::table('requisition_catalogue')->insertGetId([...$values, 'tenant_id' => $actor->tenant_id, 'stock_item_id' => $stock->id, 'created_at' => now()]);
} else {
$id = $entry->id;
DB::table('requisition_catalogue')->where('id', $id)->update($values);
}
app(RequisitionWorkflow::class)->audit($actor, 'stock.catalogue_configured', null, $entry === null ? null : (array) $entry,
['id' => $id, 'stock_item_id' => $stock->id, ...$values, 'reason' => $data['reason']]);
});
}
/** @return array<string,mixed> */
public function options(User $actor, string $search): array
{
$ready = Schema::hasTable('requisition_catalogue');
$items = [];
if ($ready && app(OrganizationAccess::class)->canRequest($actor)) {
$query = DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('published', true);
$query->orderBy('id')->chunkById(100, function ($entries) use ($actor, $search, &$items) {
$stocks = StockItem::query()->where('tenant_id', $actor->tenant_id)->whereIn('id', $entries->pluck('stock_item_id'))->get()->keyBy('id');
foreach ($entries as $entry) {
$stock = $stocks->get($entry->stock_item_id);
if ($stock === null || json_decode($entry->source_snapshot, true, 16, JSON_THROW_ON_ERROR) !== $this->source($stock)) {
continue;
}
if ($search !== '' && ! str_contains(mb_strtolower($stock->code.' '.$stock->name), mb_strtolower($search))) {
continue;
}
$items[] = ['id' => $entry->id, 'version' => $entry->version, ...$this->sourceDescriptor($entry)];
if (count($items) === 21) {
return false;
}
}
return true;
});
}
return ['ready' => $ready, 'search' => $search, 'items' => array_slice($items, 0, 20), 'more' => count($items) > 20];
}
/** @return array<string,mixed> */
private function sourceDescriptor(stdClass $entry): array
{
$source = json_decode($entry->source_snapshot, true, 16, JSON_THROW_ON_ERROR);
return ['stock_item_id' => $source['id'], 'code' => $source['code'], 'description' => $source['name'], 'unit' => $source['unit'],
'category' => $entry->category, 'purchase_type' => $entry->purchase_type];
}
/** @param array<string,mixed> $line
* @param array<int,RequisitionItem> $existing
* @return array<string,mixed>
*/
public function line(User $actor, array $line, array $existing): array
{
if (empty($line['catalogue_entry_id'])) {
return $line;
}
abort_unless(Schema::hasTable('requisition_catalogue'), 503, 'Instalação do catálogo pendente.');
$input = Validator::make($line, ['catalogue_entry_id' => 'required|integer|min:1', 'catalogue_version' => 'required|integer|min:1', 'source_line_id' => 'nullable|integer|min:1'])->validate();
$old = isset($input['source_line_id']) ? ($existing[$input['source_line_id']] ?? null) : null;
if ($old !== null && $old->catalogue_entry_id === (int) $input['catalogue_entry_id'] && ($old->catalogue_snapshot['version'] ?? null) === (int) $input['catalogue_version']) {
$snapshot = $old->catalogue_snapshot;
} else {
$entry = DB::table('requisition_catalogue')->where('tenant_id', $actor->tenant_id)->where('id', $input['catalogue_entry_id'])->lockForUpdate()->first();
abort_unless($entry !== null, 404);
$stock = StockItem::query()->where('tenant_id', $actor->tenant_id)->where('id', $entry->stock_item_id)->lockForUpdate()->firstOrFail();
if (! $entry->published || $entry->version !== (int) $input['catalogue_version'] || json_decode($entry->source_snapshot, true, 16, JSON_THROW_ON_ERROR) !== $this->source($stock)) {
throw ValidationException::withMessages(['items' => 'O artigo do catálogo mudou ou deixou de estar publicado. Pesquise e seleccione a versão actual.']);
}
$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']];
}
}