diff --git a/app/Models/Accessory.php b/app/Models/Accessory.php index 490c64c51d..13d1d454fb 100755 --- a/app/Models/Accessory.php +++ b/app/Models/Accessory.php @@ -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] [] - * - * @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); } /** diff --git a/app/Models/Actionlog.php b/app/Models/Actionlog.php index e1f11236bb..3e023aface 100755 --- a/app/Models/Actionlog.php +++ b/app/Models/Actionlog.php @@ -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'], ]; /** diff --git a/app/Models/Asset.php b/app/Models/Asset.php index cb6a716654..25bafbadae 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -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)); } /** diff --git a/app/Models/AssetModel.php b/app/Models/AssetModel.php index 1e7614b456..710b3ded72 100755 --- a/app/Models/AssetModel.php +++ b/app/Models/AssetModel.php @@ -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'); } diff --git a/app/Models/Component.php b/app/Models/Component.php index f8f65060ec..18e243360b 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -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); } /** diff --git a/app/Models/Consumable.php b/app/Models/Consumable.php index fd54f55d87..c2e8ed2994 100644 --- a/app/Models/Consumable.php +++ b/app/Models/Consumable.php @@ -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) diff --git a/app/Models/Order.php b/app/Models/Order.php index 33a3b1d6fe..be8293b12c 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -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); diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index 2e23406dd9..90082f6593 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -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); diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php index 2521891e3e..7506706479 100755 --- a/app/Models/Supplier.php +++ b/app/Models/Supplier.php @@ -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 - * - * @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 - * - * @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 - * - * @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'); } /** diff --git a/app/Models/Traits/HasOrders.php b/app/Models/Traits/HasOrders.php index 9b316f0406..6c35a858ea 100644 --- a/app/Models/Traits/HasOrders.php +++ b/app/Models/Traits/HasOrders.php @@ -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 diff --git a/app/Models/User.php b/app/Models/User.php index 0564a659c3..57216a4632 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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;