insert([ 'id' => 100, 'title' => '业务配置', 'key' => 'services', 'remark' => '', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('sys_site_config_items')->insert([ [ 'group_id' => 100, 'key' => 'box_amount', 'title' => '周转筐金额', 'describe' => '', 'values' => '5.00', 'type' => 'InputNumber', 'options' => null, 'props' => null, 'sort' => 0, 'created_at' => now(), 'updated_at' => now(), ], [ 'group_id' => 100, 'key' => 'tray_amount', 'title' => '周转托盘金额', 'describe' => '', 'values' => '20.00', 'type' => 'InputNumber', 'options' => null, 'props' => null, 'sort' => 1, 'created_at' => now(), 'updated_at' => now(), ], ]); SysSiteConfigService::refreshSiteConfig(); } /** * 完整业务链造账单(商品 5.00 × 4 = 20.00,配送费 0),按指定筐/托盘数量生成 * * @return array{0: StoreModel, 1: BillModel} */ private function makeBillWithContainers(int $boxNum, int $trayNum): array { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); $product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '5.00']); $this->actingAsMiniStore($store); $this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 4]]]) ->assertJsonPath('success', true); $order = StoreOrderModel::where('store_id', $store->id)->first(); $this->actingAsSysUser(); $this->putJson("/order/store/{$order->id}/status", ['status' => StoreOrderModel::STATUS_SUMMARIZED]) ->assertJsonPath('success', true); $this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()]) ->assertJsonPath('success', true); $purchase = PurchaseOrderModel::first(); $this->putJson("/purchase/order/{$purchase->id}/finish")->assertJsonPath('success', true); $this->postJson("/purchase/order/{$purchase->id}/bill", [ 'stores' => [[ 'store_id' => $store->id, 'delivery_fee' => 0, 'box_num' => $boxNum, 'tray_num' => $trayNum, ]], ])->assertJsonPath('success', true); return [$store, BillModel::where('store_id', $store->id)->first()]; } /** 压筐(正数):附加金额增加账单总额,写入完整快照记录 */ public function test_press_containers_adds_amount_and_writes_record(): void { $this->seedContainerConfig(); [$store, $bill] = $this->makeBillWithContainers(3, 2); // 附加 = 3×5 + 2×20 = 55;总额 = 20 + 55 = 75 $this->assertSame('55.00', (string) $bill->added_amount); $this->assertSame('75.00', (string) $bill->total_amount); $record = ContainerReturnModel::where('bill_id', $bill->id)->first(); $this->assertNotNull($record); $this->assertSame($store->id, $record->store_id); $this->assertSame(3, $record->box_num); $this->assertSame(2, $record->tray_num); $this->assertSame('5.00', (string) $record->box_price, '筐单价快照'); $this->assertSame('20.00', (string) $record->tray_price, '托盘单价快照'); $this->assertSame('55.00', (string) $record->amount); } /** 回筐(负数):抵扣账单金额,记录为负 */ public function test_return_containers_deducts_amount(): void { $this->seedContainerConfig(); [, $bill] = $this->makeBillWithContainers(-2, 0); // 附加 = -2×5 + 0 = -10;总额 = 20 - 10 = 10 $this->assertSame('-10.00', (string) $bill->added_amount); $this->assertSame('10.00', (string) $bill->total_amount); $record = ContainerReturnModel::where('bill_id', $bill->id)->first(); $this->assertNotNull($record); $this->assertSame(-2, $record->box_num); $this->assertSame(0, $record->tray_num); $this->assertSame('-10.00', (string) $record->amount); } /** 混合:筐回(负)+ 托盘压(正),金额按代数和计 */ public function test_mixed_sign_containers(): void { $this->seedContainerConfig(); [, $bill] = $this->makeBillWithContainers(-3, 1); // 附加 = -3×5 + 1×20 = 5;总额 = 20 + 5 = 25 $this->assertSame('5.00', (string) $bill->added_amount); $this->assertSame('25.00', (string) $bill->total_amount); $record = ContainerReturnModel::where('bill_id', $bill->id)->first(); $this->assertNotNull($record); $this->assertSame(-3, $record->box_num); $this->assertSame(1, $record->tray_num); $this->assertSame('5.00', (string) $record->amount); } /** 数量为 0:不写压回筐记录 */ public function test_zero_containers_writes_no_record(): void { $this->seedContainerConfig(); [, $bill] = $this->makeBillWithContainers(0, 0); $this->assertSame('20.00', (string) $bill->total_amount); $this->assertSame(0, ContainerReturnModel::where('bill_id', $bill->id)->count()); } /** 筐/托盘数量非整数 → 拒绝 */ public function test_non_integer_containers_rejected(): void { $level = CustomerLevelModel::factory()->create(); $store = StoreModel::factory()->create(['level_id' => $level->id]); $this->actingAsSysUser(); $purchase = PurchaseOrderModel::factory()->create(['status' => PurchaseOrderModel::STATUS_COMPLETED]); $this->postJson("/purchase/order/{$purchase->id}/bill", [ 'stores' => [[ 'store_id' => $store->id, 'delivery_fee' => 0, 'box_num' => 1.5, 'tray_num' => 0, ]], ])->assertJsonPath('success', false); } /** 压回筐记录只读:手动登记/删除端点已下线 */ public function test_manual_create_and_delete_routes_removed(): void { $this->actingAsSysUser(); $this->postJson('/recon/container-return', [ 'store_id' => 1, 'box_num' => 1, 'tray_num' => 0, ])->assertJsonPath('success', false); $this->deleteJson('/recon/container-return/1')->assertJsonPath('success', false); } /** 造一条压回筐记录(直接入库并指定记录时间,用于导出区间/合并断言) */ private function makeRecord(StoreModel $store, string $amount, string $createdAt): void { DB::table('container_return')->insert([ 'store_id' => $store->id, 'bill_id' => 0, 'box_num' => 0, 'tray_num' => 0, 'box_price' => '5.00', 'tray_price' => '20.00', 'amount' => $amount, 'operator_id' => 0, 'created_at' => $createdAt, 'updated_at' => $createdAt, ]); } /** 汇总导出:行=日期(区间内逐日),列=门店,同日记录合并,无记录填 0,含行/列合计 */ public function test_export_matrix_merges_same_day_and_zero_fills(): void { $storeA = StoreModel::factory()->create(); $storeB = StoreModel::factory()->create(); // 08-01:A 两条同日记录合并(+55 压筐、-10 回筐 = 45),B 一条 -20 $this->makeRecord($storeA, '55.00', '2026-08-01 09:00:00'); $this->makeRecord($storeA, '-10.00', '2026-08-01 15:30:00'); $this->makeRecord($storeB, '-20.00', '2026-08-01 10:00:00'); // 08-02:仅 A +5,B 无记录填 0 $this->makeRecord($storeA, '5.00', '2026-08-02 08:00:00'); // 区间外记录不参与 $this->makeRecord($storeB, '30.00', '2026-08-04 08:00:00'); $this->actingAsSysUser(); Excel::fake(); $this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-03')->assertOk(); Excel::assertDownloaded( '回筐记录_2026-08-01_2026-08-03.xlsx', static function (ContainerReturnExport $export) use ($storeA, $storeB): bool { $rows = $export->collection()->values(); // 标题1 + 范围1 + 空行1 + 列头1 + 日期3 + 合计1 = 8 行 if ($rows->count() !== 8) { return false; } // 列头:日期 + 门店(未选门店时取区间内有记录的门店,ID 升序)+ 合计 if ($rows[3] !== ['日期', $storeA->name, $storeB->name, '合计']) { return false; } // 08-01:同日合并 55-10=45,B -20,行合计 25 if ($rows[4] !== ['2026-08-01', 45.0, -20.0, 25.0]) { return false; } // 08-02:A 5、B 无记录填 0,行合计 5 if ($rows[5] !== ['2026-08-02', 5.0, 0.0, 5.0]) { return false; } // 08-03:当日全部无记录,整行填 0 if ($rows[6] !== ['2026-08-03', 0.0, 0.0, 0.0]) { return false; } // 合计行:A 列 50、B 列 -20、总计 30 return $rows[7] === ['合计', 50.0, -20.0, 30.0]; } ); } /** 指定门店导出:仅所选门店成列,未选门店记录忽略;所选门店无记录整列填 0 */ public function test_export_filters_by_selected_stores(): void { $storeA = StoreModel::factory()->create(); $storeB = StoreModel::factory()->create(); $storeC = StoreModel::factory()->create(); $this->makeRecord($storeA, '55.00', '2026-08-01 09:00:00'); $this->makeRecord($storeB, '-20.00', '2026-08-01 10:00:00'); // C 区间内无记录,但被选中 → 整列填 0 $this->actingAsSysUser(); Excel::fake(); $this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-02&store_ids=' . $storeA->id . ',' . $storeC->id) ->assertOk(); Excel::assertDownloaded( '回筐记录_2026-08-01_2026-08-02.xlsx', static function (ContainerReturnExport $export) use ($storeA, $storeC): bool { $rows = $export->collection()->values(); // 列头仅含所选门店(B 的记录被忽略) if ($rows[3] !== ['日期', $storeA->name, $storeC->name, '合计']) { return false; } // 08-01:A 55、C 填 0;08-02 全 0 if ($rows[4] !== ['2026-08-01', 55.0, 0.0, 55.0]) { return false; } if ($rows[5] !== ['2026-08-02', 0.0, 0.0, 0.0]) { return false; } return $rows[6] === ['合计', 55.0, 0.0, 55.0]; } ); } /** 参数校验:缺少日期 / 结束早于开始 / 区间超 366 天 → 拒绝 */ public function test_export_invalid_params_rejected(): void { $this->actingAsSysUser(); $this->get('/recon/container-return/export') ->assertJsonPath('success', false); $this->get('/recon/container-return/export?start_date=2026-08-10&end_date=2026-08-01') ->assertJsonPath('success', false); $this->get('/recon/container-return/export?start_date=2026-01-01&end_date=2027-06-30') ->assertJsonPath('success', false); } /** 区间内无记录且未选门店 → 拒绝 */ public function test_export_empty_range_rejected(): void { $this->actingAsSysUser(); $this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-03') ->assertJsonPath('success', false); } /** 无 recon.containerReturn.export 权限点 → 拦截 */ public function test_export_requires_export_permission(): void { // 先建一个占位用户:每个测试方法内首个系统用户自增 id=1, // SysAccessToken::can() 对 tokenable_id==1(超管)放行全部权限,会绕过 abilities 校验 $this->actingAsSysUser(); // 仅持有查询权限的用户 $this->actingAsSysUser(['recon.containerReturn.query']); $response = $this->get('/recon/container-return/export?start_date=2026-08-01&end_date=2026-08-03'); $this->assertFalse($response->json('success'), '缺少权限点应被拦截'); } }