diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index e9dd9f9006..2987603d12 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -25,7 +25,21 @@ class OrderItem extends Model protected $table = 'order_items'; - // NOTE: `created_by` is deliberately NOT fillable — accepting it via + /** + * The inventory model classes that can legally appear as the + * polymorphic `item` on an OrderItem. Referenced from the item_type + * validation rule and from the backfill / reconcile migrations, + * which each iterate a filtered subset of this list. + */ + public const ITEM_TYPES = [ + Accessory::class, + Consumable::class, + Component::class, + Asset::class, + License::class, + ]; + + // NOTE: `created_by` is deliberately NOT fillable, accepting it via // mass-assignment would let a request attribute a line to any // user_id the caller writes. All internal writers set it via // explicit `$line->created_by = auth()->id()`. @@ -42,6 +56,18 @@ class OrderItem extends Model 'price' => 'decimal:4', ]; + /** + * Static rules that don't depend on runtime data. The `item_type` + * rule is composed from ITEM_TYPES in getRules() below so the + * polymorphic class list has one source of truth. + */ + public $rules = [ + 'order_id' => 'required|integer|exists:orders,id', + 'item_id' => 'required|integer|min:1', + 'qty' => 'required|integer|min:1', + 'price' => 'nullable|numeric|gte:0|max:99999999999999999.99', + ]; + /** * Validation for the polymorphic pivot. item_id can't be validated * exists-style without a custom rule (the target table is a runtime @@ -50,13 +76,12 @@ class OrderItem extends Model * 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 getRules(): array + { + return $this->rules + [ + 'item_type' => 'required|string|in:'.implode(',', self::ITEM_TYPES), + ]; + } public function order(): BelongsTo { diff --git a/app/Observers/AccessoryObserver.php b/app/Observers/AccessoryObserver.php index 5440305632..359b7826fd 100644 --- a/app/Observers/AccessoryObserver.php +++ b/app/Observers/AccessoryObserver.php @@ -7,7 +7,6 @@ use App\Models\Actionlog; class AccessoryObserver { - /** * Listen to the User created event. * diff --git a/database/migrations/2026_08_03_142000_backfill_orders_from_inventory_tables.php b/database/migrations/2026_08_03_142000_backfill_orders_from_inventory_tables.php index a5a6346dec..00d41bbae9 100644 --- a/database/migrations/2026_08_03_142000_backfill_orders_from_inventory_tables.php +++ b/database/migrations/2026_08_03_142000_backfill_orders_from_inventory_tables.php @@ -1,5 +1,7 @@ source table name. Order determines the sequence - * OrderItem rows are created in (purely cosmetic — a chronological - * report would sort by Order.purchase_date or Order.created_at - * anyway). - */ - private const SOURCES = [ - \App\Models\Accessory::class => 'accessories', - \App\Models\Consumable::class => 'consumables', - \App\Models\Component::class => 'components', - \App\Models\Asset::class => 'assets', - ]; - public function up(): void { // Order dedupe cache: (order_number|supplier_id|company_id) => order_id. @@ -52,13 +41,23 @@ return new class extends Migration // inventory tables didn't have their own currency column, and // stamping every historical Order with today's system // default_currency would fabricate information we don't - // actually have — orders placed years ago may have been in a + // actually have, orders placed years ago may have been in a // different currency than the install's current setting. // Downstream display code can fall back to // $snipeSettings->default_currency at render time (matching how // the pre-Orders info panels rendered purchase_cost anyway). + // + // Order iteration is purely cosmetic: a chronological report + // would sort by Order.purchase_date or Order.created_at. + // + // License is filtered out because per-seat product-key + // semantics need their own design pass before License can join + // the Orders flow cleanly. + $sourceClasses = array_diff(OrderItem::ITEM_TYPES, [License::class]); + + foreach ($sourceClasses as $modelClass) { + $table = (new $modelClass)->getTable(); - foreach (self::SOURCES as $modelClass => $table) { // Defensive skip: if a source table has already had its // order_number column dropped (partial rerun, hand rollback, // schema drift), there's nothing to backfill from and the @@ -129,10 +128,11 @@ return new class extends Migration public function down(): void { - // Reversible only in a coarse sense — the drop wipes every row + // Reversible only in a coarse sense: the delete wipes every row // in both tables rather than trying to reconstruct the original // per-source-column state. Combined with the follow-up - // drop-columns migration (which does have a real down()), a full + // rename-columns migration (143000, which does have a real + // down() that restores the original column names), a full // rollback restores the pre-Orders layout. DB::table('order_items')->delete(); DB::table('orders')->delete(); diff --git a/database/migrations/2026_08_03_144000_reconcile_inventory_qty_from_action_logs.php b/database/migrations/2026_08_03_144000_reconcile_inventory_qty_from_action_logs.php index 12841bbad4..350e85726f 100644 --- a/database/migrations/2026_08_03_144000_reconcile_inventory_qty_from_action_logs.php +++ b/database/migrations/2026_08_03_144000_reconcile_inventory_qty_from_action_logs.php @@ -1,9 +1,9 @@ reconcileFor($modelClass); } }