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

168 lines
9.0 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\CourierQuoteVersion;
use App\Models\Business\CourierStockQuoteEntry;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class CourierStockQuoteBatchService
{
public function __construct(private readonly CourierStockQuoteEntryService $entries, private readonly CourierQuoteChangeService $changes)
{
}
public function ruleTemplates(): array
{
return CourierQuoteVersion::query()->whereNotNull('billing_rules')->latest('id')->limit(100)->get()
->filter(fn ($version) => filled($version->billing_rules))
->unique('billing_rules')->map(fn ($version) => [
'name' => $version->carrier_name . ' · ' . $version->quote_type,
'rules' => $version->billing_rules,
])->values()->all();
}
public function preview(UploadedFile $file, array $defaults, int $userId): string
{
$batch = (string) Str::uuid();
$disk = Storage::disk('uploads');
$savedPaths = [];
try {
$originalPath = $disk->putFileAs($this->directory($batch, $userId), $file, 'original.' . $file->getClientOriginalExtension(), 'private');
if ($originalPath === false) {
throw ValidationException::withMessages(['source_quote' => '报价原文件保存失败,请重试。']);
}
$savedPaths[] = $originalPath;
$sheets = $this->entries->readBatchWorkbook($file, $this->directory($batch, $userId) . '/images', $savedPaths);
$manifest = [
'company_id' => (int) company_id(), 'user_id' => $userId, 'created_at' => now()->toIso8601String(),
'defaults' => $defaults, 'sheets' => $sheets, 'result' => null,
'source_file' => ['kind' => 'source_quote', 'path' => $originalPath, 'name' => $file->getClientOriginalName(), 'sha256' => hash_file('sha256', $file->getRealPath())],
];
$savedPaths[] = $this->directory($batch, $userId) . '/manifest.json';
$this->write($batch, $userId, $manifest);
} catch (\Throwable $exception) {
$disk->delete($savedPaths);
throw $exception;
}
return $batch;
}
public function load(string $batch, int $userId): array
{
abort_unless(Str::isUuid($batch), 404);
$disk = Storage::disk('uploads');
$path = $this->directory($batch, $userId) . '/manifest.json';
abort_unless($disk->exists($path), 404, '导入预览不存在,请重新上传报价。');
$manifest = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR);
abort_unless($manifest['company_id'] === (int) company_id() && $manifest['user_id'] === $userId, 404);
return $manifest;
}
public function rows(array $manifest, array $couriers): array
{
$existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type'])
->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]);
return array_map(function (array $sheet) use ($manifest, $couriers, $existing): array {
$key = mb_strtolower(trim($sheet['sheet_name']));
$matches = array_values(array_filter($couriers, static fn ($courier) => in_array($key, [
mb_strtolower($courier['code']), mb_strtolower(trim($courier['name'])),
mb_strtolower($courier['name'] . ' · ' . $courier['code']),
], true)));
$code = count($matches) === 1 ? $matches[0]['code'] : '';
$duplicate = $code !== '' && isset($existing[$code . '|' . $manifest['defaults']['quote_type']]);
return [...$sheet, 'courier_expcode' => $code, 'duplicate' => $duplicate,
'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称重复,请选编号' : '待匹配,请选编号'))),
];
}, $manifest['sheets']);
}
public function save(string $batch, int $userId, array $data): array
{
$manifest = $this->load($batch, $userId);
if ($manifest['result'] !== null) {
return $manifest['result'];
}
$couriers = collect($this->entries->courierOptions())->keyBy('code');
$selected = array_filter($data['entries'], static fn ($row) => (bool) ($row['selected'] ?? false));
if ($selected === []) {
throw ValidationException::withMessages(['entries' => '请至少选择一家需要录入的快递。']);
}
$errors = $prepared = $identities = [];
$existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type'])
->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]);
foreach ($selected as $index => $row) {
$sheet = $manifest['sheets'][$index] ?? null;
$courier = $couriers->get(trim($row['courier_expcode'] ?? ''));
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
$metadata = [
'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
'billing_rules' => trim($rules), 'summary' => $data['summary'],
];
$validation = Validator::make($metadata, [
'effective_date' => ['required', 'date_format:Y-m-d'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
$reason ??= $courier === null ? '请选择已有快递资料中的唯一编号。' : null;
$reason ??= $validation->fails() ? $validation->errors()->first() : null;
foreach ($sheet['signed_images'] ?? [] as $image) {
if (! Storage::disk('uploads')->exists($image['path'])) {
$reason ??= '原件图片存档缺失,请重新上传原 Excel,不能带缺失附件录入。';
}
}
$identity = ($courier['code'] ?? '') . '|' . $metadata['quote_type'];
$reason ??= isset($existing[$identity]) ? '此快递编号的同类报价已录入,请取消选择;后续调价使用变价。' : null;
$reason ??= isset($identities[$identity]) ? '与本批其他工作表匹配了同一快递编号,请检查或取消选择。' : null;
if ($reason !== null) {
$errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;
} else {
$prepared[] = compact('metadata', 'courier', 'sheet');
}
$identities[$identity] = true;
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。');
// 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。
return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array {
$created = [];
foreach ($prepared as $item) {
$entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file'], ...($item['sheet']['signed_images'] ?? [])]);
if ($data['submit_for_review'] ?? false) {
$this->changes->submit($entry->change, $userId);
}
$created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode];
}
$result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
$manifest['result'] = $result;
$this->write($batch, $userId, $manifest);
return $result;
});
}
private function directory(string $batch, int $userId): string
{
return 'courier-stock-quote-batches/' . company_id() . '/' . $userId . '/' . $batch;
}
private function write(string $batch, int $userId, array $manifest): void
{
if (! Storage::disk('uploads')->put($this->directory($batch, $userId) . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'private')) {
throw ValidationException::withMessages(['source_quote' => '导入预览保存失败,请重试。']);
}
}
}