diff --git a/app/Http/Controllers/Accessories/AccessoriesController.php b/app/Http/Controllers/Accessories/AccessoriesController.php index 8f98d90841..53ecdd748f 100755 --- a/app/Http/Controllers/Accessories/AccessoriesController.php +++ b/app/Http/Controllers/Accessories/AccessoriesController.php @@ -185,13 +185,12 @@ class AccessoriesController extends Controller $accessory->category_id = request('category_id'); $accessory->company_id = Company::getIdForCurrentUser(request('company_id')); $accessory->manufacturer_id = request('manufacturer_id'); - $accessory->supplier_id = request('supplier_id'); $accessory->model_number = request('model_number'); - $accessory->purchase_date = request('purchase_date'); - // purchase_cost is create-only on the parent — post-create - // acquisitions record their own price on the OrderItem, so - // the edit form no longer exposes purchase_cost and this - // controller stops overwriting it on update. + // supplier_id, purchase_date, purchase_cost are create-only + // on the parent. Post-create acquisitions live as Orders + + // OrderItems (each with its own supplier / date / price), so + // the edit form no longer exposes any of them and the + // controller stops overwriting the parent values. $accessory->notes = request('notes'); $accessory->requestable = request('requestable', 0); diff --git a/app/Http/Controllers/Api/AccessoriesController.php b/app/Http/Controllers/Api/AccessoriesController.php index 4ba8a7db32..447df09f40 100644 --- a/app/Http/Controllers/Api/AccessoriesController.php +++ b/app/Http/Controllers/Api/AccessoriesController.php @@ -294,12 +294,19 @@ class AccessoriesController extends Controller $qtyRequested = $request->has('qty') ? (int) $request->input('qty') : $qtyBefore; $qtyDelta = $qtyRequested - $qtyBefore; - // purchase_cost is create-only on the parent — post-create - // acquisitions record their own price on the OrderItem, so an - // update-mode fill drops it. API consumers relying on the old - // "set purchase_cost via PATCH" behavior need to use the - // adjust-quantity endpoint instead. - $accessory->fill($request->except(['qty', 'order_number', 'purchase_cost'])); + // supplier_id, purchase_date, purchase_cost, and order_number + // are create-only on the parent. Post-create acquisitions live + // as Orders + OrderItems (each with its own supplier / date / + // price / order number), so update-mode drops all four. API + // consumers relying on the old "set these via PATCH" behavior + // need to use the adjust-quantity endpoint instead. + $accessory->fill($request->except([ + 'qty', + 'order_number', + 'purchase_cost', + 'purchase_date', + 'supplier_id', + ])); $accessory->company_id = Company::getIdForCurrentUser($request->input('company_id')); $accessory = $request->handleImages($accessory); @@ -537,30 +544,4 @@ class AccessoriesController extends Controller return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); } - - /** - * Orders tab datatable: every OrderItem line ever recorded against - * this accessory, with its parent Order's supplier / currency / - * purchase date joined in via eager load. - */ - public function orders(Request $request, Accessory $accessory): JsonResponse|array - { - $this->authorize('view', $accessory); - - $ordersQuery = $accessory->orderItems() - ->with(['order:id,order_number,supplier_id,currency,purchase_date', 'order.supplier:id,name']) - ->orderByDesc('id'); - - $total = (clone $ordersQuery)->count(); - $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); - $limit = app('api_limit_value'); - $lines = (clone $ordersQuery)->skip($offset)->take($limit)->get(); - - return response()->json( - (new \App\Http\Transformers\OrderItemsTransformer)->transformOrderItems($lines, $total), - 200, - ['Content-Type' => 'application/json;charset=utf8'], - JSON_UNESCAPED_UNICODE, - ); - } } diff --git a/app/Http/Controllers/Api/AssetModelsController.php b/app/Http/Controllers/Api/AssetModelsController.php index cb331bd636..821340fc86 100644 --- a/app/Http/Controllers/Api/AssetModelsController.php +++ b/app/Http/Controllers/Api/AssetModelsController.php @@ -355,34 +355,4 @@ class AssetModelsController extends Controller return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); } - - /** - * Orders tab datatable for an AssetModel: aggregate every OrderItem - * ever recorded against any Asset of this model. Since each Asset - * gets its own OrderItem via AssetObserver::created, one AssetModel - * accumulates one line per asset instance. Useful for "how many of - * this model have we bought over time, from whom, at what price". - */ - public function orders(Request $request, AssetModel $model): JsonResponse|array - { - $this->authorize('view', $model); - - $ordersQuery = \App\Models\OrderItem::query() - ->where('item_type', \App\Models\Asset::class) - ->whereIn('item_id', $model->assets()->select('id')) - ->with(['order:id,order_number,supplier_id,currency,purchase_date', 'order.supplier:id,name']) - ->orderByDesc('id'); - - $total = (clone $ordersQuery)->count(); - $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); - $limit = app('api_limit_value'); - $lines = (clone $ordersQuery)->skip($offset)->take($limit)->get(); - - return response()->json( - (new \App\Http\Transformers\OrderItemsTransformer)->transformOrderItems($lines, $total), - 200, - ['Content-Type' => 'application/json;charset=utf8'], - JSON_UNESCAPED_UNICODE, - ); - } } diff --git a/app/Http/Controllers/Api/ComponentsController.php b/app/Http/Controllers/Api/ComponentsController.php index dd2f2341d5..e004abc4f8 100644 --- a/app/Http/Controllers/Api/ComponentsController.php +++ b/app/Http/Controllers/Api/ComponentsController.php @@ -236,9 +236,16 @@ class ComponentsController extends Controller $qtyRequested = $request->has('qty') ? (int) $request->input('qty') : $qtyBefore; $qtyDelta = $qtyRequested - $qtyBefore; - // purchase_cost is create-only on the parent — post-create - // acquisitions record their own price on the OrderItem. - $component->fill($request->except(['qty', 'order_number', 'purchase_cost'])); + // supplier_id, purchase_date, purchase_cost, and order_number + // are create-only on the parent. Post-create acquisitions live + // as Orders + OrderItems, so update-mode drops all four. + $component->fill($request->except([ + 'qty', + 'order_number', + 'purchase_cost', + 'purchase_date', + 'supplier_id', + ])); $component->company_id = Company::getIdForCurrentUser($request->input('company_id')); $component = $request->handleImages($component); @@ -524,30 +531,4 @@ class ComponentsController extends Controller return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); } - - /** - * Orders tab datatable: every OrderItem line ever recorded against - * this component, with its parent Order's supplier / currency / - * purchase date joined in via eager load. - */ - public function orders(Request $request, Component $component): JsonResponse|array - { - $this->authorize('view', $component); - - $ordersQuery = $component->orderItems() - ->with(['order:id,order_number,supplier_id,currency,purchase_date', 'order.supplier:id,name']) - ->orderByDesc('id'); - - $total = (clone $ordersQuery)->count(); - $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); - $limit = app('api_limit_value'); - $lines = (clone $ordersQuery)->skip($offset)->take($limit)->get(); - - return response()->json( - (new \App\Http\Transformers\OrderItemsTransformer)->transformOrderItems($lines, $total), - 200, - ['Content-Type' => 'application/json;charset=utf8'], - JSON_UNESCAPED_UNICODE, - ); - } } diff --git a/app/Http/Controllers/Api/ConsumablesController.php b/app/Http/Controllers/Api/ConsumablesController.php index be7dff3d3c..b374adee53 100644 --- a/app/Http/Controllers/Api/ConsumablesController.php +++ b/app/Http/Controllers/Api/ConsumablesController.php @@ -223,9 +223,16 @@ class ConsumablesController extends Controller $qtyRequested = $request->has('qty') ? (int) $request->input('qty') : $qtyBefore; $qtyDelta = $qtyRequested - $qtyBefore; - // purchase_cost is create-only on the parent — post-create - // acquisitions record their own price on the OrderItem. - $consumable->fill($request->except(['qty', 'order_number', 'purchase_cost'])); + // supplier_id, purchase_date, purchase_cost, and order_number + // are create-only on the parent. Post-create acquisitions live + // as Orders + OrderItems, so update-mode drops all four. + $consumable->fill($request->except([ + 'qty', + 'order_number', + 'purchase_cost', + 'purchase_date', + 'supplier_id', + ])); $consumable->company_id = Company::getIdForCurrentUser($request->input('company_id')); $consumable = $request->handleImages($consumable); @@ -484,30 +491,4 @@ class ConsumablesController extends Controller return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); } - - /** - * Orders tab datatable: every OrderItem line ever recorded against - * this consumable, with its parent Order's supplier / currency / - * purchase date joined in via eager load. - */ - public function orders(Request $request, Consumable $consumable): JsonResponse|array - { - $this->authorize('view', $consumable); - - $ordersQuery = $consumable->orderItems() - ->with(['order:id,order_number,supplier_id,currency,purchase_date', 'order.supplier:id,name']) - ->orderByDesc('id'); - - $total = (clone $ordersQuery)->count(); - $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); - $limit = app('api_limit_value'); - $lines = (clone $ordersQuery)->skip($offset)->take($limit)->get(); - - return response()->json( - (new \App\Http\Transformers\OrderItemsTransformer)->transformOrderItems($lines, $total), - 200, - ['Content-Type' => 'application/json;charset=utf8'], - JSON_UNESCAPED_UNICODE, - ); - } } diff --git a/app/Http/Controllers/Api/OrderItemsController.php b/app/Http/Controllers/Api/OrderItemsController.php new file mode 100644 index 0000000000..fed4cb84d0 --- /dev/null +++ b/app/Http/Controllers/Api/OrderItemsController.php @@ -0,0 +1,129 @@ +input('item_type'); + $itemId = $request->input('item_id'); + $assetModelId = $request->input('asset_model_id'); + + if ($assetModelId) { + $this->authorize('view', AssetModel::findOrFail($assetModelId)); + } elseif ($itemType && $itemId && class_exists($itemType)) { + $this->authorize('view', $itemType::findOrFail($itemId)); + } elseif (! auth()->user()?->isSuperUser()) { + // Unfiltered "list every OrderItem" is a superuser-only + // capability until an OrderPolicy exists. + abort(403); + } + + $query = OrderItem::query() + ->with([ + 'admin:id,first_name,last_name,username', + 'order:id,order_number,supplier_id,currency,purchase_date,created_by', + 'order.supplier:id,name', + 'order.admin:id,first_name,last_name,username', + ]); + + if ($assetModelId) { + $query->where('order_items.item_type', Asset::class) + ->whereIn('order_items.item_id', Asset::where('model_id', $assetModelId)->select('id')); + } elseif ($itemType && $itemId) { + $query->where('order_items.item_type', $itemType) + ->where('order_items.item_id', $itemId); + } + + // leftJoin onto orders + suppliers so cross-table sort / search + // can hit those columns. Select order_items.* so the model + // hydrates cleanly and the joined columns don't leak into the + // OrderItem attributes. + $query->leftJoin('orders', 'orders.id', '=', 'order_items.order_id') + ->leftJoin('suppliers', 'suppliers.id', '=', 'orders.supplier_id') + ->select('order_items.*'); + + if ($request->filled('search')) { + $needle = '%'.$request->input('search').'%'; + $query->where(function ($q) use ($needle) { + $q->where('orders.order_number', 'like', $needle) + ->orWhere('suppliers.name', 'like', $needle); + }); + } + + $order = $request->input('order') === 'asc' ? 'asc' : 'desc'; + switch ($request->input('sort')) { + case 'order_number': + $query->orderBy('orders.order_number', $order); + break; + case 'purchase_date': + $query->orderBy('orders.purchase_date', $order); + break; + case 'currency': + $query->orderBy('orders.currency', $order); + break; + case 'supplier': + $query->orderBy('suppliers.name', $order); + break; + case 'qty': + $query->orderBy('order_items.qty', $order); + break; + case 'unit_cost': + $query->orderBy('order_items.price', $order); + break; + case 'total_cost': + // qty * price sort is computed rather than a stored column. + $query->orderByRaw('(order_items.qty * COALESCE(order_items.price, 0)) '.$order); + break; + case 'created_at': + $query->orderBy('order_items.created_at', $order); + break; + case 'created_by': + $query->orderBy('order_items.created_by', $order); + break; + default: + $query->orderBy('order_items.id', 'desc'); + } + + $total = (clone $query)->count(); + $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); + $limit = app('api_limit_value'); + $lines = (clone $query)->skip($offset)->take($limit)->get(); + + return response()->json( + (new OrderItemsTransformer)->transformOrderItems($lines, $total), + 200, + ['Content-Type' => 'application/json;charset=utf8'], + JSON_UNESCAPED_UNICODE, + ); + } +} diff --git a/resources/views/accessories/edit.blade.php b/resources/views/accessories/edit.blade.php index a402feca3c..61a6200195 100644 --- a/resources/views/accessories/edit.blade.php +++ b/resources/views/accessories/edit.blade.php @@ -42,12 +42,6 @@ categoryType="accessory" /> - - - {{-- order_number and qty are create-only. After creation, - on-hand qty is managed via the adjust-quantity modal so - every change becomes a QuantityAdjust action_log entry - (with its own note + order_number) instead of a silent - overwrite. Correcting a create-time order_number typo - isn't supported today; a dedicated Orders model built - out from action_log history is the plan if it's asked - for. --}} + {{-- Acquisition metadata is create-only. Post-create qty + changes flow through the adjust-quantity modal so each + change becomes a QuantityAdjust action_log entry (with + its own note / order_number / supplier / date / price) + instead of a silent parent-column overwrite. Every + later acquisition lives on its own Order + OrderItem. --}} @if (! $item->id) + + - @endif - + - + + + + + + + - @if (! $item->id) @endif diff --git a/resources/views/accessories/view.blade.php b/resources/views/accessories/view.blade.php index 0f481ee554..643fa490df 100644 --- a/resources/views/accessories/view.blade.php +++ b/resources/views/accessories/view.blade.php @@ -27,6 +27,7 @@ + @@ -49,10 +50,16 @@ - + + + + + + + @@ -72,6 +79,7 @@ @can('update', $accessory) + @php $lastOrder = $accessory->lastOrderDefaults(); @endphp {{ trans('general.adjust_quantity') }} diff --git a/resources/views/blade/modals/adjust-quantity.blade.php b/resources/views/blade/modals/adjust-quantity.blade.php index ceabfb91fe..a7ef4b1345 100644 --- a/resources/views/blade/modals/adjust-quantity.blade.php +++ b/resources/views/blade/modals/adjust-quantity.blade.php @@ -57,9 +57,15 @@ {{ trans('general.purchase_date') }} + {{-- Pre-populated with today's date because most --}} + {{-- adjust events happen the day they are recorded. --}} + {{-- Editable, and snipeit.js resets to today on --}} + {{-- every modal open so a stale date can't bleed --}} + {{-- across sessions. --}} @@ -99,6 +105,14 @@ > + {{-- Shown by snipeit.js when the modal opens with --}} + {{-- pre-populated cost/currency from the trigger's --}} + {{-- data-last-* attrs. Hidden as soon as the user --}} + {{-- edits either field, so it disappears the moment --}} + {{-- the pre-fill stops being authoritative. --}} + + {{ trans('general.adjust_quantity_prefilled_from_last_order') }} + {{ trans('general.notes') }} diff --git a/resources/views/components/edit.blade.php b/resources/views/components/edit.blade.php index 5f9a610994..7fd7eccd94 100644 --- a/resources/views/components/edit.blade.php +++ b/resources/views/components/edit.blade.php @@ -42,12 +42,6 @@ categoryType="component" /> - {{-- qty is create-only. After creation, on-hand qty is - managed via the adjust-quantity modal. --}} - @if (! $item->id) - - @endif - - - - {{-- Create-only. See accessories/edit for rationale. --}} + {{-- Acquisition metadata + qty are all create-only. See + accessories/edit for the full rationale. Post-create + changes flow through the adjust-quantity modal and + land as Order + OrderItem rows. --}} @if (! $item->id) + + + + + + + + + + + + + + @endif - - - - + @@ -55,6 +56,11 @@ + + + + + @@ -68,6 +74,7 @@ @can('update', $snipe_component) + @php $lastOrder = $snipe_component->lastOrderDefaults(); @endphp {{ trans('general.adjust_quantity') }} diff --git a/resources/views/consumables/edit.blade.php b/resources/views/consumables/edit.blade.php index 1bedb08cbd..05eb938bcb 100644 --- a/resources/views/consumables/edit.blade.php +++ b/resources/views/consumables/edit.blade.php @@ -42,20 +42,8 @@ categoryType="consumable" /> - {{-- qty is create-only: after creation, on-hand qty is - managed via the adjust-quantity modal. --}} - @if (! $item->id) - - @endif - - - - {{-- Create-only. See accessories/edit for rationale. --}} + {{-- Acquisition metadata + qty are all create-only. See + accessories/edit for the full rationale. Post-create + changes flow through the adjust-quantity modal and + land as Order + OrderItem rows. --}} @if (! $item->id) + + + + + + + + + + + + + + @endif - - - - + @@ -51,10 +52,16 @@ - + + + + + + + @@ -68,6 +75,7 @@ @can('update', $consumable) + @php $lastOrder = $consumable->lastOrderDefaults(); @endphp {{ trans('general.adjust_quantity') }} diff --git a/resources/views/models/view.blade.php b/resources/views/models/view.blade.php index e9aceba920..fd4b5485e9 100755 --- a/resources/views/models/view.blade.php +++ b/resources/views/models/view.blade.php @@ -29,6 +29,7 @@ + @@ -44,6 +45,12 @@ + + + + + + diff --git a/routes/api.php b/routes/api.php index e68b70f21c..096bc7f2e3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,6 +43,15 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu ]); }); + /** + * OrderItems (acquisition line-items). Standard REST index, filter + * by item_type + item_id for a specific parent, or asset_model_id + * for the AssetModel aggregate. + */ + Route::get('order-items', + [Api\OrderItemsController::class, 'index'] + )->name('api.order-items.index'); + /** * Account routes */
+ {{ trans('general.adjust_quantity_prefilled_from_last_order') }} +