3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 03:06:23 +00:00

Recalculating costs

This commit is contained in:
snipe
2026-08-05 22:24:36 +01:00
parent 55ce28027a
commit e0b45cf434
11 changed files with 282 additions and 176 deletions

View File

@ -13,6 +13,7 @@ use App\Models\Traits\Searchable;
use App\Presenters\AccessoryPresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Query\Builder;
@ -57,8 +58,6 @@ class Accessory extends SnipeModel
'model_number',
'name',
'notes',
'purchase_cost',
'purchase_date',
];
/**
@ -71,7 +70,10 @@ class Accessory extends SnipeModel
'company' => ['name'],
'location' => ['name'],
'manufacturer' => ['name'],
'supplier' => ['name'],
// Search by the parent's "typical supplier" template. Historical
// per-order supplier lookups belong on the Orders tab; this join
// keeps parent-level list-page search predictable.
'defaultSupplier' => ['name'],
// Order numbers moved to a dedicated Orders / OrderItems data
// model when the parent order_number column was removed.
// Free-text search on an order-number string walks the HasOrders
@ -96,6 +98,8 @@ class Accessory extends SnipeModel
'min_amt' => 'integer|min:0|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'purchase_date' => 'date_format:Y-m-d|nullable',
'default_supplier_id' => 'nullable|integer|exists:suppliers,id',
'default_purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
];
/**
@ -112,35 +116,48 @@ class Accessory extends SnipeModel
*
* @var array
*/
// supplier_id / purchase_date / purchase_cost are intentionally
// absent. Post-Orders acquisitions record their own values per event
// on Order + OrderItem; writing to the old names hard-fails at the
// DB (column renamed) which is the intended guard against divergent
// parent-vs-Orders state.
//
// default_supplier_id / default_purchase_cost are parent-level
// "template" values that pre-populate the adjust-quantity modal for
// items with no order history yet. See lastOrderDefaults() on the
// HasOrders trait for the merge behavior.
protected $fillable = [
'category_id',
'company_id',
'location_id',
'name',
'purchase_cost',
'purchase_date',
'model_number',
'manufacturer_id',
'supplier_id',
'image',
'qty',
'min_amt',
'requestable',
'notes',
'default_supplier_id',
'default_purchase_cost',
];
// No `supplier()` relation, no `supplier_id` / `purchase_date` /
// `purchase_cost` accessors on the parent. Those concepts are
// per-transaction now. Callers use `$accessory->orders` (all Orders
// over the lifetime) or `$accessory->lastOrderDefaults()` (most
// recent acquisition context, falling back to the parent's
// default_* template fields on items with no order history yet).
/**
* Establishes the accessory -> supplier relationship
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return Relation
* Parent-level "typical supplier" template. Distinct from
* per-acquisition supplier (which lives on Order.supplier_id).
* Used by the searchable-relation join for list-page search and by
* lastOrderDefaults() as the fallback for items with no orders yet.
*/
public function supplier()
public function defaultSupplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'supplier_id');
return $this->belongsTo(Supplier::class, 'default_supplier_id');
}
public function isDeletable()
@ -478,22 +495,10 @@ class Accessory extends SnipeModel
return $carry;
}, []);
// Account for units that predate the Orders flow. Only
// Asset::created writes an OrderItem on creation — accessory /
// consumable / component observers don't, so the "10 units
// created at purchase_cost" pre-adjust history has no matching
// OrderItem line. Add the unaccounted qty * parent.purchase_cost
// under location.currency (or default_currency if the location
// has none) so this line's currency matches how unit_cost is
// rendered in the info-panel.
$allocatedQty = (int) $lines->sum('qty');
$unaccountedQty = max(0, (int) $this->qty - $allocatedQty);
if ($unaccountedQty > 0 && $this->purchase_cost !== null) {
$fallbackCurrency = ($this->location && $this->location->currency !== '' && $this->location->currency !== null)
? $this->location->currency
: (Setting::getSettings()?->default_currency ?? '');
$totals[$fallbackCurrency] = ($totals[$fallbackCurrency] ?? 0) + ($unaccountedQty * (float) $this->purchase_cost);
}
// No fallback for unaccounted qty. Orders / OrderItems is the
// single source of truth for acquisition cost. If lines don't
// cover the current on-hand qty, the display honestly shows
// "we don't know the cost of those units" (empty totals).
return $totals;
}
@ -510,28 +515,20 @@ class Accessory extends SnipeModel
/**
* True when every recorded acquisition for this item came from the
* same supplier — parent.supplier_id plus every Order linked via
* OrderItems. The info-panel's supplier row hides itself when this
* returns false: displaying a single supplier name would misrepresent
* an item that was replenished from multiple suppliers over time.
* same supplier. Compares distinct supplier_ids across every Order
* linked via OrderItems. The info-panel's supplier row hides itself
* when this returns false so a single supplier name doesn't
* misrepresent multi-supplier history.
*/
public function hasConsistentSupplier(): bool
{
$orderSupplierIds = $this->orderItems()
return $this->orderItems()
->with('order:id,supplier_id')
->get()
->map(fn (OrderItem $line) => $line->order?->supplier_id)
->filter()
->unique()
->values()
->all();
$known = array_values(array_unique(array_filter(array_merge(
[$this->supplier_id],
$orderSupplierIds,
))));
return count($known) <= 1;
->count() <= 1;
}
/**
@ -632,7 +629,7 @@ class Accessory extends SnipeModel
*/
public function scopeOrderSupplier($query, $order)
{
return $query->leftJoin('suppliers', 'accessories.supplier_id', '=', 'suppliers.id')->orderBy('suppliers.name', $order);
return $query->leftJoin('suppliers', 'accessories.default_supplier_id', '=', 'suppliers.id')->orderBy('suppliers.name', $order);
}
/**

View File

@ -106,18 +106,23 @@ class Actionlog extends SnipeModel
'licenses' => ['name', 'serial', 'notes', 'license_email', 'license_name', 'purchase_order', 'purchase_date'],
'licenses.category' => ['name', 'notes'],
'licenses.supplier' => ['name'],
'consumables' => ['name', 'notes', 'model_number', 'item_no', 'purchase_date'],
// consumables / components / accessories no longer expose a
// supplier() or purchase_date accessor on the parent — those
// moved to the Orders / OrderItems polymorphic data model per
// acquisition event. The "default_supplier" template lives on
// defaultSupplier() and is safe to walk for search.
'consumables' => ['name', 'notes', 'model_number', 'item_no'],
'consumables.category' => ['name', 'notes'],
'consumables.location' => ['name', 'notes'],
'consumables.supplier' => ['name', 'notes'],
'components' => ['name', 'notes', 'purchase_date'],
'consumables.defaultSupplier' => ['name', 'notes'],
'components' => ['name', 'notes'],
'components.category' => ['name', 'notes'],
'components.location' => ['name', 'notes'],
'components.supplier' => ['name', 'notes'],
'accessories' => ['name', 'purchase_date'],
'components.defaultSupplier' => ['name', 'notes'],
'accessories' => ['name'],
'accessories.category' => ['name'],
'accessories.location' => ['name', 'notes'],
'accessories.supplier' => ['name', 'notes'],
'accessories.defaultSupplier' => ['name', 'notes'],
];
/**

View File

@ -1337,7 +1337,13 @@ class Asset extends Depreciable
public function getAccessoryCost()
{
return (float) $this->accessories()->sum('purchase_cost');
// purchase_cost no longer lives on the accessories parent —
// per-unit cost is on the last OrderItem's price, with the
// parent's default_purchase_cost as fallback. lastOrderDefaults()
// encapsulates that fallback ladder.
return (float) $this->accessories()
->get()
->sum(fn ($accessory) => (float) ($accessory->lastOrderDefaults()['unit_cost'] ?? 0));
}
/**

View File

@ -163,9 +163,12 @@ class AssetModel extends SnipeModel
*/
public function ordersCount(): int
{
// Purchases only (positive qty) — see HasOrders::ordersCount for
// the rationale on filtering out corrections/consumption events.
return (int) OrderItem::query()
->where('item_type', Asset::class)
->whereIn('item_id', $this->assets()->select('id'))
->where('qty', '>', 0)
->distinct()
->count('order_id');
}

