종량제3 세션 작업. 마스터 플래그(Config\Pii.designatedShopProtection)가 OFF면 현재 동작(원문 그대로) 유지, ON일 때만 마스킹·게이트 적용. - 표시 계층 단일 지점 마스킹: 대표자명/전화/이메일/계좌/가상계좌 (목록·상세·엑셀·판매대장·전화주문) - 원문 열람 게이트: 지자체 허용 IP 자동열람 or 2단계 인증(TOTP) step-up + 감사기록(revealPii) - 정확일치 검색: 블라인드 인덱스(HMAC) 컬럼 있으면 사용, 없으면 평문 폴백 - 저장계층 암호화(encryptAtRest, 기본 OFF): 모델 콜백 + spark 명령 pii:protect-designated-shops - SQL(추가·멱등): designated_shop_add_pii_bidx / local_government_add_allow_ips - Auth: TOTP 실제 통과 시에만 세션 auth_2fa_verified=true Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1938 lines
77 KiB
PHP
1938 lines
77 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\Admin;
|
|
|
|
use App\Controllers\BaseController;
|
|
use App\Models\CodeDetailModel;
|
|
use App\Models\CodeKindModel;
|
|
use App\Models\DesignatedShopModel;
|
|
use App\Models\LocalGovernmentModel;
|
|
use Config\Roles;
|
|
|
|
class DesignatedShop extends BaseController
|
|
{
|
|
private DesignatedShopModel $shopModel;
|
|
private LocalGovernmentModel $lgModel;
|
|
private Roles $roles;
|
|
private ?bool $dongColumnExists = null;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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<object> $list
|
|
* @param array<int, string> $lgMap
|
|
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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<string, mixed>|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<string, string>
|
|
*/
|
|
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<array{code: string, name: string}>
|
|
*/
|
|
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<object>, 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<object{region_key: string, region_label: string, prev_end: int, designated_y: int, cancelled_y: int, curr_end: int}>
|
|
*/
|
|
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<object> $shops
|
|
* @return array<int, array{code: string, name: string}> 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<object> $shops
|
|
* @param array<int, array{code: string, name: string}> $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 ?? '');
|
|
}
|
|
}
|
|
|