354 lines
20 KiB
PHP
354 lines
20 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);
|
||
$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),
|
||
]);
|
||
});
|
||
}
|
||
}
|