同步导入在 20 万行规模下必然超时/爆内存:新增 xlsx/csv 流式读取器(共享字符串紧凑存放、支持日期单元格、GBK CSV),上传时把工作簿转成行缓存,之后按片处理(每片 2 万行/25 秒预算),前端显示进度并自动续跑;落库改为批量 upsert + 批量审计事件;上限提升到 20 万行,上传限制放宽到 50MB。
197 lines
11 KiB
PHP
197 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Business;
|
|
|
|
use App\Models\Auth\Permission;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Modules\EcommerceFinance\Services\PayablesFinalImportRunner;
|
|
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
|
|
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
|
|
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
|
use Tests\Feature\FeatureTestCase;
|
|
use ZipArchive;
|
|
|
|
class PayablesFinalImportRunnerTest extends FeatureTestCase
|
|
{
|
|
private const HEADER = ['月份', '入库单号', '采购单号', '日期', '供应商', '货号', '商品编码', '规格名称', '数量', '单价',
|
|
'金额', '预估单价', '预估金额', '供应商商品编码', '备注', '仓库名称', '快递名称', '快递单号', '未发出', '对账人'];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->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<list<string>> $rows */
|
|
private function makeXlsxFile(array $rows, array $dateColumnIndexes = []): string
|
|
{
|
|
$path = $this->makeFile('book.xlsx', '');
|
|
$strings = [];
|
|
$sheet = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>';
|
|
|
|
foreach ($rows as $rowIndex => $row) {
|
|
$sheet .= '<row r="' . ($rowIndex + 1) . '">';
|
|
foreach ($row as $columnIndex => $value) {
|
|
$cell = chr(65 + $columnIndex) . ($rowIndex + 1);
|
|
if (in_array($columnIndex, $dateColumnIndexes, true) && $rowIndex > 0) {
|
|
$sheet .= '<c r="' . $cell . '" s="1"><v>' . (int) \PhpOffice\PhpSpreadsheet\Shared\Date::PHPToExcel('2026-09-08') . '</v></c>';
|
|
continue;
|
|
}
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
if (is_numeric($value) && ! in_array($columnIndex, [0, 1, 2], true)) {
|
|
$sheet .= '<c r="' . $cell . '"><v>' . $value . '</v></c>';
|
|
continue;
|
|
}
|
|
$strings[$value] ??= count($strings);
|
|
$sheet .= '<c r="' . $cell . '" t="s"><v>' . $strings[$value] . '</v></c>';
|
|
}
|
|
$sheet .= '</row>';
|
|
}
|
|
$sheet .= '</sheetData></worksheet>';
|
|
|
|
$shared = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . count($strings) . '" uniqueCount="' . count($strings) . '">';
|
|
foreach (array_keys($strings) as $value) {
|
|
$shared .= '<si><t>' . htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8') . '</t></si>';
|
|
}
|
|
$shared .= '</sst>';
|
|
|
|
$zip = new ZipArchive();
|
|
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
|
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>');
|
|
$zip->addFromString('_rels/.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>');
|
|
$zip->addFromString('xl/workbook.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>');
|
|
$zip->addFromString('xl/_rels/workbook.xml.rels', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>');
|
|
$zip->addFromString('xl/styles.xml', '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><cellXfs count="2"><xf numFmtId="0"/><xf numFmtId="14"/></cellXfs></styleSheet>');
|
|
$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;
|
|
}
|
|
}
|