$quantities 各门店订货量 * @return array{0: PurchaseOrderModel, 1: PurchaseOrderItemModel} */ private function buildAllocatablePurchase(array $quantities, string $actualPrice = '10.00'): array { $level = CustomerLevelModel::factory()->create(); $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); foreach ($quantities as $qty) { $store = StoreModel::factory()->create(['level_id' => $level->id]); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]]) ->assertJsonPath('success', true); } // 接单后生成采购单(生成来源为已接单订单) StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); $this->actingAsSysUser(); $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) ->assertJsonPath('success', true); $purchase = PurchaseOrderModel::first(); $item = $purchase->items->first(); // 录入实际单价(weight=0 → amount = quantity × price) $this->putJson("/purchase/order/item/{$item->id}", [ 'price' => $actualPrice, 'quantity' => $item->quantity, 'weight' => 0, ])->assertJsonPath('success', true); return [$purchase->fresh(), $item->fresh()]; } /** 金额守恒:Σallocation.amount === item.amount,尾差由最后一行承担 */ public function test_allocation_conserves_amount_with_tail_correction(): void { [$purchase, $item] = $this->buildAllocatablePurchase(['1', '1', '1']); // 把实际金额调成不可被 3 整除的 100.00(quantity=10 × price=10) $this->actingAsSysUser(); $this->putJson("/purchase/order/item/{$item->id}", [ 'price' => '10.00', 'quantity' => '10.00', 'weight' => 0, ])->assertJsonPath('success', true); $item = $item->fresh(); $this->assertSame('100.00', (string) $item->amount); $this->postJson("/purchase/order/{$purchase->id}/allocate") ->assertOk() ->assertJsonPath('success', true); $allocations = PurchaseAllocationModel::where('purchase_item_id', $item->id) ->orderBy('id') ->get(); $this->assertCount(3, $allocations); $sum = $allocations->reduce( static fn (string $carry, $a): string => bcadd($carry, (string) $a->amount, 2), '0' ); $this->assertSame('100.00', $sum, '分摊总额必须守恒'); // 三等分尾差修正:33.33 / 33.33 / 33.34 $this->assertSame('33.33', (string) $allocations[0]->amount); $this->assertSame('33.33', (string) $allocations[1]->amount); $this->assertSame('33.34', (string) $allocations[2]->amount); } /** 按订货数量比例分摊 */ public function test_allocation_follows_order_ratio(): void { [$purchase, $item] = $this->buildAllocatablePurchase(['1', '3']); // quantity=4,实际金额 4×10=40 → 1:3 → 10.00 / 30.00 $this->actingAsSysUser(); $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); $amounts = PurchaseAllocationModel::where('purchase_item_id', $item->id) ->pluck('amount') ->map(static fn ($v) => (string) $v) ->sort() ->values() ->all(); $this->assertSame(['10.00', '30.00'], $amounts); } /** 幂等:重复分摊先删旧记录再重建,数量不变 */ public function test_allocation_is_idempotent(): void { [$purchase] = $this->buildAllocatablePurchase(['2', '3']); $this->actingAsSysUser(); $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); $first = PurchaseAllocationModel::count(); $this->assertGreaterThan(0, $first); $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', true); $this->assertSame($first, PurchaseAllocationModel::count(), '重复分摊不应产生重复记录'); } /** 未录入实际金额时拒绝分摊 */ public function test_allocation_rejected_without_actual_amount(): void { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON]); ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); $this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create()); $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 1]]]); StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]); $this->actingAsSysUser(); $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]); $purchase = PurchaseOrderModel::first(); // 模拟未录入实际金额 $purchase->items->first()->update(['amount' => 0, 'price' => 0]); $this->postJson("/purchase/order/{$purchase->id}/allocate")->assertJsonPath('success', false); $this->assertSame(0, PurchaseAllocationModel::count()); } }