CaiWuXiTong/app/Http/Controllers/Business/CourierStockQuoteEntries.php

189 lines
8.6 KiB
PHP

<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierStockQuoteEntry;
use App\Support\CourierStockQuoteBatchService;
use App\Support\CourierStockQuoteEntryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierStockQuoteEntries extends Controller
{
public function __construct(private readonly CourierStockQuoteEntryService $service, private readonly CourierStockQuoteBatchService $batches)
{
}
public function create(): View
{
return view('business.pages.courier-payables.stock-quote-batch', ['batch' => null, 'manifest' => null, 'rows' => [], 'couriers' => [], 'templates' => $this->batches->ruleTemplates()]);
}
public function single(): View
{
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => null, 'couriers' => $this->service->courierOptions()]);
}
public function previewBatch(Request $request): RedirectResponse
{
$data = $request->validate([
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'billing_rules' => ['nullable', 'string', 'max:4000'],
]);
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
return redirect()->route('business.courier-stock-quotes.batch', $batch);
}
public function showBatch(string $batch): View
{
$manifest = $this->batches->load($batch, (int) user_id());
$couriers = $manifest['result'] === null ? $this->service->courierOptions() : [];
return view('business.pages.courier-payables.stock-quote-batch', [
'batch' => $batch, 'manifest' => $manifest, 'couriers' => $couriers,
'rows' => $manifest['result'] === null ? $this->batches->rows($manifest, $couriers) : [], 'templates' => [],
]);
}
public function saveBatch(Request $request, string $batch): RedirectResponse|JsonResponse
{
// JSON 请求体不受 max_input_vars 限制,防火墙也可逐字段检查,避免跨字段误匹配。
// 保留旧表单入口,已打开的页面仍由原有防护检查,不豁免任何用户输入。
if ($request->isJson()) {
$selection = $request->json()->all();
} else {
$request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
$selection = json_decode($request->input('selection_json'), true);
}
if (! is_array($selection)) {
throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']);
}
foreach (['quote_type', 'summary'] as $field) {
if (is_string($selection[$field] ?? null)) {
$selection[$field] = trim($selection[$field]);
}
}
$data = Validator::make($selection, [
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
'rule_groups.*' => ['array'],
'rule_groups.*.name' => ['required', 'string', 'max:100'],
'rule_groups.*.rules' => ['nullable', 'string', 'max:4000'],
'entries' => ['required', 'array', 'max:200'],
'entries.*' => ['array'],
'entries.*.selected' => ['required', 'boolean'],
'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'],
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
'submit_for_review' => ['required', 'boolean'],
'confirmed' => ['accepted_if:submit_for_review,true'],
])->validate();
$this->batches->save($batch, (int) user_id(), $data);
if ($request->expectsJson()) {
return response()->json(['redirect' => route('business.courier-stock-quotes.batch', $batch)]);
}
return redirect()->route('business.courier-stock-quotes.batch', $batch);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
...$this->metadataRules(),
'courier_expcode' => ['required', 'string', 'max:64'],
'quote_type' => ['required', 'string', 'max:64'],
'sheet_name' => ['nullable', 'string', 'max:255'],
...$this->fileRules(true),
]);
$entry = $this->service->create($data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $entry->change)
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
}
public function edit(CourierStockQuoteEntry $stockQuoteEntry): View
{
$this->ensureEditable($stockQuoteEntry);
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => $stockQuoteEntry, 'couriers' => []]);
}
public function update(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
{
$this->ensureEditable($stockQuoteEntry);
$data = $request->validate([...$this->metadataRules(), ...$this->fileRules(false), 'sheet_name' => ['nullable', 'string', 'max:255']]);
$this->service->update($stockQuoteEntry, $data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。');
}
public function batchImage(Request $request, string $batch, int $sheet, int $file): StreamedResponse
{
$manifest = $this->batches->load($batch, (int) user_id());
$attachment = $manifest['sheets'][$sheet]['signed_images'][$file] ?? null;
return $this->attachmentResponse($attachment, $request->boolean('preview'));
}
public function download(Request $request, CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse
{
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
$attachment = $stockQuoteEntry->files[$file] ?? null;
return $this->attachmentResponse($attachment, $request->boolean('preview'));
}
private function attachmentResponse(?array $attachment, bool $preview): StreamedResponse
{
$disk = Storage::disk('uploads');
abort_if($attachment === null || ! $disk->exists($attachment['path']), 404, '附件不存在。');
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
if ($preview) {
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
abort_unless(in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
}
return $disk->download($attachment['path'], $attachment['name'], $headers);
}
private function metadataRules(): array
{
return [
'effective_date' => ['required', 'date_format:Y-m-d'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
];
}
private function fileRules(bool $requiresSource): array
{
return [
'source_quote' => [$requiresSource ? 'required' : 'nullable', 'file', 'mimes:xlsx,xls', 'max:20480'],
'confirmation_files' => ['nullable', 'array', 'max:10'],
'confirmation_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png,xlsx,xls,eml,msg', 'max:20480'],
'signed_files' => ['nullable', 'array', 'max:10'],
'signed_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png', 'max:20480'],
];
}
private function ensureEditable(CourierStockQuoteEntry $entry): void
{
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
}
}