, * balances:array, * balancesByDate:array>, * balanceDays:list, * alerts:array, * reserveStandards:array,withdrawalAmounts:array,compliance:array, * balanceColumnLabel:string,balanceDate:string * } $report */ public function export(array $report): StreamedResponse { $spreadsheet = $this->workbook($report); return $this->download( $spreadsheet, 'withdrawal_management_' . str_replace('-', '', $report['balanceDate']) . '_' . now()->format('His') . '.xlsx', ); } public function template(): StreamedResponse { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('提现管理'); $this->writeHeader($sheet, [...self::CONFIG_HEADERS, '提现金额']); $this->sizeColumns($sheet, [14, 30, 20, 32, 16, 24, 22, 18]); $instructions = $spreadsheet->createSheet(); $instructions->setTitle('填写说明'); $instructions->fromArray([ ['提现管理导入说明'], ['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'], ['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'], ['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'], ['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'], ], null, 'A1'); $instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14); $instructions->getColumnDimension('A')->setWidth(88); $instructions->getStyle('A1:A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP); foreach (range(1, 5) as $row) { $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 34); } $spreadsheet->setActiveSheetIndex(0); return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx'); } /** * 按当前筛选预填店铺和余额参考值,同事只需填写提现金额。 * * @param array $report */ public function withdrawalTemplate(array $report): StreamedResponse { $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('提现金额'); $this->writeHeader($sheet, self::WITHDRAWAL_TEMPLATE_HEADERS); $this->sizeColumns($sheet, [14, 30, 20, 14, 18, 18, 18, 18, 14]); $rowNumber = 2; foreach ($report['rows'] as $row) { $storeId = (int) $row->id; $balanceMatch = $report['balances'][$storeId] ?? [ 'status' => 'not_ready', 'balance' => null, ]; $hasBalance = ($balanceMatch['status'] ?? '') === 'matched' && ($balanceMatch['balance'] ?? null) !== null; $reserve = $report['reserveStandards'][$storeId] ?? null; $amount = $report['withdrawalAmounts'][$storeId] ?? null; foreach ([ 'A' => (string) ($row->store_platform ?? ''), 'B' => (string) ($row->erp_store_name ?? ''), 'C' => (string) ($row->store_id ?? ''), 'D' => (string) $report['balanceDate'], ] as $column => $value) { $sheet->setCellValueExplicit("{$column}{$rowNumber}", $value, DataType::TYPE_STRING); } if ($hasBalance) { $sheet->setCellValue("E{$rowNumber}", (float) $balanceMatch['balance']); } else { $sheet->setCellValueExplicit("E{$rowNumber}", $this->balanceStatus((string) ($balanceMatch['status'] ?? 'not_ready')), DataType::TYPE_STRING); } if ($reserve !== null) { $sheet->setCellValue("F{$rowNumber}", $reserve); } else { $sheet->setCellValueExplicit("F{$rowNumber}", '待配置', DataType::TYPE_STRING); } if ($amount !== null) { $sheet->setCellValue("G{$rowNumber}", $amount); } $sheet->setCellValue( "H{$rowNumber}", "=IF(OR(NOT(ISNUMBER(E{$rowNumber})),G{$rowNumber}=\"\"),\"\",E{$rowNumber}-G{$rowNumber})", ); $sheet->setCellValue( "I{$rowNumber}", "=IF(OR(H{$rowNumber}=\"\",NOT(ISNUMBER(F{$rowNumber}))),\"\",IF(H{$rowNumber}<=F{$rowNumber},\"是\",\"否\"))", ); $rowNumber++; } $lastDataRow = max(2, $rowNumber - 1); $sheet->getStyle("E2:I{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00'); $sheet->getStyle("E2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); $sheet->getStyle("G2:G{$lastDataRow}")->getFill() ->setFillType(Fill::FILL_SOLID) ->getStartColor()->setARGB('FFF7F0DE'); $sheet->getStyle('G1')->getFill() ->setFillType(Fill::FILL_SOLID) ->getStartColor()->setARGB('FFB9954F'); $validation = new DataValidation(); $validation->setType(DataValidation::TYPE_DECIMAL) ->setOperator(DataValidation::OPERATOR_GREATERTHANOREQUAL) ->setFormula1('0') ->setAllowBlank(true) ->setShowErrorMessage(true) ->setErrorTitle('提现金额格式错误') ->setError('请填写非负金额,最多两位小数。'); $sheet->setDataValidation("G2:G{$lastDataRow}", $validation); $instructions = $spreadsheet->createSheet(); $instructions->setTitle('填写说明'); $instructions->fromArray([ ['提现金额导入说明'], ['1. 本模板按下载时的页面筛选生成,已预填店铺标识、余额日期、店铺余额和预留标准。'], ['2. 只需填写黄色“提现金额”列;未填写金额的店铺不会提交。填写“' . self::CLEAR_VALUE . '”可清空该店铺提现金额。'], ['3. 店铺余额、预留标准、提现后余额和是否达标仅用于下载时预览,系统会在上传后按最新余额日期重新计算。'], ['4. 判定规则:店铺余额 - 提现金额 ≤ 预留标准时,“是否达标”为“是”,即该笔提现按大笔提现处理。'], ['5. 模板余额日期为 ' . $report['balanceDate'] . ';日期滚动后请重新下载模板,避免上传过期金额。'], ], null, 'A1'); $instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14); $instructions->getColumnDimension('A')->setWidth(96); $instructions->getStyle('A1:A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP); foreach (range(1, 6) as $row) { $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 36); } $spreadsheet->setActiveSheetIndex(0); return $this->download( $spreadsheet, 'withdrawal_amount_import_' . str_replace('-', '', (string) $report['balanceDate']) . '.xlsx', ); } /** * @return array{ * total:int,changed:int,unchanged:int, * preview:list, * plan:list}> * } */ public function preview(UploadedFile $file, int $companyId): array { try { $spreadsheet = IOFactory::load($file->getRealPath()); $sheet = $spreadsheet->getSheetByName('提现管理') ?? $spreadsheet->getActiveSheet(); $matrix = $sheet->toArray(null, false, true, false); $spreadsheet->disconnectWorksheets(); } catch (Throwable) { throw ValidationException::withMessages([ 'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。', ]); } [$headerIndex, $headers, $headerMode] = $this->headers($matrix); $indexes = array_flip($headers); $balanceDate = $this->currentBalanceDate(); $accounts = StoreReceivingAccount::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') ->get(); [$byId, $byName] = $this->indexes($accounts); $balances = $this->shopBalances->matchesForStores($accounts, $balanceDate); $withdrawalOnlyMode = $headerMode === 'withdrawal'; $errors = []; $plan = []; $preview = []; $seen = []; $total = 0; $unchanged = 0; foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) { $values = []; foreach ([...self::CONFIG_HEADERS, '提现金额', '余额日期'] as $header) { $values[$header] = isset($indexes[$header]) ? trim((string) ($row[$indexes[$header]] ?? '')) : ''; } // 专用模板会预填所有店铺,只把填写了提现金额的行视为提交数据。 if ($withdrawalOnlyMode && $values['提现金额'] === '') { continue; } if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) { continue; } $total++; if ($total > 5000) { throw ValidationException::withMessages(['import' => '单次最多导入 5000 行,请拆分文件后重试。']); } $line = $matrixIndex + 1; $platform = mb_substr($values['店铺平台'], 0, 80); $erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191); $storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191); if ($platform === '' || ($storeId === '' && $erpStoreName === '')) { $errors[] = "第 {$line} 行缺少店铺平台或店铺标识"; continue; } if ($withdrawalOnlyMode && $values['余额日期'] !== '' && $values['余额日期'] !== $balanceDate) { $errors[] = "第 {$line} 行余额日期 {$values['余额日期']} 已过期,请按最新余额日期 {$balanceDate} 重新下载模板"; continue; } $matches = $storeId !== '' ? ($byId[$this->key($platform, $storeId)] ?? []) : ($byName[$this->key($platform, $erpStoreName)] ?? []); if (count($matches) === 0) { $identity = $storeId !== '' ? "平台店铺ID {$storeId}" : "ERP 店铺名称 {$erpStoreName}"; $errors[] = "第 {$line} 行未找到 {$platform} / {$identity}"; continue; } if (count($matches) > 1) { $errors[] = "第 {$line} 行匹配到多家店铺,请补充或核对平台店铺ID"; continue; } /** @var StoreReceivingAccount $account */ $account = $matches[0]; if (isset($seen[(int) $account->id])) { $errors[] = "第 {$line} 行与第 {$seen[(int) $account->id]} 行重复指向同一家店铺"; continue; } $seen[(int) $account->id] = $line; try { $changes = $this->changes($account, $values, $balanceDate); } catch (\InvalidArgumentException $exception) { $errors[] = "第 {$line} 行" . $exception->getMessage(); continue; } if ($changes === []) { $unchanged++; continue; } $labels = [ 'store_entity_name' => '店铺主体', 'receiving_method' => '提现路径', 'receiving_account_name' => '提现账户名称', 'bank_card_last_four' => '银行卡号后四位', 'withdrawal_amount' => '提现金额', 'withdrawal_balance_date' => '提现金额', ]; $changedLabels = array_unique(array_map( static fn (string $field): string => $labels[$field], array_keys($changes), )); $plan[] = [ 'id' => (int) $account->id, 'updated_at' => (string) $account->getRawOriginal('updated_at'), 'changes' => $changes, ]; if (count($preview) < 20) { $amount = array_key_exists('withdrawal_amount', $changes) ? ($changes['withdrawal_amount'] === null ? null : (float) $changes['withdrawal_amount']) : ( $account->withdrawal_balance_date === $balanceDate && $account->withdrawal_amount !== null && is_numeric($account->withdrawal_amount) ? (float) $account->withdrawal_amount : null ); $balance = ($balances[(int) $account->id]['status'] ?? '') === 'matched' ? (float) $balances[(int) $account->id]['balance'] : null; $reserve = WithdrawalReserveStandard::amountFor( $account->store_platform, $account->erp_store_name, ); $preview[] = [ 'platform' => $platform, 'erp_store_name' => $erpStoreName, 'store_id' => $storeId, 'changes' => implode('、', $changedLabels), 'withdrawal_amount' => $amount, 'post_withdrawal_balance' => $balance !== null && $amount !== null ? round($balance - $amount, 2) : null, 'reserve_standard' => $reserve, 'compliance' => WithdrawalManagementReport::complianceFor( [ 'status' => $balance === null ? 'not_ready' : 'matched', 'balance' => $balance, ], $reserve, $amount, ), ]; } } if ($total === 0) { throw ValidationException::withMessages(['import' => '导入文件中没有可识别的数据行。']); } if ($errors !== []) { $visible = array_slice($errors, 0, 10); if (count($errors) > count($visible)) { $visible[] = '另有 ' . (count($errors) - count($visible)) . ' 项错误'; } throw ValidationException::withMessages(['import' => implode(';', $visible) . '。']); } return [ 'total' => $total, 'changed' => count($plan), 'unchanged' => $unchanged, 'preview' => $preview, 'plan' => $plan, ]; } /** @param list}> $plan */ public function commit(array $plan, int $companyId): int { return DB::transaction(function () use ($plan, $companyId): int { $updated = 0; $currentBalanceDate = null; foreach ($plan as $item) { /** @var StoreReceivingAccount|null $account */ $account = StoreReceivingAccount::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') ->whereKey((int) $item['id']) ->lockForUpdate() ->first(); if ($account === null || (string) $account->getRawOriginal('updated_at') !== (string) $item['updated_at']) { throw ValidationException::withMessages([ 'import' => '预览后店铺配置发生变化,请重新上传并预览。', ]); } if (isset($item['changes']['withdrawal_balance_date']) && $item['changes']['withdrawal_balance_date'] !== null) { $currentBalanceDate ??= $this->currentBalanceDate(); if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) { throw ValidationException::withMessages([ 'import' => '余额日期已滚动,请重新上传并预览提现金额。', ]); } } foreach ($item['changes'] as $field => $value) { if ($field === 'bank_card_last_four') { $account->bank_card_last_four = $value; } else { $account->setAttribute($field, $value); } } $account->save(); $updated++; } return $updated; }); } private function workbook(array $report): Spreadsheet { $balanceHeaders = array_map( static fn (array $day): string => (string) $day['label'], $report['balanceDays'], ); $headers = [ ...self::CONFIG_HEADERS, ...$balanceHeaders, '余额来源', '余额预警', '预留标准', '提现金额', '是否达标', ]; $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('提现管理'); $this->writeHeader($sheet, $headers); $this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 32, 18, 18, 18, 14]); $rowNumber = 2; foreach ($report['rows'] as $row) { $reserve = $report['reserveStandards'][(int) $row->id] ?? null; $withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null; $compliance = $report['compliance'][(int) $row->id] ?? null; $alert = $report['alerts'][(int) $row->id] ?? null; $hasCompleteAlertSeries = collect($report['balanceDays'])->every( fn (array $day): bool => ( $report['balancesByDate'][(int) $row->id][(string) $day['date']]['status'] ?? '' ) === 'matched', ); $textValues = [ 1 => (string) ($row->store_platform ?? ''), 2 => (string) ($row->erp_store_name ?? ''), 3 => (string) ($row->store_id ?? ''), 4 => (string) ($row->store_entity_name ?? ''), 5 => (string) ($row->withdrawal_path ?? ''), 6 => (string) ($row->withdrawal_account_name ?? ''), 7 => (string) ($row->bank_card_last_four ?? ''), ]; foreach ($textValues as $column => $value) { $sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING); } foreach ($report['balanceDays'] as $dayIndex => $day) { $column = 8 + $dayIndex; $match = $report['balancesByDate'][(int) $row->id][$day['date']] ?? ['status' => 'not_ready', 'balance' => null]; if ($match['status'] === 'matched') { $sheet->setCellValue([$column, $rowNumber], (float) $match['balance']); } else { $sheet->setCellValueExplicit([$column, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING); } } $sourceParts = []; foreach ($report['balanceDays'] as $day) { $match = $report['balancesByDate'][(int) $row->id][$day['date']] ?? ['status' => 'not_ready']; $sourceParts[] = str_replace('店铺余额', '', (string) $day['label']) . ':' . match ($match['status']) { 'matched' => ($match['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭', 'ambiguous' => '多条匹配', 'unmatched' => '未匹配', default => '待同步', }; } $sheet->setCellValueExplicit([11, $rowNumber], implode(';', $sourceParts), DataType::TYPE_STRING); $sheet->setCellValueExplicit( [12, $rowNumber], $alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'), DataType::TYPE_STRING, ); if ($reserve === null) { $sheet->setCellValueExplicit([13, $rowNumber], '待配置', DataType::TYPE_STRING); } else { $sheet->setCellValue([13, $rowNumber], $reserve); } if ($withdrawalAmount === null) { $sheet->setCellValueExplicit([14, $rowNumber], '待接入', DataType::TYPE_STRING); } else { $sheet->setCellValue([14, $rowNumber], $withdrawalAmount); } $sheet->setCellValueExplicit([15, $rowNumber], match ($compliance) { 'yes' => '是', 'no' => '否', default => '待核验', }, DataType::TYPE_STRING); $rowNumber++; } if ($rowNumber > 2) { $sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00'); $sheet->getStyle('M2:N' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00'); $sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); $sheet->getStyle('M2:N' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); } $sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@'); $sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@'); $spreadsheet->getProperties()->setTitle('提现管理')->setDescription('余额数据日期:' . $report['balanceDate']); return $spreadsheet; } /** @return array{0:int,1:list,2:'config'|'withdrawal'} */ private function headers(array $matrix): array { foreach (array_slice($matrix, 0, 10, true) as $index => $row) { $headers = array_map(static fn (mixed $value): string => trim((string) $value), $row); if (! in_array('店铺平台', $headers, true) || ! in_array('提现金额', $headers, true)) { continue; } $this->requireUniqueHeader($headers, '店铺平台'); $this->requireUniqueHeader($headers, '提现金额'); $fullConfigurationTemplate = collect(self::CONFIG_HEADERS)->every( static fn (string $header): bool => in_array($header, $headers, true), ); if ($fullConfigurationTemplate) { foreach (self::CONFIG_HEADERS as $required) { $this->requireUniqueHeader($headers, $required); } return [(int) $index, $headers, 'config']; } if (! in_array('ERP店铺名称', $headers, true) && ! in_array('平台店铺ID', $headers, true)) { throw ValidationException::withMessages([ 'import' => '提现金额模板必须包含“ERP店铺名称”或“平台店铺ID”用于定位店铺,请下载最新模板。', ]); } foreach ([ 'ERP店铺名称', '平台店铺ID', '余额日期', '店铺余额', '预留标准', '提现后余额', '是否达标', ] as $optional) { if (count(array_keys($headers, $optional, true)) > 1) { throw ValidationException::withMessages([ 'import' => "导入表头只能包含一个“{$optional}”列。", ]); } } return [(int) $index, $headers, 'withdrawal']; } throw ValidationException::withMessages([ 'import' => '未找到提现管理导入表头,请下载最新模板。', ]); } /** @param list $headers */ private function requireUniqueHeader(array $headers, string $header): void { if (count(array_keys($headers, $header, true)) !== 1) { throw ValidationException::withMessages([ 'import' => "导入表头必须且只能包含一个“{$header}”列,请下载最新模板。", ]); } } /** @return array{0:array>,1:array>} */ private function indexes($accounts): array { $byId = []; $byName = []; foreach ($accounts as $account) { $platform = trim((string) $account->store_platform); $storeId = trim((string) ($account->store_id ?: $account->wallet_number)); $erpStoreName = trim((string) $account->erp_store_name); if ($platform !== '' && $storeId !== '') { $byId[$this->key($platform, $storeId)][] = $account; } if ($platform !== '' && $erpStoreName !== '') { $byName[$this->key($platform, $erpStoreName)][] = $account; } } return [$byId, $byName]; } /** @param array $values @return array */ private function changes(StoreReceivingAccount $account, array $values, string $balanceDate): array { $fields = [ '店铺主体' => ['field' => 'store_entity_name', 'current' => $account->store_entity_name, 'max' => 191, 'clear' => ''], '提现路径' => ['field' => 'receiving_method', 'current' => $account->withdrawal_path, 'max' => 191, 'clear' => ''], '提现账户名称' => ['field' => 'receiving_account_name', 'current' => $account->withdrawal_account_name, 'max' => 191, 'clear' => null], '银行卡号(后四位)' => ['field' => 'bank_card_last_four', 'current' => $account->bank_card_last_four, 'max' => 4, 'clear' => null], ]; $changes = []; foreach ($fields as $header => $definition) { $raw = trim((string) ($values[$header] ?? '')); if ($raw === '') { continue; } $value = $raw === self::CLEAR_VALUE ? $definition['clear'] : $raw; if ($header === '银行卡号(后四位)' && $value !== null) { if (preg_match('/^\d{4}$/', $value) !== 1) { throw new \InvalidArgumentException('的银行卡号必须填写后四位数字'); } } if ($value !== null && mb_strlen($value) > $definition['max']) { throw new \InvalidArgumentException("的{$header}超过 {$definition['max']} 个字符"); } $current = trim((string) ($definition['current'] ?? '')) ?: null; $comparisonValue = $value === '' ? null : $value; if ($current !== $comparisonValue) { $changes[$definition['field']] = $value; } } $amount = $values['提现金额'] ?? ''; if ($amount !== '' && $amount !== '待接入') { if ($amount === self::CLEAR_VALUE) { if ($account->withdrawal_amount !== null || $account->withdrawal_balance_date !== null) { $changes['withdrawal_amount'] = null; $changes['withdrawal_balance_date'] = null; } } else { if (preg_match('/^\d{1,10}(?:\.\d{1,2})?$/', $amount) !== 1) { throw new \InvalidArgumentException('的提现金额须为非负数,最多两位小数'); } $normalized = number_format((float) $amount, 2, '.', ''); if ($account->withdrawal_amount !== $normalized || $account->withdrawal_balance_date !== $balanceDate) { $changes['withdrawal_amount'] = $normalized; $changes['withdrawal_balance_date'] = $balanceDate; } } } return $changes; } private function currentBalanceDate(): string { $today = now()->startOfDay(); return $this->shopBalances->latestBalanceDate($today) ?? $today->copy()->subDay()->toDateString(); } private function key(string $platform, string $identity): string { return trim($platform) . "\0" . trim($identity); } private function balanceStatus(string $status): string { return match ($status) { 'ambiguous' => '多条匹配', 'unmatched' => '未匹配', default => '待同步', }; } private function writeHeader($sheet, array $headers): void { $lastColumn = Coordinate::stringFromColumnIndex(count($headers)); $sheet->fromArray($headers, null, 'A1'); $sheet->freezePane('A2'); $sheet->setAutoFilter("A1:{$lastColumn}1"); $sheet->getRowDimension(1)->setRowHeight(26); $sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); $sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632'); $sheet->getStyle("A1:{$lastColumn}1")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER); } private function sizeColumns($sheet, array $widths): void { foreach ($widths as $index => $width) { $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); } } private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse { return response()->streamDownload(function () use ($spreadsheet): void { (new Xlsx($spreadsheet))->save('php://output'); $spreadsheet->disconnectWorksheets(); }, $filename, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control' => 'private, no-store', ]); } }