CaiWuXiTong/app/Support/CourierStockQuoteEntryService.php

354 lines
20 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);
$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()->with(['change:id,company_id', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])->filter(fn ($entry) => !$entry->change?->version?->isVoided())
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
}
public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool
{
foreach ($existing as $entry) {
if ($entry['quote_type'] !== $quoteType) {
continue;
}
if ($start !== null && isset($entry['effective_date'])
&& ($start > ($entry['end_date'] ?? '9999-12-31') || ($end ?? '9999-12-31') < $entry['effective_date'])) 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;
}
public function assertPeriod(array $courier, string $quoteType, string $start, ?string $end, ?int $exceptId = null): void
{
if ($end !== null && $end < $start) throw ValidationException::withMessages(['end_date' => '截止日期不能早于开始日期。']);
if ($this->isDuplicate($courier, $quoteType, $this->existingIdentities($exceptId), $start, $end)) $this->duplicateError();
}
private function lockCompany(): void
{
// 同公司导入/改期串行,覆盖空集合及无编号快递,防止并发导入重叠时期。
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
}
/** 保留公式和 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);
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
$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 {
$this->lockCompany();
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
$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, 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
]);
$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'], 'end_date' => ($data['end_date'] ?? null) ?: null, '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']]);
}
$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 {
$this->lockCompany();
$this->assertPeriod($courier, $entry->quote_type, $data['effective_date'], ($data['end_date'] ?? null) ?: null, $entry->id);
$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)],
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
])->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' => '该快递的同类报价执行期间重叠,请核对起止日期;已有长期报价请先设置截止日期,不覆盖原报价。']);
}
public function setPeriodEnd(CourierStockQuoteEntry $entry, string $end, string $reason, int $userId): void
{
abort_unless($entry->company_id === (int) company_id(), 404);
DB::transaction(function () use ($entry, $end, $reason, $userId): void {
$this->lockCompany();
$entry->refresh()->load('change.version');
$version = $entry->change?->version;
if (!$version?->isApprovedStockQuote()) throw ValidationException::withMessages(['end_date' => '仅已审核且未作废的存量报价可设置截止日期;草稿请从报价历史编辑。']);
$this->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $entry->effective_date->toDateString(), $end, $entry->id);
$old = $entry->end_date?->toDateString();
$entry->update(['end_date' => $end]);
$entry->change->update(['end_date' => $end]);
$version->update(['end_date' => $end]);
\App\Models\Business\CourierQuoteChangeEvent::create([
'company_id' => company_id(), 'quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated',
'from_status' => $entry->change->status, 'to_status' => $entry->change->status, 'created_by' => $userId,
'note' => '设置执行截止日期:' . $reason,
'details_json' => json_encode(['previous_end_date' => $old, 'end_date' => $end], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
]);
});
}
}