mirror->overviewSource()['express'] ?? [] as $express) { $code = trim((string) $express->expcode); $name = trim((string) ($express->expname ?? '')); if ($name !== '') { $options[$code !== '' ? 'code:' . $code : 'name:' . $name] = ['code' => $code, 'name' => $name]; } } foreach (CourierOnboarding::query()->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED]) ->get() as $courier) { $code = trim((string) $courier->workplatform_expcode); if (filled($courier->courier_name)) { $options[$code !== '' ? 'code:' . $code : 'name:' . $courier->courier_name] ??= ['code' => $code, 'name' => $courier->courier_name]; } } foreach (CourierStockQuoteEntry::query()->whereNull('courier_expcode')->pluck('courier_name') as $name) { $options['name:' . $name] ??= ['code' => '', 'name' => $name]; } uasort($options, static fn (array $a, array $b): int => [$a['name'], $a['code']] <=> [$b['name'], $b['code']]); return array_values($options); } public function resolveCourier(array $data, ?array $options = null): array { $options ??= $this->courierOptions(); $code = trim((string) ($data['courier_expcode'] ?? '')); $name = trim((string) ($data['courier_name'] ?? '')); if ($code !== '') { $courier = collect($options)->firstWhere('code', $code); if ($courier === null || ($name !== '' && $name !== $courier['name'])) { throw ValidationException::withMessages(['courier_expcode' => '快递名称与已有编号不对应,请重新确认名称。']); } return $courier; } if ($name === '' || mb_strlen($name) > 255) { throw ValidationException::withMessages(['courier_name' => '请填写与发货数据一致的完整快递名称(最多255字)。']); } $matches = collect($options)->where('name', $name)->filter(fn ($courier) => $courier['code'] !== '')->values(); // 仅完全一致且唯一时附带真实编号;重名不猜,也不新增/改写快递资料。 return ['name' => $name, 'code' => $matches->count() === 1 ? $matches[0]['code'] : null]; } public function existingIdentities(?int $exceptId = null): array { return CourierStockQuoteEntry::withTrashed()->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId)) ->get(['courier_expcode', 'courier_name', 'quote_type']) ->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type])->all(); } public function isDuplicate(array $courier, string $quoteType, array $existing): bool { foreach ($existing as $entry) { if ($entry['quote_type'] !== $quoteType) { continue; } if (filled($courier['code']) && filled($entry['code'])) { if ($courier['code'] === $entry['code']) { return true; } } elseif (mb_strtolower(trim($courier['name'])) === mb_strtolower(trim($entry['name']))) { return true; } } return false; } /** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */ public function readWorkbook(UploadedFile $file, ?string $sheetName): array { $book = null; try { $reader = IOFactory::createReaderForFile($file->getRealPath()); $sheets = $reader->listWorksheetNames($file->getRealPath()); if (blank($sheetName) && count($sheets) !== 1) { throw ValidationException::withMessages(['sheet_name' => '文件有多个工作表,请填写本次录入的准确工作表名称:' . implode('、', $sheets)]); } $sheetName = filled($sheetName) ? $sheetName : $sheets[0]; if (! in_array($sheetName, $sheets, true)) { throw ValidationException::withMessages(['sheet_name' => '工作表不存在,可选:' . implode('、', $sheets)]); } foreach ($reader->listWorksheetInfo($file->getRealPath()) as $info) { if ($info['worksheetName'] === $sheetName && ($info['totalRows'] > 2000 || $info['totalColumns'] > 100 || $info['totalRows'] * $info['totalColumns'] > 50000)) { throw ValidationException::withMessages(['source_quote' => '单份报价最多 2000 行、100 列、50000 个单元格,请按快递及报价类型拆分。']); } } $reader->setReadDataOnly(false); $reader->setLoadSheetsOnly([$sheetName]); $book = $reader->load($file->getRealPath()); $sheet = $book->getSheetByName($sheetName); return $this->readSheet($sheet); } catch (ValidationException $exception) { throw $exception; } catch (\Throwable $exception) { throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']); } finally { $book?->disconnectWorksheets(); } } /** 一次加载整本文件;各工作表保持独立,不计算公式。 */ public function readBatchWorkbook(UploadedFile $file, string $imageDirectory, array &$savedPaths): array { $book = null; try { $reader = IOFactory::createReaderForFile($file->getRealPath()); $info = $reader->listWorksheetInfo($file->getRealPath()); if (count($info) > 200) { throw ValidationException::withMessages(['source_quote' => '一次最多导入 200 张工作表,请分批上传。']); } $cells = 0; foreach ($info as $sheet) { $cells += $sheet['totalRows'] * $sheet['totalColumns']; if ($sheet['totalRows'] > 2000 || $sheet['totalColumns'] > 100 || $sheet['totalRows'] * $sheet['totalColumns'] > 50000) { throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet['worksheetName'] . '」超过 2000 行、100 列或 50000 格,请缩小范围后上传。']); } } if ($cells > 500000) { throw ValidationException::withMessages(['source_quote' => '整本报价最多 500000 个单元格,请分批上传。']); } $reader->setReadDataOnly(false); $reader->setAllowExternalImages(false); $book = $reader->load($file->getRealPath()); $sheets = []; foreach ($book->getWorksheetIterator() as $index => $sheet) { try { $matrix = [...$this->readSheet($sheet), 'error' => null]; } catch (ValidationException $exception) { $matrix = ['sheet_name' => $sheet->getTitle(), 'error' => '空工作表,请取消选择。']; } $sheets[] = [...$matrix, ...app(CourierStockQuoteImageArchive::class)->store($sheet, $file->getRealPath(), $imageDirectory . '/' . $index, $savedPaths)]; } return $sheets; } catch (ValidationException $exception) { throw $exception; } catch (\Throwable $exception) { throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']); } finally { $book?->disconnectWorksheets(); } } private function readSheet(Worksheet $sheet): array { $lastRow = $sheet->getHighestDataRow(); $lastColumn = $sheet->getHighestDataColumn(); $columns = Coordinate::columnIndexFromString($lastColumn); $values = $formulas = []; $hasContent = false; for ($row = 1; $row <= $lastRow; $row++) { $valueRow = $formulaRow = []; for ($column = 1; $column <= $columns; $column++) { $cell = $sheet->getCell([$column, $row]); $formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null; $value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue(); $valueRow[] = is_object($value) ? (string) $value : $value; $formulaRow[] = $formula; $hasContent = $hasContent || ($value !== null && $value !== ''); } $values[] = $valueRow; $formulas[] = $formulaRow; } if (! $hasContent) { throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']); } return ['sheet_name' => $sheet->getTitle(), 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas]; } public function create(array $data, int $userId): CourierStockQuoteEntry { $data['quote_type'] = trim($data['quote_type']); $courier = $this->resolveCourier($data); if ($this->isDuplicate($courier, $data['quote_type'], $this->existingIdentities())) { $this->duplicateError(); } $matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null); return $this->createPrepared($data, $userId, $courier, $matrix); } /** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */ public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry { $savedPaths = []; try { return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry { $entry = CourierStockQuoteEntry::create([ 'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'], 'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null, 'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'], 'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'files' => [], 'created_by' => $userId, ]); $files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)]; $entry->update(['files' => $files]); $change = $this->changes->create([ 'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价', 'effective_date' => $data['effective_date'], 'summary' => $data['summary'], 'items' => [], 'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']], 'proposed_values_json' => $entry->values_json, ], $userId); $entry->update(['source_change_id' => $change->id]); return $entry->load('change'); }); } catch (\Throwable $exception) { if ($savedPaths !== []) { Storage::disk('uploads')->delete($savedPaths); } if ($exception instanceof UniqueConstraintViolationException) { $this->duplicateError(); } throw $exception; } } public function update(CourierStockQuoteEntry $entry, array $data, int $userId): void { $courier = ['code' => $entry->courier_expcode, 'name' => $entry->courier_name]; if (isset($data['courier_name']) && trim($data['courier_name']) !== $entry->courier_name) { $courier = $this->resolveCourier(['courier_name' => $data['courier_name']]); if ($this->isDuplicate($courier, $entry->quote_type, $this->existingIdentities($entry->id))) { $this->duplicateError(); } } $matrix = isset($data['source_quote']) ? $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null) : null; $savedPaths = []; try { DB::transaction(function () use ($entry, $data, $userId, $matrix, $courier, &$savedPaths): void { $files = $entry->files; if ($matrix !== null) { foreach ($files as &$file) { if ($file['kind'] === 'source_quote') { $file['kind'] = 'previous_source_quote'; } } unset($file); $entry->fill([ 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'], 'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), ]); } $entry->fill([ 'courier_name' => $courier['name'], 'courier_expcode' => $courier['code'], 'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null, 'files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)], ])->save(); $entry->change->update(['carrier_name' => $courier['name'], 'source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]); $this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId); }); } catch (\Throwable $exception) { Storage::disk('uploads')->delete($savedPaths); if ($exception instanceof UniqueConstraintViolationException) { $this->duplicateError(); } throw $exception; } } private function storeFiles(CourierStockQuoteEntry $entry, array $data, array &$savedPaths): array { $files = []; foreach (['source_quote' => isset($data['source_quote']) ? [$data['source_quote']] : [], 'confirmation' => $data['confirmation_files'] ?? [], 'signed' => $data['signed_files'] ?? []] as $kind => $uploads) { foreach ($uploads as $upload) { $path = Storage::disk('uploads')->putFile('courier-stock-quotes/' . company_id() . "/{$entry->id}/{$kind}", $upload, 'private'); if ($path === false) { throw ValidationException::withMessages(['source_quote' => '附件保存失败,请重试;本次录入未保存。']); } $savedPaths[] = $path; $files[] = ['kind' => $kind, 'path' => $path, 'name' => $upload->getClientOriginalName(), 'sha256' => hash_file('sha256', $upload->getRealPath())]; } } return $files; } private function duplicateError(): never { throw ValidationException::withMessages(['courier_name' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']); } }