instance('config', new Repository(['app' => ['platform_timezone' => 'Asia/Shanghai']])); $db = new Manager($app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->getConnection()->getSchemaBuilder()); $app->instance('validator', new Factory(new Translator(new ArrayLoader, 'zh_CN'), $app)); Facade::clearResolvedInstances(); Facade::setFacadeApplication($app); $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_16_220000_create_accessory_reship_courier_configs.php'; $migration->up(); $this->store = new AccessoryReshipCourierStore; } private function row(string $code = 'TEST', string $name = '测试快递', int $rate = 5000): array { return ['courier_code' => $code, 'courier_name' => $name, 'rate_basis_points' => $rate, 'source_created_at' => '2025-11-07 17:03:30', 'creator' => '原创建人']; } public function test_import_preserves_source_metadata_and_is_idempotent(): void { $this->assertSame(['added' => 1, 'skipped' => 0], $this->store->import(1, [$this->row()], '导入操作人', 'hash')); $this->assertSame(['added' => 0, 'skipped' => 1], $this->store->import(1, [$this->row()], '导入操作人', 'hash')); $row = DB::table(AccessoryReshipCourierStore::TABLE)->first(); $this->assertSame('2025-11-07 17:03:30', $row->source_created_at); $this->assertSame('原创建人', $row->creator); $this->assertSame(1, DB::table(AccessoryReshipCourierStore::TABLE)->count()); $this->assertSame(2, DB::table(AccessoryReshipCourierStore::EVENTS)->count()); } public function test_import_conflict_rolls_back_entire_batch_and_log(): void { $this->store->import(1, [$this->row()], '操作人', 'hash'); try { $this->store->import(1, [$this->row('NEW', '新增快递'), $this->row('TEST', '不同名称')], '操作人', 'hash2'); $this->fail('必须拒绝冲突'); } catch (ValidationException) { $this->assertSame(1, DB::table(AccessoryReshipCourierStore::TABLE)->count()); $this->assertSame(1, DB::table(AccessoryReshipCourierStore::EVENTS)->count()); } } public function test_crud_soft_delete_restore_and_company_isolation(): void { $id = $this->store->save(1, $this->row(), '操作人'); $this->store->save(1, $this->row(rate: 7000), '编辑人', $id); $this->assertSame(7000, (int) $this->store->activeRates(1)['测试快递']['rate_basis_points']); $this->assertSame([], $this->store->activeRates(2)); $this->store->changeDeleted(1, $id, true, '操作人'); $this->assertSame([], $this->store->activeRates(1)); $this->store->changeDeleted(1, $id, false, '操作人'); $this->assertCount(1, $this->store->activeRates(1)); $this->assertSame(4, DB::table(AccessoryReshipCourierStore::EVENTS)->count()); try { $this->store->save(2, $this->row(), '其他公司', $id); $this->fail('不能修改其他公司'); } catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) { $this->assertSame(404, $exception->getStatusCode()); } } public function test_rate_calculation_zero_missing_conflicting_and_rounding(): void { $calculator = new AccessoryReshipRateCalculator; $row = ['status' => 'matched', 'freight_cents' => 1235, 'matches' => [['courier' => '测试快递'], ['courier' => '另一快递']]]; $rates = ['测试快递' => $this->row(rate: 5000), '另一快递' => $this->row('SECOND', '另一快递', 5000)]; $this->assertSame('selection_required', $calculator->apply($row, $rates)['deduction_state']); $result = $calculator->apply($row, $rates, '测试快递'); $this->assertSame(618, $result['deduction_cents']); $this->assertSame('ready', $result['deduction_state']); $rates['另一快递']['rate_basis_points'] = 10000; $this->assertSame('selection_required', $calculator->apply($row, $rates)['deduction_state']); $this->assertNull($calculator->apply($row, $rates)['deduction_cents']); $this->assertSame('missing_config', $calculator->apply($row, [], '测试快递')['deduction_state']); $this->assertSame(1235, $calculator->apply($row, $rates, '另一快递')['deduction_cents']); $this->assertSame('selection_required', $calculator->apply($row, $rates, '失效快递')['deduction_state']); $row['matches'] = [['courier' => '测试快递']]; $rates['测试快递']['rate_basis_points'] = 0; $this->assertSame(0, $calculator->apply($row, $rates)['deduction_cents']); $this->assertSame('zero_rate', $calculator->apply($row, $rates)['deduction_state']); $this->assertSame(865, AccessoryReshipRateCalculator::amount(1235, 7000)); $row['status'] = 'unmatched'; $this->assertSame(0, $calculator->apply($row, $rates)['deduction_cents']); } public function test_same_courier_selects_one_waybill_without_confirmation(): void { $row = ['status' => 'matched', 'freight_cents' => 1235, 'matches' => [ ['courier' => '测试快递', 'express_no' => 'FIRST', 'month' => '2026-09'], ['courier' => '测试快递', 'express_no' => 'SECOND', 'month' => '2026-08'], ]]; $result = (new AccessoryReshipRateCalculator)->apply($row, ['测试快递' => $this->row()]); $this->assertSame('ready', $result['deduction_state']); $this->assertSame(618, $result['deduction_cents']); $this->assertCount(1, $result['matches']); $this->assertSame('FIRST', $result['matches'][0]['express_no']); $this->assertStringNotContainsString('核对', $result['deduction_note']); } public function test_selection_survives_reload_and_is_scoped_to_company_month_and_package(): void { $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_16_230000_create_accessory_reship_selections.php'; $migration->up(); $store = new AccessoryReshipSelectionStore; $key = AccessoryReshipSelectionStore::key(['source_id' => 1, 'express_no' => 'BF001']); $other = AccessoryReshipSelectionStore::key(['source_id' => 2, 'express_no' => 'BF002']); $this->assertNotSame($key, $other); $store->save(1, '2026-10', $key, '快递A', '操作人'); $store->save(1, '2026-10', $key, '快递B', '操作人'); $store->save(1, '2026-10', $other, '快递C', '操作人'); $this->assertSame([$key => '快递B', $other => '快递C'], (new AccessoryReshipSelectionStore)->forMonth(1, '2026-10')); $this->assertSame([], $store->forMonth(2, '2026-10')); $this->assertSame([], $store->forMonth(1, '2026-09')); $this->assertSame(2, DB::table(AccessoryReshipSelectionStore::TABLE)->count()); } public function test_importer_reads_string_and_numeric_percent_and_retains_dates(): void { $book = new \PhpOffice\PhpSpreadsheet\Spreadsheet; $sheet = $book->getActiveSheet(); $sheet->fromArray([['快递代码', '快递公司', '创建时间', '创建人', '扣款比率(%)'], ['A', '快递A', '2025-11-07 17:03:30', '原创建人', '50.00%'], ['B', '快递B', '2025-11-08 12:00:00', '原创建人', 0.7]]); $sheet->getStyle('E3')->getNumberFormat()->setFormatCode('0.00%'); $path = tempnam(sys_get_temp_dir(), 'reship-config-'); try { (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($path); $rows = (new AccessoryReshipCourierImporter)->read($path); $this->assertSame([5000, 7000], array_column($rows, 'rate_basis_points')); $this->assertSame('2025-11-07 17:03:30', $rows[0]['source_created_at']); $sheet->setCellValue('E3', '100.01%'); (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($path); $this->expectException(ValidationException::class); (new AccessoryReshipCourierImporter)->read($path); } finally { $book->disconnectWorksheets(); unlink($path); } } }