保留各家工作表报价原文、公式、独立执行日期及签署原件;审核后按执行日期管理版本。33项测试、336项断言通过。生产业务迁移已获明确授权,服务器私有备份已完成并逐条校验。
254 lines
13 KiB
PHP
254 lines
13 KiB
PHP
<?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' => '该快递编号的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
|
||
}
|
||
}
|