From 65582c343916eb63c38d3e4fa2a58a1d1fc3fd44 Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Mon, 13 Jul 2026 12:26:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=9E=EC=B6=A4=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=ED=91=9C=20=E2=80=94=20=EA=B8=B0=EA=B0=84=C2=B7=ED=92=88?= =?UTF-8?q?=EB=AA=A9=EA=B5=AC=EB=B6=84=C2=B7=EC=BB=AC=EB=9F=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20+=20=EC=A0=9C=EB=AA=A9=C2=B7=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EB=B3=84=20=ED=94=84=EB=A6=AC=EC=85=8B=20=EC=A0=80=EC=9E=A5/?= =?UTF-8?q?=EC=9D=B8=EC=87=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 화면에서 기간·품목구분(봉투/음식물/폐기물)·집계방식·표시컬럼 선택, 제목 지정 후 조회/인쇄 - 컬럼 13종 선택, 상세/품목별 집계 지원, 실판매(bag_sale) 기준 - 계정별(mb_idx) 프리셋 저장/불러오기/삭제 (report_preset 테이블) - report_preset 테이블 없으면 프리셋만 비활성, 리포트는 정상 동작 Co-Authored-By: Claude Opus 4.8 --- app/Config/Routes.php | 3 + app/Controllers/Admin/SalesReport.php | 268 ++++++++++++++++++ app/Models/ReportPresetModel.php | 21 ++ .../admin/sales_report/custom_report.php | 188 ++++++++++++ writable/database/menu_add_custom_report.sql | 21 ++ writable/database/report_preset_table.sql | 18 ++ 6 files changed, 519 insertions(+) create mode 100644 app/Models/ReportPresetModel.php create mode 100644 app/Views/admin/sales_report/custom_report.php create mode 100644 writable/database/menu_add_custom_report.sql create mode 100644 writable/database/report_preset_table.sql diff --git a/app/Config/Routes.php b/app/Config/Routes.php index e06d0f5..3acf5fb 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -211,6 +211,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void $routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1'); $routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1'); + $routes->get('reports/custom', 'Admin\SalesReport::customReport'); + $routes->post('reports/custom/preset/save', 'Admin\SalesReport::customReportPresetSave'); + $routes->post('reports/custom/preset/delete', 'Admin\SalesReport::customReportPresetDelete'); $routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger'); $routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary'); $routes->get('reports/period-sales', 'Admin\SalesReport::periodSales'); diff --git a/app/Controllers/Admin/SalesReport.php b/app/Controllers/Admin/SalesReport.php index 18e53ff..3292f0f 100644 --- a/app/Controllers/Admin/SalesReport.php +++ b/app/Controllers/Admin/SalesReport.php @@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel; use App\Models\DesignatedShopModel; use App\Models\LocalGovernmentModel; use App\Models\SalesAgencyModel; +use App\Models\ReportPresetModel; class SalesReport extends BaseController { + /** 맞춤 집계표에서 선택 가능한 컬럼 카탈로그 (표시 순서 = 정의 순서) */ + private const CUSTOM_REPORT_COLUMNS = [ + 'date' => ['label' => '일자', 'type' => 'str'], + 'shop_no' => ['label' => '지정번호', 'type' => 'str'], + 'shop_name' => ['label' => '판매소명', 'type' => 'str'], + 'rep_name' => ['label' => '대표자', 'type' => 'str'], + 'address' => ['label' => '소재지', 'type' => 'str'], + 'zone' => ['label' => '구역', 'type' => 'str'], + 'category' => ['label' => '품목구분', 'type' => 'str'], + 'product' => ['label' => '품목', 'type' => 'str'], + 'size' => ['label' => '용량', 'type' => 'str'], + 'qty' => ['label' => '판매량', 'type' => 'num'], + 'amount' => ['label' => '판매금액', 'type' => 'num'], + 'fee' => ['label' => '수수료', 'type' => 'num'], + 'total' => ['label' => '총액', 'type' => 'num'], + ]; + + private const CUSTOM_REPORT_DEFAULT_COLUMNS = ['date', 'shop_no', 'shop_name', 'product', 'qty', 'amount']; + + /** 품목명 → 3분류(봉투/음식물/폐기물) */ + private function customReportCategory3(string $name): string + { + if (mb_strpos($name, '음식물') !== false) { + return 'food'; + } + if (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) { + return 'waste'; + } + return 'envelope'; + } + + private function customReportCategoryLabel(string $cat): string + { + return ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'][$cat] ?? '전체'; + } + + /** 품목명에서 용량 토큰 추출(예: 10L / 1000원) */ + private function customReportSize(string $name): string + { + if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) { + return $m[1] . 'L'; + } + if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) { + return str_replace(',', '', $m[1]) . '원'; + } + return ''; + } /** * P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄) */ @@ -3378,4 +3426,224 @@ class SalesReport extends BaseController 'salesLabel' => $scopeLabel($salesScope), ]); } + + /** + * 맞춤 집계표 — 기간·품목구분·컬럼을 골라 제목을 붙여 조회/인쇄, 계정별 프리셋 저장. + */ + public function customReport() + { + helper(['admin', 'export']); + $lgIdx = admin_effective_lg_idx(); + if (! $lgIdx) { + return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); + } + $mbIdx = (int) session()->get('mb_idx'); + + // report_preset 테이블이 있을 때만 프리셋 기능 활성(없으면 조회·인쇄는 그대로 동작) + $presetsEnabled = \Config\Database::connect()->tableExists('report_preset'); + $presetModel = model(ReportPresetModel::class); + $presets = []; + if ($presetsEnabled) { + $presets = $presetModel->where('rp_lg_idx', $lgIdx)->where('rp_mb_idx', $mbIdx) + ->orderBy('rp_name', 'ASC')->findAll(); + } + + // 활성 설정 결정: 폼 적용(applied) > 프리셋 로드(preset) > 기본값 + $presetIdx = (int) ($this->request->getGet('preset') ?? 0); + $loaded = null; + if ($presetsEnabled && $presetIdx > 0) { + $loaded = $presetModel->find($presetIdx); + if ($loaded === null || (int) $loaded->rp_lg_idx !== (int) $lgIdx || (int) $loaded->rp_mb_idx !== $mbIdx) { + $loaded = null; + } + } + + $catalogKeys = array_keys(self::CUSTOM_REPORT_COLUMNS); + if ((string) ($this->request->getGet('applied') ?? '') === '1') { + $cols = (array) ($this->request->getGet('cols') ?? []); + $title = (string) ($this->request->getGet('title') ?? ''); + $category = (string) ($this->request->getGet('category') ?? 'all'); + $mode = (string) ($this->request->getGet('mode') ?? 'detail'); + } elseif ($loaded !== null) { + $cols = json_decode((string) ($loaded->rp_columns ?? '[]'), true) ?: []; + $title = (string) $loaded->rp_title; + $category = (string) $loaded->rp_category; + $mode = (string) $loaded->rp_mode; + } else { + $cols = self::CUSTOM_REPORT_DEFAULT_COLUMNS; + $title = ''; + $category = 'all'; + $mode = 'detail'; + } + // 정규화 + $selected = array_values(array_filter($catalogKeys, static fn ($k) => in_array($k, $cols, true))); + if ($selected === []) { + $selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS; + } + if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) { + $category = 'all'; + } + $mode = $mode === 'summary' ? 'summary' : 'detail'; + + $isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1; + $startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); + $endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); + if (! $isYmd($startDate)) { + $startDate = date('Y-m-01'); + } + if (! $isYmd($endDate)) { + $endDate = date('Y-m-d'); + } + if ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; + } + + $db = \Config\Database::connect(); + $hasBsFee = $db->fieldExists('bs_fee', 'bag_sale'); + $detail = $this->fetchSalesLedgerDetailRows($db, (int) $lgIdx, $startDate, $endDate, 0, 0, $hasBsFee); + + // 카테고리 필터 + 행 매핑 + $rows = []; + foreach ($detail as $r) { + $name = (string) ($r->bs_bag_name ?? ''); + $cat3 = $this->customReportCategory3($name); + if ($category !== 'all' && $cat3 !== $category) { + continue; + } + $qty = (int) ($r->line_qty ?? 0); + $amount = (float) ($r->line_amount ?? 0); + $fee = (float) ($r->line_fee ?? 0); + $rows[] = [ + 'date' => substr((string) ($r->bs_sale_date ?? ''), 0, 10), + 'shop_no' => (string) ($r->ds_shop_no ?? ''), + 'shop_name' => (string) ($r->ds_name ?? ''), + 'rep_name' => (string) ($r->ds_rep_name ?? ''), + 'address' => (string) ($r->ds_addr ?? ''), + 'zone' => (string) ($r->ds_zone_code ?? ''), + 'category' => $this->customReportCategoryLabel($cat3), + 'product' => $name, + 'size' => $this->customReportSize($name), + 'qty' => $qty, + 'amount' => (int) round($amount), + 'fee' => (int) round($fee), + 'total' => (int) round($amount + $fee), + ]; + } + + // 집계 모드: 품목(구분+품명+용량) 기준 합산 + if ($mode === 'summary') { + $agg = []; + foreach ($rows as $row) { + $key = $row['category'] . '|' . $row['product'] . '|' . $row['size']; + if (! isset($agg[$key])) { + $agg[$key] = ['category' => $row['category'], 'product' => $row['product'], 'size' => $row['size'], + 'date' => '', 'shop_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'zone' => '', + 'qty' => 0, 'amount' => 0, 'fee' => 0, 'total' => 0]; + } + $agg[$key]['qty'] += $row['qty']; + $agg[$key]['amount'] += $row['amount']; + $agg[$key]['fee'] += $row['fee']; + $agg[$key]['total'] += $row['total']; + } + $rows = array_values($agg); + } + + // 숫자 컬럼 합계 + $totals = []; + foreach ($selected as $key) { + if ((self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num') { + $totals[$key] = array_sum(array_map(static fn ($r) => (int) $r[$key], $rows)); + } + } + + return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [ + 'columnCatalog' => self::CUSTOM_REPORT_COLUMNS, + 'selected' => $selected, + 'title' => $title, + 'category' => $category, + 'mode' => $mode, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'rows' => $rows, + 'totals' => $totals, + 'presets' => $presets, + 'activePresetIdx' => $loaded !== null ? (int) $loaded->rp_idx : 0, + 'presetsEnabled' => $presetsEnabled, + ]); + } + + /** 맞춤 집계표 프리셋 저장(신규/수정) — 계정 소유 */ + public function customReportPresetSave() + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + if (! $lgIdx) { + return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); + } + $mbIdx = (int) session()->get('mb_idx'); + + if (! \Config\Database::connect()->tableExists('report_preset')) { + return redirect()->to(site_url('bag/reports/custom')) + ->with('error', '프리셋 저장 테이블이 아직 생성되지 않았습니다. (report_preset_table.sql 실행 필요)'); + } + $name = trim((string) $this->request->getPost('rp_name')); + if ($name === '') { + return redirect()->to(site_url('bag/reports/custom'))->with('error', '프리셋 이름을 입력해 주세요.'); + } + $cols = (array) ($this->request->getPost('cols') ?? []); + $catalog = array_keys(self::CUSTOM_REPORT_COLUMNS); + $selected = array_values(array_filter($catalog, static fn ($k) => in_array($k, $cols, true))); + if ($selected === []) { + $selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS; + } + $category = (string) $this->request->getPost('rp_category'); + if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) { + $category = 'all'; + } + $mode = (string) $this->request->getPost('rp_mode') === 'summary' ? 'summary' : 'detail'; + + $data = [ + 'rp_lg_idx' => $lgIdx, + 'rp_mb_idx' => $mbIdx, + 'rp_name' => mb_substr($name, 0, 100), + 'rp_title' => mb_substr((string) $this->request->getPost('rp_title'), 0, 200), + 'rp_category' => $category, + 'rp_mode' => $mode, + 'rp_columns' => json_encode($selected, JSON_UNESCAPED_UNICODE), + ]; + + $presetModel = model(ReportPresetModel::class); + $rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0); + if ($rpIdx > 0) { + $existing = $presetModel->find($rpIdx); + if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) { + $presetModel->update($rpIdx, $data); + } + } else { + $presetModel->insert($data); + $rpIdx = (int) $presetModel->getInsertID(); + } + + $q = http_build_query(['preset' => $rpIdx, 'start_date' => $this->request->getPost('start_date'), 'end_date' => $this->request->getPost('end_date')]); + + return redirect()->to(site_url('bag/reports/custom') . '?' . $q)->with('success', '프리셋을 저장했습니다.'); + } + + /** 맞춤 집계표 프리셋 삭제 — 계정 소유 */ + public function customReportPresetDelete() + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + $mbIdx = (int) session()->get('mb_idx'); + $rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0); + $presetModel = model(ReportPresetModel::class); + $existing = $rpIdx > 0 ? $presetModel->find($rpIdx) : null; + if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) { + $presetModel->delete($rpIdx); + + return redirect()->to(site_url('bag/reports/custom'))->with('success', '프리셋을 삭제했습니다.'); + } + + return redirect()->to(site_url('bag/reports/custom'))->with('error', '삭제할 프리셋을 찾을 수 없습니다.'); + } } diff --git a/app/Models/ReportPresetModel.php b/app/Models/ReportPresetModel.php new file mode 100644 index 0000000..521a5c2 --- /dev/null +++ b/app/Models/ReportPresetModel.php @@ -0,0 +1,21 @@ + $columnCatalog + * @var list $selected + * @var string $title + * @var string $category + * @var string $mode + * @var string $startDate + * @var string $endDate + * @var list> $rows + * @var array $totals + * @var list $presets + * @var int $activePresetIdx + */ +$catLabels = ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물']; +$printTitle = $title !== '' ? $title : '맞춤 집계표'; +$nf = static fn ($n): string => number_format((int) $n); +?> + $printTitle, + 'printDate' => date('Y-m-d'), + 'printExtraLines' => ['조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 품목구분: ' . ($catLabels[$category] ?? '전체') . ' · (단위: 매 / 원)'], +]) ?> + +
+ 맞춤 집계표 + +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+ $meta): ?> + + +
+

