user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } public function test_stream_reader_reads_xlsx_rows_and_dates(): void { $path = $this->makeXlsxFile([ self::HEADER, ['2026-09', 'RK-1', 'CG-1', '', '供应商甲', 'X-1', 'P-1', '规格甲', '2', '10', '20', '9', '18', 'SC-1', '备注甲', '华东仓', '顺丰', 'SF-1', '未发出', '对账人甲'], ], dateColumnIndexes: [3]); try { $reader = PayablesWorkbookStreamReader::forFile($path, 'xlsx'); $this->assertSame(['Sheet1'], $reader->sheetNames()); $rows = iterator_to_array($reader->rows(0), true); $this->assertCount(2, $rows); $this->assertSame('月份', $rows[1][0]); $this->assertSame('供应商甲', $rows[2][4]); $this->assertSame('2026-09-08', $rows[2][3], '日期格式的单元格应转成 Y-m-d'); } finally { @unlink($path); } } public function test_stream_reader_reads_gbk_csv(): void { $content = mb_convert_encoding(implode(',', self::HEADER) . "\n2026-09,RK-9,CG-9,2026-09-08,供应商九,X-9,P-9,规格甲,2,10,20,9,18,SC-1,备注甲,华东仓,顺丰,SF-9,未发出,对账人甲\n", 'GBK', 'UTF-8'); $path = $this->makeFile('gbk.csv', $content); try { $reader = PayablesWorkbookStreamReader::forFile($path, 'csv'); $rows = iterator_to_array($reader->rows(0), true); $this->assertSame('供应商九', $rows[2][4]); } finally { @unlink($path); } } public function test_runner_imports_in_slices_and_cleans_up(): void { $lines = [implode(',', self::HEADER)]; for ($i = 1; $i <= 7; $i++) { $lines[] = "2026-09,RK-{$i},CG-{$i},2026-09-08,供应商{$i},X-{$i},P-{$i},规格甲,2,10,20,9,18,SC-1,备注甲,华东仓,顺丰,SF-{$i},未发出,对账人甲"; } $file = UploadedFile::fake()->createWithContent('rows.csv', "\xEF\xBB\xBF" . implode("\n", $lines) . "\n"); $runner = app(PayablesFinalImportRunner::class); $fields = app(PayablesFinalRegistry::class)->fields('supplier-payables-final', 'payment-reconciliation'); $dataset = 'final:supplier:payment-reconciliation'; $prepared = $runner->prepare($file, $fields, $dataset, (int) $this->company->id, (int) $this->user->id); $this->assertSame(7, $prepared['total']); $offset = 0; $created = 0; $slices = 0; do { $progress = $runner->process($prepared['token'], $offset, 3); $offset = $progress['offset']; $created = $progress['created']; $slices++; } while (! $progress['done'] && $slices < 10); $this->assertTrue($progress['done']); $this->assertGreaterThanOrEqual(3, $slices, '应当分多片处理'); $this->assertSame(7, $created); $this->assertCount(7, app(SupplierPayableOverrideStore::class)->listRows($dataset, (int) $this->company->id)); $this->assertDirectoryDoesNotExist($runner->storagePath() . '/' . $prepared['token'], '完成后应删除缓存'); } public function test_import_endpoints_accept_uploads_and_chunks(): void { $file = UploadedFile::fake()->createWithContent( 'rows.csv', "\xEF\xBB\xBF" . implode(',', self::HEADER) . "\n2026-09,RK-1,CG-1,2026-09-08,供应商甲,X-1,P-1,规格甲,2,10,20,9,18,SC-1,备注甲,华东仓,顺丰,SF-1,未发出,对账人甲\n", ); $upload = $this->loginAs()->postJson(route('business.supplier-payables-final.payment-reconciliation.import.upload'), [ 'import' => $file, ]); $upload->assertOk()->assertJsonPath('total', 1); $token = $upload->json('token'); $this->assertIsString($token); $chunk = $this->loginAs()->postJson(route('business.supplier-payables-final.payment-reconciliation.import.chunk'), [ 'token' => $token, 'offset' => 0, ]); $chunk->assertOk()->assertJsonPath('done', true)->assertJsonPath('created', 1); $this->withExceptionHandling() ->loginAs() ->postJson(route('business.supplier-payables-final.payment-reconciliation.import.chunk'), [ 'token' => str_repeat('0', 32), 'offset' => 0, ]) ->assertStatus(422); } public function test_final_page_wires_the_chunked_import_endpoints(): void { $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'supplier-payables-final', 'page' => 'payment-reconciliation', ])) ->assertOk() ->assertSee('workspace/supplier-payables-final/actions/payment-reconciliation/import-upload', false) ->assertSee('workspace/supplier-payables-final/actions/payment-reconciliation/import-chunk', false) ->assertSee('data-supplier-import-progress', false); } /** @param list> $rows */ private function makeXlsxFile(array $rows, array $dateColumnIndexes = []): string { $path = $this->makeFile('book.xlsx', ''); $strings = []; $sheet = ''; foreach ($rows as $rowIndex => $row) { $sheet .= ''; foreach ($row as $columnIndex => $value) { $cell = chr(65 + $columnIndex) . ($rowIndex + 1); if (in_array($columnIndex, $dateColumnIndexes, true) && $rowIndex > 0) { $sheet .= '' . (int) \PhpOffice\PhpSpreadsheet\Shared\Date::PHPToExcel('2026-09-08') . ''; continue; } if ($value === '') { continue; } if (is_numeric($value) && ! in_array($columnIndex, [0, 1, 2], true)) { $sheet .= '' . $value . ''; continue; } $strings[$value] ??= count($strings); $sheet .= '' . $strings[$value] . ''; } $sheet .= ''; } $sheet .= ''; $shared = ''; foreach (array_keys($strings) as $value) { $shared .= '' . htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8') . ''; } $shared .= ''; $zip = new ZipArchive(); $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); $zip->addFromString('[Content_Types].xml', ''); $zip->addFromString('_rels/.rels', ''); $zip->addFromString('xl/workbook.xml', ''); $zip->addFromString('xl/_rels/workbook.xml.rels', ''); $zip->addFromString('xl/styles.xml', ''); $zip->addFromString('xl/worksheets/sheet1.xml', $sheet); $zip->addFromString('xl/sharedStrings.xml', $shared); $zip->close(); return $path; } private function makeFile(string $name, string $content): string { $path = sys_get_temp_dir() . '/caiwu-import-test-' . bin2hex(random_bytes(6)) . '-' . $name; file_put_contents($path, $content); return $path; } }