镜像 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。
298 lines
9.7 KiB
PHP
298 lines
9.7 KiB
PHP
<?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;
|
||
}
|
||
}
|