setHeader('Content-Type', 'text/csv; charset=UTF-8'); $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Pragma', 'no-cache'); $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); // UTF-8 BOM (한글 엑셀 호환) $output = "\xEF\xBB\xBF"; // 헤더 행 $output .= csv_encode_row($headers); // 데이터 행 foreach ($rows as $row) { $output .= csv_encode_row(array_values((array) $row)); } $response->setBody($output); $response->send(); exit; } } if (! function_exists('csv_encode_row')) { /** * 배열 한 행을 CSV 문자열로 변환 * * @param array $fields * @return string */ function csv_encode_row(array $fields): string { $escaped = []; foreach ($fields as $field) { $val = (string) ($field ?? ''); // 쌍따옴표 이스케이프 및 감싸기 if (str_contains($val, '"') || str_contains($val, ',') || str_contains($val, "\n") || str_contains($val, "\r")) { $val = '"' . str_replace('"', '""', $val) . '"'; } $escaped[] = $val; } return implode(',', $escaped) . "\r\n"; } } if (! function_exists('export_excel_2003_xml')) { /** * Excel 2003 XML(SpreadsheetML)로 브라우저 다운로드 (.xls 확장자, 별도 라이브러리 불필요) * * @param string $filename 저장 파일명(확장자는 .xls로 정규화) * @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자) * @param string[] $headers 컬럼 헤더 * @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력) */ function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows): void { $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls'; $safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName); $safeSheet = function_exists('mb_substr') ? mb_substr($safeSheet, 0, 31, 'UTF-8') : substr($safeSheet, 0, 31); $esc = static function (mixed $v): string { return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }; $parts = []; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; foreach ($headers as $h) { $parts[] = '' . $esc($h) . ''; } $parts[] = ''; foreach ($rows as $row) { $parts[] = ''; foreach (array_values((array) $row) as $cell) { $parts[] = '' . $esc($cell) . ''; } $parts[] = ''; } $parts[] = '
'; $parts[] = '
'; $parts[] = '
'; $output = implode('', $parts); $response = service('response'); $response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8'); $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Pragma', 'no-cache'); $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); $response->setBody($output); $response->send(); exit; } } if (! function_exists('export_excel_2003_xml_workbook')) { /** * Excel 2003 XML — 다중 시트, 인쇄 미리보기와 유사한 헤더·줄바꿈·열 너비 * * @param string $filename 저장 파일명 * @param list, rows: list>, col_widths?: list}> $sheets */ function export_excel_2003_xml_workbook(string $filename, array $sheets): void { $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls'; $esc = static function (mixed $v): string { return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }; $safeSheetName = static function (string $name) use ($esc): string { $safe = str_replace(['/', '\\', '?', '*', '[', ']', ':'], '', $name); $safe = function_exists('mb_substr') ? mb_substr($safe, 0, 31, 'UTF-8') : substr($safe, 0, 31); return $esc($safe !== '' ? $safe : 'Sheet'); }; $parts = []; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; $parts[] = ''; foreach ($sheets as $sheet) { $sheetName = $safeSheetName((string) ($sheet['name'] ?? 'Sheet')); $headers = array_values((array) ($sheet['headers'] ?? [])); $rows = (array) ($sheet['rows'] ?? []); $colWidths = array_values((array) ($sheet['col_widths'] ?? [])); $parts[] = ''; $parts[] = ''; $parts[] = ''; $colCount = max(count($headers), 1); for ($i = 0; $i < $colCount; $i++) { $px = (int) ($colWidths[$i] ?? 72); $width = max(48, min(280, $px)); $excelW = round($width / 6.5, 1); $parts[] = ''; } $parts[] = ''; foreach ($headers as $h) { $parts[] = '' . $esc($h) . ''; } $parts[] = ''; foreach ($rows as $row) { $parts[] = ''; foreach (array_values((array) $row) as $cell) { $parts[] = '' . $esc($cell) . ''; } $parts[] = ''; } $parts[] = '
'; $parts[] = '
'; } $parts[] = '
'; $output = implode('', $parts); $response = service('response'); $response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8'); $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Pragma', 'no-cache'); $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); $response->setBody($output); $response->send(); exit; } } if (! function_exists('bag_flow_report_build_spreadsheet')) { /** * 기간별 봉투 수불 엑셀 통합문서 생성 (PhpSpreadsheet — 열 너비·병합 안정) * * @param list> $reportRows * @param list $metaLines */ function bag_flow_report_build_spreadsheet( string $lgName, string $title, array $metaLines, array $reportRows ): \PhpOffice\PhpSpreadsheet\Spreadsheet { $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $spreadsheet->getDefaultStyle()->getFont()->setName('맑은 고딕')->setSize(10); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('수불현황'); $bodyFontSize = 10; $lastCol = 'N'; $colWidths = [ 'A' => 22.0, 'B' => 26.0, 'C' => 12.0, 'D' => 11.0, 'E' => 11.0, 'F' => 11.0, 'G' => 12.0, 'H' => 11.0, 'I' => 12.0, 'J' => 12.0, 'K' => 12.0, 'L' => 11.0, 'M' => 12.0, 'N' => 12.0, ]; foreach ($colWidths as $col => $width) { $sheet->getColumnDimension($col)->setWidth($width); $sheet->getColumnDimension($col)->setAutoSize(false); } $r = 1; if ($lgName !== '') { $sheet->setCellValue("A{$r}", $lgName); $sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('666666'); $r++; } $sheet->setCellValue("A{$r}", $title); $sheet->mergeCells("A{$r}:{$lastCol}{$r}"); $sheet->getStyle("A{$r}")->getFont()->setBold(true)->setSize($bodyFontSize); $r++; foreach ($metaLines as $line) { $sheet->setCellValue("A{$r}", $line); $sheet->mergeCells("A{$r}:{$lastCol}{$r}"); $sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('555555'); $r++; } $r++; $h1 = $r; $h2 = $r + 1; $sheet->setCellValue("A{$h1}", '일자'); $sheet->mergeCells("A{$h1}:A{$h2}"); $sheet->setCellValue("B{$h1}", '품목'); $sheet->mergeCells("B{$h1}:B{$h2}"); $sheet->setCellValue("C{$h1}", '전일'); $sheet->mergeCells("C{$h1}:C{$h2}"); $sheet->setCellValue("D{$h1}", '입고'); $sheet->mergeCells("D{$h1}:G{$h1}"); $sheet->setCellValue("H{$h1}", '출고'); $sheet->mergeCells("H{$h1}:M{$h1}"); $sheet->setCellValue("N{$h1}", '잔량'); $sheet->mergeCells("N{$h1}:N{$h2}"); $subHeaders = ['입고', '반품', '기타', '입계', '판매', '일반', '무료', '반품', '기타', '출계']; foreach ($subHeaders as $i => $label) { $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(4 + $i); $sheet->setCellValue("{$col}{$h2}", $label); } $headerStyle = [ 'font' => ['bold' => true, 'size' => $bodyFontSize], 'alignment' => [ 'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER, 'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER, 'wrapText' => false, ], 'fill' => [ 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'startColor' => ['rgb' => 'E9ECEF'], ], 'borders' => [ 'bottom' => ['borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN], ], ]; $sheet->getStyle("A{$h1}:{$lastCol}{$h2}")->applyFromArray($headerStyle); $dataRow = $h2 + 1; foreach ($reportRows as $row) { $rowType = (string) ($row['row_type'] ?? 'data'); if (! in_array($rowType, ['data', 'subtotal', 'grand'], true)) { continue; } $sheet->fromArray([ (string) ($row['date'] ?? ''), (string) ($row['item_name'] ?? ''), (int) ($row['prev_stock'] ?? 0), (int) ($row['recv_in'] ?? 0), (int) ($row['recv_return'] ?? 0), (int) ($row['recv_misc'] ?? 0), (int) ($row['recv_total'] ?? 0), (int) ($row['out_sale'] ?? 0), (int) ($row['out_issue_gen'] ?? 0), (int) ($row['out_issue_free'] ?? 0), (int) ($row['out_return'] ?? 0), (int) ($row['out_misc'] ?? 0), (int) ($row['out_total'] ?? 0), (int) ($row['balance'] ?? 0), ], null, "A{$dataRow}", true); $sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}") ->getNumberFormat() ->setFormatCode('#,##0'); $sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}") ->getAlignment() ->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_RIGHT); $sheet->getStyle("A{$dataRow}:B{$dataRow}") ->getAlignment() ->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT) ->setWrapText(false); $sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}") ->getFont() ->setSize($bodyFontSize); if (in_array($rowType, ['subtotal', 'grand'], true)) { $sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}")->applyFromArray([ 'font' => ['bold' => true, 'size' => $bodyFontSize], 'fill' => [ 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FFF8E1'], ], ]); } $dataRow++; } if ($dataRow > $h2 + 1) { $sheet->getStyle('A' . ($h2 + 1) . ':' . $lastCol . ($dataRow - 1)) ->getBorders() ->getAllBorders() ->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_HAIR); } $sheet->getPageSetup()->setOrientation(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::ORIENTATION_PORTRAIT); $sheet->getPageSetup()->setPaperSize(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::PAPERSIZE_A4); $sheet->getPageSetup()->setFitToWidth(1); $sheet->getPageSetup()->setFitToHeight(0); return $spreadsheet; } } if (! function_exists('export_bag_flow_report_excel')) { /** * 기간별 봉투 수불 (/bag/flow) — 인쇄와 동일한 헤더·2단 표 (xlsx, PhpSpreadsheet) * * @param list> $reportRows * @param list $metaLines */ function export_bag_flow_report_excel( string $filename, string $lgName, string $title, array $metaLines, array $reportRows ): void { $baseName = preg_replace('/\.[^.]+$/u', '', $filename); $baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow'; $baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow'; $filename = $baseName . '.xlsx'; $spreadsheet = bag_flow_report_build_spreadsheet($lgName, $title, $metaLines, $reportRows); $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); ob_start(); try { $writer->save('php://output'); } catch (\Throwable $e) { ob_end_clean(); $spreadsheet->disconnectWorksheets(); unset($spreadsheet); throw $e; } $spreadsheet->disconnectWorksheets(); unset($spreadsheet); $output = ob_get_clean(); if ($output === false) { $output = ''; } $response = service('response'); $response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $asciiName = preg_replace('/[^\x20-\x7E]+/', '_', $filename) ?? 'bag_flow.xlsx'; $response->setHeader( 'Content-Disposition', 'attachment; filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename) ); $response->setHeader('Pragma', 'no-cache'); $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); $response->setBody($output); $response->send(); exit; } } if (! function_exists('export_xlsx')) { /** * Office Open XML(.xlsx) 브라우저 다운로드 (PhpSpreadsheet) * * @param string $filename 저장 파일명(확장자는 .xlsx로 정규화) * @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자) * @param string[] $headers 컬럼 헤더 * @param array $rows 데이터 행(각 행은 배열) */ function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void { $filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx'; $safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName); $safeSheet = function_exists('mb_substr') ? mb_substr($safeSheet, 0, 31, 'UTF-8') : substr($safeSheet, 0, 31); if ($safeSheet === '') { $safeSheet = 'Sheet1'; } $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle($safeSheet); $data = [array_map(static fn ($v): string => (string) ($v ?? ''), array_values($headers))]; foreach ($rows as $row) { $data[] = array_map(static fn ($v): string => (string) ($v ?? ''), array_values((array) $row)); } $sheet->fromArray($data, null, 'A1', true); $headerCount = max(1, count($headers)); $rowCount = max(1, count($data)); $lastCol = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($headerCount); $fullRange = 'A1:' . $lastCol . $rowCount; // 헤더/데이터 모두 좌측 정렬(요구사항) $sheet->getStyle($fullRange)->getAlignment()->setHorizontal( \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT ); // 가독성을 위해 기본 열 너비를 넓게 지정 for ($i = 1; $i <= $headerCount; $i++) { $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i); $sheet->getColumnDimension($col)->setWidth(22); } $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); ob_start(); try { $writer->save('php://output'); } catch (\Throwable $e) { ob_end_clean(); $spreadsheet->disconnectWorksheets(); unset($spreadsheet); throw $e; } $spreadsheet->disconnectWorksheets(); unset($spreadsheet); $output = ob_get_clean(); if ($output === false) { $output = ''; } $response = service('response'); $response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); $response->setHeader('Pragma', 'no-cache'); $response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); $response->setBody($output); $response->send(); exit; } }