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'); } }