'快递名称', 'expressNo' => '快递单号', 'purchaseNo' => '单据编号(采购单号)', 'createdAt' => '创建时间', 'supplier' => '供应商', 'barcode' => '条码', 'skuName' => '规格名称', 'qty' => '数量', 'unitPrice' => '单价', 'amount' => '金额', ]; private const MAX_ROWS = 200000; /** 允许留空的列:快递信息可缺;单价/金额留空时按「金额÷数量」或「数量×单价」补齐,都空按 0。 */ private const OPTIONAL_COLUMNS = ['expressName', 'expressNo', 'purchaseNo', 'unitPrice', 'amount']; /** 必须是数字的列。 */ private const NUMERIC_COLUMNS = ['qty', 'unitPrice', 'amount']; public function path(): string { return resource_path('templates/supplier-reconciliation/statement-template.xlsx'); } public function filename(): string { return '供应商对账单模板.xlsx'; } /** * 解析并严格校验厂家上传的对账单。 * * @return list> 模块内部键的记录(条码/单价/数量/金额等) */ public function parse(UploadedFile $file, ?string $expectedSupplier = null): array { $extension = strtolower((string) $file->getClientOriginalExtension()); if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) { throw ValidationException::withMessages(['import' => '只能上传 Excel(xlsx / xls)或 CSV 文件。']); } try { $reader = PayablesWorkbookStreamReader::forFile((string) $file->getRealPath(), $extension); $header = $this->locateHeader($reader); } catch (ValidationException $exception) { throw $exception; } catch (Throwable) { throw ValidationException::withMessages(['import' => '无法读取文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。']); } $records = []; $fields = array_keys(self::COLUMNS); foreach ($reader->rows($header['sheet'], $header['row']) as $rowNumber => $cells) { $values = []; foreach ($fields as $index => $field) { $value = trim((string) ($cells[$index] ?? '')); $values[$field] = $value === '' ? null : $value; } if (collect($values)->every(static fn (mixed $value): bool => $value === null)) { continue; } foreach (self::COLUMNS as $field => $label) { if ($values[$field] === null) { if (in_array($field, self::OPTIONAL_COLUMNS, true)) { continue; } throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」不能为空。"]); } if (in_array($field, self::NUMERIC_COLUMNS, true)) { $normalized = str_replace([' ', ',', ','], '', (string) $values[$field]); if (preg_match('/^-?\d+(?:\.\d+)?$/', $normalized) !== 1) { throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」必须是数字。"]); } $values[$field] = $normalized; } } $values = $this->completeAmounts($values); if ($expectedSupplier !== null && $values['supplier'] !== $expectedSupplier) { throw ValidationException::withMessages([ 'import' => "第 {$rowNumber} 行:供应商必须填写「{$expectedSupplier}」,不能提交其他厂家的数据。", ]); } $records[] = $values; if (count($records) > self::MAX_ROWS) { throw ValidationException::withMessages(['import' => '单次最多上传 200,000 行,请拆分文件后重试。']); } } if ($records === []) { throw ValidationException::withMessages(['import' => '表格里没有数据行,请按模板填写后重新上传。']); } return $records; } /** * 找出模板表头:列名、顺序、起始列必须与模板完全一致。 * * @return array{sheet:int,row:int} */ private function locateHeader(PayablesWorkbookStreamReader $reader): array { $expected = array_values(self::COLUMNS); $expectedNormalized = array_map($this->normalizeLabel(...), $expected); $bestLabels = null; $bestScore = -1; foreach (array_keys($reader->sheetNames()) as $sheetIndex) { foreach ($reader->rows($sheetIndex, null, 20) as $rowNumber => $row) { $labels = []; foreach ($row as $columnIndex => $value) { $label = $this->normalizeLabel((string) $value); if ($label !== '') { $labels[$columnIndex] = $label; } } ksort($labels); if (array_values($labels) === $expectedNormalized && array_keys($labels) === range(0, count($expected) - 1)) { return ['sheet' => $sheetIndex, 'row' => $rowNumber]; } $score = count(array_intersect(array_values($labels), $expectedNormalized)); if ($score > $bestScore) { $bestScore = $score; $bestLabels = array_values($labels); } } } throw ValidationException::withMessages([ 'import' => '表头与模板不一致:' . $this->describeHeader($bestLabels, $expectedNormalized) . '请下载最新模板填写,不要增删或修改列(列名、顺序必须与模板完全相同)。', ]); } /** @param list|null $actual 标准化后的实际表头;@param list $expected 标准化后的模板表头 */ private function describeHeader(?array $actual, array $expected): string { if ($actual === null) { return ''; } $display = []; foreach (self::COLUMNS as $label) { $display[$this->normalizeLabel($label)] = $label; } $missing = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($expected, $actual))); $extra = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($actual, $expected))); $parts = []; if ($missing !== []) { $parts[] = '缺少「' . implode('」「', $missing) . '」'; } if ($extra !== []) { $parts[] = '多出「' . implode('」「', $extra) . '」'; } $commonActual = array_values(array_filter($actual, static fn (string $key): bool => in_array($key, $expected, true))); $commonExpected = array_values(array_filter($expected, static fn (string $key): bool => in_array($key, $actual, true))); if ($commonActual !== $commonExpected) { $parts[] = '列顺序与模板不同'; } return $parts === [] ? '' : implode(',', $parts) . ';'; } /** 比较表头时忽略空白与全半角括号差异。 */ private function normalizeLabel(string $label): string { $label = preg_replace('/\s+/u', '', trim($label)) ?? ''; $label = str_replace(['(', ')'], ['(', ')'], $label); return mb_strtolower($label); } /** 单价/金额留空时互相补算;两者都空按 0(未定价行厂家直接导出时会出现)。 */ private function completeAmounts(array $values): array { $qty = (float) ($values['qty'] ?? 0); $unitPrice = $values['unitPrice'] === null ? null : (float) $values['unitPrice']; $amount = $values['amount'] === null ? null : (float) $values['amount']; if ($unitPrice === null && $amount !== null && $qty != 0.0) { $values['unitPrice'] = (string) round($amount / $qty, 4); } elseif ($unitPrice === null) { $values['unitPrice'] = '0'; } if ($amount === null) { $values['amount'] = (string) round($qty * (float) $values['unitPrice'], 2); } return $values; } }