Files
autocrm/app/Models/Tenant/Part.php
T
Vasyka 00fb4a304a feat(units): add Unit-of-measure nomenclator (per-tenant, 3 langs)
- Migration: create `units` table + nullable unit_id FK on
  parts / wo_parts / purchase_items / labor_parts. Seeds ~11 standard
  units (buc/set/l/ml/kg/g/m/cm/m²/oră/pack) for every existing
  tenant and backfills unit_id by matching the legacy string `unit`
  column. Keeps `unit` string as fallback so old code paths keep
  rendering.
- Unit model: label(locale), forSelect(locale), labelFor(id, code)
  helpers. All 4 owner models get unitModel() relation + unitLabel()
  accessor.
- UnitResource under Depozit group with Filament UI: code, sort,
  is_active + separate name_ro/name_ru/name_en fields.
- Filament forms/tables updated: Part / PurchaseItem / LaborPart /
  WorkOrderPart now use Select('unit_id')->options(Unit::forSelect())
  for input and TextColumn->getStateUsing(unitLabel()) for display.
  Selecting a part auto-fills unit_id when the source has one.
- +10 translations for the new resource + defaults; nav.label
  'Unități de măsură' added to all 3 lang files.

All 306 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-16 20:52:06 +00:00

172 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Part extends Model implements HasMedia
{
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
public function registerMediaCollections(): void
{
// Multi-image gallery (catalog uses imageUrl() = first; detail page renders all).
$this->addMediaCollection('image');
}
public function imageUrl(): ?string
{
$m = $this->getFirstMedia('image');
if (! $m) return null;
if (! @file_exists($m->getPath())) return null;
return $m->getUrl();
}
/** @return list<string> All published image URLs (excluding any whose file is missing). */
public function imageUrls(): array
{
return $this->getMedia('image')
->filter(fn ($m) => @file_exists($m->getPath()))
->map(fn ($m) => $m->getUrl())
->values()->all();
}
public const CATEGORIES = [
'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide',
'Distribuție', 'Anvelope', 'Electrică', 'Caroserie', 'Altele',
];
protected $fillable = [
'company_id', 'name', 'article', 'brand', 'category',
'qty', 'qty_reserved', 'unit', 'unit_id', 'min_qty',
'buy_price', 'sell_price', 'hidden_markup_pct',
'location', 'barcode', 'preferred_supplier_id',
'is_active', 'is_published', 'notes',
];
public function unitModel()
{
return $this->belongsTo(Unit::class, 'unit_id');
}
/** Localised UM label (Unit::label() or the legacy `unit` string). */
public function unitLabel(?string $locale = null): string
{
return Unit::labelFor($this->unit_id, $this->unit, $locale);
}
protected $casts = [
'qty' => 'decimal:2',
'qty_reserved' => 'decimal:3',
'min_qty' => 'decimal:2',
'buy_price' => 'decimal:2',
'sell_price' => 'decimal:2',
'hidden_markup_pct' => 'decimal:2',
'is_active' => 'boolean',
'is_published' => 'boolean',
];
/** Internal cost+hidden markup (NOT shown to customer). Used for margin analytics + B2B contract pricing. */
public function internalCostWithHiddenMarkup(): float
{
$base = (float) $this->buy_price;
$pct = (float) ($this->hidden_markup_pct ?: 0);
return round($base * (1 + $pct / 100), 2);
}
public function preferredSupplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
}
public function batches(): HasMany
{
return $this->hasMany(PartBatch::class);
}
public function reservations(): HasMany
{
return $this->hasMany(PartReservation::class);
}
public function events(): HasMany
{
return $this->hasMany(WarehouseEvent::class);
}
public function priceHistory(): HasMany
{
return $this->hasMany(SupplierPartPrice::class);
}
public function crossRefs(): HasMany
{
return $this->hasMany(PartCrossRef::class);
}
public function scopePublished($q)
{
return $q->where('is_active', true)->where('is_published', true);
}
/**
* Search published parts by free text against name / article / brand and
* any registered cross-reference article. Returns a query builder.
*/
public static function searchPublished(?string $term)
{
$q = static::published();
if ($term = trim((string) $term)) {
$like = '%' . $term . '%';
$q->where(function ($w) use ($like, $term) {
$w->where('name', 'like', $like)
->orWhere('article', 'like', $like)
->orWhere('brand', 'like', $like)
->orWhereHas('crossRefs', fn ($c) => $c->where('cross_article', 'like', $like));
});
}
return $q;
}
/** Live total across all batches of all warehouses (source of truth). */
public function qtyOnHand(?int $warehouseId = null): float
{
$q = $this->batches()->newQuery()->where('part_id', $this->id);
if ($warehouseId) $q->where('warehouse_id', $warehouseId);
return (float) $q->sum('qty_remaining');
}
/** Available for new reservations = on hand already reserved. */
public function qtyAvailable(?int $warehouseId = null): float
{
return max(0.0, $this->qtyOnHand($warehouseId) - (float) $this->qty_reserved);
}
public function isLow(): bool
{
return (float) $this->qty <= (float) $this->min_qty;
}
public function isOut(): bool
{
return (float) $this->qty <= 0;
}
/**
* Legacy direct-stock adjustment.
* NOTE: this only moves the cached `qty` column. Real stock changes
* should go through WarehouseService so batches + events stay in sync.
*/
public function adjustStock(float $delta, ?string $reason = null): void
{
$this->qty = max(0, (float) $this->qty + $delta);
$this->save();
}
}