189 lines
8.6 KiB
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);
|
|
}
|
|
}
|