CaiWuXiTong/app/Business/JstOrderExportReader.php
qiuxiang daa4c939e7 聚水潭订单标签明细:支持按聚水潭导出的订单 Excel 导入,并放开筛选下拉上限
镜像 ops_jst_orders 只覆盖 2026-04-01 之后,缺的历史区间只能靠财务在聚水潭「导出订单」导文件补。所以:- 新增 JstOrderExportReader:ZipArchive+expat 流式解析聚水潭导出 xlsx。这类文件字符串走 inlineStr、dimension 只写 A1、单文件上万行 ×135 列(解压 78MB),常规读法会读成空表或吃满内存;实测 12286 行 3.5 秒、峰值内存 10MB。
- jst-order-labels:import 新增 --file=:按「线上订单号+标签」去重后拆行写入,可配合 --from/--to 过滤窗口;带 --replace 时先成功读到数据行再清区间,避免文件有问题却先删了线上数据。
- 筛选下拉上限 500 -> 5000:导入 4~7 月后店铺已达 604 个,原来下拉里会缺 104 个店铺;提交筛选值的截断上限同步放开。
- 补两个测试:导出文件去重拆行、窗口过滤+replace。
2026-10-07 16:53:55 +08:00

298 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Business;
use Generator;
use RuntimeException;
use ZipArchive;
/**
* 聚水潭「导出订单」Excel(.xlsx)流式读取器。
*
* 聚水潭导出的文件有几个特点,直接套常规 Excel 读法会踩坑:
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
*
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
*/
class JstOrderExportReader
{
private const READ_CHUNK = 262144;
/**
* 逐行读取导出文件(跳过表头行)。
*
* @param list<string> $columns 需要取出并作为返回键的表头名,必须与文件第一行完全一致
* @return Generator<int, array<string, string>>
*/
public function rows(string $path, array $columns): Generator
{
if (! is_file($path)) {
throw new RuntimeException('订单导出文件不存在:' . $path);
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path);
}
$stream = $zip->getStream($this->worksheetPath($zip));
if ($stream === false) {
$zip->close();
throw new RuntimeException('订单导出文件里没有可读的工作表。');
}
try {
yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns);
} finally {
fclose($stream);
$zip->close();
}
}
/**
* @param resource $stream
* @param list<string> $shared
* @param list<string> $columns
* @return Generator<int, array<string, string>>
*/
private function parseSheet($stream, array $shared, array $columns): Generator
{
$state = [
'row' => [],
'column' => -1,
'type' => null,
'value' => '',
'inValue' => false,
'rowNumber' => 0,
'wanted' => [],
'pending' => [],
];
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name, array $attributes) use (&$state): void {
switch ($name) {
case 'row':
$state['row'] = [];
$state['column'] = -1;
break;
case 'c':
$state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1);
$state['type'] = $attributes['t'] ?? null;
$state['value'] = '';
break;
case 'v':
case 't':
$state['inValue'] = true;
break;
}
},
static function ($parser, string $name) use (&$state, $shared, $columns): void {
switch ($name) {
case 'v':
case 't':
$state['inValue'] = false;
break;
case 'c':
$state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared);
break;
case 'row':
$state['rowNumber']++;
if ($state['rowNumber'] === 1) {
$state['wanted'] = self::wantedColumns($state['row'], $columns);
break;
}
$values = [];
foreach ($state['wanted'] as $index => $column) {
$values[$column] = $state['row'][$index] ?? '';
}
$state['pending'][] = $values;
break;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void {
if ($state['inValue']) {
$state['value'] .= $data;
}
});
$first = true;
try {
while (! feof($stream)) {
$chunk = fread($stream, self::READ_CHUNK);
if ($chunk === false || $chunk === '') {
break;
}
if ($first) {
// 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。
$chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1);
$first = false;
}
if (xml_parse($parser, $chunk, false) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
if (xml_parse($parser, '', true) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
} finally {
xml_parser_free($parser);
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
/**
* 表头行 -> [列下标 => 列名],并校验需要的列都在。
*
* @param array<int, string> $header
* @param list<string> $columns
* @return array<int, string>
*/
private static function wantedColumns(array $header, array $columns): array
{
$byName = [];
foreach ($header as $index => $name) {
$name = trim($name);
if ($name !== '' && ! isset($byName[$name])) {
$byName[$name] = $index;
}
}
$wanted = [];
foreach ($columns as $column) {
if (! isset($byName[$column])) {
throw new RuntimeException('订单导出文件缺少列:' . $column);
}
$wanted[$byName[$column]] = $column;
}
return $wanted;
}
/**
* @param list<string> $shared
*/
private static function cellValue(?string $type, string $value, array $shared): string
{
if ($type === 's') {
return $shared[(int) $value] ?? '';
}
return trim($value);
}
/** 单元格引用(如 AV12)-> 0 基列下标。 */
private static function columnIndex(?string $reference, int $fallback): int
{
if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) {
return $fallback;
}
$letters = strtoupper($matches[1]);
$index = 0;
for ($i = 0, $length = strlen($letters); $i < $length; $i++) {
$index = $index * 26 + (ord($letters[$i]) - 64);
}
return $index - 1;
}
private function worksheetPath(ZipArchive $zip): string
{
$workbook = $zip->getFromName('xl/workbook.xml');
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
if (is_string($workbook) && is_string($relationships)
&& preg_match('/<sheet[^>]*r:id="([^"]+)"/', $workbook, $sheet) === 1
&& preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) {
$path = ltrim($target[1], '/');
return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) {
return $name;
}
}
return 'xl/worksheets/sheet1.xml';
}
/**
* 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。
*
* @return list<string>
*/
private function sharedStrings(ZipArchive $zip): array
{
$xml = $zip->getFromName('xl/sharedStrings.xml');
if (! is_string($xml) || $xml === '') {
return [];
}
$out = [];
$index = -1;
$buffer = '';
$inside = false;
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
if ($name === 'si') {
$index++;
$buffer = '';
} elseif ($name === 't') {
$inside = true;
}
},
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
if ($name === 't') {
$inside = false;
} elseif ($name === 'si') {
$out[] = $buffer;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void {
if ($inside) {
$buffer .= $data;
}
});
try {
xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true);
} finally {
xml_parser_free($parser);
}
return $out;
}
}