3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Models/Traits/HasOrders.php

420 lines
16 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\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;
}
}