*/ private const REQUIRED_HEADERS = [ '货主', '月份', '仓储方', '仓库', '商品编码', '分类', '期初数量', '期末数量', '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ]; /** @var list */ private const MOVEMENT_HEADERS = [ '加工进仓', '加工出仓', '采购进仓', '采购退货', '其它进仓', '其它退货', '销售出仓', '销售退货', '盘点', '调拨出', '调拨入', '期初', '其它出库', ]; private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $databases) { $this->connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** @return array{status:string,source_rows:int,source_month:string,generation:string} */ public function import(string $path): array { $path = $this->validatedPath($path); $this->assertSchemaReady(); $generation = (string) Str::uuid(); $now = now(); $hash = hash_file('sha256', $path); if (! is_string($hash)) { throw new RuntimeException('无法计算进销存月报文件摘要。'); } $this->connection->table(self::STATE_TABLE)->insertOrIgnore([ 'dataset_key' => self::DATASET_KEY, 'status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ]); $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ 'status' => 'running', 'pending_generation' => $generation, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); try { $source = $this->readAndStore($path, $generation, $now); $this->connection->transaction(function () use ( $generation, $path, $hash, $source, $now, ): void { if ($this->hasMonthlyStateTable()) { $this->connection->table(self::MONTH_TABLE)->updateOrInsert( [ 'dataset_key' => self::DATASET_KEY, 'source_month' => $source['source_month'], ], [ 'status' => 'succeeded', 'active_generation' => $generation, 'source_filename' => mb_substr(basename($path), 0, 255), 'source_sha256' => $hash, 'source_row_count' => $source['source_rows'], 'last_succeeded_at' => $now, 'updated_at' => $now, ], ); } $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ 'status' => 'succeeded', 'active_generation' => $generation, 'pending_generation' => null, 'source_filename' => mb_substr(basename($path), 0, 255), 'source_sha256' => $hash, 'source_month' => $source['source_month'], 'source_row_count' => $source['source_rows'], 'last_succeeded_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); }); } catch (Throwable $exception) { $this->connection->table(self::ROW_TABLE) ->where('generation', $generation) ->delete(); $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ 'status' => 'failed', 'pending_generation' => null, 'last_error_code' => class_basename($exception), 'updated_at' => now(), ]); throw $exception; } try { $keepGenerations = [$generation]; if ($this->hasMonthlyStateTable()) { $keepGenerations = array_merge( $keepGenerations, $this->connection->table(self::MONTH_TABLE) ->where('dataset_key', self::DATASET_KEY) ->whereNotNull('active_generation') ->pluck('active_generation') ->map(static fn (mixed $value): string => (string) $value) ->all(), ); } $keepGenerations = array_values(array_unique(array_filter($keepGenerations))); $this->connection->table(self::ROW_TABLE) ->whereNotIn('generation', $keepGenerations) ->delete(); } catch (Throwable $exception) { report($exception); } return [ 'status' => 'succeeded', 'source_rows' => $source['source_rows'], 'source_month' => $source['source_month'], 'generation' => $generation, ]; } private function validatedPath(string $path): string { $realPath = realpath($path); if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { throw new InvalidArgumentException('进销存月报文件不存在或不可读。'); } if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') { throw new InvalidArgumentException('进销存权威月报导入只支持 XLSX 文件。'); } return $realPath; } private function assertSchemaReady(): void { $schema = Schema::connection($this->connection->getName()); foreach ([self::STATE_TABLE, self::ROW_TABLE] as $table) { if (! $schema->hasTable($table)) { throw new RuntimeException('进销存权威月报表尚未完成迁移:' . $table); } } } private function hasMonthlyStateTable(): bool { return Schema::connection($this->connection->getName())->hasTable(self::MONTH_TABLE); } /** @return array{source_rows:int,source_month:string} */ private function readAndStore(string $path, string $generation, mixed $importedAt): array { $archive = new ZipArchive(); if ($archive->open($path) !== true) { throw new InvalidArgumentException('无法打开进销存月报 XLSX 文件。'); } try { $sharedStrings = $this->sharedStrings($path, $archive); $worksheet = $this->worksheetWithHeaders($path, $archive, $sharedStrings); } finally { $archive->close(); } $reader = new XMLReader(); if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { throw new InvalidArgumentException('无法读取进销存月报 XLSX 工作表。'); } $columns = []; $seen = []; $sourceMonth = null; $rowCount = 0; $batch = []; try { while ($reader->read()) { if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { continue; } $rowNumber = (int) ($reader->getAttribute('r') ?: 0); $values = $this->row($reader->readOuterXml(), $sharedStrings); if ($columns === []) { $columns = $this->headerColumns($values); if ($columns === [] && $rowNumber < 20) { continue; } if ($columns === []) { throw new InvalidArgumentException('前 20 行中未找到进销存月报标准表头。'); } continue; } if ($this->emptyRow($values)) { continue; } $month = $this->month($values[$columns['月份']] ?? null, $rowNumber); if ($sourceMonth !== null && $sourceMonth !== $month) { throw new InvalidArgumentException('进销存权威月报必须只包含一个月份。'); } $sourceMonth = $month; $owner = $this->requiredText($values[$columns['货主']] ?? null, $rowNumber, '货主'); $warehouseProvider = $this->requiredText( $values[$columns['仓储方']] ?? null, $rowNumber, '仓储方', ); $warehouse = $this->requiredText($values[$columns['仓库']] ?? null, $rowNumber, '仓库'); $productCode = ProductCatalogAuthoritativeExportImporter::normalizeSkuId( (string) ($values[$columns['商品编码']] ?? ''), ); if ($productCode === '') { throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。"); } $rowKey = hash('sha256', implode("\0", [ $month, $warehouseProvider, $warehouse, $productCode, ])); if (isset($seen[$rowKey])) { throw new InvalidArgumentException( "第 {$rowNumber} 行与第 {$seen[$rowKey]} 行的月份、仓储方、仓库和商品编码重复。", ); } $seen[$rowKey] = $rowNumber; $opening = $this->number($values[$columns['期初数量']] ?? null, $rowNumber, '期初数量'); $closing = $this->number($values[$columns['期末数量']] ?? null, $rowNumber, '期末数量'); $movement = 0.0; $movementValues = []; foreach (self::MOVEMENT_HEADERS as $header) { $movementValues[$header] = $this->number( $values[$columns[$header]] ?? null, $rowNumber, $header, ); $movement += $movementValues[$header]; } if (abs(($opening + $movement) - $closing) > 0.0001) { throw new InvalidArgumentException( "第 {$rowNumber} 行期初数量加进出数量不等于期末数量。", ); } $batch[] = [ 'generation' => $generation, 'row_key' => $rowKey, 'report_month' => $month, 'owner' => mb_substr($owner, 0, 191), 'warehouse_provider' => mb_substr($warehouseProvider, 0, 191), 'warehouse' => mb_substr($warehouse, 0, 191), 'product_code' => mb_substr($productCode, 0, 191), 'category' => $this->nullableText($values[$columns['分类']] ?? null, 191), 'opening_quantity' => $opening, 'closing_quantity' => $closing, 'processing_in' => $movementValues['加工进仓'], 'processing_out' => $movementValues['加工出仓'], 'purchase_in' => $movementValues['采购进仓'], 'purchase_return' => $movementValues['采购退货'], 'other_in' => $movementValues['其它进仓'], 'other_return' => $movementValues['其它退货'], 'sales_out' => $movementValues['销售出仓'], 'sales_return' => $movementValues['销售退货'], 'stocktake' => $movementValues['盘点'], 'transfer_out' => $movementValues['调拨出'], 'transfer_in' => $movementValues['调拨入'], 'opening' => $movementValues['期初'], 'other_out' => $movementValues['其它出库'], 'imported_at' => $importedAt, ]; $rowCount++; if (count($batch) >= 500) { $this->connection->table(self::ROW_TABLE)->insert($batch); $batch = []; } } } finally { $reader->close(); } if ($batch !== []) { $this->connection->table(self::ROW_TABLE)->insert($batch); } if ($columns === [] || $rowCount === 0 || $sourceMonth === null) { throw new InvalidArgumentException('进销存权威月报中没有可用明细。'); } return ['source_rows' => $rowCount, 'source_month' => $sourceMonth]; } /** @return array */ private function row(string $xml, array $sharedStrings): array { $row = simplexml_load_string($xml); if (! $row instanceof SimpleXMLElement) { return []; } $values = []; foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) { $reference = (string) ($cell['r'] ?? ''); if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) { continue; } $column = Coordinate::columnIndexFromString(strtoupper($matches[1])); $type = (string) ($cell['t'] ?? ''); if ($type === 'inlineStr') { $nodes = $cell->xpath('.//*[local-name()="t"]') ?: []; $value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes)); } else { $nodes = $cell->xpath('./*[local-name()="v"]') ?: []; $raw = isset($nodes[0]) ? (string) $nodes[0] : ''; $value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw; } $values[$column] = $value; } return $values; } /** @param array $values @return array */ private function headerColumns(array $values): array { $lookup = []; foreach ($values as $column => $value) { $heading = trim((string) $value); if ($heading !== '') { $lookup[$heading] = $column; } } foreach (self::REQUIRED_HEADERS as $header) { if (! isset($lookup[$header])) { return []; } } return array_intersect_key($lookup, array_flip(self::REQUIRED_HEADERS)); } /** @param array $values */ private function emptyRow(array $values): bool { foreach ($values as $value) { if (trim((string) $value) !== '') { return false; } } return true; } private function month(mixed $value, int $rowNumber): string { try { $date = is_numeric($value) ? Carbon::instance(ExcelDate::excelToDateTimeObject((float) $value)) : Carbon::parse(str_replace('/', '-', trim((string) $value))); } catch (Throwable) { throw new InvalidArgumentException("第 {$rowNumber} 行“月份”不是有效日期。"); } return $date->startOfMonth()->toDateString(); } private function requiredText(mixed $value, int $rowNumber, string $label): string { $text = trim((string) $value); if ($text === '') { throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不能为空。"); } return $text; } private function nullableText(mixed $value, int $length): ?string { $text = trim((string) $value); return $text === '' ? null : mb_substr($text, 0, $length); } private function number(mixed $value, int $rowNumber, string $label): float { if ($value === null || trim((string) $value) === '') { return 0.0; } $normalized = str_replace([',', ','], '', trim((string) $value)); if (! is_numeric($normalized)) { throw new InvalidArgumentException("第 {$rowNumber} 行“{$label}”不是有效数字。"); } return round((float) $normalized, 4); } /** @return list */ private function sharedStrings(string $path, ZipArchive $archive): array { if ($archive->locateName('xl/sharedStrings.xml') === false) { return []; } $reader = new XMLReader(); if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) { throw new InvalidArgumentException('无法读取 XLSX 共享文本。'); } $strings = []; try { while ($reader->read()) { if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') { continue; } $item = simplexml_load_string($reader->readOuterXml()); $nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : []; $strings[] = implode('', array_map( static fn (SimpleXMLElement $node): string => (string) $node, $nodes, )); } } finally { $reader->close(); } return $strings; } /** * Exports may contain pivot summaries or helper sheets before the detail * sheet. Select the worksheet that actually contains the authoritative * monthly-report headers instead of assuming sheet1 is the data sheet. * * @param array $sharedStrings */ private function worksheetWithHeaders(string $path, ZipArchive $archive, array $sharedStrings): string { $worksheets = []; for ($index = 0; $index < $archive->numFiles; $index++) { $name = $archive->getNameIndex($index); if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) { $worksheets[] = $name; } } sort($worksheets, SORT_NATURAL); if ($worksheets === []) { throw new InvalidArgumentException('XLSX 文件中没有工作表。'); } foreach ($worksheets as $worksheet) { $reader = new XMLReader(); if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { continue; } try { while ($reader->read()) { if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') { continue; } if ($this->headerColumns($this->row($reader->readOuterXml(), $sharedStrings)) !== []) { return $worksheet; } } } finally { $reader->close(); } } throw new InvalidArgumentException('XLSX 文件中未找到包含完整进销存月报表头的工作表。'); } private function zipUri(string $path, string $entry): string { return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; } }