> */ 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> */ 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 * } */ 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> $rows * @return array{0:int,1:array}|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> $positions @param list $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 $values * @param array $columns * @param array{warehouse:array,provider:array} $maps * @return array */ 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 $values * @param array $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 $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,provider:array} */ 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} */ 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 */ 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 $sharedStrings * @return array */ 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 $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; } }