0 ? 称重×单价 : 数量×单价,单价 = 成本/包规) */ class ReconciliationTest extends ProcurementTestCase { /** * 构造已完成订单链路:2 门店下单(2/3 件,等级价 10.00;成本 8,包规 1斤 → 单价 8.00),订单置为已完成 * 预期:publish 20/30,actual 16/24,diff 4/6 * * @return array{0: array, 1: SupplierModel} */ private function buildCompletedOrders(): array { $level = CustomerLevelModel::factory()->create(); $supplier = SupplierModel::factory()->create(); $product = ProductModel::factory()->create([ 'status' => ProductModel::STATUS_ON, 'supplier_id' => $supplier->id, 'cost_price' => 8, 'spec' => '1斤', ]); ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => '10.00']); $stores = []; foreach ([2, 3] as $qty) { $store = StoreModel::factory()->create(['level_id' => $level->id]); $stores[] = $store; $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_COMPLETED]); return [$stores, $supplier]; } private function createRecon(array $extra = []): int { $response = $this->postJson('/recon/list', array_merge([ 'title' => '测试对账', 'period_start' => now()->toDateString(), 'period_end' => now()->toDateString(), ], $extra)); $response->assertJsonPath('success', true); return (int) $response->json('data.id'); } /** 构建明细:publish=订货金额,actual=采购成本(数量×单价),diff=publish-actual,头汇总回写 */ public function test_build_creates_reconciliation_items(): void { [$stores] = $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true); $items = ReconciliationItemModel::where('recon_id', $reconId)->get(); $this->assertCount(2, $items, '两门店已完成订单 → 两条对账明细'); $byStore = $items->keyBy('store_id'); $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->publish_amount, '订货金额 2×10'); $this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount, '采购成本 2×8'); $this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount); $this->assertSame('30.00', (string) $byStore[$stores[1]->id]->publish_amount); $this->assertSame('24.00', (string) $byStore[$stores[1]->id]->actual_amount); $recon = ReconciliationModel::find($reconId); $this->assertSame('50.00', (string) $recon->publish_amount); $this->assertSame('40.00', (string) $recon->actual_amount); $this->assertSame('10.00', (string) $recon->diff_amount); $this->assertSame(ReconciliationModel::STATUS_WORKING, $recon->status); } /** 未完成订单不计入对账 */ public function test_build_excludes_unfinished_orders(): void { [$stores] = $this->buildCompletedOrders(); // 第二家门店订单回退为配送中 → 仅第一家进入对账 StoreOrderModel::where('store_id', $stores[1]->id) ->update(['status' => StoreOrderModel::STATUS_DISTRIBUTION]); $this->actingAsSysUser(); $reconId = $this->createRecon(); $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', true); $items = ReconciliationItemModel::where('recon_id', $reconId)->get(); $this->assertCount(1, $items); $this->assertSame($stores[0]->id, $items->first()->store_id); } /** 供应商筛选:仅拉取该供应商的订单数据 */ public function test_build_filters_by_supplier(): void { [, $supplier] = $this->buildCompletedOrders(); $this->actingAsSysUser(); // 无关供应商 → 无数据报错 $other = SupplierModel::factory()->create(); $reconId = $this->createRecon(['supplier_id' => $other->id]); $this->postJson("/recon/list/{$reconId}/build")->assertJsonPath('success', false); // 正确供应商 → 构建成功 $reconId2 = $this->createRecon(['supplier_id' => $supplier->id]); $this->postJson("/recon/list/{$reconId2}/build")->assertJsonPath('success', true); $this->assertSame(2, ReconciliationItemModel::where('recon_id', $reconId2)->count()); } /** D4 修改明细:自动重算本行 diff 与对账单头汇总 */ public function test_update_item_recalculates_diff_and_header(): void { $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); $this->postJson("/recon/list/{$reconId}/build"); $item = ReconciliationItemModel::where('recon_id', $reconId)->orderBy('id')->first(); $this->putJson("/recon/item/{$item->id}", ['actual_amount' => '25.00']) ->assertJsonPath('success', true); $item = $item->fresh(); $this->assertSame('-5.00', (string) $item->diff_amount, '20.00 - 25.00'); $recon = ReconciliationModel::find($reconId); $this->assertSame('49.00', (string) $recon->actual_amount, '25 + 24'); $this->assertSame('1.00', (string) $recon->diff_amount, '50 - 49'); } /** D8 对账状态标记翻转 */ public function test_toggle_reconciled_flag(): void { $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); $this->postJson("/recon/list/{$reconId}/build"); $item = ReconciliationItemModel::where('recon_id', $reconId)->first(); $this->assertSame(0, $item->is_reconciled); $this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true); $this->assertSame(1, $item->fresh()->is_reconciled); $this->putJson("/recon/item/{$item->id}/toggle")->assertJsonPath('success', true); $this->assertSame(0, $item->fresh()->is_reconciled); } /** D9 结算:按门店生成结算表,对账单转为已结算且不可重复结算 */ public function test_settle_creates_settlements_per_store(): void { [$stores] = $this->buildCompletedOrders(); $this->actingAsSysUser(); $reconId = $this->createRecon(); $this->postJson("/recon/list/{$reconId}/build"); $this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', true); $settlements = SettlementModel::where('recon_id', $reconId)->get(); $this->assertCount(2, $settlements, '按门店各生成一张结算表'); $byStore = $settlements->keyBy('store_id'); $this->assertSame('20.00', (string) $byStore[$stores[0]->id]->total_amount); $this->assertSame('16.00', (string) $byStore[$stores[0]->id]->actual_amount); $this->assertSame('4.00', (string) $byStore[$stores[0]->id]->diff_amount); $this->assertStringStartsWith('JS', $byStore[$stores[0]->id]->settlement_no); $this->assertSame(ReconciliationModel::STATUS_SETTLED, ReconciliationModel::find($reconId)->status); // 已结算不可重复结算 $this->postJson("/recon/list/{$reconId}/settle")->assertJsonPath('success', false); } }