집계 방식이 「품목별 집계」이면 일자·판매소 등 건별 컬럼은 빈 값으로 표시됩니다.

+
+
+
+ + + +
+
+
+
+ + +
+ + +
+ + 0): ?> +
+ + + +
+ + + +
+ + + + + + + +
+
+ + +
+ +
+
+
+ + + +
+
+

+

조회기간 ~ · 품목구분 · 건수

+
+
+ + + + + + + + + + + + + + $row): ?> + + + + + + + + + + + + + + + + + +
No
조회된 데이터가 없습니다.
합계
+
+
+ + diff --git a/writable/database/menu_add_custom_report.sql b/writable/database/menu_add_custom_report.sql new file mode 100644 index 0000000..7886e40 --- /dev/null +++ b/writable/database/menu_add_custom_report.sql @@ -0,0 +1,21 @@ +-- 판매 현황 카테고리에 '맞춤 집계표' 소메뉴 추가 (사이트 메뉴) +-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_add_custom_report.sql +-- 멱등: 이미 있으면 건너뜀. + +SET @mt_site := (SELECT mt_idx FROM menu_type WHERE mt_code = 'site' LIMIT 1); +SET @parent_sales := ( + SELECT mm_idx FROM menu + WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = 0 AND mm_name = '판매 현황' + LIMIT 1 +); +SET @next_num := ( + SELECT IFNULL(MAX(mm_num), -1) + 1 FROM menu WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = @parent_sales +); + +INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_cnode, mm_level, mm_is_view) +SELECT @mt_site, 1, '맞춤 집계표', 'bag/reports/custom', @parent_sales, 1, @next_num, 0, '', 'Y' +WHERE @parent_sales IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM menu m + WHERE m.mt_idx = @mt_site AND m.lg_idx = 1 AND m.mm_pidx = @parent_sales AND m.mm_name = '맞춤 집계표' + ); diff --git a/writable/database/report_preset_table.sql b/writable/database/report_preset_table.sql new file mode 100644 index 0000000..d41ca13 --- /dev/null +++ b/writable/database/report_preset_table.sql @@ -0,0 +1,18 @@ +-- 맞춤 집계표: 계정별 리포트 프리셋(제목·품목구분·컬럼구성) 저장 +-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/report_preset_table.sql +-- 멱등(IF NOT EXISTS). + +CREATE TABLE IF NOT EXISTS `report_preset` ( + `rp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `rp_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체', + `rp_mb_idx` INT UNSIGNED NOT NULL COMMENT '소유 계정(mb_idx) — 계정별 저장', + `rp_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '프리셋 이름', + `rp_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '리포트(표) 제목', + `rp_category` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT 'all|envelope|food|waste', + `rp_mode` VARCHAR(10) NOT NULL DEFAULT 'detail' COMMENT 'detail|summary', + `rp_columns` TEXT NULL COMMENT '선택 컬럼 키 JSON 배열', + `rp_regdate` DATETIME NULL, + `rp_updated` DATETIME NULL, + PRIMARY KEY (`rp_idx`), + KEY `idx_rp_owner` (`rp_lg_idx`, `rp_mb_idx`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;