View File

@ -12,6 +12,7 @@ use App\Presenters\ComponentPresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -57,6 +58,8 @@ class Component extends SnipeModel
'purchase_date' => 'date_format:Y-m-d|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'manufacturer_id' => 'integer|exists:manufacturers,id|nullable',
'default_supplier_id' => 'nullable|integer|exists:suppliers,id',
'default_purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
];
/**
@ -75,20 +78,23 @@ class Component extends SnipeModel
*
* @var array
*/
// supplier_id / purchase_date / purchase_cost are intentionally
// absent. See Accessory::$fillable for the full rationale.
// default_supplier_id / default_purchase_cost are parent-level
// "template" values that seed the adjust-quantity modal.
protected $fillable = [
'category_id',
'company_id',
'supplier_id',
'location_id',
'manufacturer_id',
'model_number',
'name',
'purchase_cost',
'purchase_date',
'min_amt',
'qty',
'serial',
'notes',
'default_supplier_id',
'default_purchase_cost',
];
use Searchable;
@ -101,8 +107,6 @@ class Component extends SnipeModel
protected $searchableAttributes = [
'name',
'serial',
'purchase_cost',
'purchase_date',
'notes',
'model_number',
];
@ -116,7 +120,9 @@ class Component extends SnipeModel
'category' => ['name'],
'company' => ['name'],
'location' => ['name'],
'supplier' => ['name'],
// Search by the parent's "typical supplier" template — see the
// Accessory model for the rationale.
'defaultSupplier' => ['name'],
'manufacturer' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
// See Accessory::$searchableRelations. Search hits order_number
@ -172,11 +178,16 @@ class Component extends SnipeModel
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
}
/**
* Per-pivot line cost for components-assets. Pulls the per-unit
* price from the last acquisition (with the same default_* fallback
* that lastOrderDefaults() applies) and multiplies by pivot qty.
*/
protected function calculatedPurchaseCost(): Attribute
{
return Attribute::make(
get: function ($value) {
$unitPurchaseCost = $this->getRawOriginal('purchase_cost');
$unitPurchaseCost = $this->lastOrderDefaults()['unit_cost'] ?? null;
$assignedQty = $this->pivot?->assigned_qty;
if ($unitPurchaseCost === null) {
@ -229,9 +240,16 @@ class Component extends SnipeModel
*
* @return Relation
*/
public function supplier()
// No `supplier()` relation, no `supplier_id` / `purchase_date` /
// `purchase_cost` accessors — see Accessory model for rationale.
// Callers use `$component->orders` or `$component->lastOrderDefaults()`.
/**
* Parent-level "typical supplier" template — see Accessory model.
*/
public function defaultSupplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'supplier_id');
return $this->belongsTo(Supplier::class, 'default_supplier_id');
}
/**
@ -430,20 +448,9 @@ class Component extends SnipeModel
return $carry;
}, []);
// Account for units created before the Orders flow. Component
// creation doesn't write an OrderItem (unlike Asset::created),
// so the initial N units at parent.purchase_cost never land in
// the OrderItem ledger. Add them here under location.currency
// (or default_currency if the location has none) so this line's
// currency matches how unit_cost is rendered in the info-panel.
$allocatedQty = (int) $lines->sum('qty');
$unaccountedQty = max(0, (int) $this->qty - $allocatedQty);
if ($unaccountedQty > 0 && $this->purchase_cost !== null) {
$fallbackCurrency = ($this->location && $this->location->currency !== '' && $this->location->currency !== null)
? $this->location->currency
: (Setting::getSettings()?->default_currency ?? '');
$totals[$fallbackCurrency] = ($totals[$fallbackCurrency] ?? 0) + ($unaccountedQty * (float) $this->purchase_cost);
}
// Orders / OrderItems is the single source of truth. No
// fallback to legacy_* columns (those will be dropped in a
// later version). See Accessory::totalCostSumByCurrency.
return $totals;
}
@ -465,21 +472,13 @@ class Component extends SnipeModel
*/
public function hasConsistentSupplier(): bool
{
$orderSupplierIds = $this->orderItems()
return $this->orderItems()
->with('order:id,supplier_id')
->get()
->map(fn (OrderItem $line) => $line->order?->supplier_id)
->filter()
->unique()
->values()
->all();
$known = array_values(array_unique(array_filter(array_merge(
[$this->supplier_id],
$orderSupplierIds,
))));
return count($known) <= 1;
->count() <= 1;
}
/**
* -----------------------------------------------
@ -556,7 +555,7 @@ class Component extends SnipeModel
*/
public function scopeOrderSupplier($query, $order)
{
return $query->leftJoin('suppliers', 'components.supplier_id', '=', 'suppliers.id')->orderBy('suppliers.name', $order);
return $query->leftJoin('suppliers', 'components.default_supplier_id', '=', 'suppliers.id')->orderBy('suppliers.name', $order);
}
/**

View File

@ -11,7 +11,9 @@ use App\Models\Traits\Loggable;
use App\Models\Traits\Searchable;
use App\Presenters\ConsumablePresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Query\Builder;
@ -36,11 +38,9 @@ class Consumable extends SnipeModel
protected $table = 'consumables';
protected $casts = [
'purchase_date' => 'datetime',
'requestable' => 'boolean',
'category_id' => 'integer',
'company_id' => 'integer',
'supplier_id',
'qty' => 'integer',
'min_amt' => 'integer',
];
@ -57,6 +57,8 @@ class Consumable extends SnipeModel
'min_amt' => 'integer|min:0|max:99999|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'purchase_date' => 'date_format:Y-m-d|nullable',
'default_supplier_id' => 'nullable|integer|exists:suppliers,id',
'default_purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
];
/**
@ -75,21 +77,24 @@ class Consumable extends SnipeModel
*
* @var array
*/
// supplier_id / purchase_date / purchase_cost are intentionally
// absent. See Accessory::$fillable for the full rationale.
// default_supplier_id / default_purchase_cost are parent-level
// "template" values that seed the adjust-quantity modal.
protected $fillable = [
'category_id',
'company_id',
'item_no',
'location_id',
'manufacturer_id',
'supplier_id',
'name',
'model_number',
'purchase_cost',
'purchase_date',
'qty',
'min_amt',
'requestable',
'notes',
'default_supplier_id',
'default_purchase_cost',
];
use Searchable;
@ -101,8 +106,6 @@ class Consumable extends SnipeModel
*/
protected $searchableAttributes = [
'name',
'purchase_cost',
'purchase_date',
'item_no',
'model_number',
'notes',
@ -118,7 +121,9 @@ class Consumable extends SnipeModel
'company' => ['name'],
'location' => ['name'],
'manufacturer' => ['name'],
'supplier' => ['name'],
// Search by the parent's "typical supplier" template — see the
// Accessory model for the rationale.
'defaultSupplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
// See Accessory::$searchableRelations. Search hits order_number
// through the HasOrders trait's orders() HasManyThrough into
@ -297,9 +302,16 @@ class Consumable extends SnipeModel
*
* @return Relation
*/
public function supplier()
// No `supplier()` relation, no `supplier_id` / `purchase_date` /
// `purchase_cost` accessors — see Accessory model for rationale.
// Callers use `$consumable->orders` or `$consumable->lastOrderDefaults()`.
/**
* Parent-level "typical supplier" template — see Accessory model.
*/
public function defaultSupplier(): BelongsTo
{
return $this->belongsTo(Supplier::class, 'supplier_id');
return $this->belongsTo(Supplier::class, 'default_supplier_id');
}
/**
@ -404,20 +416,9 @@ class Consumable extends SnipeModel
return $carry;
}, []);
// Account for units created before the Orders flow. Consumable
// creation doesn't write an OrderItem (unlike Asset::created),
// so the initial N units at parent.purchase_cost never land in
// the OrderItem ledger. Add them here under location.currency
// (or default_currency if the location has none) so this line's
// currency matches how unit_cost is rendered in the info-panel.
$allocatedQty = (int) $lines->sum('qty');
$unaccountedQty = max(0, (int) $this->qty - $allocatedQty);
if ($unaccountedQty > 0 && $this->purchase_cost !== null) {
$fallbackCurrency = ($this->location && $this->location->currency !== '' && $this->location->currency !== null)
? $this->location->currency
: (Setting::getSettings()?->default_currency ?? '');
$totals[$fallbackCurrency] = ($totals[$fallbackCurrency] ?? 0) + ($unaccountedQty * (float) $this->purchase_cost);
}
// Orders / OrderItems is the single source of truth. No
// fallback to legacy_* columns (those will be dropped in a
// later version). See Accessory::totalCostSumByCurrency.
return $totals;
}
@ -439,21 +440,13 @@ class Consumable extends SnipeModel
*/
public function hasConsistentSupplier(): bool
{
$orderSupplierIds = $this->orderItems()
return $this->orderItems()
->with('order:id,supplier_id')
->get()
->map(fn (OrderItem $line) => $line->order?->supplier_id)
->filter()
->unique()
->values()
->all();
$known = array_values(array_unique(array_filter(array_merge(
[$this->supplier_id],
$orderSupplierIds,
))));
return count($known) <= 1;
->count() <= 1;
}
/**
@ -581,7 +574,7 @@ class Consumable extends SnipeModel
*/
public function scopeOrderSupplier($query, $order)
{
return $query->leftJoin('suppliers', 'consumables.supplier_id', '=', 'suppliers.id')->orderBy('suppliers.name', $order);
return $query->leftJoin('suppliers', 'consumables.default_supplier_id', '=', 'suppliers.id')->orderBy('suppliers.name', $order);
}
public function scopeOrderByCreatedBy($query, $order)

View File

@ -46,6 +46,23 @@ class Order extends SnipeModel
'purchase_date' => 'date',
];
/**
* FKs use `exists` so bad IDs from a request never persist. Every
* write path already runs through the ValidatingTrait via SnipeModel;
* a validation failure blocks the save() and surfaces the error to
* the caller.
*/
public $rules = [
'order_number' => 'nullable|string|max:255',
'supplier_id' => 'nullable|integer|exists:suppliers,id',
'company_id' => 'nullable|integer|exists:companies,id',
'purchase_date' => 'nullable|date',
'notes' => 'nullable|string|max:1000',
'currency' => 'nullable|string|max:10',
];
protected $injectUniqueIdentifier = true;
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);

View File

@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Watson\Validating\ValidatingTrait;
/**
* One line on an Order. Polymorphic to the inventory model that was
@ -19,6 +20,7 @@ class OrderItem extends Model
{
use HasFactory;
use SoftDeletes;
use ValidatingTrait;
protected $table = 'order_items';
@ -39,6 +41,22 @@ class OrderItem extends Model
'price' => 'decimal:4',
];
/**
* Validation for the polymorphic pivot. item_id can't be validated
* exists-style without a custom rule (the target table is a runtime
* decision keyed off item_type), so we scope item_type to the
* inventory classes that legally participate in Orders and leave
* item_id to caller sanity. order_id blocks reference to a deleted
* Order via the exists rule (ignoring soft-deleted rows).
*/
public $rules = [
'order_id' => 'required|integer|exists:orders,id',
'item_type' => 'required|string|in:App\\Models\\Accessory,App\\Models\\Consumable,App\\Models\\Component,App\\Models\\Asset,App\\Models\\License',
'item_id' => 'required|integer|min:1',
'qty' => 'required|integer|min:1',
'price' => 'nullable|numeric|gte:0|max:99999999999999999.99',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);

View File

@ -78,16 +78,36 @@ class Supplier extends SnipeModel
public function isDeletable()
{
// accessories / consumables / components no longer block delete:
// their `default_supplier_id` reference is a soft template that
// the deleting hook below nulls out on delete. assets, licenses,
// and maintenances still use a per-record `supplier_id` column
// (out of scope for the Orders refactor) — those remain hard
// blockers to avoid stranding acquisition records mid-lifecycle.
return Gate::allows('delete', $this)
&& (($this->assets_count ?? $this->assets()->count()) === 0)
&& (($this->licenses_count ?? $this->licenses()->count()) === 0)
&& (($this->consumables_count ?? $this->consumables()->count()) === 0)
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
&& (($this->components_count ?? $this->components()->count()) === 0)
&& (($this->maintenances_count ?? $this->maintenances()->count()) === 0)
&& ($this->deleted_at == '');
}
/**
* On delete (soft or force), null out the `default_supplier_id`
* pointers on any accessory / consumable / component that used this
* supplier as its "typical supplier" template. Historical Orders
* keep their `supplier_id` untouched — that's an acquisition record
* of what actually happened, and the belongsTo relation returns null
* transparently when the referenced Supplier is soft-deleted anyway.
*/
protected static function booted(): void
{
static::deleting(function (self $supplier) {
Accessory::where('default_supplier_id', $supplier->id)->update(['default_supplier_id' => null]);
Consumable::where('default_supplier_id', $supplier->id)->update(['default_supplier_id' => null]);
Component::where('default_supplier_id', $supplier->id)->update(['default_supplier_id' => null]);
});
}
/**
* Eager load counts
*
@ -120,45 +140,33 @@ class Supplier extends SnipeModel
}
/**
* Establishes the supplier -> accessories relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v1.0]
*
* @return Relation
* Accessories that use this supplier as their default template.
* Post-Orders, "supplier of an accessory" is a per-transaction fact
* (on Order.supplier_id); this relation reads the parent-level
* `default_supplier_id` template instead — the field the show page
* "N items from this supplier" counts and tabs actually reflect.
*/
public function accessories()
{
return $this->hasMany(Accessory::class, 'supplier_id');
return $this->hasMany(Accessory::class, 'default_supplier_id');
}
/**
* Establishes the supplier -> component relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v6.1.1]
*
* @return Relation
* Components that use this supplier as their default template.
* See accessories() for rationale.
*/
public function components()
{
return $this->hasMany(Component::class, 'supplier_id');
return $this->hasMany(Component::class, 'default_supplier_id');
}
/**
* Establishes the supplier -> component relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v6.1.1]
*
* @return Relation
* Consumables that use this supplier as their default template.
* See accessories() for rationale.
*/
public function consumables()
{
return $this->hasMany(Consumable::class, 'supplier_id');
return $this->hasMany(Consumable::class, 'default_supplier_id');
}
/**

View File

@ -4,6 +4,7 @@ namespace App\Models\Traits;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Supplier;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
@ -71,46 +72,106 @@ trait HasOrders
}
/**
* Count of distinct Orders this item has appeared on. Useful for
* the info-panel's "how many times has this been ordered" hint.
* DISTINCT is required because HasManyThrough joins through
* order_items and one Order can carry multiple lines for the same
* item under staggered receipts.
* 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()->distinct()->count('orders.id');
return (int) $this->orders()
->where('order_items.qty', '>', 0)
->distinct()
->count('orders.id');
}
/**
* Last acquisition context for pre-populating the adjust-quantity
* modal and driving the info-panel's "last" fields (unit cost,
* currency, purchase date). Returns the most recent OrderItem's
* price and its parent Order's currency + purchase_date, or null
* when the item has no OrderItems yet. One query per invocation.
* Cheap on the view page (1 model per page); needs eager-loading
* for the index page.
* 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.
*
* @return array{unit_cost: ?string, currency: ?string, purchase_date: ?string}|null
* 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')
->with('order:id,currency,purchase_date,supplier_id')
->latest('id')
->first();
if (! $line) {
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->getAttribute('default_supplier_id');
$defaultCost = $this->getAttribute('default_purchase_cost');
if ($defaultSupplier === null && $defaultCost === null) {
return null;
}
return [
'unit_cost' => $line->price !== null ? (string) $line->price : null,
'currency' => $line->order?->currency ?: null,
'purchase_date' => $line->order?->purchase_date?->toDateString(),
'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
{
if ($this->relationLoaded('orderItems')) {
$line = $this->orderItems->sortByDesc('id')->first();
$order = $line?->order;
if ($order && $order->relationLoaded('supplier') && $order->supplier) {
return $order->supplier;
}
$supplierId = $order?->supplier_id;
} else {
$line = $this->orderItems()
->with('order:id,supplier_id')
->latest('id')
->first();
$supplierId = $line?->order?->supplier_id;
}
$supplierId = $supplierId ?? $this->getAttribute('default_supplier_id');
return $supplierId ? Supplier::find($supplierId) : null;
}
/**
* Sort scope that lets the bootstrap-table sortable header for an
* order-number column keep working after the parent order_number

View File

@ -1717,19 +1717,18 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
}
$this->license_cost = $license_cost;
// For accessory / consumable unit cost, prefer the last
// OrderItem's price so this tally matches what the accessory /
// consumable rows in the tab tables show (both switched to
// lastOrderDefaults for the "last unit cost" info-panel parity).
// Falls back to parent.purchase_cost for legacy rows without
// an OrderItem.
// Accessory / consumable unit cost tracks the info-panel's "last
// unit cost" so this tally matches the per-item rows in the tab
// tables. lastOrderDefaults() already merges last-Order price
// with the parent's `default_purchase_cost` template value when
// an item has no order history, so nothing to fall back to here.
foreach ($this->accessories as $accessory) {
$accessory_cost += (float) ($accessory->lastOrderDefaults()['unit_cost'] ?? $accessory->purchase_cost);
$accessory_cost += (float) ($accessory->lastOrderDefaults()['unit_cost'] ?? 0);
}
$this->accessory_cost = $accessory_cost;
foreach ($this->consumables as $consumable) {
$consumable_cost += (float) ($consumable->lastOrderDefaults()['unit_cost'] ?? $consumable->purchase_cost);
$consumable_cost += (float) ($consumable->lastOrderDefaults()['unit_cost'] ?? 0);
}
$this->consumable_cost = $consumable_cost;