返回

Filament 订单金额不更新问题解析及解决方案

php

Filament资源:解决产品/数量更改时总计不更新的问题

在开发电商平台或订单管理系统时,动态计算并更新订单总金额是核心功能之一。当使用Filament构建这类应用时,如果遇到产品数量或种类变更后,订单总金额未实时更新的问题,会严重影响用户体验和数据的准确性。本文将深入分析该问题,并提供多种解决方案。

问题分析

提供的代码片段中,OrderResource 使用 Filament 的 Repeater 组件来管理订单明细。order_details 关系中包含了 product_id, quantitytotal 字段。代码逻辑通过 afterStateUpdated 钩子函数来监听字段变化并重新计算总金额。具体来说:

  • updateProductSelection 函数在产品选择变更时,更新产品单价并重新计算总金额。
  • updateProductTotal 函数在产品数量变更时,更新单个产品总价并重新计算总金额。
  • recalculateGrandTotal 函数计算最终的总金额,包括折扣。

问题在于,尽管日志显示产品和数量的更新是正确的,但总金额却停留在0,或者只有在折扣字段更改时才更新。这通常表明 recalculateGrandTotal 函数没有被正确触发,或者触发时获取到的 orderDetails 数据不正确。

解决方案

方案一:确保 Repeater 数据的同步性

Repeater 组件的数据处理有时会存在延迟,或者在 afterStateUpdated 钩子触发时,数据尚未完全同步。我们可以通过强制刷新数据或稍作延迟来解决此问题。

代码示例:

// OrderResource.php

use Filament\Forms;
use Filament\Resources\Form;
use Filament\Resources\Resource;
use App\Models\Order;
use App\Models\Product;

class OrderResource extends Resource
{
    // ... 其他代码 ...

    public static function form(Form $form): Form
    {
        return $form->schema([
            Forms\Components\Repeater::make('order_details')
                ->relationship('orderDetails')
                ->schema([
                    // ... 其他字段 ...
                ])
                ->afterStateUpdated(function(callable $get, callable $set){
                    // 稍作延迟后重新计算总金额
                    dispatch(function () use ($get, $set){
                         self::recalculateGrandTotal($get, $set);
                    })->delay(0.1);
                 })
                ,

            // ... 其他字段 ...
        ]);
    }

    // ... 其他函数 ...

    protected static function recalculateGrandTotal(callable $get, callable $set)
    {
        // 重新加载订单详情数据,确保数据同步
        $orderDetails = [];
        if (filled($get('order_details'))) {
            foreach ($get('order_details') as $orderDetail){
                if (is_array($orderDetail) && isset($orderDetail['product_id']) && isset($orderDetail['quantity'])) {
                        $product = Product::find($orderDetail['product_id']);
                        if ($product) {
                            $orderDetails[] = [
                                'total' => $product->price * $orderDetail['quantity']
                            ];
                        }

                    } else if (is_int($orderDetail)){
                         $orderDetailModel = \App\Models\OrderDetail::find($orderDetail);
                        if ($orderDetailModel) {
                                 $orderDetails[] = [
                                     'total' => $orderDetailModel->total
                                 ];
                             }

                    }
            }
        }

        $discount = (float) $get('discount') ?? 0;

        $grandTotal = collect($orderDetails)->sum('total');

        if ($discount > 0) {
            $grandTotal -= ($grandTotal * $discount) / 100;
        }

        $set('grand_total', $grandTotal);

        \Illuminate\Support\Facades\Log::info('Grand Total recalculated:', ['grandTotal' => $grandTotal, 'orderDetails' => $orderDetails]);
    }
}

操作步骤:

  1. OrderResource 文件中,修改 form 函数,为 order_details Repeater 组件添加 afterStateUpdated 钩子。
  2. 将原有的 recalculateGrandTotal 调用封装在 dispatch 函数中,并添加短暂延迟。
  3. 修改 recalculateGrandTotal 函数,在函数顶部重新从数据库加载订单详情数据。

