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'], 'end_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'], 'end_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.*.courier_name' => ['nullable', 'string', 'max:255'], 'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'], 'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'], 'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'], 'entries.*.periods' => ['sometimes', 'array', 'min:1', 'max:20'], 'entries.*.periods.*' => ['array'], 'entries.*.periods.*.effective_date' => ['required', 'date_format:Y-m-d'], 'entries.*.periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:entries.*.periods.*.effective_date'], '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(true), 'courier_expcode' => ['nullable', 'string', 'max:64'], 'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'], 'quote_type' => ['required', 'string', 'max:64'], 'sheet_name' => ['nullable', 'string', 'max:255'], ...$this->fileRules(true), ]); $entries = $this->service->createPeriods($data, (int) user_id()); if (count($entries) > 1) { return redirect()->route('business.courier-quote-changes.index') ->with('success', '已按 ' . count($entries) . ' 个执行区间保存报价草稿,共用原报价与附件;请分别核对并提交审核。'); } $entry = $entries[0]; 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'], 'courier_name' => ['sometimes', 'required', '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 courierNames(Request $request, ShippingOrderRepository $shipping): JsonResponse { $data = $request->validate(['q' => ['required', 'string', 'min:2', 'max:64']]); try { return response()->json(['items' => $shipping->recentCourierNames(trim($data['q']), 20)]); } catch (\Throwable $exception) { return response()->json(['items' => [], 'message' => '发货名称暂不可用,可直接手填。'], 503); } } 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, ['application/pdf', '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(bool $multiplePeriods = false): array { return [ ...($multiplePeriods ? [ 'periods' => ['sometimes', 'array', 'min:1', 'max:20'], 'periods.*' => ['array'], 'periods.*.effective_date' => ['required', 'date_format:Y-m-d'], 'periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:periods.*.effective_date'], ] : []), 'effective_date' => [$multiplePeriods ? 'required_without:periods' : 'required', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'], '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); } public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse { abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403); abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404); $data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]); $this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id()); return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。'); } }