commit
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-28 12:06:05 +02:00
parent 90a6848721
commit 4d42f083ce
82 changed files with 7538 additions and 76 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $role
* @property string $decision
* @property int|null $approver_id
*/
class ApprovalStep extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use LogicException;
class AuditLog extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['before' => 'array', 'after' => 'array'];
}
protected static function booted(): void
{
static::updating(fn () => throw new LogicException('O histórico de auditoria não pode ser alterado.'));
static::deleting(fn () => throw new LogicException('O histórico de auditoria não pode ser eliminado.'));
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
$tenantId = auth()->check() ? auth()->user()->tenant_id : null;
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0);
});
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Models\Concerns;
use LogicException;
trait ImmutablePurchaseRecord
{
protected static function bootImmutablePurchaseRecord(): void
{
static::updating(fn () => throw new LogicException('O documento emitido não pode ser reescrito.'));
static::deleting(fn () => throw new LogicException('O documento emitido deve ser preservado.'));
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use App\Enums\PurchasingRole;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use LogicException;
/**
* @property int $id
* @property int $tenant_id
* @property int $user_id
* @property int|null $supplier_id
* @property int|null $requisition_id
* @property int|null $root_id
* @property bool $has_conflict
*/
class ConflictDeclaration extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['has_conflict' => 'boolean', 'known_since' => 'date:Y-m-d', 'declared_at' => 'datetime'];
}
protected static function booted(): void
{
static::updating(fn () => throw new LogicException('As declarações são preservadas; registe uma correcção.'));
static::deleting(fn () => throw new LogicException('As declarações de conflito não podem ser eliminadas.'));
}
/** @param Builder<ConflictDeclaration> $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
if (! in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
$query->where('user_id', $user->id);
}
}
/** @return BelongsTo<User, $this> */
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/** @return BelongsTo<Supplier, $this> */
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
/** @return BelongsTo<Requisition, $this> */
public function requisition(): BelongsTo
{
return $this->belongsTo(Requisition::class);
}
/** @return HasOne<ConflictDeclaration, $this> */
public function successor(): HasOne
{
return $this->hasOne(self::class, 'previous_id');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $department_id
*/
class CostCenter extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id */
class Department extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $quality
* @property string $history
* @property string $payment
*/
class ProposalEvaluation extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected function casts(): array
{
return ['no_conflict' => 'boolean', 'quality' => 'decimal:2', 'history' => 'decimal:2', 'payment' => 'decimal:2'];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $tenant_id
* @property int $requisition_id
* @property int $quotation_id
* @property int $created_by
* @property string $status
* @property string $comparison_hash
* @property array<string, mixed> $approval_snapshot
* @property list<array<string, mixed>> $decision_matrix
*/
class PurchaseAward extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected function casts(): array
{
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'delivery_date' => 'date:Y-m-d'];
}
/** @return HasMany<PurchaseAwardApproval, $this> */
public function approvals(): HasMany
{
return $this->hasMany(PurchaseAwardApproval::class)->orderBy('position');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class PurchaseAwardApproval extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use App\Models\Concerns\ImmutablePurchaseRecord;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $tenant_id
* @property int $requisition_id
* @property string $number
* @property array<string, mixed> $snapshot
*/
class PurchaseOrder extends Model
{
use BelongsToTenant, ImmutablePurchaseRecord;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['snapshot' => 'array', 'issued_at' => 'datetime'];
}
/** @return HasMany<PurchaseOrderItem, $this> */
public function items(): HasMany
{
return $this->hasMany(PurchaseOrderItem::class);
}
/** @param Builder<PurchaseOrder> $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
$query->whereIn('requisition_id', Requisition::query()->visibleTo($user)->select('id'));
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use App\Models\Concerns\ImmutablePurchaseRecord;
use Illuminate\Database\Eloquent\Model;
class PurchaseOrderItem extends Model
{
use BelongsToTenant, ImmutablePurchaseRecord;
public $timestamps = false;
protected $guarded = ['id'];
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** @property array<string, mixed> $weights */
class Quotation extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected function casts(): array
{
return ['weights' => 'array', 'valid_until' => 'date:Y-m-d'];
}
/** @return BelongsTo<Supplier, $this> */
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
/** @return HasMany<QuotationItem, $this> */
public function items(): HasMany
{
return $this->hasMany(QuotationItem::class);
}
/** @return HasMany<ProposalEvaluation, $this> */
public function evaluations(): HasMany
{
return $this->hasMany(ProposalEvaluation::class);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $quantity
* @property string $tax_rate
*/
class QuotationItem extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected function casts(): array
{
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Models;
use App\Enums\PurchasingRole;
use App\Models\Concerns\BelongsToTenant;
use App\Services\ConflictGuard;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $tenant_id
* @property int $department_id
* @property int $requester_id
* @property string $number
* @property string $status
* @property int $subtotal_minor
* @property int $tax_minor
* @property int $total_minor
* @property Collection<int, ApprovalStep> $steps
* @property Collection<int, RequisitionItem> $items
*/
class Requisition extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected function casts(): array
{
return ['approval_snapshot' => 'array', 'submitted_at' => 'datetime', 'needed_by' => 'date:Y-m-d'];
}
/** @return HasMany<RequisitionItem, $this> */
public function items(): HasMany
{
return $this->hasMany(RequisitionItem::class);
}
/** @return HasMany<ApprovalStep, $this> */
public function steps(): HasMany
{
return $this->hasMany(ApprovalStep::class)->orderBy('position');
}
/** @return BelongsTo<User, $this> */
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requester_id');
}
/** @return BelongsTo<Department, $this> */
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
/** @return BelongsTo<CostCenter, $this> */
public function costCenter(): BelongsTo
{
return $this->belongsTo(CostCenter::class);
}
/** @param Builder<Requisition> $query */
public function scopeVisibleTo(Builder $query, User $user): void
{
match ($user->purchasing_role) {
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id),
default => $query->where('requester_id', $user->id),
};
}
public function canDecide(User $user): bool
{
if ($this->tenant_id !== $user->tenant_id || $this->status !== 'pending' || $this->requester_id === $user->id) {
return false;
}
if ($this->steps->contains('approver_id', $user->id)) {
return false;
}
$next = $this->steps->firstWhere('decision', 'pending');
return $next !== null && $next->role === $user->purchasing_role->value
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* Decimal casts expose exact decimal strings for monetary calculations.
*
* @property string $quantity
* @property string $tax_rate
*/
class RequisitionItem extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $tenant_id
* @property int $created_by
* @property int|null $last_changed_by
* @property int $version
*/
class Supplier extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $attributes = ['version' => 1];
protected function casts(): array
{
return ['blocked' => 'boolean', 'next_review' => 'date:Y-m-d'];
}
public function eligible(): bool
{
return $this->status === 'active' && ! $this->blocked && $this->next_review >= today();
}
/** @return HasMany<SupplierDocument, $this> */
public function documents(): HasMany
{
return $this->hasMany(SupplierDocument::class);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $supplier_id
* @property string $path
* @property string $original_name
*/
class SupplierDocument extends Model
{
use BelongsToTenant;
protected $guarded = ['id'];
protected $hidden = ['path', 'tenant_id'];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name
* @property int $first_limit_minor
* @property int $second_limit_minor
* @property bool $approval_includes_tax
* @property bool $rules_provisional
*/
class Tenant extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean'];
}
}
+5
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\PurchasingRole;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -12,6 +13,9 @@ use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int|null $tenant_id
* @property int|null $department_id
* @property PurchasingRole $purchasing_role
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
@@ -38,6 +42,7 @@ class User extends Authenticatable
protected function casts(): array
{
return [
'purchasing_role' => PurchasingRole::class,
'email_verified_at' => 'datetime',
'password' => 'hashed',
];