原理说明:

通过 dispatch 函数,我们可以将总金额的重新计算操作放入队列中,稍作延迟执行,确保此时 Repeater 组件的数据已经完成同步。同时,通过在recalculateGrandTotal 函数开始时重新加载订单详情数据,保证了计算总金额时使用的是最新的、已同步的数据。另外通过对 orderDetail 不同状态的判断(数组和直接的模型ID),可以处理不同的数据存储格式,增加代码的鲁棒性。

安全建议:

  • 控制延迟时间,避免过长的延迟影响用户体验。
  • 在实际应用中,可能需要结合具体的业务场景和数据量大小调整延迟时间。
  • 考虑数据校验,确保 orderDetails 中的数据格式和内容是正确的,防止因数据错误导致计算错误或安全漏洞。

方案二:使用 JavaScript 触发计算

如果上述方案无法解决问题,或者希望实现更精细的控制,可以考虑使用 JavaScript 来监听字段变化并触发计算。

代码示例:

// OrderResource.php

use Filament\Forms;
use Filament\Resources\Form;
use Filament\Resources\Resource;
use App\Models\Order;
use App\Models\Product;
use Filament\Forms\Components\View;

class OrderResource extends Resource
{
    // ... 其他代码 ...

    public static function form(Form $form): Form
    {
        return $form->schema([
            Forms\Components\Repeater::make('order_details')
                ->relationship('orderDetails')
                ->schema([
                    // ... 其他字段 ...
                ]),

            // ... 其他字段 ...
              Forms\Components\TextInput::make('grand_total')->disabled(),
            View::make('filament.order-total-script') //引入JS视图
        ]);
    }
    // ... 其他函数 ...

    public static function getPages(): array
    {
        return [
            'index' => \App\Filament\Resources\OrderResource\Pages\ListOrders::route('/'),
            'create' => \App\Filament\Resources\OrderResource\Pages\CreateOrder::route('/create'),
            'edit' => \App\Filament\Resources\OrderResource\Pages\EditOrder::route('/{record}/edit'),
        ];
    }
}
// resources/views/filament/order-total-script.blade.php
<script>
    document.addEventListener('DOMContentLoaded', function() {
        let repeater = document.querySelector('[data-repeater="order_details"]');
        if(repeater){
                let observerConfig = { childList: true, subtree: true };
                let observer = new MutationObserver(calculateGrandTotal);
                observer.observe(repeater, observerConfig);

                 let discountField = document.querySelector('[data-field-name="discount"] input');
                     if (discountField){
                         discountField.addEventListener('input', calculateGrandTotal);
                      }

                 calculateGrandTotal();
        }

        function calculateGrandTotal() {
             let orderDetails = [];
              document.querySelectorAll('[data-repeater-item="order_details"]').forEach(item => {
                 let productId = item.querySelector('[name*="[product_id]"]').value;
                let quantity = item.querySelector('[name*="[quantity]"]').value;
                   let priceElement = item.querySelector('[name*="[product_id]"]');
                if (priceElement && quantity > 0 ) {
                         let price = 0;
                       let options = priceElement.options;
                       if (options) {
                               for (let i = 0; i < options.length; i++) {
                                    if (options[i].value == productId) {
                                       price =  @json(\App\Models\Product::pluck('price', 'id')->toArray())[productId] ?? 0 ;
                                        break;
                                     }
                                  }

                            }else {
                               let parentRepeater = priceElement.closest('[data-repeater-item]');
                               if (parentRepeater) {
                                    let hiddenProductIdInput = parentRepeater.querySelector('input[type="hidden"][name$="[product_id]"]');
                                  if (hiddenProductIdInput){
                                       productId = hiddenProductIdInput.value;
                                        price =   @json(\App\Models\Product::pluck('price', 'id')->toArray())[productId] ?? 0;

                                   }
                                }

                           }
                            orderDetails.push({ quantity: quantity, price: price });
                   }