0, 'name' => '测试分类', 'sort' => 1, 'status' => ProductCategoryModel::STATUS_NORMAL, ]); } /** 造门店(等级上浮 percent)+ 上架商品(成本价 cost) */ private function makeStoreWithPercentProduct(float $cost = 10, float $percent = 30): array { $level = CustomerLevelModel::factory()->create(['percent' => $percent]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'cost_price' => $cost, ]); return [$store, $product]; } /** 售价换算:成本价 × (100 + percent) / 100 */ public function test_calc_level_price(): void { $this->assertSame('13.00', CustomerLevelModel::calcLevelPrice('10.00', '30'), '10 元上浮 30% = 13.00'); $this->assertSame('13.05', CustomerLevelModel::calcLevelPrice('10.00', '30.50'), '10 元上浮 30.5% = 13.05'); $this->assertSame('5.50', CustomerLevelModel::calcLevelPrice('5.50', '0'), '上浮 0% = 成本价'); } /** 换算边界:成本为 0 时售价按 0 兜底 */ public function test_calc_level_price_edge_cases(): void { $this->assertSame('0.00', CustomerLevelModel::calcLevelPrice('0', '30'), '成本为 0 时售价按 0 兜底'); $this->assertSame('10.00', CustomerLevelModel::calcLevelPrice('10.00', '0'), '上浮 0% = 成本价'); } /** 后台商品列表:含成本价列 + 各启用等级的换算价 */ public function test_admin_list_contains_cost_price_and_level_prices(): void { $this->actingAsSysUser(); $level = CustomerLevelModel::factory()->create(['percent' => 30]); $product = ProductModel::factory()->create(['cost_price' => 10.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'], '后台列表应含成本价'); $priceRow = collect($row['prices'])->firstWhere('level_id', $level->id); $this->assertNotNull($priceRow, '后台列表应含该等级的换算价'); $this->assertSame('13.00', (string) $priceRow['price'], '10 元上浮 30% = 13.00'); $this->assertSame(30.0, (float) $priceRow['percent']); } /** 小程序列表:返回换算后实际价,且成本价不泄漏 */ public function test_mini_list_returns_actual_price_without_cost_price(): void { [$store, $product] = $this->makeStoreWithPercentProduct(); $this->actingAsMiniStore($store); $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, '价格明细(含上浮比例)不应下发给小程序'); } /** 后台创建商品:成本价落库(售价由等级上浮比例换算,不再随商品提交价格行) */ public function test_create_product_with_cost_price(): void { $this->actingAsSysUser(); $category = $this->makeCategory(); $this->postJson('/product/goods', [ 'category_id' => $category->id, 'name' => '上浮计价商品', 'content' => '测试图文详情', 'cost_price' => 10, ])->assertOk()->assertJsonPath('success', true); $product = ProductModel::where('name', '上浮计价商品')->first(); $this->assertNotNull($product); $this->assertSame('10.00', (string) $product->cost_price); } /** 单价单位:不传默认「元/斤」,创建/编辑可修改 */ public function test_product_price_unit_default_and_editable(): void { $this->actingAsSysUser(); $category = $this->makeCategory(); // 不传单价单位 → 默认「元/斤」 $this->postJson('/product/goods', [ 'category_id' => $category->id, 'name' => '默认单价单位商品', 'content' => '测试图文详情', ])->assertOk()->assertJsonPath('success', true); $product = ProductModel::where('name', '默认单价单位商品')->first(); $this->assertSame('元/斤', $product->price_unit); // 创建时指定 + 编辑修改 $this->postJson('/product/goods', [ 'category_id' => $category->id, 'name' => '箱装商品', 'content' => '测试图文详情', 'price_unit' => '元/箱', ])->assertOk()->assertJsonPath('success', true); $product2 = ProductModel::where('name', '箱装商品')->first(); $this->assertSame('元/箱', $product2->price_unit); $this->putJson("/product/goods/{$product2->id}", [ 'category_id' => $category->id, 'name' => '箱装商品', 'price_unit' => '元/件', ])->assertOk()->assertJsonPath('success', true); $this->assertSame('元/件', $product2->refresh()->price_unit); // 超长拒绝 $this->postJson('/product/goods', [ 'category_id' => $category->id, 'name' => '异常单价单位商品', 'content' => '测试图文详情', 'price_unit' => str_repeat('元', 21), ])->assertOk()->assertJsonPath('success', false); } /** 批量调价-成本价行:更新成本并通知门店;等级售价随上浮比例联动 */ public function test_batch_price_cost_row_update(): void { $this->actingAsSysUser(); $level = CustomerLevelModel::factory()->create(['percent' => 30]); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create(['cost_price' => 10]); $this->putJson('/product/goods/batchPrice', [ 'updates' => [ ['product_id' => $product->id, 'cost_price' => 20], ], ])->assertOk()->assertJsonPath('success', true); $this->assertSame('20.00', (string) $product->refresh()->cost_price, '成本价应已更新'); $this->assertSame( 1, NoticeModel::where('store_id', $store->id)->where('type', NoticeModel::TYPE_PRICE)->count(), '门店应收到价格变更通知' ); // 等级售价联动:20 元上浮 30% = 26.00 $this->actingAsMiniStore($store); $row = collect($this->getJson('/mini/product/list')->json('data.data'))->firstWhere('id', $product->id); $this->assertSame('26.00', (string) $row['price'], '成本变更后小程序端售价应按上浮比例联动'); } /** 批量调价-空行(仅 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', '调价行缺少成本价'); } /** 价格矩阵:行含成本价,等级格为按上浮比例换算的售价;成本价未设置时等级格为 null */ public function test_price_matrix_includes_cost_and_level_price_columns(): void { $this->actingAsSysUser(); $level = CustomerLevelModel::factory()->create(['percent' => 30]); $product = ProductModel::factory()->create(['cost_price' => 10]); $noCostProduct = ProductModel::factory()->create(['cost_price' => 0]); $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], '矩阵等级格应显示换算价'); $noCostRow = collect($response->json('data.rows'))->firstWhere('id', $noCostProduct->id); $this->assertNotNull($noCostRow); $this->assertNull($noCostRow['price_' . $level->id], '成本价未设置时等级格应为 null'); } /** 小程序下单:按等级上浮换算价重算金额并快照 */ public function test_mini_order_uses_level_price(): void { [$store, $product] = $this->makeStoreWithPercentProduct(10, 30); $this->actingAsMiniStore($store); $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_level_price(): void { [$store, $product] = $this->makeStoreWithPercentProduct(10, 30); $this->actingAsMiniStore($store); $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'); } }