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:
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user