shopModel = model(DesignatedShopModel::class); $this->lgModel = model(LocalGovernmentModel::class); $this->roles = config('Roles'); } private function isSuperAdmin(): bool { return Roles::isSuperAdminEquivalent((int) session()->get('mb_level')); } private function isLocalAdmin(): bool { return (int) session()->get('mb_level') === Roles::LEVEL_LOCAL_ADMIN; } /** * DB 행에서 컬럼이 없을 수 있는 환경(구 스키마)까지 고려한 문자열 읽기. */ private function designatedShopScalar(object $row, string $field): string { return isset($row->{$field}) ? (string) $row->{$field} : ''; } /** * DATE 컬럼을 상세 JSON/화면용 문자열로. */ private function designatedShopDateOut(object $row, string $field): string { if (! isset($row->{$field})) { return ''; } $da = $row->{$field}; if ($da === null || $da === '' || $da === '0000-00-00') { return ''; } return (string) $da; } /** * 가상계좌: 은행·계좌번호 분리 입력 + 구 단일 필드(ds_va_number) 하위 호환. * * @return array{ds_va_bank: string, ds_va_account: string, ds_va_number: string} */ private function resolveVirtualAccountFromRequest(): array { $bank = trim((string) $this->request->getPost('ds_va_bank')); $account = trim((string) $this->request->getPost('ds_va_account')); $legacy = trim((string) $this->request->getPost('ds_va_number')); if ($account === '' && $legacy !== '') { $account = $legacy; } $number = $account !== '' ? $account : $legacy; return [ 'ds_va_bank' => $bank, 'ds_va_account' => $account, 'ds_va_number' => $number, ]; } private function normalizeOptionalDate(?string $raw): ?string { $s = trim((string) ($raw ?? '')); if ($s === '' || $s === '0000-00-00') { return null; } $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $s); return ($dt !== false && $dt->format('Y-m-d') === $s) ? $s : null; } /** * 주소 문자열 비교용(공백 제거). */ private function compactAddressText(string $s): string { return preg_replace('/\s+/u', '', trim($s)) ?? ''; } /** * 시·도 또는 구·군 명칭이 지자체 마스터와 맞는지 본다. * - 카카오 `sido`/`sigungu` 또는 도로명·지번 전체에 `lg_sido`·`lg_gugun`이 포함되면 허용. */ private function koreanRegionTokenMatches(string $lgNeedle, string $primaryToken, string $fullBlob): bool { $needle = $this->compactAddressText($lgNeedle); if ($needle === '') { return true; } $blob = $this->compactAddressText($fullBlob); if ($blob !== '' && mb_stripos($blob, $needle, 0, 'UTF-8') !== false) { return true; } $primary = $this->compactAddressText($primaryToken); if ($primary !== '') { if (mb_stripos($primary, $needle, 0, 'UTF-8') !== false) { return true; } if (mb_stripos($needle, $primary, 0, 'UTF-8') !== false) { return true; } } return false; } /** * 우편·도로명·지번 중 하나라도 있으면, 효과 지자체(`lg_sido`, `lg_gugun`) 관할인지 검사한다. */ private function isDesignatedShopAddressWithinLocalGovernment( object $lg, string $addrSido, string $addrSigungu, string $road, string $jibun, string $zip ): bool { $road = trim($road); $jibun = trim($jibun); $zip = trim($zip); if ($road === '' && $jibun === '' && $zip === '') { return true; } $lgSido = trim((string) ($lg->lg_sido ?? '')); $lgGugun = trim((string) ($lg->lg_gugun ?? '')); $blob = trim($addrSido . ' ' . $addrSigungu . ' ' . $road . ' ' . $jibun . ' ' . $zip); if ($lgSido !== '' && ! $this->koreanRegionTokenMatches($lgSido, $addrSido, $blob)) { return false; } if ($lgGugun !== '' && ! $this->koreanRegionTokenMatches($lgGugun, $addrSigungu, $blob)) { return false; } return true; } private function hasDesignatedShopPostalAddress(string $zip, string $road, string $jibun): bool { return trim($zip . $road . $jibun) !== ''; } /** * 우편·도로명·지번이 있으면 카카오 검색으로만 채웠는지(시도 hidden) 확인한다. */ private function designatedShopAddressFilledWithoutSearch(string $addrSido, string $zip, string $road, string $jibun): bool { return $this->hasDesignatedShopPostalAddress($zip, $road, $jibun) && trim($addrSido) === ''; } /** * 목록 검색과 동일한 조건을 모델 쿼리에 적용한다. */ private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): void { $model->where('ds_lg_idx', $lgIdx); if ($dsName !== null && $dsName !== '') { $model->like('ds_name', $dsName); } if ($dsGugunCode !== null && $dsGugunCode !== '') { $model->where('ds_gugun_code', $dsGugunCode); } if ($dsState !== null && $dsState !== '') { $model->where('ds_state', (int) $dsState); } $this->applyPiiSearchFilter($model, $dsRep, $dsTel); } /** * 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치, * 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백. * * @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q */ private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void { $dsRep = $dsRep !== null ? trim($dsRep) : ''; $dsTel = $dsTel !== null ? trim($dsTel) : ''; if ($dsRep === '' && $dsTel === '') { return; } helper('pii_encryption'); $db = \Config\Database::connect(); if ($dsRep !== '') { $bidx = pii_blind_index($dsRep); if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) { $q->where('ds_rep_name_bidx', $bidx); } else { $q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치) } } if ($dsTel !== '') { $bidx = pii_blind_index($dsTel); if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) { $q->where('ds_tel_bidx', $bidx); } else { $q->where('ds_tel', $dsTel); } } } /** * @return array{1: int, 2: int, 3: int, total: int} */ private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array { $db = \Config\Database::connect(); $builder = $db->table('designated_shop'); $builder->where('ds_lg_idx', $lgIdx); if ($dsName !== null && $dsName !== '') { $builder->like('ds_name', $dsName); } if ($dsGugunCode !== null && $dsGugunCode !== '') { $builder->where('ds_gugun_code', $dsGugunCode); } if ($dsState !== null && $dsState !== '') { $builder->where('ds_state', (int) $dsState); } $this->applyPiiSearchFilter($builder, $dsRep, $dsTel); $rows = $builder->select('ds_state, COUNT(*) AS cnt', false) ->groupBy('ds_state') ->get() ->getResultArray(); $counts = [1 => 0, 2 => 0, 3 => 0]; foreach ($rows as $r) { $st = (int) ($r['ds_state'] ?? 0); if (isset($counts[$st])) { $counts[$st] = (int) $r['cnt']; } } $counts['total'] = $counts[1] + $counts[2] + $counts[3]; return $counts; } /** * @param list $list * @param array $lgMap * @param array $dongMap ds_idx => [동코드, 동명] * @return list> */ private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array { helper(['admin', 'pii_encryption']); $lgIdx = admin_effective_lg_idx() ?? 0; $gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : []; $payload = []; foreach ($list as $row) { $sn = (string) ($row->ds_shop_no ?? ''); if (preg_match('/(\d{3})$/', $sn, $m)) { $shortNo = $m[1]; } elseif ($sn !== '' && strlen($sn) >= 3) { $shortNo = substr($sn, -3); } else { $shortNo = $sn; } $st = (int) ($row->ds_state ?? 1); $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지']; $da = $row->ds_designated_at ?? null; $daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : ''; $idxKey = (int) ($row->ds_idx ?? 0); $dongCode = $dongMap[$idxKey]['code'] ?? (string) ($row->ds_dong_code ?? ''); $dongName = $dongMap[$idxKey]['name'] ?? ''; $gugunName = $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? ''); // 구·군 표시값: 동코드가 있으면 "동코드 (동명)", 없으면 기존 구·군명 if ($dongCode !== '') { $dongDisplay = $dongCode . ($dongName !== '' ? ' (' . $dongName . ')' : ''); } else { $dongDisplay = $gugunName; } $p = [ 'ds_idx' => (int) $row->ds_idx, 'ds_shop_no' => $sn, 'shop_no_display' => $shortNo, 'ds_rep_name' => (string) ($row->ds_rep_name ?? ''), 'ds_name' => (string) ($row->ds_name ?? ''), 'ds_state' => $st, 'state_label' => $stateMap[$st] ?? '', 'ds_biz_no' => (string) ($row->ds_biz_no ?? ''), 'ds_biz_type' => $this->designatedShopScalar($row, 'ds_biz_type'), 'ds_biz_kind' => $this->designatedShopScalar($row, 'ds_biz_kind'), 'ds_va_number' => (string) ($row->ds_va_number ?? ''), 'ds_va_bank' => $this->designatedShopScalar($row, 'ds_va_bank'), 'ds_va_account' => $this->designatedShopScalar($row, 'ds_va_account'), 'ds_zip' => (string) ($row->ds_zip ?? ''), 'ds_addr' => (string) ($row->ds_addr ?? ''), 'ds_addr_jibun' => (string) ($row->ds_addr_jibun ?? ''), 'ds_addr_detail' => $this->designatedShopScalar($row, 'ds_addr_detail'), 'ds_tel' => (string) ($row->ds_tel ?? ''), 'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''), 'ds_email' => (string) ($row->ds_email ?? ''), 'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''), 'gugun_name' => $gugunName, 'ds_dong_code' => $dongCode, 'dong_name' => $dongName, 'dong_display' => $dongDisplay, 'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'), 'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'), 'ds_designated_at' => $daOut, 'ds_state_changed_at' => $this->designatedShopDateOut($row, 'ds_state_changed_at'), 'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'), 'ds_regdate' => (string) ($row->ds_regdate ?? ''), 'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '', 'pii_masked' => false, ]; // 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹 if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) { foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) { $p[$f] = mask_shop_field($f, (string) $p[$f]); } $p['pii_masked'] = true; } $payload[] = $p; } return $payload; } /** * 목록·검색·상세 표시에 공통으로 쓰는 뷰 데이터 (지자체 미선택 시 null). * * @return array|null */ private function designatedShopIndexViewData(): ?array { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return null; } // 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스) $dsName = $this->request->getGet('ds_name'); $dsGugunCode = $this->request->getGet('ds_gugun_code'); $dsState = $this->request->getGet('ds_state'); $dsRep = $this->request->getGet('ds_rep'); $dsTel = $this->request->getGet('ds_tel'); $this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel); // 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시 $list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll(); $pager = null; // 지자체 이름 매핑용 $lgMap = []; foreach ($this->lgModel->findAll() as $lg) { $lgMap[$lg->lg_idx] = $lg->lg_name; } $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel); $gugunNameMap = $this->gugunCodeNameMap($lgIdx); // 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용 $dongMap = $this->resolveDongCodesForShops($lgIdx, $list); $this->persistDesignatedShopDongCodes($list, $dongMap); $detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap, $dongMap); // 리스트(서버 렌더)에서 동코드 표시용: ds_idx => "동코드 (동명)" $dongDisplayMap = []; foreach ($dongMap as $idx => $d) { $code = (string) ($d['code'] ?? ''); $name = (string) ($d['name'] ?? ''); if ($code !== '') { $dongDisplayMap[$idx] = $code . ($name !== '' ? ' (' . $name . ')' : ''); } } // 구군코드 목록 (검색 필터용) $db = \Config\Database::connect(); $gugunCodes = $db->query("SELECT DISTINCT ds_gugun_code FROM designated_shop WHERE ds_lg_idx = ? AND ds_gugun_code != '' ORDER BY ds_gugun_code", [$lgIdx])->getResult(); return [ 'list' => $list, 'lgMap' => $lgMap, 'pager' => $pager, 'dsName' => $dsName ?? '', 'dsGugunCode' => $dsGugunCode ?? '', 'dsState' => $dsState ?? '', 'dsRep' => $dsRep ?? '', 'dsTel' => $dsTel ?? '', 'gugunCodes' => $gugunCodes, 'stateCounts' => $stateCounts, 'gugunNameMap' => $gugunNameMap, 'dongDisplayMap' => $dongDisplayMap, 'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE), 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), ]; } /** * 지정판매소 목록 (효과 지자체 기준: super admin = 선택 지자체, 지자체관리자 = mb_lg_idx) */ public function index() { $data = $this->designatedShopIndexViewData(); if ($data === null) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); } $data['readOnly'] = false; return $this->renderWorkPage('지정판매소 관리', 'admin/designated_shop/index', $data); } /** * 지정판매소 조회 전용 (목록·검색·상세만, 등록·수정·삭제·엑셀 없음) */ public function browse() { $data = $this->designatedShopIndexViewData(); if ($data === null) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); } $data['readOnly'] = true; return $this->renderWorkPage('지정판매소 조회', 'admin/designated_shop/manage', $data); } /** * 지정판매소 바코드 출력 전용 목록 (선택 후 인쇄). */ public function barcode() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); } $currentLg = $this->lgModel->find($lgIdx); $fixedGugunCode = trim((string) ($currentLg->lg_code ?? '')); $fixedGugunLabel = trim((string) ($currentLg->lg_gugun ?? '')); if ($fixedGugunLabel === '') { $fixedGugunLabel = trim((string) ($currentLg->lg_name ?? '')); } $zone = trim((string) $this->request->getGet('ds_zone_code')); $order = trim((string) $this->request->getGet('order_by')); if (! in_array($order, ['shop_no', 'name'], true)) { $order = 'shop_no'; } $builder = $this->shopModel->where('ds_lg_idx', $lgIdx); if ($fixedGugunCode !== '') { $builder->where('ds_gugun_code', $fixedGugunCode); } if ($zone !== '') { $builder->where('ds_zone_code', $zone); } if ($order === 'name') { $builder->orderBy('ds_name', 'ASC'); } else { $builder->orderBy('ds_shop_no', 'ASC'); } $builder->orderBy('ds_idx', 'ASC'); $list = $builder->paginate(100); $db = \Config\Database::connect(); $zones = $db->query( "SELECT DISTINCT TRIM(ds_zone_code) AS zone_code FROM designated_shop WHERE ds_lg_idx = ? AND (? = '' OR ds_gugun_code = ?) AND TRIM(ds_zone_code) != '' ORDER BY zone_code", [$lgIdx, $fixedGugunCode, $fixedGugunCode] )->getResult(); return $this->renderWorkPage('지정판매소 바코드 출력', 'admin/designated_shop/barcode', [ 'list' => $list, 'pager' => $this->shopModel->pager, 'fixedGugunLabel' => $fixedGugunLabel, 'zoneFilter' => $zone, 'zones' => $zones, 'orderBy' => $order, ]); } /** * 지정판매소 바코드 인쇄 페이지 (선택된 판매소 기준). */ public function barcodePrint() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); } $ids = $this->request->getPost('ds_idx'); $ids = is_array($ids) ? array_values(array_unique(array_map('intval', $ids))) : []; $ids = array_values(array_filter($ids, static fn ($v): bool => $v > 0)); if ($ids === []) { return redirect()->to(mgmt_url('designated-shops/barcode')) ->with('error', '출력할 지정판매소를 선택해 주세요.'); } $rows = $this->shopModel ->where('ds_lg_idx', $lgIdx) ->whereIn('ds_idx', $ids) ->orderBy('ds_shop_no', 'ASC') ->findAll(); if ($rows === []) { return redirect()->to(mgmt_url('designated-shops/barcode')) ->with('error', '선택한 지정판매소를 찾을 수 없습니다.'); } $zoneLabel = trim((string) $this->request->getPost('zone_label')); if ($zoneLabel === '') { $firstZone = trim((string) ($rows[0]->ds_zone_code ?? '')); $zoneLabel = $firstZone !== '' ? $firstZone : '전체'; } return view('admin/designated_shop/barcode_print', [ 'rows' => $rows, 'zoneLabel' => $zoneLabel, 'printedAt' => date('Y.m.d'), 'totalCount' => count($rows), ]); } public function export() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if (!$lgIdx) { return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.'); } helper('pii_encryption'); $list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll(); // 동코드(구·군 동코드) 산출 $dongMap = $this->resolveDongCodesForShops($lgIdx, $list); $rows = []; foreach ($list as $row) { $stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지']; $idxKey = (int) ($row->ds_idx ?? 0); $dCode = (string) ($dongMap[$idxKey]['code'] ?? ''); $dName = (string) ($dongMap[$idxKey]['name'] ?? ''); $dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : ''; $da = $row->ds_designated_at ?? null; $daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : ''; // 엑셀에도 마스킹 적용(열람 권한 없으면) $canView = can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)); $repName = (string) ($row->ds_rep_name ?? ''); $telVal = (string) ($row->ds_tel ?? ''); $vaAccount = $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : (string) ($row->ds_va_number ?? ''); if (! $canView) { $repName = mask_shop_field('ds_rep_name', $repName); $telVal = mask_shop_field('ds_tel', $telVal); $vaAccount = mask_shop_field('ds_va_account', $vaAccount); } $rows[] = [ $row->ds_idx, $row->ds_shop_no, $row->ds_name, $repName, $dongDisp, $daDisp, $row->ds_biz_no, $this->designatedShopScalar($row, 'ds_biz_type'), $this->designatedShopScalar($row, 'ds_biz_kind'), $this->designatedShopScalar($row, 'ds_va_bank'), $vaAccount, $telVal, $row->ds_addr ?? '', $this->designatedShopScalar($row, 'ds_zone_code'), $this->designatedShopScalar($row, 'ds_branch_no'), $this->designatedShopDateOut($row, 'ds_state_changed_at'), $this->designatedShopScalar($row, 'ds_change_reason'), $stateMap[(int) $row->ds_state] ?? '', $row->ds_regdate ?? '', ]; } export_csv( '지정판매소_' . date('Ymd') . '.csv', [ '번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종', '가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호', '변경일자', '변경사유', '상태', '등록일', ], $rows ); } /** * 지정판매소 등록 폼 (효과 지자체 기준) */ public function create() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); } $currentLg = $this->lgModel->find($lgIdx); if ($currentLg === null) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '선택한 지자체 정보를 찾을 수 없습니다.'); } return $this->renderWorkPage('지정판매소 등록', 'admin/designated_shop/create', [ 'localGovs' => [], 'currentLg' => $currentLg, 'addrTenantScope' => [ 'lg_sido' => (string) ($currentLg->lg_sido ?? ''), 'lg_gugun' => (string) ($currentLg->lg_gugun ?? ''), ], 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), ]); } /** * 지정판매소 등록 처리 */ public function store() { if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '지정판매소 등록은 관리자만 가능합니다.'); } $rules = [ 'ds_name' => 'required|max_length[100]', 'ds_biz_no' => 'required|max_length[20]', 'ds_rep_name' => 'required|max_length[50]', 'ds_biz_type' => 'permit_empty|max_length[100]', 'ds_biz_kind' => 'permit_empty|max_length[100]', 'ds_va_number' => 'permit_empty|max_length[50]', 'ds_va_bank' => 'permit_empty|max_length[80]', 'ds_va_account' => 'permit_empty|max_length[50]', 'ds_email' => 'permit_empty|valid_email|max_length[100]', 'ds_zone_code' => 'permit_empty|max_length[80]', 'ds_branch_no' => 'permit_empty|max_length[50]', 'ds_change_reason' => 'permit_empty|max_length[500]', 'ds_state_changed_at' => 'permit_empty|max_length[10]', 'addr_search_sido' => 'permit_empty|max_length[80]', 'addr_search_sigungu' => 'permit_empty|max_length[80]', 'ds_addr_detail' => 'permit_empty|max_length[200]', ]; if (! $this->validate($rules)) { return redirect()->back() ->withInput() ->with('errors', $this->validator->getErrors()); } helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->back() ->withInput() ->with('error', '소속 지자체가 올바르지 않습니다.'); } $lg = $this->lgModel->find($lgIdx); if ($lg === null || (string) $lg->lg_code === '') { return redirect()->back() ->withInput() ->with('error', '지자체 코드 정보를 찾을 수 없습니다.'); } $addrSido = (string) $this->request->getPost('addr_search_sido'); $addrSigungu = (string) $this->request->getPost('addr_search_sigungu'); $dsAddr = (string) $this->request->getPost('ds_addr'); $dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun'); $dsZip = (string) $this->request->getPost('ds_zip'); if ($this->designatedShopAddressFilledWithoutSearch($addrSido, $dsZip, $dsAddr, $dsAddrJibun)) { return redirect()->back() ->withInput() ->with('error', '주소는 「주소 검색」으로만 지정할 수 있습니다.'); } if (! $this->isDesignatedShopAddressWithinLocalGovernment($lg, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip)) { return redirect()->back() ->withInput() ->with('error', '작업 중인 지자체(' . (string) $lg->lg_name . ') 관할 주소만 등록할 수 있습니다. 주소 검색으로 선택하거나 시·구에 맞게 입력해 주세요.'); } $resolvedNo = $this->resolveDesignatedShopNumberFromAddress( $lgIdx, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip, $lg ); if (! $resolvedNo['ok']) { return redirect()->back() ->withInput() ->with('error', $resolvedNo['error']); } $va = $this->resolveVirtualAccountFromRequest(); $data = [ 'ds_lg_idx' => $lgIdx, 'ds_mb_idx' => null, 'ds_shop_no' => $resolvedNo['shop_no'], 'ds_name' => (string) $this->request->getPost('ds_name'), 'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'), 'ds_rep_name' => (string) $this->request->getPost('ds_rep_name'), 'ds_biz_type' => (string) $this->request->getPost('ds_biz_type'), 'ds_biz_kind' => (string) $this->request->getPost('ds_biz_kind'), 'ds_va_bank' => $va['ds_va_bank'], 'ds_va_account' => $va['ds_va_account'], 'ds_va_number' => $va['ds_va_number'], 'ds_zip' => (string) $this->request->getPost('ds_zip'), 'ds_addr' => (string) $this->request->getPost('ds_addr'), 'ds_addr_jibun' => (string) $this->request->getPost('ds_addr_jibun'), 'ds_addr_detail' => (string) $this->request->getPost('ds_addr_detail'), 'ds_tel' => (string) $this->request->getPost('ds_tel'), 'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'), 'ds_email' => (string) $this->request->getPost('ds_email'), 'ds_gugun_code' => $resolvedNo['gugun_code'], 'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'), 'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'), 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, 'ds_state' => 1, // 동코드(컬럼이 있을 때만 저장) ...($this->designatedShopHasDongColumn() ? ['ds_dong_code' => (string) ($resolvedNo['dong_code'] ?? '')] : []), 'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')), 'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'), 'ds_regdate' => date('Y-m-d H:i:s'), ]; $this->shopModel->insert($data); return redirect()->to(mgmt_url('designated-shops')) ->with('success', '지정판매소가 등록되었습니다.'); } /** * 지정판매소 수정 폼 (효과 지자체 소속만 허용) * 문서: docs/기본 개발계획/23-지정판매소_수정_삭제_기능.md */ public function edit(int $id) { if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '권한이 없습니다.'); } helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $shop = $this->shopModel->find($id); if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.'); } $currentLg = $this->lgModel->find($lgIdx); return $this->renderWorkPage('지정판매소 수정', 'admin/designated_shop/edit', [ 'shop' => $shop, 'currentLg' => $currentLg, 'addrTenantScope' => $currentLg !== null ? [ 'lg_sido' => (string) ($currentLg->lg_sido ?? ''), 'lg_gugun' => (string) ($currentLg->lg_gugun ?? ''), ] : ['lg_sido' => '', 'lg_gugun' => ''], 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), ]); } /** * 지정판매소 수정 처리 */ public function update(int $id) { if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '권한이 없습니다.'); } helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $shop = $this->shopModel->find($id); if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.'); } $rules = [ 'ds_name' => 'required|max_length[100]', 'ds_biz_no' => 'required|max_length[20]', 'ds_rep_name' => 'required|max_length[50]', 'ds_biz_type' => 'permit_empty|max_length[100]', 'ds_biz_kind' => 'permit_empty|max_length[100]', 'ds_va_number' => 'permit_empty|max_length[50]', 'ds_va_bank' => 'permit_empty|max_length[80]', 'ds_va_account' => 'permit_empty|max_length[50]', 'ds_email' => 'permit_empty|valid_email|max_length[100]', 'ds_state' => 'permit_empty|in_list[1,2,3]', 'ds_zone_code' => 'permit_empty|max_length[80]', 'ds_branch_no' => 'permit_empty|max_length[50]', 'ds_change_reason' => 'permit_empty|max_length[500]', 'ds_state_changed_at' => 'permit_empty|max_length[10]', 'addr_search_sido' => 'permit_empty|max_length[80]', 'addr_search_sigungu' => 'permit_empty|max_length[80]', 'ds_addr_detail' => 'permit_empty|max_length[200]', ]; if (! $this->validate($rules)) { return redirect()->back() ->withInput() ->with('errors', $this->validator->getErrors()); } $lg = $this->lgModel->find($lgIdx); if ($lg === null) { return redirect()->back() ->withInput() ->with('error', '지자체 정보를 찾을 수 없습니다.'); } $addrSido = (string) $this->request->getPost('addr_search_sido'); $addrSigungu = (string) $this->request->getPost('addr_search_sigungu'); $dsAddr = (string) $this->request->getPost('ds_addr'); $dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun'); $dsZip = (string) $this->request->getPost('ds_zip'); if ($this->designatedShopAddressFilledWithoutSearch($addrSido, $dsZip, $dsAddr, $dsAddrJibun)) { return redirect()->back() ->withInput() ->with('error', '주소는 「주소 검색」으로만 지정할 수 있습니다.'); } if (! $this->isDesignatedShopAddressWithinLocalGovernment($lg, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip)) { return redirect()->back() ->withInput() ->with('error', '작업 중인 지자체(' . (string) $lg->lg_name . ') 관할 주소만 입력할 수 있습니다. 주소 검색으로 선택하거나 시·구에 맞게 입력해 주세요.'); } $va = $this->resolveVirtualAccountFromRequest(); // 주소로 동코드 재산출(컬럼이 있을 때만 저장). 매칭 실패 시 동코드는 갱신하지 않음. $dongCodeData = []; if ($this->designatedShopHasDongColumn()) { $resolvedDong = $this->resolveDesignatedShopNumberFromAddress( $lgIdx, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip, $lg ); if (($resolvedDong['ok'] ?? false) && ($resolvedDong['dong_code'] ?? '') !== '') { $dongCodeData = ['ds_dong_code' => (string) $resolvedDong['dong_code']]; } } $data = [ 'ds_name' => (string) $this->request->getPost('ds_name'), 'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'), 'ds_rep_name' => (string) $this->request->getPost('ds_rep_name'), 'ds_biz_type' => (string) $this->request->getPost('ds_biz_type'), 'ds_biz_kind' => (string) $this->request->getPost('ds_biz_kind'), 'ds_va_bank' => $va['ds_va_bank'], 'ds_va_account' => $va['ds_va_account'], 'ds_va_number' => $va['ds_va_number'], 'ds_zip' => (string) $this->request->getPost('ds_zip'), 'ds_addr' => (string) $this->request->getPost('ds_addr'), 'ds_addr_jibun' => (string) $this->request->getPost('ds_addr_jibun'), 'ds_addr_detail' => (string) $this->request->getPost('ds_addr_detail'), 'ds_tel' => (string) $this->request->getPost('ds_tel'), 'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'), 'ds_email' => (string) $this->request->getPost('ds_email'), ...$dongCodeData, 'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'), 'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'), 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, 'ds_state' => (int) ($this->request->getPost('ds_state') ?: 1), 'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')), 'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'), ]; $this->shopModel->update($id, $data); return redirect()->to(mgmt_url('designated-shops')) ->with('success', '지정판매소 정보가 수정되었습니다.'); } /** * 지정판매소 삭제 (물리 삭제, 효과 지자체 소속만 허용) * 문서: docs/기본 개발계획/23-지정판매소_수정_삭제_기능.md */ public function delete(int $id) { if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '권한이 없습니다.'); } helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $shop = $this->shopModel->find($id); if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) { return redirect()->to(mgmt_url('designated-shops')) ->with('error', '해당 지정판매소를 찾을 수 없거나 삭제할 수 없습니다.'); } $this->shopModel->delete($id); return redirect()->to(mgmt_url('designated-shops')) ->with('success', '지정판매소가 삭제되었습니다.'); } /** * P2-17: 지정판매소 지도 표시 */ public function map() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $shops = $this->shopModel ->where('ds_lg_idx', $lgIdx) ->where('ds_state', 1) ->findAll(); return $this->renderWorkPage('지정판매소 지도', 'admin/designated_shop/map', [ 'shops' => $shops, 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), ]); } /** * 구·군 코드 → 표시명 (코드종류 C, 플랫폼+지자체 범위). * * @return array */ private function gugunCodeNameMap(int $lgIdx): array { $ckIdx = $this->codeKindIdxByCkCode('C'); if ($ckIdx === null) { return []; } $rows = model(CodeDetailModel::class)->getByKind($ckIdx, true, $lgIdx); $map = []; foreach ($rows as $r) { $map[(string) $r->cd_code] = (string) $r->cd_name; } return $map; } /** * 효과 지자체 기준 구·군 마스터 행 (기본코드 C → 없으면 지정판매소에 실제 입력된 코드). * * @return list */ private function gugunMasterRowsForLg(int $lgIdx): array { $map = $this->gugunCodeNameMap($lgIdx); $rows = []; foreach ($map as $code => $name) { $code = trim((string) $code); if ($code === '') { continue; } $rows[] = ['code' => $code, 'name' => trim((string) $name)]; } usort($rows, static fn (array $a, array $b): int => strcmp($a['code'], $b['code'])); if ($rows !== []) { return $rows; } $db = \Config\Database::connect(); $q = $db->query( "SELECT DISTINCT TRIM(ds_gugun_code) AS c FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_gugun_code) != '' ORDER BY c", [$lgIdx] )->getResult(); foreach ($q as $o) { $c = trim((string) ($o->c ?? '')); if ($c === '') { continue; } $rows[] = ['code' => $c, 'name' => $c]; } return $rows; } /** * GBMS형: 마스터 구·군 순서로 행을 채우고, 집계는 designatedShopDistrictStatusRows(구군 전체)와 병합. * * @return array{rows: list, total: object} */ private function buildGbmsNewCancelRows(int $lgIdx, int $year): array { $byCode = []; foreach ($this->designatedShopDistrictStatusRows($lgIdx, $year, '', 'gugun') as $r) { $byCode[(string) $r->gugun_code] = $r; } $master = $this->gugunMasterRowsForLg($lgIdx); $out = []; $seen = []; foreach ($master as $m) { $c = $m['code']; $seen[$c] = true; $st = $byCode[$c] ?? null; $out[] = $this->makeGbmsDistrictRow($m['name'], $c, $st); } foreach ($byCode as $c => $st) { if ($c === '' || isset($seen[$c])) { continue; } $out[] = $this->makeGbmsDistrictRow((string) $st->region_label, $c, $st); $seen[$c] = true; } if (isset($byCode[''])) { $st = $byCode['']; $out[] = $this->makeGbmsDistrictRow('(구·군 미입력)', '', $st); } if ($out === [] && $byCode !== []) { foreach ($byCode as $c => $st) { $out[] = $this->makeGbmsDistrictRow((string) $st->region_label, $c, $st); } } $sumP = $sumD = $sumC = $sumCur = 0; foreach ($out as $o) { $sumP += (int) $o->prev_end; $sumD += (int) $o->designated_y; $sumC += (int) $o->cancelled_y; $sumCur += (int) $o->curr_end; } return [ 'rows' => $out, 'total' => (object) [ 'region_label' => '계', 'prev_end' => $sumP, 'designated_y' => $sumD, 'cancelled_y' => $sumC, 'curr_end' => $sumCur, ], ]; } /** * @param object|null $st designatedShopDistrictStatusRows 한 행 또는 null */ private function makeGbmsDistrictRow(string $displayLabel, string $gugunCode, ?object $st): object { return (object) [ 'region_label' => $displayLabel, 'gugun_code' => $gugunCode, 'prev_end' => $st !== null ? (int) $st->prev_end : 0, 'designated_y' => $st !== null ? (int) $st->designated_y : 0, 'cancelled_y' => $st !== null ? (int) $st->cancelled_y : 0, 'curr_end' => $st !== null ? (int) $st->curr_end : 0, ]; } /** * 연도·구군·집계 단위별 신규/취소 현황 (종전=전년말, 지정·취소=당해, 현행=금년말). * * @return list */ private function designatedShopDistrictStatusRows( int $lgIdx, int $year, string $filterGugunCode, string $granularity ): array { $year = max(1990, min(2100, $year)); $prevEnd = sprintf('%04d-12-31', $year - 1); $currEnd = sprintf('%04d-12-31', $year); $granularity = $granularity === 'dong' ? 'dong' : 'gugun'; $db = \Config\Database::connect(); $gugunMap = $this->gugunCodeNameMap($lgIdx); $filterGugunCode = trim($filterGugunCode); $bind = [ $prevEnd, $prevEnd, $year, $year, $currEnd, $currEnd, $lgIdx, $filterGugunCode, $filterGugunCode, ]; if ($granularity === 'dong') { $sql = " SELECT IFNULL(NULLIF(TRIM(ds_gugun_code), ''), '') AS gugun_key, IFNULL(NULLIF(TRIM(ds_zone_code), ''), '') AS zone_key, SUM( CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) THEN 1 ELSE 0 END ) AS prev_end, SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END) AS designated_y, SUM( CASE WHEN ds_state IN (2,3) AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END ) AS cancelled_y, SUM( CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) THEN 1 ELSE 0 END ) AS curr_end FROM designated_shop WHERE ds_lg_idx = ? AND (? = '' OR ds_gugun_code = ?) GROUP BY gugun_key, zone_key ORDER BY gugun_key, zone_key "; } else { $sql = " SELECT IFNULL(NULLIF(TRIM(ds_gugun_code), ''), '') AS gugun_key, SUM( CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) THEN 1 ELSE 0 END ) AS prev_end, SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END) AS designated_y, SUM( CASE WHEN ds_state IN (2,3) AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END ) AS cancelled_y, SUM( CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) THEN 1 ELSE 0 END ) AS curr_end FROM designated_shop WHERE ds_lg_idx = ? AND (? = '' OR ds_gugun_code = ?) GROUP BY gugun_key ORDER BY gugun_key "; } $raw = $db->query($sql, $bind)->getResult(); $out = []; foreach ($raw as $row) { $gk = (string) ($row->gugun_key ?? ''); $gn = $gk !== '' ? ($gugunMap[$gk] ?? $gk) : '(구·군 미입력)'; if ($granularity === 'dong') { $zk = (string) ($row->zone_key ?? ''); $zn = $zk !== '' ? $zk : '(구역 미입력)'; $label = $gn . ' / ' . $zn; $rkey = $gk . "\t" . $zk; } else { $label = $gn; $rkey = $gk; } $prev = (int) ($row->prev_end ?? 0); $curr = (int) ($row->curr_end ?? 0); $des = (int) ($row->designated_y ?? 0); $can = (int) ($row->cancelled_y ?? 0); $zk = $granularity === 'dong' ? (string) ($row->zone_key ?? '') : ''; $out[] = (object) [ 'region_key' => $rkey, 'region_label' => $label, 'gugun_code' => $gk, 'zone_code' => $zk, 'prev_end' => $prev, 'designated_y' => $des, 'cancelled_y' => $can, 'curr_end' => $curr, 'delta_curr_prev' => $curr - $prev, 'delta_des_cancel' => $des - $can, ]; } return $out; } /** * P2-18: 지정판매소 현황 (연도별 신규/취소 + 구·군·동 집계) */ public function status() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $yearRaw = $this->request->getGet('year'); $year = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y'); $currentLg = $this->lgModel->find($lgIdx); $fixedGugunCode = trim((string) ($currentLg->lg_code ?? '')); $fixedGugunLabel = trim((string) ($currentLg->lg_gugun ?? '')); if ($fixedGugunLabel === '') { $fixedGugunLabel = trim((string) ($currentLg->lg_name ?? '')); } $db = \Config\Database::connect(); // 연도별 신규등록 건수 (ds_designated_at 기준) $newByYear = $db->query(" SELECT YEAR(ds_designated_at) as yr, COUNT(*) as cnt FROM designated_shop WHERE ds_lg_idx = ? AND ds_designated_at IS NOT NULL GROUP BY YEAR(ds_designated_at) ORDER BY yr DESC ", [$lgIdx])->getResult(); // 연도별 취소/비활성 건수 (ds_state != 1, ds_regdate 기준) $cancelByYear = $db->query(" SELECT YEAR(ds_regdate) as yr, COUNT(*) as cnt FROM designated_shop WHERE ds_lg_idx = ? AND ds_state != 1 GROUP BY YEAR(ds_regdate) ORDER BY yr DESC ", [$lgIdx])->getResult(); // 전체 현황 합계 $totalActive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->countAllResults(false); $totalInactive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state !=', 1)->countAllResults(false); $districtRows = $this->designatedShopDistrictStatusRows($lgIdx, $year, $fixedGugunCode, 'gugun'); $zoneRowsRaw = $this->designatedShopDistrictStatusRows($lgIdx, $year, $fixedGugunCode, 'dong'); $zoneSummaryRows = []; foreach ($zoneRowsRaw as $zr) { $zoneLabel = trim((string) ($zr->zone_code ?? '')); if ($zoneLabel === '') { $zoneLabel = '(구역 미입력)'; } $zoneSummaryRows[] = (object) [ 'zone_label' => $zoneLabel, 'prev_end' => (int) ($zr->prev_end ?? 0), 'designated_y' => (int) ($zr->designated_y ?? 0), 'cancelled_y' => (int) ($zr->cancelled_y ?? 0), 'curr_end' => (int) ($zr->curr_end ?? 0), 'delta_curr_prev' => (int) ($zr->delta_curr_prev ?? 0), 'delta_des_cancel' => (int) ($zr->delta_des_cancel ?? 0), ]; } usort($zoneSummaryRows, static function ($a, $b): int { $cmp = $b->curr_end <=> $a->curr_end; if ($cmp !== 0) { return $cmp; } return strcmp((string) $a->zone_label, (string) $b->zone_label); }); $sumP = array_sum(array_map(static fn ($r) => $r->prev_end, $districtRows)); $sumD = array_sum(array_map(static fn ($r) => $r->designated_y, $districtRows)); $sumC = array_sum(array_map(static fn ($r) => $r->cancelled_y, $districtRows)); $sumCur = array_sum(array_map(static fn ($r) => $r->curr_end, $districtRows)); $districtTotal = (object) [ 'region_label' => '합계', 'gugun_code' => '', 'zone_code' => '', 'prev_end' => $sumP, 'designated_y' => $sumD, 'cancelled_y' => $sumC, 'curr_end' => $sumCur, 'delta_curr_prev' => $sumCur - $sumP, 'delta_des_cancel' => $sumD - $sumC, ]; $yearChoices = []; $yMax = (int) date('Y'); for ($y = $yMax; $y >= $yMax - 15; $y--) { $yearChoices[] = $y; } return $this->renderWorkPage('지정판매소 현황', 'admin/designated_shop/status', [ 'newByYear' => $newByYear, 'cancelByYear' => $cancelByYear, 'totalActive' => $totalActive, 'totalInactive' => $totalInactive, 'districtRows' => $districtRows, 'districtTotal' => $districtTotal, 'zoneSummaryRows'=> $zoneSummaryRows, 'reportYear' => $year, 'fixedGugunLabel'=> $fixedGugunLabel, 'yearChoices' => $yearChoices, ]); } /** * 구·군(·동) 신규/취소 현황 CSV (status 화면과 동일 조건) */ public function statusExport() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $yearRaw = $this->request->getGet('year'); $year = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y'); $currentLg = $this->lgModel->find($lgIdx); $fixedGugunCode = trim((string) ($currentLg->lg_code ?? '')); $rows = $this->designatedShopDistrictStatusRows($lgIdx, $year, $fixedGugunCode, 'gugun'); $sumP = $sumD = $sumC = $sumCur = 0; foreach ($rows as $r) { $sumP += $r->prev_end; $sumD += $r->designated_y; $sumC += $r->cancelled_y; $sumCur += $r->curr_end; } $labelCol = '군·구'; $csvRows = []; $n = 0; foreach ($rows as $r) { ++$n; $curr = (int) $r->curr_end; $prev = (int) $r->prev_end; $pctShare = $sumCur > 0 ? round(($curr / $sumCur) * 100, 1) : 0.0; $yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : ''; $csvRows[] = [ $n, $r->region_label, $r->gugun_code, $r->prev_end, $r->designated_y, $r->cancelled_y, $r->curr_end, $r->delta_curr_prev, $r->delta_des_cancel, $pctShare, $yoyPct === '' ? '' : $yoyPct, ]; } $totYoy = $sumP > 0 ? round((($sumCur - $sumP) / $sumP) * 100, 1) : ''; $csvRows[] = [ '', '합계', '', $sumP, $sumD, $sumC, $sumCur, $sumCur - $sumP, $sumD - $sumC, 100, $totYoy === '' ? '' : $totYoy, ]; export_csv( '지정판매소_신규취소현황_' . $year . '_' . date('Ymd') . '.csv', [ '순번', $labelCol, '구코드', '종전(전년도말)', '지정(' . $year . '년)', '취소(' . $year . '년)', '현행(금년도말)', '증감(현행−종전)', '지정−취소(' . $year . '년)', '현행비중(%)', '전년대비증감률(%)', ], $csvRows ); } /** * GBMS형 지정 판매소 신규/취소 현황 (구·군은 효과 지자체 마스터 고정, 연도별 조회). */ public function districtNewCancel() { helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $yearRaw = $this->request->getGet('year'); $year = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y'); $year = max(1990, min(2100, $year)); $currentLg = $this->lgModel->find($lgIdx); $bundle = $this->buildGbmsNewCancelRows($lgIdx, $year); $yearChoices = []; $yMax = (int) date('Y'); for ($y = $yMax; $y >= $yMax - 15; $y--) { $yearChoices[] = $y; } return $this->renderWorkPage('지정 판매소 신규/취소 현황', 'admin/designated_shop/district_new_cancel', [ 'currentLg' => $currentLg, 'reportYear' => $year, 'yearChoices' => $yearChoices, 'districtRows' => $bundle['rows'], 'districtTotal' => $bundle['total'], ]); } /** * GBMS형 신규/취소 현황 CSV (districtNewCancel 화면과 동일 조건). */ public function districtNewCancelExport() { helper(['admin', 'export']); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return redirect()->to(work_area_home_url()) ->with('error', '작업할 지자체가 선택되지 않았습니다.'); } $yearRaw = $this->request->getGet('year'); $year = ($yearRaw !== null && $yearRaw !== '') ? (int) $yearRaw : (int) date('Y'); $year = max(1990, min(2100, $year)); $bundle = $this->buildGbmsNewCancelRows($lgIdx, $year); $rows = $bundle['rows']; $tot = $bundle['total']; $csvRows = []; foreach ($rows as $r) { $csvRows[] = [ $r->region_label, $r->prev_end, $r->designated_y, $r->cancelled_y, $r->curr_end, ]; } $csvRows[] = [ $tot->region_label, $tot->prev_end, $tot->designated_y, $tot->cancelled_y, $tot->curr_end, ]; export_csv( '지정판매소_신규취소현황_' . $year . '_' . date('Ymd') . '.csv', [ '군·구', '종전(전년도말)', '지정(' . $year . '년)', '취소(' . $year . '년)', '현행(금년도말)', ], $csvRows ); } /** * 기본코드 종류(B·C·D)의 code_kind PK (미사용·미등록 시 null). */ private function codeKindIdxByCkCode(string $ckCode): ?int { $k = model(CodeKindModel::class) ->where('ck_code', $ckCode) ->where('ck_state', 1) ->first(); return $k !== null ? (int) $k->ck_idx : null; } /** * 주소 문자열에 행정구역 명칭이 포함되는지(공백 무시·부분 일치). */ private function addressHaystackContainsRegionName(string $haystack, string $name): bool { $name = trim($name); if ($name === '') { return false; } $h = $this->compactAddressText($haystack); $n = $this->compactAddressText($name); return $h !== '' && $n !== '' && mb_stripos($h, $n, 0, 'UTF-8') !== false; } /** * 동일 지자체(ds_lg_idx) 소속 판매소번호 중, 끝 3자리가 모두 숫자인 것만 모아 최댓값. * 목록은 끝 3자리만 표시하므로, 구·동 접두(B+C+D)와 무관하게 일련이 이어지게 한다. */ private function maxDesignatedShopThreeDigitSerialForLocalGovernment(int $lgIdx): int { $rows = $this->shopModel->select('ds_shop_no')->where('ds_lg_idx', $lgIdx)->findAll(); $max = 0; foreach ($rows as $row) { $no = trim((string) ($row->ds_shop_no ?? '')); if (strlen($no) < 3) { continue; } $tail = substr($no, -3); if ($tail === '' || ! ctype_digit($tail)) { continue; } $n = (int) $tail; if ($n > $max) { $max = $n; } } return $max; } /** * 판매소번호: 기본코드 B + C + D(각 cd_code에서 상위 코드 접두 제거 후 이어 붙임) + 3자리 일련. * 주소(카카오 시·구·도로명·지번)와 code_detail만 사용하며 kr_address 등 외부 참조 테이블은 사용하지 않음. * * @return array{ok:true, shop_no:string, gugun_code:string}|array{ok:false, error:string} */ private function resolveDesignatedShopNumberFromAddress( int $lgIdx, string $addrSido, string $addrSigungu, string $road, string $jibun, string $zip, object $lg ): array { $bCk = $this->codeKindIdxByCkCode('B'); $cCk = $this->codeKindIdxByCkCode('C'); $dCk = $this->codeKindIdxByCkCode('D'); if ($bCk === null || $cCk === null || $dCk === null) { return [ 'ok' => false, 'error' => '기본코드 종류(B·C·D)가 등록되어 있지 않습니다. 시스템 관리자에게 문의하세요.', ]; } $detailModel = model(CodeDetailModel::class); $bRows = $detailModel->getByKind($bCk, true, $lgIdx); $cRows = $detailModel->getByKind($cCk, true, $lgIdx); $dRows = $detailModel->getByKind($dCk, true, $lgIdx); $sido = trim($addrSido); $sig = trim($addrSigungu); $blob = trim($sido . ' ' . $sig . ' ' . $road . ' ' . $jibun . ' ' . $zip); if ($blob === '') { $blob = trim((string) ($lg->lg_sido ?? '') . ' ' . (string) ($lg->lg_gugun ?? '')); } $bCode = null; foreach ($bRows as $row) { $nm = trim((string) $row->cd_name); $cd = trim((string) $row->cd_code); if ($nm === '' || $cd === '') { continue; } if ($this->koreanRegionTokenMatches($nm, $sido, $blob)) { $bCode = $cd; break; } } if ($bCode === null || $bCode === '') { return [ 'ok' => false, 'error' => '주소에서 시·도 기본코드(B)를 찾을 수 없습니다. 기본코드(B)에 해당 광역단위를 등록했는지 확인해 주세요.', ]; } $cCode = null; foreach ($cRows as $row) { $cd = trim((string) $row->cd_code); if ($cd === '' || ! str_starts_with($cd, $bCode)) { continue; } $nm = trim((string) $row->cd_name); if ($nm === '') { continue; } if ($this->koreanRegionTokenMatches($nm, $sig, $blob)) { $cCode = $cd; break; } } if ($cCode === null || $cCode === '') { return [ 'ok' => false, 'error' => '주소에서 구·군 기본코드(C)를 찾을 수 없습니다. 기본코드(C)를 확인하거나 주소 검색 결과(시·군·구)를 확인해 주세요.', ]; } $dCandidates = []; foreach ($dRows as $row) { $cd = trim((string) $row->cd_code); if ($cd === '' || ! str_starts_with($cd, $cCode)) { continue; } $nm = trim((string) $row->cd_name); if ($nm === '') { continue; } $dCandidates[] = [ 'len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd, ]; } usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']); $dCode = null; $dName = ''; foreach ($dCandidates as $cand) { if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) { $dCode = $cand['cd']; $dName = $cand['nm']; break; } } if ($dCode === null || $dCode === '') { return [ 'ok' => false, 'error' => '주소에서 동 기본코드(D)를 찾을 수 없습니다. 지번·도로명에 법정동명이 포함되는지, 기본코드(D)에 해당 동이 등록되어 있는지 확인해 주세요.', ]; } $cRest = str_starts_with($cCode, $bCode) ? substr($cCode, strlen($bCode)) : $cCode; $dRest = str_starts_with($dCode, $cCode) ? substr($dCode, strlen($cCode)) : $dCode; $prefix = $bCode . $cRest . $dRest; // 목록 UI는 판매소번호 끝 3자리만 보여 주므로, 동일 지자체(ds_lg_idx) 안에서는 // 구·동 접두와 무관하게 일련(마지막 3자리)이 이어지도록 한다(구형 lg_code+일련과 호환). $maxSerial = $this->maxDesignatedShopThreeDigitSerialForLocalGovernment($lgIdx); return [ 'ok' => true, 'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1), 'gugun_code' => $cCode, 'dong_code' => $dCode, 'dong_name' => $dName, ]; } /** * [AJAX] 주소 선택 시, 해당 주소의 동 기본코드(D)와 동명을 반환. * 등록 폼에서 주소 검색 직후 「구·군(동코드)」 표시에 사용한다. */ public function resolveAddressCodes() { if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) { return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '권한이 없습니다.']); } helper('admin'); $lgIdx = admin_effective_lg_idx(); if ($lgIdx === null || $lgIdx <= 0) { return $this->response->setJSON(['ok' => false, 'error' => '작업할 지자체가 선택되지 않았습니다.']); } $lg = $this->lgModel->find($lgIdx); if ($lg === null) { return $this->response->setJSON(['ok' => false, 'error' => '지자체 정보를 찾을 수 없습니다.']); } $addrSido = (string) $this->request->getPost('addr_search_sido'); $addrSigungu = (string) $this->request->getPost('addr_search_sigungu'); $dsAddr = (string) $this->request->getPost('ds_addr'); $dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun'); $dsZip = (string) $this->request->getPost('ds_zip'); $resolved = $this->resolveDesignatedShopNumberFromAddress( $lgIdx, $addrSido, $addrSigungu, $dsAddr, $dsAddrJibun, $dsZip, $lg ); if (! $resolved['ok']) { return $this->response->setJSON(['ok' => false, 'error' => $resolved['error']]); } return $this->response->setJSON([ 'ok' => true, 'dong_code' => $resolved['dong_code'] ?? '', 'dong_name' => $resolved['dong_name'] ?? '', 'gugun_code' => $resolved['gugun_code'] ?? '', ]); } /** * [AJAX] 개인정보 원문 보기 — step-up 재인증(TOTP) + 열람사유 → 세션 열람 승인 + 감사기록. * 조건 미충족(IP밖·Super Admin 등) 사용자가 마스킹된 PII를 열람할 때 사용. */ public function revealPii() { helper(['admin', 'pii_encryption']); $lgIdx = admin_effective_lg_idx(); if (! $lgIdx) { return $this->response->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']); } $mbIdx = (int) session()->get('mb_idx'); $code = trim((string) $this->request->getPost('totp_code')); $reason = trim((string) $this->request->getPost('reason')); if ($code === '' || ! preg_match('/^\d{6}$/', $code)) { return $this->response->setJSON(['ok' => false, 'error' => '6자리 인증코드를 입력해 주세요.']); } if ($reason === '') { return $this->response->setJSON(['ok' => false, 'error' => '열람 사유를 입력해 주세요.']); } $member = model(\App\Models\MemberModel::class)->find($mbIdx); $secret = $member ? pii_decrypt((string) ($member->mb_totp_secret ?? '')) : ''; if ($secret === '' || (int) ($member->mb_totp_enabled ?? 0) !== 1) { return $this->response->setJSON(['ok' => false, 'error' => '2단계 인증(TOTP)이 등록돼 있지 않아 원문 열람을 승인할 수 없습니다.']); } $totp = new \App\Libraries\TotpService(); if (! $totp->verify($secret, $code)) { return $this->response->setJSON(['ok' => false, 'error' => '인증코드가 올바르지 않습니다.']); } // 이 세션에서 해당 지자체 PII 원문 열람 승인(10분) session()->set('pii_stepup_grant', ['lg_idx' => (int) $lgIdx, 'until' => time() + 600]); // 감사기록 — 누가/언제/사유 helper('audit'); audit_log('pii_view', 'designated_shop', 0, null, [ 'lg_idx' => (int) $lgIdx, 'reason' => mb_substr($reason, 0, 200), 'ip' => (string) $this->request->getIPAddress(), ]); return $this->response->setJSON(['ok' => true]); } /** * designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시). */ private function designatedShopHasDongColumn(): bool { if ($this->dongColumnExists === null) { $this->dongColumnExists = \Config\Database::connect()->fieldExists('ds_dong_code', 'designated_shop'); } return $this->dongColumnExists; } /** * 여러 지정판매소의 동 기본코드(D)를 주소로 일괄 산출한다. * code_detail(B·C·D) 행을 한 번만 로드해 각 판매소를 매칭한다. * * @param list $shops * @return array ds_idx => [동코드, 동명] */ private function resolveDongCodesForShops(int $lgIdx, array $shops): array { $out = []; if ($shops === []) { return $out; } $bCk = $this->codeKindIdxByCkCode('B'); $cCk = $this->codeKindIdxByCkCode('C'); $dCk = $this->codeKindIdxByCkCode('D'); if ($bCk === null || $cCk === null || $dCk === null) { return $out; } $detailModel = model(CodeDetailModel::class); $bRows = $detailModel->getByKind($bCk, true, $lgIdx); $cRows = $detailModel->getByKind($cCk, true, $lgIdx); $dRows = $detailModel->getByKind($dCk, true, $lgIdx); foreach ($shops as $row) { $idx = (int) ($row->ds_idx ?? 0); $road = (string) ($row->ds_addr ?? ''); $jibun = (string) ($row->ds_addr_jibun ?? ''); $zip = (string) ($row->ds_zip ?? ''); $blob = trim($road . ' ' . $jibun . ' ' . $zip); if ($blob === '') { $out[$idx] = ['code' => '', 'name' => '']; continue; } // B: 시·도 $bCode = null; foreach ($bRows as $r) { $nm = trim((string) $r->cd_name); $cd = trim((string) $r->cd_code); if ($nm === '' || $cd === '') { continue; } if ($this->koreanRegionTokenMatches($nm, '', $blob)) { $bCode = $cd; break; } } if ($bCode === null || $bCode === '') { $out[$idx] = ['code' => '', 'name' => '']; continue; } // C: 구·군 $cCode = null; foreach ($cRows as $r) { $cd = trim((string) $r->cd_code); if ($cd === '' || ! str_starts_with($cd, $bCode)) { continue; } $nm = trim((string) $r->cd_name); if ($nm === '') { continue; } if ($this->koreanRegionTokenMatches($nm, '', $blob)) { $cCode = $cd; break; } } if ($cCode === null || $cCode === '') { $out[$idx] = ['code' => '', 'name' => '']; continue; } // D: 동 — 긴 이름 우선 매칭(예: '노원동1가'가 '노원동'보다 먼저) $cands = []; foreach ($dRows as $r) { $cd = trim((string) $r->cd_code); if ($cd === '' || ! str_starts_with($cd, $cCode)) { continue; } $nm = trim((string) $r->cd_name); if ($nm === '') { continue; } $cands[] = ['len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd]; } usort($cands, static fn (array $a, array $b): int => $b['len'] <=> $a['len']); $dCode = ''; $dName = ''; foreach ($cands as $cand) { if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) { $dCode = $cand['cd']; $dName = $cand['nm']; break; } } $out[$idx] = ['code' => $dCode, 'name' => $dName]; } return $out; } /** * 산출된 동코드를 ds_dong_code 컬럼에 저장(값이 다를 때만). 컬럼이 없으면 아무것도 하지 않음. * * @param list $shops * @param array $dongMap */ private function persistDesignatedShopDongCodes(array $shops, array $dongMap): void { if ($dongMap === [] || ! $this->designatedShopHasDongColumn()) { return; } $db = \Config\Database::connect(); foreach ($shops as $row) { $idx = (int) ($row->ds_idx ?? 0); if ($idx <= 0 || ! isset($dongMap[$idx])) { continue; } $code = $dongMap[$idx]['code']; $current = (string) ($row->ds_dong_code ?? ''); if ($code !== '' && $code !== $current) { $db->table('designated_shop')->where('ds_idx', $idx)->update(['ds_dong_code' => $code]); $row->ds_dong_code = $code; // 뷰 페이로드에도 반영 } } } /** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */ private function kakaoJavascriptKey(): string { return (string) (config(\Config\Kakao::class)->javascriptKey ?? ''); } }