feat: 맞춤 집계표 — 기간·품목구분·컬럼 선택 + 제목·계정별 프리셋 저장/인쇄

- 한 화면에서 기간·품목구분(봉투/음식물/폐기물)·집계방식·표시컬럼 선택, 제목 지정 후 조회/인쇄
- 컬럼 13종 선택, 상세/품목별 집계 지원, 실판매(bag_sale) 기준
- 계정별(mb_idx) 프리셋 저장/불러오기/삭제 (report_preset 테이블)
- report_preset 테이블 없으면 프리셋만 비활성, 리포트는 정상 동작

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-07-13 12:26:44 +09:00
parent 03c2bbc05c
commit 65582c3439
6 changed files with 519 additions and 0 deletions

View File

@@ -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', '삭제할 프리셋을 찾을 수 없습니다.');
}
}