940 lines
31 KiB
PHP
940 lines
31 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||
use DateTimeImmutable;
|
||
use DOMDocument;
|
||
use DOMXPath;
|
||
use Illuminate\Support\Facades\DB;
|
||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use XMLReader;
|
||
use ZipArchive;
|
||
|
||
/**
|
||
* 供应商汇总表月度增量导入。
|
||
*
|
||
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
|
||
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
|
||
* 单行字段异常会进入失败清单,不阻断其他有效行。
|
||
*/
|
||
class SupplierSummaryInboundImporter
|
||
{
|
||
private const TABLE = 'inventory_inbound_detail_rows';
|
||
|
||
private const DETAIL_SHEET = '明细';
|
||
|
||
/** @var array<string,list<string>> */
|
||
private const REQUIRED_HEADERS = [
|
||
'source_no' => ['来源单号'],
|
||
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
|
||
'supplier' => ['供应商'],
|
||
'item_no' => ['货号'],
|
||
'barcode' => ['条码'],
|
||
'spec_name' => ['规格名'],
|
||
'quantity' => ['库存变动量'],
|
||
'unit_price' => ['单价'],
|
||
'amount' => ['金额'],
|
||
'estimated_unit_price' => ['预估单价'],
|
||
'estimated_amount' => ['预估金额'],
|
||
'supplier_barcode' => ['供应商条码'],
|
||
'remark' => ['备注'],
|
||
'warehouse' => ['仓库'],
|
||
];
|
||
|
||
/** @var array<string,list<string>> */
|
||
private const OPTIONAL_HEADERS = [
|
||
'express_name' => ['快递名称'],
|
||
'express_no' => ['快递单号'],
|
||
'not_sent' => ['未发出'],
|
||
'owner_name' => ['负责人'],
|
||
];
|
||
|
||
/**
|
||
* @return array{
|
||
* status:string,
|
||
* report_month:string,
|
||
* source_filename:string,
|
||
* success:int,
|
||
* duplicates:int,
|
||
* failed:int,
|
||
* mapped:int,
|
||
* total_in_table:int,
|
||
* failure_reason_count:int,
|
||
* failure_reasons:list<array{row:int,reason:string}>
|
||
* }
|
||
*/
|
||
public function import(string $file, string $reportMonth): array
|
||
{
|
||
$path = realpath($file);
|
||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
|
||
}
|
||
|
||
$month = $this->normalizeMonth($reportMonth);
|
||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||
$maps = $this->shortNameMap();
|
||
|
||
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
|
||
|
||
try {
|
||
$success = 0;
|
||
$duplicates = 0;
|
||
$mapped = 0;
|
||
$failed = 0;
|
||
$reasons = [];
|
||
$occurrences = [];
|
||
$batch = [];
|
||
|
||
DB::connection($connection)->transaction(function () use (
|
||
$connection,
|
||
$reader,
|
||
$sharedStrings,
|
||
$maps,
|
||
$month,
|
||
&$success,
|
||
&$duplicates,
|
||
&$mapped,
|
||
&$failed,
|
||
&$reasons,
|
||
&$occurrences,
|
||
&$batch,
|
||
): void {
|
||
$flush = function () use (
|
||
$connection,
|
||
$month,
|
||
&$success,
|
||
&$duplicates,
|
||
&$batch,
|
||
): void {
|
||
if ($batch === []) {
|
||
return;
|
||
}
|
||
|
||
$keys = array_column($batch, 'row_key');
|
||
$existing = DB::connection($connection)
|
||
->table(self::TABLE)
|
||
->where('report_month', $month)
|
||
->whereIn('row_key', $keys)
|
||
->pluck('row_key')
|
||
->map(static fn (mixed $key): string => (string) $key)
|
||
->all();
|
||
$existing = array_fill_keys($existing, true);
|
||
|
||
$insertable = [];
|
||
foreach ($batch as $record) {
|
||
if (isset($existing[$record['row_key']])) {
|
||
$duplicates++;
|
||
continue;
|
||
}
|
||
$insertable[] = $record;
|
||
}
|
||
|
||
if ($insertable !== []) {
|
||
$inserted = DB::connection($connection)
|
||
->table(self::TABLE)
|
||
->insertOrIgnore($insertable);
|
||
$success += (int) $inserted;
|
||
$duplicates += count($insertable) - (int) $inserted;
|
||
}
|
||
$batch = [];
|
||
};
|
||
|
||
$headerRow = null;
|
||
$columns = [];
|
||
$headerCandidates = [];
|
||
|
||
while ($reader->read()) {
|
||
if (
|
||
$reader->nodeType !== XMLReader::ELEMENT
|
||
|| $reader->name !== 'row'
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
$rowNumber = (int) $reader->getAttribute('r');
|
||
$values = $this->readRow($reader, $sharedStrings);
|
||
|
||
if ($headerRow === null) {
|
||
$headerCandidates[$rowNumber] = $values;
|
||
$header = $this->headerColumns($headerCandidates);
|
||
if ($header !== null) {
|
||
[$headerRow, $columns] = $header;
|
||
$headerCandidates = [];
|
||
continue;
|
||
}
|
||
if ($rowNumber >= 20) {
|
||
throw new RuntimeException(
|
||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if ($rowNumber <= $headerRow) {
|
||
continue;
|
||
}
|
||
|
||
if ($this->emptyRow($values)) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
$record = $this->record(
|
||
$values,
|
||
$columns,
|
||
$maps,
|
||
$month,
|
||
$rowNumber,
|
||
);
|
||
} catch (Throwable $exception) {
|
||
$failed++;
|
||
$reasons[] = [
|
||
'row' => $rowNumber,
|
||
'reason' => $exception->getMessage(),
|
||
];
|
||
continue;
|
||
}
|
||
|
||
$baseKey = (string) $record['row_key'];
|
||
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
|
||
$occurrences[$baseKey] = $occurrence;
|
||
if ($occurrence > 1) {
|
||
$record['row_key'] = hash(
|
||
'sha256',
|
||
$baseKey . '|occurrence:' . $occurrence,
|
||
);
|
||
}
|
||
$mapped++;
|
||
$batch[] = $record;
|
||
|
||
if (count($batch) >= 500) {
|
||
$flush();
|
||
}
|
||
}
|
||
|
||
if ($headerRow === null) {
|
||
throw new RuntimeException(
|
||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||
);
|
||
}
|
||
|
||
$flush();
|
||
});
|
||
|
||
$status = $failed === 0
|
||
? 'success'
|
||
: ($success > 0 ? 'partial' : 'failed');
|
||
|
||
return [
|
||
'status' => $status,
|
||
'report_month' => $month,
|
||
'source_filename' => basename($path),
|
||
'success' => $success,
|
||
'duplicates' => $duplicates,
|
||
'failed' => $failed,
|
||
'mapped' => $mapped,
|
||
'total_in_table' => DB::connection($connection)
|
||
->table(self::TABLE)
|
||
->where('report_month', $month)
|
||
->count(),
|
||
'failure_reason_count' => count($reasons),
|
||
'failure_reasons' => $reasons,
|
||
];
|
||
} finally {
|
||
$reader->close();
|
||
}
|
||
}
|
||
|
||
private function normalizeMonth(string $month): string
|
||
{
|
||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||
throw new RuntimeException('月份格式应为 YYYY-MM。');
|
||
}
|
||
|
||
return $month . '-01';
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<int,mixed>> $rows
|
||
* @return array{0:int,1:array<string,int>}|null
|
||
*/
|
||
private function headerColumns(array $rows): ?array
|
||
{
|
||
ksort($rows);
|
||
foreach ($rows as $row => $values) {
|
||
$positions = [];
|
||
foreach ($values as $index => $value) {
|
||
$heading = $this->normalizeHeader(
|
||
(string) $this->plainValue($value),
|
||
);
|
||
if ($heading !== '') {
|
||
$positions[$heading][] = $index;
|
||
}
|
||
}
|
||
|
||
$columns = [];
|
||
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
|
||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||
if ($position !== null) {
|
||
$columns[$field] = $position;
|
||
}
|
||
}
|
||
|
||
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
|
||
if (count($createdPositions) >= 2) {
|
||
$columns['created_serial'] = $createdPositions[0];
|
||
$columns['created_at_raw'] = $createdPositions[1];
|
||
}
|
||
|
||
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
|
||
continue;
|
||
}
|
||
|
||
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
|
||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||
if ($position !== null) {
|
||
$columns[$field] = $position;
|
||
}
|
||
}
|
||
|
||
foreach (['created_serial', 'created_at_raw'] as $field) {
|
||
if (! isset($columns[$field])) {
|
||
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
|
||
}
|
||
}
|
||
|
||
return [$row, $columns];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @param array<string,list<int>> $positions @param list<string> $aliases */
|
||
private function firstHeaderPosition(array $positions, array $aliases): ?int
|
||
{
|
||
foreach ($aliases as $alias) {
|
||
$heading = $this->normalizeHeader($alias);
|
||
if (isset($positions[$heading][0])) {
|
||
return $positions[$heading][0];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function normalizeHeader(string $heading): string
|
||
{
|
||
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
|
||
}
|
||
|
||
/**
|
||
* @param array<int,mixed> $values
|
||
* @param array<string,int> $columns
|
||
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
|
||
* @return array<string,mixed>
|
||
*/
|
||
private function record(
|
||
array $values,
|
||
array $columns,
|
||
array $maps,
|
||
string $month,
|
||
int $rowNumber,
|
||
): array {
|
||
$text = fn (string $field): ?string => $this->text(
|
||
$values[$columns[$field] ?? -1] ?? null,
|
||
);
|
||
$number = function (string $label, string $field) use (
|
||
$values,
|
||
$columns,
|
||
$rowNumber,
|
||
): ?float {
|
||
$value = $values[$columns[$field] ?? -1] ?? null;
|
||
if (is_array($value)) {
|
||
$formula = trim((string) ($value['__formula'] ?? ''));
|
||
if ($formula !== '') {
|
||
return $this->formulaValue(
|
||
$label,
|
||
$field,
|
||
$formula,
|
||
$rowNumber,
|
||
$values,
|
||
$columns,
|
||
);
|
||
}
|
||
$value = $value['__cached'] ?? null;
|
||
}
|
||
|
||
$text = str_replace(
|
||
[',', ','],
|
||
'',
|
||
trim((string) $this->plainValue($value)),
|
||
);
|
||
if ($text === '') {
|
||
return null;
|
||
}
|
||
if (! is_numeric($text)) {
|
||
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
|
||
}
|
||
|
||
return (float) $text;
|
||
};
|
||
|
||
$barcode = $text('barcode');
|
||
$warehouse = $text('warehouse');
|
||
if ($barcode === null) {
|
||
throw new RuntimeException('商品编码(条码)为空');
|
||
}
|
||
if ($warehouse === null) {
|
||
throw new RuntimeException('仓库为空');
|
||
}
|
||
|
||
$shortName = $maps['warehouse'][$warehouse]
|
||
?? $maps['provider'][$warehouse]
|
||
?? null;
|
||
if ($shortName === null || trim($shortName) === '') {
|
||
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
|
||
}
|
||
|
||
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
|
||
if ($createdRaw === null) {
|
||
throw new RuntimeException('第二个「创建时间」为空或格式无效');
|
||
}
|
||
|
||
$record = [
|
||
'report_month' => $month,
|
||
'source_no' => $text('source_no'),
|
||
'document_no' => $text('document_no'),
|
||
'created_serial' => $text('created_serial'),
|
||
'supplier' => $text('supplier'),
|
||
'item_no' => $text('item_no'),
|
||
'barcode' => mb_strtoupper($barcode),
|
||
'spec_name' => $text('spec_name'),
|
||
'quantity' => $number('库存变动量', 'quantity'),
|
||
'unit_price' => $number('单价', 'unit_price'),
|
||
'amount' => $number('金额', 'amount'),
|
||
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
|
||
'estimated_amount' => $number('预估金额', 'estimated_amount'),
|
||
'supplier_barcode' => $text('supplier_barcode'),
|
||
'remark' => $text('remark'),
|
||
'warehouse' => $warehouse,
|
||
'warehouse_short_name' => $shortName,
|
||
'created_at_raw' => $createdRaw,
|
||
'express_name' => $text('express_name'),
|
||
'express_no' => $text('express_no'),
|
||
'not_sent' => $text('not_sent'),
|
||
'owner_name' => $text('owner_name'),
|
||
];
|
||
|
||
unset($record['warehouse_short_name']);
|
||
$record['row_key'] = hash(
|
||
'sha256',
|
||
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
);
|
||
$record['warehouse_short_name'] = $shortName;
|
||
$record['imported_at'] = now();
|
||
|
||
return $record;
|
||
}
|
||
|
||
/**
|
||
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
|
||
*
|
||
* @param array<int,mixed> $values
|
||
* @param array<string,int> $columns
|
||
*/
|
||
private function formulaValue(
|
||
string $label,
|
||
string $field,
|
||
string $formula,
|
||
int $rowNumber,
|
||
array $values,
|
||
array $columns,
|
||
): float {
|
||
$expectedFields = match ($field) {
|
||
'amount' => ['quantity', 'unit_price'],
|
||
'estimated_amount' => ['estimated_unit_price', 'quantity'],
|
||
default => [],
|
||
};
|
||
if ($expectedFields === []) {
|
||
throw new RuntimeException(
|
||
"字段「{$label}」不允许使用公式:" . $formula,
|
||
);
|
||
}
|
||
|
||
if (
|
||
preg_match(
|
||
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
|
||
$formula,
|
||
$matches,
|
||
) !== 1
|
||
) {
|
||
throw new RuntimeException(
|
||
"字段「{$label}」公式无法安全识别:" . $formula,
|
||
);
|
||
}
|
||
|
||
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
|
||
throw new RuntimeException(
|
||
"字段「{$label}」公式引用了其他行:" . $formula,
|
||
);
|
||
}
|
||
|
||
$actualColumns = [
|
||
$this->columnIndex($matches[1]),
|
||
$this->columnIndex($matches[3]),
|
||
];
|
||
$expectedColumns = [
|
||
$columns[$expectedFields[0]] + 1,
|
||
$columns[$expectedFields[1]] + 1,
|
||
];
|
||
sort($actualColumns);
|
||
sort($expectedColumns);
|
||
if ($actualColumns !== $expectedColumns) {
|
||
throw new RuntimeException(
|
||
"字段「{$label}」公式与取数口径不一致:" . $formula,
|
||
);
|
||
}
|
||
|
||
$left = $this->numericReference(
|
||
$values[$actualColumns[0] - 1] ?? null,
|
||
$label,
|
||
);
|
||
$right = $this->numericReference(
|
||
$values[$actualColumns[1] - 1] ?? null,
|
||
$label,
|
||
);
|
||
|
||
return $left * $right;
|
||
}
|
||
|
||
private function numericReference(mixed $value, string $label): float
|
||
{
|
||
if (is_array($value)) {
|
||
if (($value['__formula'] ?? '') !== '') {
|
||
throw new RuntimeException(
|
||
"字段「{$label}」的公式引用了另一个公式单元格",
|
||
);
|
||
}
|
||
$value = $value['__cached'] ?? null;
|
||
}
|
||
|
||
$text = str_replace(
|
||
[',', ','],
|
||
'',
|
||
trim((string) $this->plainValue($value)),
|
||
);
|
||
if (! is_numeric($text)) {
|
||
throw new RuntimeException(
|
||
"字段「{$label}」的公式引用值不是有效数字:" . $text,
|
||
);
|
||
}
|
||
|
||
return (float) $text;
|
||
}
|
||
|
||
/** @param array<int,mixed> $values */
|
||
private function emptyRow(array $values): bool
|
||
{
|
||
foreach ($values as $value) {
|
||
if (trim((string) $this->plainValue($value)) !== '') {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function text(mixed $value): ?string
|
||
{
|
||
$text = trim((string) $this->plainValue($value));
|
||
|
||
return $text === '' ? null : $text;
|
||
}
|
||
|
||
private function plainValue(mixed $value): mixed
|
||
{
|
||
if (is_array($value)) {
|
||
return $value['__cached'] ?? null;
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private function moment(mixed $value): ?string
|
||
{
|
||
$value = $this->plainValue($value);
|
||
if ($value === null || trim((string) $value) === '') {
|
||
return null;
|
||
}
|
||
if (is_numeric($value)) {
|
||
try {
|
||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
$value = trim((string) $value);
|
||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||
$date = DateTimeImmutable::createFromFormat($format, $value);
|
||
if ($date instanceof DateTimeImmutable) {
|
||
return $date->format('Y-m-d H:i:s');
|
||
}
|
||
}
|
||
$timestamp = strtotime($value);
|
||
|
||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||
}
|
||
|
||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||
private function shortNameMap(): array
|
||
{
|
||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||
$grouped = [];
|
||
foreach ($rows as $row) {
|
||
$warehouse = trim((string) $row->warehouse);
|
||
if ($warehouse !== '') {
|
||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||
}
|
||
}
|
||
|
||
$byWarehouse = [];
|
||
foreach ($grouped as $warehouse => $names) {
|
||
$unique = array_values(array_unique($names));
|
||
if (count($unique) === 1 && $unique[0] !== '') {
|
||
$byWarehouse[$warehouse] = $unique[0];
|
||
}
|
||
}
|
||
|
||
$providerGrouped = [];
|
||
foreach ($rows as $row) {
|
||
$provider = trim((string) $row->warehouse_provider);
|
||
if ($provider !== '') {
|
||
$providerGrouped[$provider][] = [
|
||
'warehouse' => trim((string) $row->warehouse),
|
||
'short_name' => trim((string) $row->warehouse_short_name),
|
||
];
|
||
}
|
||
}
|
||
$byProvider = [];
|
||
foreach ($providerGrouped as $provider => $providerRows) {
|
||
$unique = array_values(array_unique(array_column(
|
||
$providerRows,
|
||
'short_name',
|
||
)));
|
||
if (count($unique) === 1 && $unique[0] !== '') {
|
||
$byProvider[$provider] = $unique[0];
|
||
continue;
|
||
}
|
||
|
||
$mainShortNames = [];
|
||
foreach ($providerRows as $providerRow) {
|
||
if (str_contains($providerRow['warehouse'], '主仓')) {
|
||
$mainShortNames[] = $providerRow['short_name'];
|
||
}
|
||
}
|
||
$mainShortNames = array_values(array_unique($mainShortNames));
|
||
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
|
||
$byProvider[$provider] = $mainShortNames[0];
|
||
}
|
||
}
|
||
|
||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||
}
|
||
|
||
/** @return array{0:XMLReader,1:list<string>} */
|
||
private function streamDetailSheet(string $path): array
|
||
{
|
||
$zip = new ZipArchive();
|
||
if ($zip->open($path) !== true) {
|
||
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
|
||
}
|
||
|
||
try {
|
||
$sheetPath = $this->detailSheetPath($zip);
|
||
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
|
||
? $this->readSharedStrings($path)
|
||
: [];
|
||
} finally {
|
||
$zip->close();
|
||
}
|
||
|
||
return [
|
||
$this->openZipEntry($path, $sheetPath),
|
||
$sharedStrings,
|
||
];
|
||
}
|
||
|
||
private function detailSheetPath(ZipArchive $zip): string
|
||
{
|
||
$workbookXml = $zip->getFromName('xl/workbook.xml');
|
||
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
|
||
if ($workbookXml === false || $relationshipsXml === false) {
|
||
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
|
||
}
|
||
|
||
$workbook = new DOMDocument();
|
||
$relationships = new DOMDocument();
|
||
if (
|
||
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|
||
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
|
||
) {
|
||
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
|
||
}
|
||
|
||
$relationshipId = null;
|
||
$xpath = new DOMXPath($workbook);
|
||
$xpath->registerNamespace(
|
||
'main',
|
||
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||
);
|
||
$xpath->registerNamespace(
|
||
'office',
|
||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||
);
|
||
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
|
||
if ($sheet instanceof \DOMElement
|
||
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
|
||
) {
|
||
$relationshipId = $sheet->getAttributeNS(
|
||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||
'id',
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
if ($relationshipId === null || $relationshipId === '') {
|
||
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
|
||
}
|
||
|
||
$target = null;
|
||
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
|
||
if ($relationship->getAttribute('Id') === $relationshipId) {
|
||
$target = $relationship->getAttribute('Target');
|
||
break;
|
||
}
|
||
}
|
||
if ($target === null || $target === '') {
|
||
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
|
||
}
|
||
|
||
return $this->normalizeZipPath(
|
||
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
|
||
);
|
||
}
|
||
|
||
private function normalizeZipPath(string $path): string
|
||
{
|
||
$segments = [];
|
||
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
|
||
if ($segment === '' || $segment === '.') {
|
||
continue;
|
||
}
|
||
if ($segment === '..') {
|
||
array_pop($segments);
|
||
continue;
|
||
}
|
||
$segments[] = $segment;
|
||
}
|
||
|
||
return implode('/', $segments);
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function readSharedStrings(string $path): array
|
||
{
|
||
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
|
||
$strings = [];
|
||
$current = null;
|
||
$stringDepth = null;
|
||
$phoneticDepth = null;
|
||
|
||
try {
|
||
while ($reader->read()) {
|
||
if (
|
||
$reader->nodeType === XMLReader::ELEMENT
|
||
&& $reader->name === 'si'
|
||
) {
|
||
$current = '';
|
||
$stringDepth = $reader->depth;
|
||
$phoneticDepth = null;
|
||
continue;
|
||
}
|
||
if ($current === null) {
|
||
continue;
|
||
}
|
||
if (
|
||
$reader->nodeType === XMLReader::ELEMENT
|
||
&& $reader->name === 'rPh'
|
||
) {
|
||
$phoneticDepth = $reader->depth;
|
||
continue;
|
||
}
|
||
if (
|
||
$reader->nodeType === XMLReader::END_ELEMENT
|
||
&& $reader->name === 'rPh'
|
||
&& $reader->depth === $phoneticDepth
|
||
) {
|
||
$phoneticDepth = null;
|
||
continue;
|
||
}
|
||
if (
|
||
$reader->nodeType === XMLReader::ELEMENT
|
||
&& $reader->name === 't'
|
||
&& $phoneticDepth === null
|
||
) {
|
||
$current .= $reader->readString();
|
||
continue;
|
||
}
|
||
if (
|
||
$reader->nodeType === XMLReader::END_ELEMENT
|
||
&& $reader->name === 'si'
|
||
&& $reader->depth === $stringDepth
|
||
) {
|
||
$strings[] = $current;
|
||
$current = null;
|
||
}
|
||
}
|
||
} finally {
|
||
$reader->close();
|
||
}
|
||
|
||
return $strings;
|
||
}
|
||
|
||
private function openZipEntry(string $path, string $entry): XMLReader
|
||
{
|
||
$reader = new XMLReader();
|
||
$flags = LIBXML_NONET | LIBXML_COMPACT;
|
||
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
|
||
throw new RuntimeException("无法读取工作簿内容:{$entry}");
|
||
}
|
||
|
||
return $reader;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $sharedStrings
|
||
* @return array<int,mixed>
|
||
*/
|
||
private function readRow(XMLReader $reader, array $sharedStrings): array
|
||
{
|
||
$values = [];
|
||
$rowDepth = $reader->depth;
|
||
while ($reader->read()) {
|
||
if (
|
||
$reader->nodeType === XMLReader::END_ELEMENT
|
||
&& $reader->name === 'row'
|
||
&& $reader->depth === $rowDepth
|
||
) {
|
||
break;
|
||
}
|
||
if (
|
||
$reader->nodeType !== XMLReader::ELEMENT
|
||
|| $reader->name !== 'c'
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
$reference = $reader->getAttribute('r') ?? '';
|
||
if (
|
||
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
|
||
) {
|
||
continue;
|
||
}
|
||
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
|
||
$reader,
|
||
$sharedStrings,
|
||
);
|
||
}
|
||
|
||
return $values;
|
||
}
|
||
|
||
/** @param list<string> $sharedStrings */
|
||
private function readCell(XMLReader $reader, array $sharedStrings): mixed
|
||
{
|
||
if ($reader->isEmptyElement) {
|
||
return null;
|
||
}
|
||
|
||
$type = $reader->getAttribute('t') ?? '';
|
||
$cellDepth = $reader->depth;
|
||
$cached = null;
|
||
$formula = null;
|
||
$hasFormula = false;
|
||
$inlineParts = [];
|
||
$phoneticDepth = null;
|
||
|
||
while ($reader->read()) {
|
||
if (
|
||
$reader->nodeType === XMLReader::END_ELEMENT
|
||
&& $reader->name === 'c'
|
||
&& $reader->depth === $cellDepth
|
||
) {
|
||
break;
|
||
}
|
||
if ($reader->nodeType === XMLReader::ELEMENT) {
|
||
if ($reader->name === 'f') {
|
||
$hasFormula = true;
|
||
$formula = $reader->readString();
|
||
continue;
|
||
}
|
||
if ($reader->name === 'v') {
|
||
$cached = $reader->readString();
|
||
continue;
|
||
}
|
||
if ($reader->name === 'rPh') {
|
||
$phoneticDepth = $reader->depth;
|
||
continue;
|
||
}
|
||
if (
|
||
$reader->name === 't'
|
||
&& $phoneticDepth === null
|
||
) {
|
||
$inlineParts[] = $reader->readString();
|
||
}
|
||
}
|
||
if (
|
||
$reader->nodeType === XMLReader::END_ELEMENT
|
||
&& $reader->name === 'rPh'
|
||
&& $reader->depth === $phoneticDepth
|
||
) {
|
||
$phoneticDepth = null;
|
||
}
|
||
}
|
||
|
||
$value = match ($type) {
|
||
's' => is_numeric($cached)
|
||
? ($sharedStrings[(int) $cached] ?? null)
|
||
: null,
|
||
'inlineStr' => implode('', $inlineParts),
|
||
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
|
||
default => $cached ?? implode('', $inlineParts),
|
||
};
|
||
|
||
if ($hasFormula) {
|
||
return [
|
||
'__formula' => (string) $formula,
|
||
'__cached' => $value,
|
||
];
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private function columnIndex(string $column): int
|
||
{
|
||
$index = 0;
|
||
foreach (str_split(strtoupper($column)) as $letter) {
|
||
$index = ($index * 26) + (ord($letter) - 64);
|
||
}
|
||
|
||
return $index;
|
||
}
|
||
}
|