From c6f69c5c5518041987e7ec264200835648b0c7f0 Mon Sep 17 00:00:00 2001 From: liu <2302563948@qq.com> Date: Thu, 6 Aug 2026 14:52:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E5=A2=9E=E5=8A=A0=E7=99=BE?= =?UTF-8?q?=E5=88=86=E6=AF=94=E4=B8=8A=E6=B5=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .phpunit.result.cache | 2 +- app/Http/Controllers/Mini/CartController.php | 23 +- app/Http/Controllers/Mini/OrderController.php | 16 +- .../Controllers/Mini/ProductController.php | 5 +- .../Controllers/Product/ProductController.php | 80 +++- .../Requests/Product/BatchPriceRequest.php | 63 ++- .../Requests/Product/ProductFormRequest.php | 32 +- app/Models/ProductModel.php | 8 + app/Models/ProductPriceModel.php | 54 +++ app/Services/PurchaseGenerateService.php | 29 +- database/factories/ProductModelFactory.php | 1 + .../factories/ProductPriceModelFactory.php | 2 + ...2026_07_23_030609_create_product_table.php | 5 +- tests/Feature/ProductCostPriceTest.php | 377 ++++++++++++++++++ tests/Feature/PurchaseGenerateTest.php | 37 ++ web/api/product/goods.ts | 9 +- .../XinFormField/ImageUploader/index.tsx | 11 +- web/domain/iProduct.ts | 37 +- web/pages/product/goods.tsx | 260 ++++++++++-- 19 files changed, 958 insertions(+), 93 deletions(-) create mode 100644 tests/Feature/ProductCostPriceTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index e8892ad..fa1572d 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":8,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":7,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":7,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.007,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.005,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.009,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.007,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.008,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.007,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.006,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.012,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.009,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.008,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.02,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.073,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.02,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.023,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.016,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.028,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.029,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.028,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.034,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.037,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.013,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.021,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.021,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.008,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.003,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.011,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.005,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.003,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.085,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.019,"Tests\\Feature\\ExportTest::test_export_pdf":1.275,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.022,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.016,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.007,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.008,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.007,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.007,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.007,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.006,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.011,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.01,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.006,"Tests\\Feature\\CartTest::test_update_quantity":0.009,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.01,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.008,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.006,"Tests\\Feature\\CartTest::test_delete_item":0.008,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.024,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.01,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":8,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":7,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":7,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":7,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":7,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":5,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":7,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":7,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.003,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.019,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.021,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.009,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.009,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.008,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.01,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.012,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.011,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.015,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.024,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.031,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.009,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.037,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.09,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.025,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.028,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.019,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.044,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.041,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.04,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.038,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.038,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.02,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.018,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.023,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.013,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.004,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.015,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.006,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.006,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.004,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.062,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.019,"Tests\\Feature\\ExportTest::test_export_pdf":1.413,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.028,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.024,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.003,"Tests\\Feature\\CartTest::test_add_to_cart_succeeds":0.008,"Tests\\Feature\\CartTest::test_duplicate_add_merges_quantity":0.011,"Tests\\Feature\\CartTest::test_off_shelf_product_rejected":0.008,"Tests\\Feature\\CartTest::test_soft_deleted_product_rejected":0.007,"Tests\\Feature\\CartTest::test_product_without_level_price_rejected":0.007,"Tests\\Feature\\CartTest::test_unbound_user_rejected":0.007,"Tests\\Feature\\CartTest::test_store_without_level_rejected":0.008,"Tests\\Feature\\CartTest::test_list_with_prices_amounts_and_totals":0.013,"Tests\\Feature\\CartTest::test_list_marks_off_shelf_item_unbuyable":0.013,"Tests\\Feature\\CartTest::test_empty_cart_returns_empty_items":0.009,"Tests\\Feature\\CartTest::test_update_quantity":0.01,"Tests\\Feature\\CartTest::test_update_other_users_item_rejected":0.011,"Tests\\Feature\\CartTest::test_update_missing_item_rejected":0.012,"Tests\\Feature\\CartTest::test_invalid_quantity_rejected":0.007,"Tests\\Feature\\CartTest::test_delete_item":0.009,"Tests\\Feature\\CartTest::test_delete_other_users_item_rejected":0.009,"Tests\\Feature\\CartTest::test_clear_only_own_cart":0.014,"Tests\\Feature\\CartTest::test_unauthenticated_returns_401":0.002,"Tests\\Feature\\CartTest::test_cart_isolated_between_users":0.013,"Tests\\Feature\\DebugCartListTest::test_dump_cart_list_response":0.057,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_fixed_and_percent":0.001,"Tests\\Feature\\ProductCostPriceTest::test_calc_actual_price_edge_cases":0.002,"Tests\\Feature\\ProductCostPriceTest::test_admin_list_contains_cost_price_and_actual_price":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_list_returns_actual_price_without_cost_price":0.007,"Tests\\Feature\\ProductCostPriceTest::test_create_product_with_cost_and_percent_pricing":0.01,"Tests\\Feature\\ProductCostPriceTest::test_create_percent_row_without_percent_fails":0.013,"Tests\\Feature\\ProductCostPriceTest::test_update_switches_fixed_to_percent":0.019,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_cost_only_notifies_percent_level_stores":0.021,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_percent_row_update":0.011,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_mixed_row_updates_cost_and_level":0.011,"Tests\\Feature\\ProductCostPriceTest::test_batch_price_empty_row_rejected":0.01,"Tests\\Feature\\ProductCostPriceTest::test_price_matrix_includes_cost_and_percent_columns":0.011,"Tests\\Feature\\ProductCostPriceTest::test_mini_order_uses_actual_price_for_percent_row":0.01,"Tests\\Feature\\ProductCostPriceTest::test_mini_cart_uses_actual_price_for_percent_row":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_uses_min_actual_price_across_types":0.016}} \ No newline at end of file diff --git a/app/Http/Controllers/Mini/CartController.php b/app/Http/Controllers/Mini/CartController.php index ce024ba..976d346 100644 --- a/app/Http/Controllers/Mini/CartController.php +++ b/app/Http/Controllers/Mini/CartController.php @@ -45,11 +45,12 @@ class CartController extends BaseMiniController if ($product === null) { throw new RepositoryException('商品不存在或已下架,请刷新后重试'); } - $price = ProductPriceModel::query() + // 存在性校验与计价类型无关(百分比行 price 可能为 0 也能加购) + $hasPrice = ProductPriceModel::query() ->where('product_id', $productId) ->where('level_id', $store->level_id) - ->value('price'); - if ($price === null) { + ->exists(); + if (! $hasPrice) { throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法加购'); } @@ -115,10 +116,11 @@ class CartController extends BaseMiniController ->unique()->values()->all(); $products = ProductModel::withTrashed()->whereIn('id', $productIds)->get()->keyBy('id'); - $prices = ProductPriceModel::query() + $priceRows = ProductPriceModel::query() ->where('level_id', $store->level_id) ->whereIn('product_id', $productIds) - ->pluck('price', 'product_id'); + ->get(['product_id', 'price', 'price_type', 'percent']) + ->keyBy('product_id'); // 图片一次查回(避免 ProductModel::$appends images_arr 的 N+1)。 // 注意:image_ids 有 imageIds Attribute 访问器(get 返回数组),需取原始值 @@ -140,7 +142,16 @@ class CartController extends BaseMiniController foreach ($rows as $row) { $product = $products->get($row->product_id); $productOn = $product !== null && $product->status === ProductModel::STATUS_ON; - $price = $prices->get($row->product_id); // string|null + // 实际价(百分比计价行按成本价上浮换算);未设等级价为 null + $priceRow = $priceRows->get($row->product_id); + $price = $priceRow === null + ? null + : ProductPriceModel::calcActualPrice( + (int) $priceRow->price_type, + $priceRow->price, + $priceRow->percent, + (float) ($product?->cost_price ?? 0), + ); $buyable = $productOn && $price !== null; $quantity = (string) $row->quantity; diff --git a/app/Http/Controllers/Mini/OrderController.php b/app/Http/Controllers/Mini/OrderController.php index 2342716..24abd02 100644 --- a/app/Http/Controllers/Mini/OrderController.php +++ b/app/Http/Controllers/Mini/OrderController.php @@ -47,10 +47,11 @@ class OrderController extends BaseMiniController ->get() ->keyBy('id'); - $prices = ProductPriceModel::query() + $priceRows = ProductPriceModel::query() ->where('level_id', $store->level_id) ->whereIn('product_id', $productIds) - ->pluck('price', 'product_id'); + ->get(['product_id', 'price', 'price_type', 'percent']) + ->keyBy('product_id'); $totalQuantity = '0'; $totalAmount = '0'; @@ -62,11 +63,18 @@ class OrderController extends BaseMiniController if ($product === null) { throw new RepositoryException('存在已下架或不存在的商品,请刷新后重试'); } - if (! isset($prices[$productId])) { + if (! isset($priceRows[$productId])) { throw new RepositoryException('商品「' . $product->name . '」未设置您所在等级的价格,无法下单'); } - $price = (string) $prices[$productId]; + // 实际价(百分比计价行按成本价上浮换算,$products 已含 cost_price) + $priceRow = $priceRows[$productId]; + $price = ProductPriceModel::calcActualPrice( + (int) $priceRow->price_type, + $priceRow->price, + $priceRow->percent, + $product->cost_price, + ); $quantity = (string) $row['quantity']; $amount = bcmul($price, $quantity, 2); $totalQuantity = bcadd($totalQuantity, $quantity, 2); diff --git a/app/Http/Controllers/Mini/ProductController.php b/app/Http/Controllers/Mini/ProductController.php index 22c6db0..751c9c6 100644 --- a/app/Http/Controllers/Mini/ProductController.php +++ b/app/Http/Controllers/Mini/ProductController.php @@ -88,9 +88,10 @@ class ProductController extends BaseMiniController ->paginate($pageSize) ->toArray(); - // 扁平化价格:prices[0].price → price(未设等级价为 null) + // 扁平化价格:prices[0].actual_price → price(访问器经 toArray 自动输出换算后实际价;未设等级价为 null) + // unset prices 同时移除 price_type/percent,门店端无法反推成本;cost_price 已被 $hidden 过滤 foreach ($data['data'] as &$row) { - $row['price'] = $row['prices'][0]['price'] ?? null; + $row['price'] = $row['prices'][0]['actual_price'] ?? null; unset($row['prices']); } diff --git a/app/Http/Controllers/Product/ProductController.php b/app/Http/Controllers/Product/ProductController.php index 082a099..39f05e1 100644 --- a/app/Http/Controllers/Product/ProductController.php +++ b/app/Http/Controllers/Product/ProductController.php @@ -38,7 +38,7 @@ class ProductController extends BaseController protected array $quickSearchField = ['name', 'spec']; - /** A1 商品列表(含分类/供应商/各等级价格) */ + /** A1 商品列表(含分类/供应商/各等级价格;cost_price 在 $hidden 中,后台列表需显式恢复) */ #[GetRoute(authorize: 'query')] public function query(Request $request): JsonResponse { @@ -50,9 +50,9 @@ class ProductController extends BaseController ) ->orderBy('sort') ->orderBy('id', 'desc') - ->paginate($pageSize) - ->toArray(); - return $this->success($data); + ->paginate($pageSize); + $data->getCollection()->makeVisible('cost_price'); + return $this->success($data->toArray()); } /** 上传商品分类图片文件 */ @@ -85,6 +85,8 @@ class ProductController extends BaseController 'product_id' => $product->id, 'level_id' => (int) $row['level_id'], 'price' => $row['price'], + 'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED), + 'percent' => $row['percent'] ?? 0, ]); } return $product; @@ -114,7 +116,11 @@ class ProductController extends BaseController $levelIds[] = $levelId; ProductPriceModel::updateOrCreate( ['product_id' => $product->id, 'level_id' => $levelId], - ['price' => $row['price']], + [ + 'price' => $row['price'], + 'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED), + 'percent' => $row['percent'] ?? 0, + ], ); } $product->prices()->whereNotIn('level_id', $levelIds)->delete(); @@ -140,12 +146,14 @@ class ProductController extends BaseController } /** - * A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),列=全部启用等级,值=price(缺失为 null) + * A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页), + * 列=全部启用等级,值=实际销售价(缺失为 null);行内含 cost_price 与每等级 + * price_type_{levelId} / percent_{levelId},供前端判断计价类型与联动重算 */ #[GetRoute('/priceMatrix', 'query')] public function priceMatrix(Request $request): JsonResponse { - $query = ProductModel::query()->with('prices:id,product_id,level_id,price'); + $query = ProductModel::query()->with('prices:id,product_id,level_id,price,price_type,percent'); if (($categoryId = (int) $request->input('category_id', 0)) > 0) { $query->where('category_id', $categoryId); } @@ -172,11 +180,20 @@ class ProductController extends BaseController 'name' => $product->name, 'spec' => $product->spec, 'unit' => $product->unit, + 'cost_price' => (float) $product->cost_price, ]; foreach ($levels as $level) { - $row['price_' . $level->id] = isset($priceMap[$level->id]) - ? (float) $priceMap[$level->id]->price + $price = $priceMap[$level->id] ?? null; + $row['price_' . $level->id] = $price + ? (float) ProductPriceModel::calcActualPrice( + (int) $price->price_type, + $price->price, + $price->percent, + $product->cost_price, + ) : null; + $row['price_type_' . $level->id] = $price ? (int) $price->price_type : ProductPriceModel::PRICE_TYPE_FIXED; + $row['percent_' . $level->id] = $price ? (float) $price->percent : 0; } return $row; }); @@ -189,7 +206,8 @@ class ProductController extends BaseController } /** - * A2 批量调价:事务写入,写完后给受影响门店生成 Notice(type=price) + * A2 批量调价:三类更新行(成本价 / 固定价 / 成本百分比,可混合同一行),事务写入, + * 写完后给受影响门店生成 Notice(type=price) */ #[PutRoute('/batchPrice', 'batchPrice')] public function batchPrice(BatchPriceRequest $request): JsonResponse @@ -200,28 +218,52 @@ class ProductController extends BaseController $productIds = []; $levelIds = []; foreach ($updates as $row) { - ProductPriceModel::updateOrCreate( - ['product_id' => (int) $row['product_id'], 'level_id' => (int) $row['level_id']], - ['price' => $row['price']], - ); - $productIds[(int) $row['product_id']] = true; - $levelIds[(int) $row['level_id']] = true; + $productId = (int) $row['product_id']; + $productIds[$productId] = true; + + // 分支1:成本价更新(百分比计价的基数,可与等级价行同在一行) + if (array_key_exists('cost_price', $row) && $row['cost_price'] !== null) { + ProductModel::whereKey($productId)->update(['cost_price' => $row['cost_price']]); + } + + // 分支2/3:等级价格行(固定价或成本百分比,按 price_type 区分) + if (isset($row['level_id'])) { + $levelId = (int) $row['level_id']; + $levelIds[$levelId] = true; + ProductPriceModel::updateOrCreate( + ['product_id' => $productId, 'level_id' => $levelId], + [ + 'price' => $row['price'] ?? 0, + 'price_type' => (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED), + 'percent' => $row['percent'] ?? 0, + ], + ); + } } + // 成本价变更只影响「该商品下百分比计价」的等级;受影响门店等级 = 等级更新行 ∪ 百分比行等级 + $percentLevelIds = ProductPriceModel::query() + ->whereIn('product_id', array_keys($productIds)) + ->where('price_type', ProductPriceModel::PRICE_TYPE_PERCENT) + ->pluck('level_id') + ->merge($levelIds) + ->unique() + ->all(); + $productNames = ProductModel::whereIn('id', array_keys($productIds)) ->pluck('name') ->implode('、'); $content = mb_substr('以下商品价格已调整:' . $productNames . ',下次登录小程序后按新价格显示', 0, 500); - // 受影响门店:客户等级在本次调价等级范围内的正常门店,通知其绑定的正常用户 + // 受影响门店:客户等级在受影响等级范围内的正常门店,通知其绑定的正常用户 $userIds = UserModel::query() ->where('type', UserModel::TYPE_STORE) ->where('status', UserModel::STATUS_NORMAL) - ->whereIn('store_id', function ($q) use ($levelIds) { + ->whereIn('store_id', function ($q) use ($percentLevelIds) { $q->select('id') ->from('store') ->where('status', StoreModel::STATUS_NORMAL) - ->whereIn('level_id', array_keys($levelIds)); + ->whereIn('level_id', $percentLevelIds); }) ->pluck('id'); diff --git a/app/Http/Requests/Product/BatchPriceRequest.php b/app/Http/Requests/Product/BatchPriceRequest.php index 906992d..836ccea 100644 --- a/app/Http/Requests/Product/BatchPriceRequest.php +++ b/app/Http/Requests/Product/BatchPriceRequest.php @@ -2,10 +2,18 @@ namespace App\Http\Requests\Product; +use App\Models\ProductPriceModel; +use Closure; +use Illuminate\Validation\Validator; use Modules\Common\Http\Requests\BaseFormRequest; /** * 批量调价 验证(A2 价格矩阵编辑提交) + * + * updates 每行支持三类更新(可混合同一行): + * 成本行 {product_id, cost_price} + * 固定价行 {product_id, level_id, price_type?:0, price} + * 百分比行 {product_id, level_id, price_type:1, percent}(price 可选,等价固定价) */ class BatchPriceRequest extends BaseFormRequest { @@ -16,11 +24,54 @@ class BatchPriceRequest extends BaseFormRequest return [ 'updates' => 'required|array|min:1', 'updates.*.product_id' => 'required|integer|exists:product,id', - 'updates.*.level_id' => 'required|integer|exists:customer_level,id', - 'updates.*.price' => 'required|numeric|min:0', + 'updates.*.cost_price' => 'nullable|numeric|min:0|max:99999999', + 'updates.*.level_id' => 'nullable|integer|exists:customer_level,id', + 'updates.*.price_type' => 'nullable|integer|in:0,1', + 'updates.*.price' => 'nullable|numeric|min:0|max:99999999', + 'updates.*.percent' => 'nullable|numeric|min:0|max:999.99', ]; } + /** + * 行级交叉校验(在 after() 内按实际数据逐行判断,避免 required_with* 通配符参数解析不可靠): + * - 每行必须至少包含成本价或等级价格更新 + * - 出现等级字段(price/percent/price_type)时必须带 level_id + * - 等级行必须有 price 或 percent + * - 成本百分比计价(price_type=1)时上浮百分点必填 + */ + public function after(): Closure + { + return function (Validator $validator): void { + $data = (array) $validator->getData(); + foreach ((array) ($data['updates'] ?? []) as $index => $row) { + $row = (array) $row; + $hasCost = array_key_exists('cost_price', $row) && $row['cost_price'] !== null && $row['cost_price'] !== ''; + $hasLevel = isset($row['level_id']); + $hasPrice = array_key_exists('price', $row) && $row['price'] !== null && $row['price'] !== ''; + $hasPercent = array_key_exists('percent', $row) && $row['percent'] !== null && $row['percent'] !== ''; + $hasType = array_key_exists('price_type', $row); + + if (! $hasCost && ! $hasLevel) { + $validator->errors()->add("updates.{$index}", '调价行缺少成本价或等级价格'); + continue; + } + if (($hasPrice || $hasPercent || $hasType) && ! $hasLevel) { + $validator->errors()->add("updates.{$index}.level_id", '等级价格行缺少客户等级'); + continue; + } + if ($hasLevel && ! $hasPrice && ! $hasPercent) { + $validator->errors()->add("updates.{$index}", '等级价格行缺少单价或上浮百分点'); + continue; + } + + $priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED); + if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT && ! $hasPercent) { + $validator->errors()->add("updates.{$index}.percent", '按成本百分比计价时必须填写上浮百分点'); + } + } + }; + } + public function messages(): array { return [ @@ -28,11 +79,15 @@ class BatchPriceRequest extends BaseFormRequest 'updates.min' => '请至少提交一条价格调整', 'updates.*.product_id.required' => '调价行缺少商品', 'updates.*.product_id.exists' => '商品不存在', - 'updates.*.level_id.required' => '调价行缺少客户等级', + 'updates.*.cost_price.numeric' => '成本价必须为数字', + 'updates.*.cost_price.min' => '成本价不能小于 0', 'updates.*.level_id.exists' => '客户等级不存在', - 'updates.*.price.required' => '调价行缺少单价', + 'updates.*.price_type.in' => '计价类型不正确', 'updates.*.price.numeric' => '单价必须为数字', 'updates.*.price.min' => '单价不能小于 0', + 'updates.*.percent.numeric' => '上浮百分点必须为数字', + 'updates.*.percent.min' => '上浮百分点不能小于 0', + 'updates.*.percent.max' => '上浮百分点不能超过 999.99', ]; } } diff --git a/app/Http/Requests/Product/ProductFormRequest.php b/app/Http/Requests/Product/ProductFormRequest.php index 5f5cc84..a84ef1f 100644 --- a/app/Http/Requests/Product/ProductFormRequest.php +++ b/app/Http/Requests/Product/ProductFormRequest.php @@ -3,8 +3,11 @@ namespace App\Http\Requests\Product; use App\Models\ProductCategoryModel; +use App\Models\ProductPriceModel; use App\Models\SupplierModel; +use Closure; use Illuminate\Validation\Rules\Exists; +use Illuminate\Validation\Validator; use Modules\Common\Http\Requests\BaseFormRequest; use Modules\SystemTool\Models\SysFileModel; @@ -30,13 +33,34 @@ class ProductFormRequest extends BaseFormRequest 'shelf_life' => 'nullable|integer|min:0', 'stock' => 'nullable|integer|min:0', 'status' => 'nullable|integer|in:0,1', + 'cost_price' => 'nullable|numeric|min:0|max:99999999', 'remark' => 'nullable|string|max:255', 'prices' => 'nullable|array', 'prices.*.level_id' => 'required|integer|exists:customer_level,id', - 'prices.*.price' => 'required|numeric|min:0', + 'prices.*.price_type' => 'nullable|integer|in:0,1', + 'prices.*.price' => 'required|numeric|min:0|max:99999999', + 'prices.*.percent' => 'nullable|numeric|min:0|max:999.99', ]; } + /** + * 交叉校验:按成本百分比计价(price_type=1)时上浮百分点必填 + * (Laravel 12 FormRequest 的 after() 需返回单个 Closure,由容器 call 后注册到 Validator) + */ + public function after(): Closure + { + return function (Validator $validator): void { + $data = (array) $validator->getData(); + foreach ((array) ($data['prices'] ?? []) as $index => $row) { + $priceType = (int) ($row['price_type'] ?? ProductPriceModel::PRICE_TYPE_FIXED); + if ($priceType === ProductPriceModel::PRICE_TYPE_PERCENT + && (! array_key_exists('percent', (array) $row) || $row['percent'] === null || $row['percent'] === '')) { + $validator->errors()->add("prices.{$index}.percent", '按成本百分比计价时必须填写上浮百分点'); + } + } + }; + } + public function messages(): array { return [ @@ -51,6 +75,12 @@ class ProductFormRequest extends BaseFormRequest 'prices.*.price.required' => '价格行缺少单价', 'prices.*.price.numeric' => '单价必须为数字', 'prices.*.price.min' => '单价不能小于 0', + 'cost_price.numeric' => '成本价必须为数字', + 'cost_price.min' => '成本价不能小于 0', + 'prices.*.price_type.in' => '计价类型不正确', + 'prices.*.percent.numeric' => '上浮百分点必须为数字', + 'prices.*.percent.min' => '上浮百分点不能小于 0', + 'prices.*.percent.max' => '上浮百分点不能超过 999.99', ]; } } diff --git a/app/Models/ProductModel.php b/app/Models/ProductModel.php index 685a213..0bf9080 100644 --- a/app/Models/ProductModel.php +++ b/app/Models/ProductModel.php @@ -37,6 +37,7 @@ class ProductModel extends Model 'shelf_life', 'stock', 'status', + 'cost_price', 'remark', ]; @@ -47,12 +48,19 @@ class ProductModel extends Model 'stock' => 'integer', 'sort' => 'integer', 'status' => 'integer', + 'cost_price' => 'decimal:2', 'created_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; protected $appends = ['images_arr']; + /** + * 成本价属商业敏感数据,默认不随 toArray 输出(防止泄漏到小程序/供应商端); + * 后台管理接口需在查询结果上调用 makeVisible('cost_price') 恢复。 + */ + protected $hidden = ['cost_price']; + /** * 封面图片 */ diff --git a/app/Models/ProductPriceModel.php b/app/Models/ProductPriceModel.php index d78bee6..a9cad79 100644 --- a/app/Models/ProductPriceModel.php +++ b/app/Models/ProductPriceModel.php @@ -8,11 +8,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * 商品价格模型(同一商品按客户等级定价,联合键 product_id + level_id) + * + * 计价类型:固定价(price 即实际单价)或成本百分比(实际单价 = 成本价 × (100 + percent) / 100) */ class ProductPriceModel extends Model { use HasFactory; + /** 计价类型:固定价 */ + public const int PRICE_TYPE_FIXED = 0; + /** 计价类型:成本百分比(按成本价上浮 percent 百分点) */ + public const int PRICE_TYPE_PERCENT = 1; + protected $table = 'product_price'; protected $primaryKey = 'id'; @@ -20,14 +27,21 @@ class ProductPriceModel extends Model 'product_id', 'level_id', 'price', + 'price_type', + 'percent', ]; protected $casts = [ 'product_id' => 'integer', 'level_id' => 'integer', 'price' => 'decimal:2', + 'price_type' => 'integer', + 'percent' => 'decimal:2', ]; + /** 序列化时附带实际销售价(后台列表/小程序列表直接展示) */ + protected $appends = ['actual_price']; + /** * 所属商品 */ @@ -51,4 +65,44 @@ class ProductPriceModel extends Model { return $query->where('product_id', $productId)->where('level_id', $levelId); } + + /** + * 计算实际销售价(统一换算入口,金额走 bcmath 保证两位小数精度) + * + * 固定价返回 price 原值;成本百分比返回 cost × (100 + percent) / 100(四舍五入保留两位)。 + * + * @param string|int|float $price 固定价(decimal cast 后为 '5.50' 形式字符串) + * @param string|int|float $percent 成本上浮百分点(30 = 上浮 30%) + * @param string|int|float $costPrice 商品成本价(decimal cast 字符串) + * @return string 两位小数字符串,如 '13.05' + */ + public static function calcActualPrice( + int $priceType, + string|int|float $price, + string|int|float $percent, + string|int|float $costPrice, + ): string { + if ($priceType === self::PRICE_TYPE_PERCENT) { + $multiplier = bcadd('100', (string) $percent, 4); + return bcdiv(bcmul((string) $costPrice, $multiplier, 4), '100', 2); + } + // 固定价:归一化为两位小数字符串 + return bcadd((string) $price, '0', 2); + } + + /** + * 实际销售价访问器(供 toArray 输出 actual_price) + * + * 依赖 product 关系取成本价;prices 经商品 eager load 加载时逆向关系自动填充,无 N+1。 + * 注意:单独序列化本模型且未加载 product 关系时会触发一次查询,成本价缺失按 0 兜底。 + */ + protected function getActualPriceAttribute(): string + { + return self::calcActualPrice( + (int) $this->price_type, + (string) $this->price, + (string) $this->percent, + (string) ($this->product?->cost_price ?? 0), + ); + } } diff --git a/app/Services/PurchaseGenerateService.php b/app/Services/PurchaseGenerateService.php index d4f7a89..8a41b4b 100644 --- a/app/Services/PurchaseGenerateService.php +++ b/app/Services/PurchaseGenerateService.php @@ -16,7 +16,7 @@ use Illuminate\Support\Facades\DB; * 流程(事务内): * 1. 行锁查询当日全部「待汇总」订单(无则报错;状态条件天然排除已汇总订单,幂等) * 2. 展开明细按商品聚合(Σquantity,快照品名/规格;供应商取商品默认供应商) - * 3. 估算单价 = 该商品最低等级价(product_price MIN),amount = quantity × 估算单价 + * 3. 估算单价 = 该商品最低实际等级价(按计价类型换算后取 min,PHP 侧兼容 MySQL/SQLite),amount = quantity × 估算单价 * 4. 创建采购单头(PO 单号,estimate_amount = Σitems.amount) * 5. 明细按「分类 sort → 商品 sort」排序写入 sort 行号 * 6. 源订单批量回写 status = 已汇总 @@ -78,12 +78,29 @@ class PurchaseGenerateService ->get() ->keyBy('id'); - // 3. 估算单价 = 最低等级价 - $minPrices = ProductPriceModel::query() + // 3. 估算单价 = 最低实际等级价(逐行按计价类型换算后取 min;数量级 = 当日 SKU × 等级,可控) + $priceRows = ProductPriceModel::query() ->whereIn('product_id', array_keys($aggregated)) - ->groupBy('product_id') - ->selectRaw('product_id, MIN(price) as min_price') - ->pluck('min_price', 'product_id'); + ->get(['product_id', 'price', 'price_type', 'percent']) + ->groupBy('product_id'); + + $minPrices = []; + foreach ($aggregated as $productId => $item) { + $product = $products->get($productId); + $minPrice = null; + foreach ($priceRows->get($productId, collect()) as $priceRow) { + $actual = ProductPriceModel::calcActualPrice( + (int) $priceRow->price_type, + $priceRow->price, + $priceRow->percent, + (float) ($product->cost_price ?? 0), + ); + if ($minPrice === null || bccomp($actual, $minPrice, 2) < 0) { + $minPrice = $actual; + } + } + $minPrices[$productId] = $minPrice ?? '0'; + } // 组装明细行并按「分类 sort → 商品 sort」排序 $rows = []; diff --git a/database/factories/ProductModelFactory.php b/database/factories/ProductModelFactory.php index 9197e81..73075f5 100644 --- a/database/factories/ProductModelFactory.php +++ b/database/factories/ProductModelFactory.php @@ -36,6 +36,7 @@ class ProductModelFactory extends Factory 'shelf_life' => 0, 'stock' => 0, 'status' => ProductModel::STATUS_ON, + 'cost_price' => 0, 'remark' => '', ]; } diff --git a/database/factories/ProductPriceModelFactory.php b/database/factories/ProductPriceModelFactory.php index a6f4fcf..4d183d5 100644 --- a/database/factories/ProductPriceModelFactory.php +++ b/database/factories/ProductPriceModelFactory.php @@ -24,6 +24,8 @@ class ProductPriceModelFactory extends Factory 'product_id' => 0, 'level_id' => 0, 'price' => number_format(random_int(100, 10000) / 100 + $seq * 0.01, 2, '.', ''), + 'price_type' => ProductPriceModel::PRICE_TYPE_FIXED, + 'percent' => 0, ]; } } diff --git a/database/migrations/2026_07_23_030609_create_product_table.php b/database/migrations/2026_07_23_030609_create_product_table.php index 2732f3f..ad87731 100644 --- a/database/migrations/2026_07_23_030609_create_product_table.php +++ b/database/migrations/2026_07_23_030609_create_product_table.php @@ -42,6 +42,7 @@ return new class extends Migration $table->integer('shelf_life')->default(0)->comment('保质期'); $table->integer('stock')->default(0)->comment('库存'); $table->integer('status')->default(1)->comment('状态(1上架 0下架)'); + $table->decimal('cost_price', 10, 2)->default(0)->comment('成本价(元,成本百分比计价基数)'); $table->string('remark', 255)->default('')->comment('备注'); $table->timestamps(); $table->softDeletes(); @@ -57,7 +58,9 @@ return new class extends Migration $table->increments('id')->comment('价格ID'); $table->integer('product_id')->comment('商品ID'); $table->integer('level_id')->comment('客户等级ID'); - $table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价'); + $table->decimal('price', 10, 2)->default(0)->comment('该等级下的商品单价(固定价=实际单价;百分比=等价固定价)'); + $table->unsignedTinyInteger('price_type')->default(0)->comment('计价类型(0固定价 1成本百分比)'); + $table->decimal('percent', 5, 2)->default(0)->comment('成本上浮百分点(如 30 = 上浮30%,仅 price_type=1 生效)'); $table->timestamps(); $table->unique(['product_id', 'level_id'], 'product_price_product_level_unique'); $table->comment('商品等级价格表'); diff --git a/tests/Feature/ProductCostPriceTest.php b/tests/Feature/ProductCostPriceTest.php new file mode 100644 index 0000000..9abc53b --- /dev/null +++ b/tests/Feature/ProductCostPriceTest.php @@ -0,0 +1,377 @@ + 0, + 'name' => '测试分类', + 'sort' => 1, + 'status' => ProductCategoryModel::STATUS_NORMAL, + ]); + } + + /** 造门店 + 上架商品(成本价 + 指定计价类型的价格行) + 该店用户 */ + private function makeStoreWithPercentProduct(float $cost = 10, float $percent = 30): array + { + $level = CustomerLevelModel::factory()->create(); + $store = StoreModel::factory()->create(['level_id' => $level->id]); + $product = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => $cost, + ]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $level->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => $percent, + 'price' => 13.00, + ]); + + return [$store, $product, UserModel::factory()->forStore($store->id)->create()]; + } + + /** 实际价换算:固定价原值;百分比 = 成本 × (100 + percent) / 100 */ + public function test_calc_actual_price_fixed_and_percent(): void + { + $this->assertSame( + '5.50', + ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_FIXED, '5.50', '0', '10.00'), + '固定价原样返回' + ); + $this->assertSame( + '13.00', + ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '10.00'), + '10 元上浮 30% = 13.00' + ); + $this->assertSame( + '13.05', + ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30.50', '10.00'), + '10 元上浮 30.5% = 13.05' + ); + } + + /** 换算边界:成本为 0、上浮 0%、非法类型兜底 */ + public function test_calc_actual_price_edge_cases(): void + { + $this->assertSame( + '0.00', + ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '30', '0'), + '成本为 0 时实际价按 0 兜底' + ); + $this->assertSame( + '10.00', + ProductPriceModel::calcActualPrice(ProductPriceModel::PRICE_TYPE_PERCENT, '13.00', '0', '10.00'), + '上浮 0% = 成本价' + ); + $this->assertSame( + '5.50', + ProductPriceModel::calcActualPrice(99, '5.50', '30', '10.00'), + '非法计价类型按固定价兜底' + ); + } + + /** 后台商品列表:含成本价列 + 等级价格行的 actual_price */ + public function test_admin_list_contains_cost_price_and_actual_price(): void + { + $this->actingAsSysUser(); + + $level = CustomerLevelModel::factory()->create(); + $product = ProductModel::factory()->create(['cost_price' => 10.00]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $level->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => 30, + 'price' => 13.00, + ]); + + $response = $this->getJson('/product/goods'); + $response->assertOk()->assertJsonPath('success', true); + + $row = collect($response->json('data.data'))->firstWhere('id', $product->id); + $this->assertNotNull($row); + $this->assertSame('10.00', (string) $row['cost_price'], '后台列表应含成本价'); + $this->assertSame('13.00', (string) $row['prices'][0]['actual_price'], '后台列表等级价格应为实际价'); + } + + /** 小程序列表:返回换算后实际价,且成本价不泄漏 */ + public function test_mini_list_returns_actual_price_without_cost_price(): void + { + [$store, $product, $user] = $this->makeStoreWithPercentProduct(); + $this->actingAsMiniUser($user); + + $response = $this->getJson('/mini/product/list'); + $response->assertOk()->assertJsonPath('success', true); + + $row = collect($response->json('data.data'))->firstWhere('id', $product->id); + $this->assertNotNull($row); + $this->assertSame('13.00', (string) $row['price'], '小程序端应返回百分比换算后的实际价'); + $this->assertArrayNotHasKey('cost_price', $row, '成本价为商业敏感数据,不得泄漏到小程序端'); + $this->assertArrayNotHasKey('prices', $row, '价格行原始数据(含 percent)也不应下发给小程序'); + } + + /** 后台创建商品:成本价 + 百分比计价行落库 */ + public function test_create_product_with_cost_and_percent_pricing(): void + { + $this->actingAsSysUser(); + $category = $this->makeCategory(); + $level = CustomerLevelModel::factory()->create(); + + $this->postJson('/product/goods', [ + 'category_id' => $category->id, + 'name' => '百分比商品', + 'content' => '测试图文详情', + 'cost_price' => 10, + 'prices' => [ + ['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 30, 'price' => 13], + ], + ])->assertOk()->assertJsonPath('success', true); + + $product = ProductModel::where('name', '百分比商品')->first(); + $this->assertNotNull($product); + $this->assertSame('10.00', (string) $product->cost_price); + + $price = $product->prices->first(); + $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); + $this->assertSame('30.00', (string) $price->percent); + $this->assertSame('13.00', (string) $price->actual_price, '百分比行实际价应按成本价换算'); + } + + /** 百分比计价缺少上浮百分点 → 验证失败(XinAdmin 验证错误为 200 + success=false) */ + public function test_create_percent_row_without_percent_fails(): void + { + $this->actingAsSysUser(); + $category = $this->makeCategory(); + $level = CustomerLevelModel::factory()->create(); + + $this->postJson('/product/goods', [ + 'category_id' => $category->id, + 'name' => '缺百分比', + 'content' => '', + 'prices' => [ + // 有单价但缺上浮百分点:after() 交叉校验应拦截 + ['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'price' => 13], + ], + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '按成本百分比计价时必须填写上浮百分点'); + } + + /** 编辑商品:从固定价切换为成本百分比计价 */ + public function test_update_switches_fixed_to_percent(): void + { + $this->actingAsSysUser(); + $category = $this->makeCategory(); + $level = CustomerLevelModel::factory()->create(); + + $product = ProductModel::factory()->create(['category_id' => $category->id, 'cost_price' => 10]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $level->id, + 'price' => 5.00, + ]); + + $this->putJson('/product/goods/' . $product->id, [ + 'category_id' => $category->id, + 'name' => $product->name, + 'cost_price' => 10, + 'prices' => [ + ['level_id' => $level->id, 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, 'percent' => 50, 'price' => 15], + ], + ])->assertOk()->assertJsonPath('success', true); + + $price = ProductPriceModel::forProductLevel($product->id, $level->id)->first(); + $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); + $this->assertSame('50.00', (string) $price->percent); + $this->assertSame('15.00', (string) $price->actual_price, '10 元上浮 50% = 15.00'); + } + + /** 批量调价-纯成本价行:百分比等级门店收到通知,纯固定价等级门店不通知 */ + public function test_batch_price_cost_only_notifies_percent_level_stores(): void + { + $this->actingAsSysUser(); + + $percentLevel = CustomerLevelModel::factory()->create(); + $fixedLevel = CustomerLevelModel::factory()->create(); + $percentStore = StoreModel::factory()->create(['level_id' => $percentLevel->id]); + $fixedStore = StoreModel::factory()->create(['level_id' => $fixedLevel->id]); + $percentUser = UserModel::factory()->forStore($percentStore->id)->create(); + $fixedUser = UserModel::factory()->forStore($fixedStore->id)->create(); + + $product = ProductModel::factory()->create(['cost_price' => 10]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $percentLevel->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => 30, + 'price' => 13, + ]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $fixedLevel->id, + 'price' => 8, + ]); + + $this->putJson('/product/goods/batchPrice', [ + 'updates' => [ + ['product_id' => $product->id, 'cost_price' => 12], + ], + ])->assertOk()->assertJsonPath('success', true); + + $this->assertSame('12.00', (string) $product->fresh()->cost_price, '成本价应已更新'); + $this->assertSame( + 1, + NoticeModel::where('user_id', $percentUser->id)->where('type', NoticeModel::TYPE_PRICE)->count(), + '成本价变更影响百分比计价等级,该等级门店用户应收到通知' + ); + $this->assertSame( + 0, + NoticeModel::where('user_id', $fixedUser->id)->count(), + '固定价等级不受成本价变更影响,不应收到通知' + ); + } + + /** 批量调价-百分比行:存 percent 与等价固定价 */ + public function test_batch_price_percent_row_update(): void + { + $this->actingAsSysUser(); + $level = CustomerLevelModel::factory()->create(); + $product = ProductModel::factory()->create(['cost_price' => 10]); + + $this->putJson('/product/goods/batchPrice', [ + 'updates' => [ + [ + 'product_id' => $product->id, + 'level_id' => $level->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => 40, + 'price' => 14, + ], + ], + ])->assertOk()->assertJsonPath('success', true); + + $price = ProductPriceModel::forProductLevel($product->id, $level->id)->first(); + $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); + $this->assertSame('40.00', (string) $price->percent); + $this->assertSame('14.00', (string) $price->actual_price, '10 元上浮 40% = 14.00'); + } + + /** 批量调价-混合行:同一行同时改成本价与等级价 */ + public function test_batch_price_mixed_row_updates_cost_and_level(): void + { + $this->actingAsSysUser(); + $level = CustomerLevelModel::factory()->create(); + $product = ProductModel::factory()->create(['cost_price' => 10]); + ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 8]); + + $this->putJson('/product/goods/batchPrice', [ + 'updates' => [ + [ + 'product_id' => $product->id, + 'cost_price' => 12, + 'level_id' => $level->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => 25, + 'price' => 15, + ], + ], + ])->assertOk()->assertJsonPath('success', true); + + $this->assertSame('12.00', (string) $product->fresh()->cost_price); + $price = ProductPriceModel::forProductLevel($product->id, $level->id)->first(); + $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $price->price_type); + $this->assertSame('25.00', (string) $price->percent); + $this->assertSame('15.00', (string) $price->actual_price, '12 元上浮 25% = 15.00'); + } + + /** 批量调价-空行(仅 product_id)→ 验证失败 */ + public function test_batch_price_empty_row_rejected(): void + { + $this->actingAsSysUser(); + $product = ProductModel::factory()->create(); + + $this->putJson('/product/goods/batchPrice', [ + 'updates' => [ + ['product_id' => $product->id], + ], + ])->assertOk() + ->assertJsonPath('success', false) + ->assertJsonPath('msg', '调价行缺少成本价或等级价格'); + } + + /** 价格矩阵:行含成本价与每等级的计价类型/百分比,等级格为实际价 */ + public function test_price_matrix_includes_cost_and_percent_columns(): void + { + $this->actingAsSysUser(); + + $level = CustomerLevelModel::factory()->create(); + $product = ProductModel::factory()->create(['cost_price' => 10]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $level->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => 30, + 'price' => 13, + ]); + + $response = $this->getJson('/product/goods/priceMatrix'); + $response->assertOk()->assertJsonPath('success', true); + + $row = collect($response->json('data.rows'))->firstWhere('id', $product->id); + $this->assertNotNull($row); + $this->assertSame(10.0, (float) $row['cost_price']); + $this->assertSame(13.0, (float) $row['price_' . $level->id], '矩阵等级格应显示实际价'); + $this->assertSame(ProductPriceModel::PRICE_TYPE_PERCENT, (int) $row['price_type_' . $level->id]); + $this->assertSame(30.0, (float) $row['percent_' . $level->id]); + } + + /** 小程序下单:百分比计价行按实际价重算金额 */ + public function test_mini_order_uses_actual_price_for_percent_row(): void + { + [$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30); + $this->actingAsMiniUser($user); + + $this->postJson('/mini/order', [ + 'items' => [['product_id' => $product->id, 'quantity' => 3]], + ])->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('data.total_amount', '39.00'); + + $item = $store->orders()->latest('id')->first()->items->first(); + $this->assertSame('13.00', (string) $item->price, '订单明细快照应为实际价'); + $this->assertSame('39.00', (string) $item->amount); + } + + /** 小程序购物车:列表金额按百分比换算的实际价计算 */ + public function test_mini_cart_uses_actual_price_for_percent_row(): void + { + [$store, $product, $user] = $this->makeStoreWithPercentProduct(10, 30); + $this->actingAsMiniUser($user); + + $this->postJson('/mini/cart', ['product_id' => $product->id, 'quantity' => 2.5]) + ->assertOk()->assertJsonPath('success', true); + + $this->getJson('/mini/cart') + ->assertOk() + ->assertJsonPath('data.items.0.price', '13.00') + ->assertJsonPath('data.items.0.amount', '32.50') + ->assertJsonPath('data.total_amount', '32.50'); + } +} diff --git a/tests/Feature/PurchaseGenerateTest.php b/tests/Feature/PurchaseGenerateTest.php index 9d93958..4dd6134 100644 --- a/tests/Feature/PurchaseGenerateTest.php +++ b/tests/Feature/PurchaseGenerateTest.php @@ -99,4 +99,41 @@ class PurchaseGenerateTest extends ProcurementTestCase $this->assertSame(1, PurchaseOrderModel::count(), '第二次生成应被拒绝,不产生新采购单'); } + + /** 估算单价取「最低实际价」:固定价与百分比计价混合时按换算后值比较 */ + public function test_generate_uses_min_actual_price_across_types(): void + { + $level = CustomerLevelModel::factory()->create(); + $levelFixed = CustomerLevelModel::factory()->create(); + $store = StoreModel::factory()->create(['level_id' => $level->id]); + $product = ProductModel::factory()->create([ + 'status' => ProductModel::STATUS_ON, + 'cost_price' => 10, + ]); + // 百分比行:10 × (1+40%) = 14.00;固定价行 5.00 → 估算应取 5.00 + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $level->id, + 'price_type' => ProductPriceModel::PRICE_TYPE_PERCENT, + 'percent' => 40, + 'price' => 14.00, + ]); + ProductPriceModel::factory()->create([ + 'product_id' => $product->id, + 'level_id' => $levelFixed->id, + 'price' => 5.00, + ]); + + $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); + $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 3]]]) + ->assertJsonPath('success', true); + + $this->actingAsSysUser(); + $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) + ->assertJsonPath('success', true); + + $item = PurchaseOrderModel::first()->items->first(); + $this->assertSame('5.00', (string) $item->price, '估算单价应取换算后的最低实际价'); + $this->assertSame('15.00', (string) $item->amount, '3 × 5.00'); + } } diff --git a/web/api/product/goods.ts b/web/api/product/goods.ts index e9d0876..303c00c 100644 --- a/web/api/product/goods.ts +++ b/web/api/product/goods.ts @@ -9,7 +9,10 @@ export interface PriceMatrixParams { pageSize?: number; } -/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null) */ +/** + * A2 价格矩阵:行=商品,列=启用等级,值=实际销售价(缺失 null); + * 行内含 cost_price 与 price_type_{level_id} / percent_{level_id} + */ export async function getPriceMatrix(params?: PriceMatrixParams) { return createAxios({ url: '/product/goods/priceMatrix', @@ -18,7 +21,9 @@ export async function getPriceMatrix(params?: PriceMatrixParams) { }); } -/** A2 批量调价(提交后给受影响门店生成价格变更通知) */ +/** + * A2 批量调价:支持成本价 / 固定价 / 成本百分比三类更新行(提交后给受影响门店生成价格变更通知) + */ export async function batchPrice(updates: IBatchPriceUpdate[]) { return createAxios({ url: '/product/goods/batchPrice', diff --git a/web/components/XinFormField/ImageUploader/index.tsx b/web/components/XinFormField/ImageUploader/index.tsx index 18edb6e..95f012d 100644 --- a/web/components/XinFormField/ImageUploader/index.tsx +++ b/web/components/XinFormField/ImageUploader/index.tsx @@ -54,12 +54,13 @@ const ImageUploader: React.FC = ({ const valueArray = Array.isArray(value) ? value : [value]; // changeType='id' 时表单值为文件 id:拉取文件信息回显; if (changeType === 'id') { - const ids = valueArray as number[]; + const ids = valueArray.map(i => Number(i)); + const oldFileList = fileList.filter(i => ids.includes(Number(i.uid))) - const idSet = new Set(fileList.map(obj => Number(obj.uid))); + const idSet = new Set(oldFileList.map(obj => Number(obj.uid))); // 过滤出不在 Set 中的数字 - const missing = ids.filter(num => !idSet.has(Number(num))); + const missing = ids.filter(num => !idSet.has(num)); if (missing.length > 0) { const fetchers = missing.map((id) => getFileInfo(id)); @@ -68,8 +69,10 @@ const ImageUploader: React.FC = ({ .map((res) => res.data.data) .filter(i => !!i) .map(fileToList); - setFileList([...files, ...fileList]); + setFileList([...files, ...oldFileList]); }) + } else { + setFileList(oldFileList); } } else { const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList); diff --git a/web/domain/iProduct.ts b/web/domain/iProduct.ts index 4793f0f..1c1b82d 100644 --- a/web/domain/iProduct.ts +++ b/web/domain/iProduct.ts @@ -2,12 +2,19 @@ import type IProductCategory from '@/domain/iProductCategory.ts'; import type ISupplier from '@/domain/iSupplier.ts'; import type {ISysFileInfo} from "@/domain/iSysFile.ts"; -/** 商品等级价格行 */ +/** 商品等级价格行(null = 该等级未设定价格,编辑时移除该行) */ export interface IProductPrice { id?: number; product_id?: number; level_id?: number; - price?: string | number; + /** 单价:固定价=实际单价;成本百分比=等价固定价 */ + price?: string | number | null; + /** 计价类型:0 固定价(默认) 1 成本百分比 */ + price_type?: number; + /** 成本上浮百分点(price_type=1 时生效,如 30 = 上浮 30%) */ + percent?: string | number | null; + /** 实际销售价(模型换算:百分比 = 成本价 × (100 + percent) / 100) */ + actual_price?: string | number; level?: { id: number; name: string }; } @@ -38,6 +45,8 @@ export default interface IProduct { stock?: number; /** 状态 */ status?: number; + /** 成本价(元,成本百分比计价的基数) */ + cost_price?: string | number; /** 描述 */ remark?: string; /** 分类关联数据 */ @@ -55,12 +64,16 @@ export const PRODUCT_STATUS_MAP: Record 1: { text: '上架', color: 'success' }, }; -/** 价格矩阵行(price_{level_id} 动态列) */ +/** + * 价格矩阵行:固定列 + price_{level_id}(实际价)/ price_type_{level_id} / percent_{level_id} 动态列 + */ export type IPriceMatrixRow = { id: number; name: string; spec?: string; unit?: string; + /** 成本价(null = 未设置或已清除) */ + cost_price?: string | number | null; } & Record; export interface IPriceMatrix { @@ -69,8 +82,22 @@ export interface IPriceMatrix { total: number; } +/** + * 批量调价更新行(三类,可混合同一行): + * - 成本行 { product_id, cost_price } + * - 固定价行 { product_id, level_id, price_type: 0, price } + * - 百分比行 { product_id, level_id, price_type: 1, percent }(price 可选,等价固定价) + */ export interface IBatchPriceUpdate { product_id: number; - level_id: number; - price: number | string; + /** 成本价(成本行) */ + cost_price?: number; + /** 客户等级ID(等级价格行) */ + level_id?: number; + /** 计价类型:0 固定价(默认) 1 成本百分比 */ + price_type?: number; + /** 固定价 / 百分比行的等价固定价 */ + price?: number; + /** 成本上浮百分点(price_type=1 时必填) */ + percent?: number; } diff --git a/web/pages/product/goods.tsx b/web/pages/product/goods.tsx index f164388..7b507a7 100644 --- a/web/pages/product/goods.tsx +++ b/web/pages/product/goods.tsx @@ -6,6 +6,7 @@ import { Input, InputNumber, message, + Select, Space, Table, Tag, Tree, @@ -29,14 +30,27 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts'; const { Title, Text } = Typography; +/** 计价类型:0 固定价 / 1 成本百分比(与后端 ProductPriceModel::PRICE_TYPE_* 一致) */ +const PRICE_TYPE_FIXED = 0; +const PRICE_TYPE_PERCENT = 1; +const PRICE_TYPE_OPTIONS = [ + { value: PRICE_TYPE_FIXED, label: '固定价' }, + { value: PRICE_TYPE_PERCENT, label: '成本百分比' }, +]; + +/** 四舍五入保留两位 */ +const round2 = (v: number) => Math.round(v * 100) / 100; + /** - * 等级价格表单 + * 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框; + * 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100)) */ const LevelPriceFields: React.FC<{ form: FormInstance; levels: ICustomerLevel[]; }> = ({ form, levels }) => { const prices = Form.useWatch('prices', form) ?? []; + const costPrice = Number(Form.useWatch('cost_price', form) ?? 0); if (levels.length === 0) { return ( @@ -46,30 +60,92 @@ const LevelPriceFields: React.FC<{ ); } - const setLevelPrice = (levelId: number, price: number | null) => { + const updateRow = (levelId: number, patch: Partial) => { const next = prices.filter((p) => p.level_id !== levelId); - if (price !== null) { - next.push({ level_id: levelId, price }); - } + next.push({ ...patch, level_id: levelId }); form.setFieldValue('prices', next); }; + const removeRow = (levelId: number) => { + form.setFieldValue('prices', prices.filter((p) => p.level_id !== levelId)); + }; + + /** 切换计价类型:按成本价自动换算(成本未设置时百分比计价不可用) */ + const onTypeChange = (levelId: number, row: IProductPrice | undefined, type: number) => { + if (type === PRICE_TYPE_PERCENT) { + if (!(costPrice > 0)) { + message.warning('请先在商品信息中设置成本价,才能按成本百分比计价'); + return; // Select 为受控组件,未更新表单值即回弹 + } + const price = Number(row?.price ?? 0); + updateRow(levelId, { + price_type: type, + percent: price > 0 ? round2((price / costPrice - 1) * 100) : null, + }); + } else { + const percent = Number(row?.percent ?? 0); + updateRow(levelId, { + price_type: type, + price: costPrice > 0 && percent > 0 ? round2(costPrice * (1 + percent / 100)) : (row?.price ?? null), + }); + } + }; + return ( {levels.map((level) => { if (level.id == null) return null; const row = prices.find((p) => p.level_id === level.id); + const type = row?.price_type ?? PRICE_TYPE_FIXED; return ( - {level.name}} - suffix={'¥'} - placeholder="未设定" - value={(row?.price as number | null) ?? null} - onChange={(v) => setLevelPrice(level.id as number, v)} - style={{ width: 240 }} - /> + + {level.name} +