*/ 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,anchor_skus:int,anchor_at: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->readSource($path); $anchorAt = Carbon::parse($source['period_end'])->addDay()->startOfDay(); $anchors = array_map( static fn (array $anchor): array => [ 'generation' => $generation, 'sku_code' => $anchor['sku_code'], 'qty' => $anchor['qty'], 'anchor_at' => $anchorAt, 'imported_at' => $now, ], $source['anchors'], ); $authoritativeRows = array_map( static fn (array $row): array => $row + [ 'generation' => $generation, 'imported_at' => $now, ], $source['rows'], ); foreach (array_chunk($authoritativeRows, 500) as $batch) { $this->connection->table(self::ROW_TABLE)->insert($batch); } foreach (array_chunk($anchors, 1000) as $batch) { $this->connection->table(self::ANCHOR_TABLE)->insert($batch); } $this->connection->transaction(function () use ( $generation, $path, $hash, $source, $anchorAt, $now, ): void { $this->connection->table(self::STATE_TABLE) ->where('dataset_key', self::DATASET_KEY) ->update([ 'status' => 'succeeded', 'active_generation' => $generation, 'pending_generation' => null, 'anchor_at' => $anchorAt, 'source_filename' => mb_substr(basename($path), 0, 255), 'source_sha256' => $hash, 'source_period_start' => $source['period_start'], 'source_period_end' => $source['period_end'], 'source_row_count' => $source['row_count'], 'anchor_sku_count' => count($source['anchors']), 'last_succeeded_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); }); } catch (Throwable $exception) { $this->connection->table(self::ANCHOR_TABLE) ->where('generation', $generation) ->delete(); $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 { $this->connection->table(self::ANCHOR_TABLE) ->where('generation', '!=', $generation) ->delete(); $this->connection->table(self::ROW_TABLE) ->where('generation', '!=', $generation) ->delete(); } catch (Throwable $exception) { report($exception); } return [ 'status' => 'succeeded', 'source_rows' => $source['row_count'], 'anchor_skus' => count($source['anchors']), 'anchor_at' => $anchorAt->toDateTimeString(), '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::ANCHOR_TABLE, self::ROW_TABLE] as $table) { if (! $schema->hasTable($table)) { throw new RuntimeException('三方仓权威进销存表尚未完成迁移:' . $table); } } } /** * @return array{period_start:string,period_end:string,row_count:int,rows:list>,anchors:list} */ private function readSource(string $path): array { $archive = new ZipArchive(); if ($archive->open($path) !== true) { throw new InvalidArgumentException('无法打开三方仓进销存 XLSX 导出文件。'); } try { $worksheet = $this->firstWorksheet($archive); $sharedStrings = $this->sharedStrings($path, $archive); } finally { $archive->close(); } $reader = new XMLReader(); if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) { throw new InvalidArgumentException('无法读取三方仓进销存 XLSX 工作表。'); } $columns = []; $seen = []; $dates = []; $anchors = []; $rows = []; $minimumDate = null; $maximumDate = null; $rowCount = 0; 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; } $date = $this->date($values[$columns['日期']] ?? null, $rowNumber); $sku = $this->sku($values[$columns['商品编码']] ?? null, $rowNumber); $key = $date . "\0" . $sku; if (isset($seen[$key])) { throw new InvalidArgumentException( "第 {$rowNumber} 行与第 {$seen[$key]} 行的日期和商品编码重复。", ); } $seen[$key] = $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} 行期初数量加进出数量不等于期末数量。", ); } $dates[$date] = true; $minimumDate = $minimumDate === null || $date < $minimumDate ? $date : $minimumDate; $maximumDate = $maximumDate === null || $date > $maximumDate ? $date : $maximumDate; $rows[] = [ 'report_date' => $date, 'sku_code' => $sku, 'opening_qty' => $opening, 'closing_qty' => $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_aux' => $movementValues['期初'], 'other_out' => $movementValues['其它出库'], ]; if (! isset($anchors[$sku]) || $date >= $anchors[$sku]['date']) { $anchors[$sku] = ['sku_code' => $sku, 'qty' => $closing, 'date' => $date]; } $rowCount++; } } finally { $reader->close(); } if ($columns === [] || $rowCount === 0 || $minimumDate === null || $maximumDate === null) { throw new InvalidArgumentException('三方仓进销存导出文件中没有可用明细。'); } $start = Carbon::parse($minimumDate); $end = Carbon::parse($maximumDate); if (! $start->isSameMonth($end) || $start->day !== 1 || ! $end->isLastOfMonth()) { throw new InvalidArgumentException('导出文件必须完整覆盖同一个自然月。'); } for ($date = $start->copy(); $date->lte($end); $date->addDay()) { if (! isset($dates[$date->toDateString()])) { throw new InvalidArgumentException('导出文件缺少日期:' . $date->toDateString()); } } if ($anchors === []) { throw new InvalidArgumentException('导出文件月末没有可用的商品期末数量。'); } ksort($anchors, SORT_STRING); $anchors = array_map( static fn (array $anchor): array => [ 'sku_code' => $anchor['sku_code'], 'qty' => $anchor['qty'], ], $anchors, ); return [ 'period_start' => $minimumDate, 'period_end' => $maximumDate, 'row_count' => $rowCount, 'rows' => $rows, 'anchors' => array_values($anchors), ]; } /** @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 date(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->toDateString(); } private function sku(mixed $value, int $rowNumber): string { $sku = ProductCatalogAuthoritativeExportImporter::normalizeSkuId((string) $value); if ($sku === '') { throw new InvalidArgumentException("第 {$rowNumber} 行“商品编码”不能为空。"); } return $sku; } 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; } private function firstWorksheet(ZipArchive $archive): 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 文件中没有工作表。'); } return $worksheets[0]; } private function zipUri(string $path, string $entry): string { return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry; } }