CaiWuXiTong/app/Support/CourierStockQuoteEntryService.php
limeng 9a25db91cc feat(报价管理): 存量报价整本批量录入及签署原图归档 (#346)
保留各家工作表报价原文、公式、独立执行日期及签署原件;审核后按执行日期管理版本。33项测试、336项断言通过。生产业务迁移已获明确授权,服务器私有备份已完成并逐条校验。
2026-10-16 14:56:44 +08:00

254 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Support;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierStockQuoteEntry;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class CourierStockQuoteEntryService
{
public function __construct(
private readonly CourierInformationMirrorRepository $mirror,
private readonly CourierQuoteChangeService $changes,
) {
}
/** 编号是唯一身份;不按品牌或名称合并不同网点、结算主体。 */
public function courierOptions(): array
{
$options = [];
foreach ($this->mirror->overviewSource()['express'] ?? [] as $express) {
$code = trim((string) $express->expcode);
if ($code !== '' && filled($express->expname)) {
$options[$code] = ['code' => $code, 'name' => (string) $express->expname];
}
}
foreach (CourierOnboarding::query()->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
->whereNotNull('workplatform_expcode')->get() as $courier) {
$code = trim((string) $courier->workplatform_expcode);
if ($code !== '' && filled($courier->courier_name)) {
$options[$code] ??= ['code' => $code, 'name' => $courier->courier_name];
}
}
uasort($options, static fn (array $a, array $b): int => [$a['name'], $a['code']] <=> [$b['name'], $b['code']]);
return array_values($options);
}
/** 保留公式和 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 = collect($this->courierOptions())->firstWhere('code', $data['courier_expcode']);
if ($courier === null) {
throw ValidationException::withMessages(['courier_expcode' => '请选择已有快递资料中的唯一编号;没有编号时请先完善快递资料。']);
}
if (CourierStockQuoteEntry::withTrashed()->where('courier_expcode', $courier['code'])->where('quote_type', $data['quote_type'])->exists()) {
$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'],
'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
{
$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, &$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(['files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)]])->save();
$entry->change->update(['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);
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_expcode' => '该快递编号的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
}
}