사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 DB·메뉴·E2E를 운영 반영한다.

통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
taekyoungc
2026-06-01 16:15:15 +09:00
parent 21e7b91871
commit 0f1d414f37
129 changed files with 18068 additions and 1585 deletions

View File

@@ -264,13 +264,12 @@ if (! function_exists('normalize_menu_link_for_url')) {
}
}
if (! function_exists('mgmt_url')) {
if (! function_exists('mgmt_path')) {
/**
* 업무 화면 링크: 정식 URL 은 /bag/* (adminAuth). 포장 단위 CRUD 는 packaging-units/manage 로 치환.
* 업무 화면 상대 경로 (페이저 setPath 등). 선행 슬래시 없음.
*/
function mgmt_url(string $path): string
function mgmt_path(string $path): string
{
helper('url');
$path = trim($path, '/');
// bag/packaging-units 는 조회 전용(Bag) — CRUD 는 /bag/packaging-units/manage/* 로 분리
if ($path === 'packaging-units') {
@@ -279,7 +278,35 @@ if (! function_exists('mgmt_url')) {
$path = 'packaging-units/manage/' . substr($path, strlen('packaging-units/'));
}
return site_url('bag/' . $path);
return 'bag/' . $path;
}
}
if (! function_exists('mgmt_url')) {
/**
* 업무 화면 링크: 정식 URL 은 /bag/* (adminAuth). 포장 단위 CRUD 는 packaging-units/manage 로 치환.
*/
function mgmt_url(string $path): string
{
helper('url');
return site_url(mgmt_path($path));
}
}
if (! function_exists('apply_pager_path')) {
/**
* CI4 페이저: setPath 는 상대 경로만 허용(전체 URL 시 baseURL 이중 결합).
* 검색 조건은 only() 로 유지합니다.
*
* @param \CodeIgniter\Pager\Pager $pager
*/
function apply_pager_path($pager, string $path, array $queryForPager = []): void
{
$pager->setPath($path);
if ($queryForPager !== []) {
$pager->only(array_keys($queryForPager));
}
}
}
@@ -367,6 +394,10 @@ if (! function_exists('menu_link_candidate_paths')) {
$cands[] = 'admin/packaging-units' . ($m[1] ?? '');
} elseif (preg_match('#^admin/packaging-units(/.*)?$#', $p, $m)) {
$cands[] = 'bag/packaging-units/manage' . ($m[1] ?? '');
} elseif ($p === 'bag/inventory/inspection-select') {
// 실사 선별 조회 메뉴는 작업 화면(inspection-work)도 동일 메뉴로 활성 처리
$cands[] = 'bag/inventory/inspection-work';
$cands[] = 'bag/inventory/inspection';
} elseif (str_starts_with($p, 'admin/')) {
$cands[] = 'bag/' . substr($p, strlen('admin/'));
} elseif (str_starts_with($p, 'bag/')) {

View File

@@ -67,3 +67,441 @@ if (! function_exists('csv_encode_row')) {
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[] = '<?xml version="1.0" encoding="UTF-8"?>';
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
$parts[] = '<Table>';
$parts[] = '<Row>';
foreach ($headers as $h) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
}
$parts[] = '</Row>';
foreach ($rows as $row) {
$parts[] = '<Row>';
foreach (array_values((array) $row) as $cell) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '</Table>';
$parts[] = '</Worksheet>';
$parts[] = '</Workbook>';
$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<array{name: string, headers: list<string>, rows: list<list<string>>, col_widths?: list<int>}> $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[] = '<?xml version="1.0" encoding="UTF-8"?>';
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
$parts[] = '<Styles>';
$parts[] = '<Style ss:ID="Default"><Alignment ss:Vertical="Top" ss:WrapText="1" ss:Horizontal="Left"/><Font ss:FontName="맑은 고딕" x:CharSet="129" ss:Size="9"/></Style>';
$parts[] = '<Style ss:ID="Header"><Font ss:Bold="1" ss:Size="9" ss:FontName="맑은 고딕" x:CharSet="129"/><Interior ss:Color="#F3F4F6" ss:Pattern="Solid"/><Alignment ss:Horizontal="Left" ss:Vertical="Center" ss:WrapText="1"/><Borders><Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1"/></Borders></Style>';
$parts[] = '</Styles>';
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[] = '<Worksheet ss:Name="' . $sheetName . '">';
$parts[] = '<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><PageSetup><Layout x:Orientation="Landscape"/></PageSetup></WorksheetOptions>';
$parts[] = '<Table>';
$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[] = '<Column ss:Index="' . ($i + 1) . '" ss:AutoFitWidth="0" ss:Width="' . $excelW . '"/>';
}
$parts[] = '<Row ss:StyleID="Header">';
foreach ($headers as $h) {
$parts[] = '<Cell ss:StyleID="Header"><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
}
$parts[] = '</Row>';
foreach ($rows as $row) {
$parts[] = '<Row>';
foreach (array_values((array) $row) as $cell) {
$parts[] = '<Cell ss:StyleID="Default"><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '</Table>';
$parts[] = '</Worksheet>';
}
$parts[] = '</Workbook>';
$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<array<string, mixed>> $reportRows
* @param list<string> $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<array<string, mixed>> $reportRows
* @param list<string> $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;
}
}