Files
InvoiceShelf/app/Domains/Metadata/Models/CustomField.php
T

200 lines
5.7 KiB
PHP

<?php
namespace App\Domains\Metadata\Models;
use App\Domains\Accounts\Models\Company;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* An extra question a company attaches to one of its record types.
*
* The definition holds the question -- label, input type, option list,
* placeholder, position in the form, whether an answer is compulsory -- and
* the answer to fall back on when none is given. That fallback is kept in
* whichever of the six typed columns the input type maps to, exactly the way
* a record's own answer is, so a single mapping helper serves both.
*
* Two things are looser than they look. The record type is a free string:
* nothing checks it against the handful of types the interface offers. And
* the slug a template addresses the field by is stamped once, at creation,
* and never recomputed, so renaming a field leaves every placeholder that
* already names it working.
*/
class CustomField extends Model
{
protected $table = 'custom_fields';
use HasFactory;
/**
* Everything but the key may be mass assigned.
*
* @var array
*/
protected $guarded = [
'id',
];
/**
* Inert. The framework stopped reading this property, so the two columns
* named here are handed out as the plain strings the driver returns, not
* as date objects. Kept because it is part of the class as published.
*
* @var array
*/
protected $dates = [
'date_answer',
'date_time_answer',
];
/**
* The fallback answer travels with every serialized definition, under the
* camel-cased name it is computed from.
*
* @var array
*/
protected $appends = [
'defaultAnswer',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'options' => 'array',
];
}
/**
* Reduce a time-of-day fallback to H:i:s.
*
* An empty value never reaches the attribute bag -- not even as null --
* so clearing the time on a definition that already has one silently
* leaves the old time in place. A value the parser cannot read becomes
* midnight rather than an error.
*/
public function setTimeAnswerAttribute(mixed $value): void
{
if ($value) {
$this->attributes['time_answer'] = date('H:i:s', strtotime($value));
}
}
/**
* Encode the option list on the way in.
*
* A set mutator wins over the array cast, so this runs in its place and
* encodes whatever arrives: null is stored as the four characters "null",
* and a string that is already JSON is encoded a second time and reads
* back as a string rather than as the structure it spells.
*/
public function setOptionsAttribute(mixed $value): void
{
$this->attributes['options'] = json_encode($value);
}
/**
* The fallback answer, read from the column this field's input type maps
* to. A type outside the mapping reads the string column.
*/
public function getDefaultAnswerAttribute()
{
$answerColumn = getCustomFieldValueKey($this->type);
return $this->{$answerColumn};
}
/**
* Whether any record has an answer on file for this field.
*
* Serialized with the definition so the interface can warn before a
* delete; nothing on the delete path itself consults it.
*/
public function getInUseAttribute()
{
return $this->customFieldValues()->exists();
}
/**
* The company the field was defined in.
*/
public function company(): BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
/**
* Every answer recorded against this field, whatever record type holds it.
*/
public function customFieldValues(): HasMany
{
return $this->hasMany(CustomFieldValue::class, 'custom_field_id');
}
/**
* Narrow to the company the current request is acting on.
*
* The company is read from the request header; the scope takes no
* argument and cannot be pointed at a different company.
*/
public function scopeWhereCompany($query)
{
$company = request()->header('company');
return $query->where('custom_fields.company_id', $company);
}
/**
* Partial match on either name the field goes by, grouped so it stays one
* condition when it is combined with others.
*/
public function scopeWhereSearch($query, $search)
{
$needle = '%'.$search.'%';
$query->where(function ($grouped) use ($needle) {
$grouped->where('label', 'LIKE', $needle)
->orWhere('name', 'LIKE', $needle);
});
}
/**
* Fields attached to one record type.
*/
public function scopeWhereType($query, $type)
{
$query->where('custom_fields.model_type', $type);
}
/**
* Apply the listing filters that carry a value. An empty string, a zero
* or a null counts as a filter that was not sent.
*/
public function scopeApplyFilters($query, array $filters)
{
$wanted = collect($filters);
if ($type = $wanted->get('type')) {
$query->whereType($type);
}
if ($search = $wanted->get('search')) {
$query->whereSearch($search);
}
}
/**
* A page of the requested size, or the whole set for the sentinel limit
* "all".
*/
public function scopePaginateData($query, $limit)
{
return $limit == 'all' ? $query->get() : $query->paginate($limit);
}
}