mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
420 lines
16 KiB
PHP
420 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Models\Traits;
|
||
|
||
use App\Models\Actionlog;
|
||
use App\Models\Order;
|
||
use App\Models\OrderItem;
|
||
use App\Models\Setting;
|
||
use App\Models\Supplier;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||
|
||
/**
|
||
* Wires an inventory model up to the polymorphic Orders + OrderItems
|
||
* data model. Consumed by Accessory / Consumable / Component (via the
|
||
* AdjustsQuantity flow) and by Asset / License (for historical order
|
||
* lookups even though they don't get replenished per-event).
|
||
*
|
||
* `orderItems()` is the direct polymorphic relation — one row per line
|
||
* on an Order that referenced this model. `orders()` is a
|
||
* HasManyThrough convenience that surfaces the underlying Order rows
|
||
* so free-text search on
|
||
* `$searchableRelations = ['orders' => ['order_number']]` lands on the
|
||
* joined column without the caller having to walk through order_items
|
||
* manually.
|
||
*/
|
||
trait HasOrders
|
||
{
|
||
/**
|
||
* Hook a model lifecycle listener so that force-deleting an
|
||
* inventory row (Accessory / Consumable / Component / Asset /
|
||
* License) soft-deletes every OrderItem pointing at it. Preserves
|
||
* the acquisition ledger — the Order row and its (now-trashed)
|
||
* OrderItem lines remain queryable via `withTrashed()` for
|
||
* historical reports, while ordinary `->orderItems()` reads exclude
|
||
* them. Soft-delete on the parent does not propagate. The Order
|
||
* data model treats a soft-deleted inventory row as still-existing.
|
||
*/
|
||
protected static function bootHasOrders(): void
|
||
{
|
||
static::deleting(function ($model) {
|
||
if (! method_exists($model, 'isForceDeleting') || ! $model->isForceDeleting()) {
|
||
return;
|
||
}
|
||
|
||
OrderItem::where('item_type', static::class)
|
||
->where('item_id', $model->id)
|
||
->delete();
|
||
});
|
||
}
|
||
|
||
public function orderItems(): MorphMany
|
||
{
|
||
return $this->morphMany(OrderItem::class, 'item');
|
||
}
|
||
|
||
/**
|
||
* HasManyThrough into Orders via the polymorphic order_items pivot.
|
||
* The extra where on order_items.item_type filters the pivot to
|
||
* lines that reference THIS model class, which HasManyThrough on
|
||
* its own can't do because it doesn't understand morph maps.
|
||
*/
|
||
public function orders(): HasManyThrough
|
||
{
|
||
return $this->hasManyThrough(
|
||
Order::class,
|
||
OrderItem::class,
|
||
'item_id', // FK on order_items pointing at model
|
||
'id', // FK on orders (its primary key)
|
||
'id', // local key on this model
|
||
'order_id', // FK on order_items pointing at orders
|
||
)->where('order_items.item_type', static::class);
|
||
}
|
||
|
||
/**
|
||
* Count of distinct Orders this item has been *purchased* on. Feeds
|
||
* the info-panel's "Total Orders" row and the Orders-tab badge.
|
||
* Filters to lines with positive qty so corrections / consumption
|
||
* events (0- or negative-qty OrderItems) don't inflate the count —
|
||
* those aren't purchases, and treating them as such was misleading
|
||
* when a lifecycle had more corrections than actual acquisitions.
|
||
* DISTINCT because one Order can carry multiple positive lines for
|
||
* the same item under staggered receipts.
|
||
*/
|
||
public function ordersCount(): int
|
||
{
|
||
return (int) $this->orders()
|
||
->where('order_items.qty', '>', 0)
|
||
->distinct()
|
||
->count('orders.id');
|
||
}
|
||
|
||
/**
|
||
* Prefill context for the adjust-quantity modal and the info-panel's
|
||
* "last" fields. Prefers the most recent Order/OrderItem when one
|
||
* exists (companies drift — the last supplier they actually bought
|
||
* from beats a stale parent "default" field), and falls back to the
|
||
* parent's `default_*` template values on items that have never been
|
||
* ordered yet.
|
||
*
|
||
* Returns null only when there is no last-order data AND no template
|
||
* defaults on the parent — a brand-new item with no history to seed
|
||
* from.
|
||
*
|
||
* One query per invocation. Cheap on the view page (1 model per
|
||
* page); eager-load on index pages.
|
||
*
|
||
* @return array{unit_cost: ?string, currency: ?string, purchase_date: ?string, supplier_id: ?int}|null
|
||
*/
|
||
public function lastOrderDefaults(): ?array
|
||
{
|
||
$line = $this->orderItems()
|
||
->with('order:id,currency,purchase_date,supplier_id')
|
||
->latest('id')
|
||
->first();
|
||
|
||
if ($line) {
|
||
return [
|
||
'unit_cost' => $line->price !== null ? (string) $line->price : null,
|
||
'currency' => $line->order?->currency ?: null,
|
||
'purchase_date' => $line->order?->purchase_date?->toDateString(),
|
||
'supplier_id' => $line->order?->supplier_id,
|
||
];
|
||
}
|
||
|
||
$defaultSupplier = $this->default_supplier_id;
|
||
$defaultCost = $this->default_purchase_cost;
|
||
|
||
if ($defaultSupplier === null && $defaultCost === null) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'unit_cost' => $defaultCost !== null ? (string) $defaultCost : null,
|
||
'currency' => null,
|
||
'purchase_date' => null,
|
||
'supplier_id' => $defaultSupplier !== null ? (int) $defaultSupplier : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Resolve a Supplier for the "last acquisition" view (transformers,
|
||
* info-panel, report callbacks). Same fallback ladder as
|
||
* lastOrderDefaults(): last Order.supplier_id wins, falls back to
|
||
* the parent's default_supplier_id template value on items with no
|
||
* order history. Returns null when both are unset.
|
||
*
|
||
* Prefers walking eager-loaded relations (orderItems.order.supplier)
|
||
* when the caller pre-loaded them; otherwise issues one query for
|
||
* the latest OrderItem's Order.supplier_id and then hydrates the
|
||
* Supplier. Callers rendering a list should always eager-load to
|
||
* avoid N+1.
|
||
*/
|
||
public function lastAcquisitionSupplier(): ?Supplier
|
||
{
|
||
$cachedSupplier = $this->cachedLastOrderSupplier();
|
||
if ($cachedSupplier !== null) {
|
||
return $cachedSupplier;
|
||
}
|
||
|
||
$supplierId = $this->lastOrderSupplierId() ?? $this->default_supplier_id;
|
||
|
||
return $supplierId ? Supplier::find($supplierId) : null;
|
||
}
|
||
|
||
/**
|
||
* Fast-path: return the eager-loaded Supplier from the
|
||
* `orderItems.order.supplier` chain if the caller pre-loaded it.
|
||
* Callers rendering a list should eager-load this chain to avoid
|
||
* an N+1 through Supplier::find below.
|
||
*/
|
||
private function cachedLastOrderSupplier(): ?Supplier
|
||
{
|
||
if (! $this->relationLoaded('orderItems')) {
|
||
return null;
|
||
}
|
||
|
||
$order = $this->orderItems->sortByDesc('id')->first()?->order;
|
||
|
||
if ($order?->relationLoaded('supplier') && $order->supplier) {
|
||
return $order->supplier;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Slow-path: read the latest OrderItem's Order.supplier_id via a
|
||
* fresh query. Called only when the eager-loaded fast-path missed.
|
||
*/
|
||
private function lastOrderSupplierId(): ?int
|
||
{
|
||
if ($this->relationLoaded('orderItems')) {
|
||
return $this->orderItems->sortByDesc('id')->first()?->order?->supplier_id;
|
||
}
|
||
|
||
return $this->orderItems()->with('order:id,supplier_id')->latest('id')->first()?->order?->supplier_id;
|
||
}
|
||
|
||
/**
|
||
* Sort scope that lets the bootstrap-table sortable header for an
|
||
* order-number column keep working after the parent order_number
|
||
* column moved to Orders. Attaches a correlated subquery selecting
|
||
* the latest Order.order_number for each row (matched via the
|
||
* polymorphic order_items pivot), then orders by that alias.
|
||
*
|
||
* "Latest" is defined as the Order with the most recent created_at
|
||
* among all OrderItems that reference this row. Rows with no Order
|
||
* history sort last on `asc` / first on `desc` (natural NULL sort
|
||
* behavior on both MySQL and SQLite).
|
||
*
|
||
* Uses addSelect so preceding withCount subqueries survive — a
|
||
* plain select() would wipe them and re-introduce N+1 downstream.
|
||
*/
|
||
public function scopeOrderByOrderNumber(Builder $query, string $direction = 'asc'): Builder
|
||
{
|
||
$modelTable = (new static)->getTable();
|
||
|
||
return $query->addSelect([
|
||
'sort_order_number' => OrderItem::query()
|
||
->join('orders', 'orders.id', '=', 'order_items.order_id')
|
||
->whereColumn('order_items.item_id', $modelTable.'.id')
|
||
->where('order_items.item_type', static::class)
|
||
->orderByDesc('orders.created_at')
|
||
->limit(1)
|
||
->select('orders.order_number'),
|
||
])->orderBy('sort_order_number', $direction);
|
||
}
|
||
|
||
/**
|
||
* Sort by the most recent OrderItem's price. Rows with no order
|
||
* history sort last on `asc` / first on `desc` (natural NULL sort
|
||
* behavior on both MySQL and SQLite). Uses addSelect for the same
|
||
* reason as scopeOrderByOrderNumber above.
|
||
*/
|
||
public function scopeOrderByLastPurchaseCost(Builder $query, string $direction = 'asc'): Builder
|
||
{
|
||
$modelTable = (new static)->getTable();
|
||
|
||
return $query->addSelect([
|
||
'sort_last_purchase_cost' => OrderItem::query()
|
||
->whereColumn('order_items.item_id', $modelTable.'.id')
|
||
->where('order_items.item_type', static::class)
|
||
->orderByDesc('order_items.id')
|
||
->limit(1)
|
||
->select('order_items.price'),
|
||
])->orderBy('sort_last_purchase_cost', $direction);
|
||
}
|
||
|
||
/**
|
||
* Sort by the most recent Order.purchase_date across this row's
|
||
* OrderItems. See scopeOrderByLastPurchaseCost for the null-sort
|
||
* caveat.
|
||
*/
|
||
public function scopeOrderByLastPurchaseDate(Builder $query, string $direction = 'asc'): Builder
|
||
{
|
||
$modelTable = (new static)->getTable();
|
||
|
||
return $query->addSelect([
|
||
'sort_last_purchase_date' => OrderItem::query()
|
||
->join('orders', 'orders.id', '=', 'order_items.order_id')
|
||
->whereColumn('order_items.item_id', $modelTable.'.id')
|
||
->where('order_items.item_type', static::class)
|
||
->orderByDesc('order_items.id')
|
||
->limit(1)
|
||
->select('orders.purchase_date'),
|
||
])->orderBy('sort_last_purchase_date', $direction);
|
||
}
|
||
|
||
/**
|
||
* Sort by the sum of (qty * price) across every OrderItem attached
|
||
* to this row. Matches the info-panel's total_cost display so a
|
||
* click on the total-cost column header lands rows in the same
|
||
* order the panel shows them.
|
||
*/
|
||
public function scopeOrderByTotalOrderCost(Builder $query, string $direction = 'asc'): Builder
|
||
{
|
||
$modelTable = (new static)->getTable();
|
||
|
||
return $query->addSelect([
|
||
'sort_total_order_cost' => OrderItem::query()
|
||
->whereColumn('order_items.item_id', $modelTable.'.id')
|
||
->where('order_items.item_type', static::class)
|
||
->selectRaw('COALESCE(SUM(order_items.qty * order_items.price), 0)'),
|
||
])->orderBy('sort_total_order_cost', $direction);
|
||
}
|
||
|
||
/**
|
||
* Sum every OrderItem's line total (qty × price) grouped by the
|
||
* parent Order's currency, so mixed-currency acquisitions render
|
||
* as a per-currency breakdown instead of a single misleading total.
|
||
*
|
||
* Returns [] when the item has no OrderItems yet or every line has
|
||
* a null price. The info-panel skips the "Total cost" line entirely
|
||
* in that case rather than showing 0. Orders / OrderItems is the
|
||
* single source of truth for acquisition cost, no fallback to
|
||
* legacy_* columns (those will be dropped in a later version).
|
||
*
|
||
* @return array<string, float> currency code => sum in that currency
|
||
*/
|
||
public function totalCostSumByCurrency(): array
|
||
{
|
||
return $this->orderItems()
|
||
->with('order:id,currency')
|
||
->get()
|
||
->reduce(function (array $carry, OrderItem $line) {
|
||
if ($line->price === null) {
|
||
return $carry;
|
||
}
|
||
$currency = $line->order?->currency
|
||
?? Setting::getSettings()?->default_currency
|
||
?? '';
|
||
$carry[$currency] = ($carry[$currency] ?? 0) + ($line->qty * (float) $line->price);
|
||
|
||
return $carry;
|
||
}, []);
|
||
}
|
||
|
||
/**
|
||
* Naive cross-currency sum, kept for backwards compatibility with
|
||
* external callers. New code should prefer totalCostSumByCurrency()
|
||
* so mixed-currency totals stay disambiguated.
|
||
*/
|
||
public function totalCostSum()
|
||
{
|
||
return array_sum($this->totalCostSumByCurrency()) ?: null;
|
||
}
|
||
|
||
/**
|
||
* True when every recorded acquisition for this item came from the
|
||
* same supplier. Info-panel supplier row hides when false so a
|
||
* single supplier name doesn't misrepresent multi-supplier history.
|
||
*/
|
||
public function hasConsistentSupplier(): bool
|
||
{
|
||
return $this->orderItems()
|
||
->with('order:id,supplier_id')
|
||
->get()
|
||
->map(fn (OrderItem $line) => $line->order?->supplier_id)
|
||
->filter()
|
||
->unique()
|
||
->count() <= 1;
|
||
}
|
||
|
||
/**
|
||
* Called from the inventory observer's `created` event. When the row
|
||
* lands with qty > 0, write a placeholder Order + OrderItem to
|
||
* represent the initial acquisition. Transaction metadata
|
||
* (supplier_id, purchase_date, purchase_cost, currency, order_number)
|
||
* only lives on Orders / OrderItems now, form-driven creates enrich
|
||
* the placeholder via the controller's enrichInitialOrderFromRequest,
|
||
* and factory / seeder paths fill it via an afterCreating hook.
|
||
*
|
||
* qty=0/null is container-only: a bare inventory row with no initial
|
||
* acquisition. The Order + OrderItem write is skipped entirely,
|
||
* later adjust-quantity events write their own when stock actually
|
||
* arrives.
|
||
*
|
||
* The create action_log always writes, linked to the OrderItem when
|
||
* one exists and unlinked (order_item_id = null) for container-only
|
||
* creates. quantity captures the initial on-hand count so auditors
|
||
* get a "started with N units" anchor, subsequent QuantityAdjust
|
||
* logs record deltas rather than running totals.
|
||
*/
|
||
public function writeInitialInventoryCreate(): void
|
||
{
|
||
$initialQty = (int) ($this->getAttributes()['qty'] ?? 0);
|
||
$orderItem = $this->writeInitialOrderIfStocked($initialQty);
|
||
|
||
$log = new Actionlog;
|
||
$log->item_type = static::class;
|
||
$log->item_id = $this->id;
|
||
$log->created_at = date('Y-m-d H:i:s');
|
||
// See AssetModelObserver::created for the seeder-friendly
|
||
// auth fallback rationale.
|
||
$log->created_by = auth()->id() ?? $this->created_by;
|
||
$log->quantity = $initialQty;
|
||
$log->order_item_id = $orderItem?->id;
|
||
if ($this->imported) {
|
||
$log->setActionSource('importer');
|
||
}
|
||
$log->logaction('create');
|
||
}
|
||
|
||
private function writeInitialOrderIfStocked(int $initialQty): ?OrderItem
|
||
{
|
||
if ($initialQty <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$locationCurrency = $this->location?->currency;
|
||
$currency = ($locationCurrency !== '' && $locationCurrency !== null)
|
||
? $locationCurrency
|
||
: Setting::getSettings()?->default_currency;
|
||
|
||
$order = new Order([
|
||
'order_number' => null,
|
||
'supplier_id' => null,
|
||
'company_id' => $this->company_id,
|
||
'purchase_date' => null,
|
||
'currency' => $currency,
|
||
]);
|
||
$order->created_by = $this->created_by ?? auth()->id();
|
||
$order->save();
|
||
|
||
$orderItem = new OrderItem([
|
||
'order_id' => $order->id,
|
||
'item_type' => static::class,
|
||
'item_id' => $this->id,
|
||
'qty' => $initialQty,
|
||
'price' => null,
|
||
]);
|
||
$orderItem->created_by = $this->created_by ?? auth()->id();
|
||
$orderItem->save();
|
||
|
||
return $orderItem;
|
||
}
|
||
}
|