2026-03-25 12:05:33 +09:00
< ? php
namespace App\Controllers\Admin ;
use App\Controllers\BaseController ;
2026-04-14 00:14:53 +09:00
use App\Models\CodeDetailModel ;
use App\Models\CodeKindModel ;
2026-03-25 12:05:33 +09:00
use App\Models\DesignatedShopModel ;
use App\Models\LocalGovernmentModel ;
use Config\Roles ;
class DesignatedShop extends BaseController
{
private DesignatedShopModel $shopModel ;
private LocalGovernmentModel $lgModel ;
private Roles $roles ;
public function __construct ()
{
$this -> shopModel = model ( DesignatedShopModel :: class );
$this -> lgModel = model ( LocalGovernmentModel :: class );
$this -> roles = config ( 'Roles' );
}
private function isSuperAdmin () : bool
{
2026-03-26 15:29:55 +09:00
return Roles :: isSuperAdminEquivalent (( int ) session () -> get ( 'mb_level' ));
2026-03-25 12:05:33 +09:00
}
private function isLocalAdmin () : bool
{
return ( int ) session () -> get ( 'mb_level' ) === Roles :: LEVEL_LOCAL_ADMIN ;
}
/**
2026-04-14 00:14:53 +09:00
* DB 행에서 컬럼이 없을 수 있는 환경 ( 구 스키마 ) 까지 고려한 문자열 읽기 .
2026-03-25 12:05:33 +09:00
*/
2026-04-14 00:14:53 +09:00
private function designatedShopScalar ( object $row , string $field ) : string
2026-03-25 12:05:33 +09:00
{
2026-04-14 00:14:53 +09:00
return isset ( $row -> { $field }) ? ( string ) $row -> { $field } : '' ;
}
2026-03-25 12:05:33 +09:00
2026-04-14 00:14:53 +09:00
/**
* 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 '' ;
2026-03-25 12:05:33 +09:00
}
2026-04-14 00:14:53 +09:00
return ( string ) $da ;
}
2026-03-26 16:50:28 +09:00
2026-04-14 00:14:53 +09:00
/**
* 가상계좌 : 은행·계좌번호 분리 입력 + 구 단일 필드 ( 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 ,
];
}
2026-03-26 16:50:28 +09:00
2026-04-14 00:14:53 +09:00
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 ) : 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 );
}
}
/**
* @ return array { 1 : int , 2 : int , 3 : int , total : int }
*/
private function countDesignatedShopsByState ( int $lgIdx , ? string $dsName , ? string $dsGugunCode , ? string $dsState ) : array
{
$db = \Config\Database :: connect ();
$builder = $db -> table ( 'designated_shop' );
$builder -> where ( 'ds_lg_idx' , $lgIdx );
2026-03-26 16:50:28 +09:00
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 );
}
2026-04-14 00:14:53 +09:00
$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
* @ return list < array < string , mixed >>
*/
private function buildDesignatedShopDetailPayload ( array $list , array $lgMap ) : array
{
2026-04-22 15:35:28 +09:00
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx () ? ? 0 ;
$gugunMap = $lgIdx > 0 ? $this -> gugunCodeNameMap ( $lgIdx ) : [];
2026-04-14 00:14:53 +09:00
$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 : '' ;
$payload [] = [
'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 ? ? '' ),
2026-04-22 15:35:28 +09:00
'gugun_name' => $gugunMap [( string ) ( $row -> ds_gugun_code ? ? '' )] ? ? ( string ) ( $row -> ds_gugun_code ? ? '' ),
2026-04-14 00:14:53 +09:00
'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 )] ? ? '' ,
];
}
2026-03-26 16:50:28 +09:00
2026-04-14 00:14:53 +09:00
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' );
$this -> applyDesignatedShopListFilters ( $this -> shopModel , $lgIdx , $dsName , $dsGugunCode , $dsState );
$list = $this -> shopModel -> orderBy ( 'ds_idx' , 'DESC' ) -> paginate ( 20 );
2026-03-26 16:40:49 +09:00
$pager = $this -> shopModel -> pager ;
2026-03-25 12:05:33 +09:00
// 지자체 이름 매핑용
$lgMap = [];
foreach ( $this -> lgModel -> findAll () as $lg ) {
$lgMap [ $lg -> lg_idx ] = $lg -> lg_name ;
}
2026-04-14 00:14:53 +09:00
$stateCounts = $this -> countDesignatedShopsByState ( $lgIdx , $dsName , $dsGugunCode , $dsState );
2026-04-22 15:35:28 +09:00
$gugunNameMap = $this -> gugunCodeNameMap ( $lgIdx );
2026-04-14 00:14:53 +09:00
$detailRows = $this -> buildDesignatedShopDetailPayload ( $list , $lgMap );
2026-03-26 16:50:28 +09:00
// 구군코드 목록 (검색 필터용)
2026-04-14 00:14:53 +09:00
$db = \Config\Database :: connect ();
2026-03-26 16:50:28 +09:00
$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 ();
2026-04-14 00:14:53 +09:00
return [
'list' => $list ,
'lgMap' => $lgMap ,
'pager' => $pager ,
'dsName' => $dsName ? ? '' ,
'dsGugunCode' => $dsGugunCode ? ? '' ,
'dsState' => $dsState ? ? '' ,
'gugunCodes' => $gugunCodes ,
'stateCounts' => $stateCounts ,
2026-04-22 15:35:28 +09:00
'gugunNameMap' => $gugunNameMap ,
2026-04-14 00:14:53 +09:00
'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' , '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.' );
}
2026-04-22 15:35:28 +09:00
$data [ 'readOnly' ] = false ;
2026-04-14 00:14:53 +09:00
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 ;
2026-04-22 15:35:28 +09:00
return $this -> renderWorkPage ( '지정판매소 조회' , 'admin/designated_shop/manage' , $data );
2026-04-14 00:14:53 +09:00
}
/**
* 지정판매소 바코드 출력 전용 목록 ( 선택 후 인쇄 ) .
*/
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 ),
2026-03-25 12:05:33 +09:00
]);
}
2026-03-26 16:40:49 +09:00
public function export ()
{
helper ([ 'admin' , 'export' ]);
$lgIdx = admin_effective_lg_idx ();
if ( ! $lgIdx ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' )) -> with ( 'error' , '지자체를 선택해 주세요.' );
2026-03-26 16:40:49 +09:00
}
$list = $this -> shopModel -> where ( 'ds_lg_idx' , $lgIdx ) -> orderBy ( 'ds_idx' , 'DESC' ) -> findAll ();
$rows = [];
foreach ( $list as $row ) {
$stateMap = [ 1 => '정상' , 2 => '폐업' , 3 => '직권해지' ];
$rows [] = [
$row -> ds_idx ,
$row -> ds_shop_no ,
$row -> ds_name ,
$row -> ds_rep_name ,
$row -> ds_biz_no ,
2026-04-14 00:14:53 +09:00
$this -> designatedShopScalar ( $row , 'ds_biz_type' ),
$this -> designatedShopScalar ( $row , 'ds_biz_kind' ),
$this -> designatedShopScalar ( $row , 'ds_va_bank' ),
$this -> designatedShopScalar ( $row , 'ds_va_account' ) !== '' ? $this -> designatedShopScalar ( $row , 'ds_va_account' ) : ( $row -> ds_va_number ? ? '' ),
2026-03-26 16:40:49 +09:00
$row -> ds_tel ? ? '' ,
$row -> ds_addr ? ? '' ,
2026-04-14 00:14:53 +09:00
$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' ),
2026-03-26 16:40:49 +09:00
$stateMap [( int ) $row -> ds_state ] ? ? '' ,
$row -> ds_regdate ? ? '' ,
];
}
export_csv (
'지정판매소_' . date ( 'Ymd' ) . '.csv' ,
2026-04-14 00:14:53 +09:00
[
'번호' , '판매소번호' , '상호명' , '대표자' , '사업자번호' , '업태' , '업종' ,
'가상계좌은행' , '계좌번호' , '전화번호' , '주소' , '구역' , '종사업장번호' ,
'변경일자' , '변경사유' , '상태' , '등록일' ,
],
2026-03-26 16:40:49 +09:00
$rows
);
}
2026-03-25 12:05:33 +09:00
/**
* 지정판매소 등록 폼 ( 효과 지자체 기준 )
*/
public function create ()
{
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx ();
if ( $lgIdx === null || $lgIdx <= 0 ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.' );
}
$currentLg = $this -> lgModel -> find ( $lgIdx );
if ( $currentLg === null ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '선택한 지자체 정보를 찾을 수 없습니다.' );
}
2026-04-08 00:19:00 +09:00
return $this -> renderWorkPage ( '지정판매소 등록' , 'admin/designated_shop/create' , [
2026-04-14 00:14:53 +09:00
'localGovs' => [],
'currentLg' => $currentLg ,
'addrTenantScope' => [
'lg_sido' => ( string ) ( $currentLg -> lg_sido ? ? '' ),
'lg_gugun' => ( string ) ( $currentLg -> lg_gugun ? ? '' ),
],
'kakaoJavascriptKey' => $this -> kakaoJavascriptKey (),
2026-03-25 12:05:33 +09:00
]);
}
/**
* 지정판매소 등록 처리
*/
public function store ()
{
if ( ! $this -> isSuperAdmin () && ! $this -> isLocalAdmin ()) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '지정판매소 등록은 관리자만 가능합니다.' );
}
$rules = [
2026-04-14 00:14:53 +09:00
'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]' ,
2026-03-25 12:05:33 +09:00
];
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' , '지자체 코드 정보를 찾을 수 없습니다.' );
}
2026-04-14 00:14:53 +09:00
$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 ();
2026-03-25 12:05:33 +09:00
$data = [
2026-04-14 00:14:53 +09:00
'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 ,
'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' ),
2026-03-25 12:05:33 +09:00
];
$this -> shopModel -> insert ( $data );
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'success' , '지정판매소가 등록되었습니다.' );
}
/**
* 지정판매소 수정 폼 ( 효과 지자체 소속만 허용 )
* 문서 : docs / 기본 개발계획 / 23 - 지정판매소_수정_삭제_기능 . md
*/
public function edit ( int $id )
{
if ( ! $this -> isSuperAdmin () && ! $this -> isLocalAdmin ()) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '권한이 없습니다.' );
}
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx ();
if ( $lgIdx === null || $lgIdx <= 0 ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '작업할 지자체가 선택되지 않았습니다.' );
}
$shop = $this -> shopModel -> find ( $id );
if ( $shop === null || ( int ) $shop -> ds_lg_idx !== $lgIdx ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.' );
}
$currentLg = $this -> lgModel -> find ( $lgIdx );
2026-04-08 00:19:00 +09:00
return $this -> renderWorkPage ( '지정판매소 수정' , 'admin/designated_shop/edit' , [
2026-04-14 00:14:53 +09:00
'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 (),
2026-03-25 12:05:33 +09:00
]);
}
/**
* 지정판매소 수정 처리
*/
public function update ( int $id )
{
if ( ! $this -> isSuperAdmin () && ! $this -> isLocalAdmin ()) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '권한이 없습니다.' );
}
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx ();
if ( $lgIdx === null || $lgIdx <= 0 ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '작업할 지자체가 선택되지 않았습니다.' );
}
$shop = $this -> shopModel -> find ( $id );
if ( $shop === null || ( int ) $shop -> ds_lg_idx !== $lgIdx ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.' );
}
$rules = [
2026-04-14 00:14:53 +09:00
'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]' ,
2026-03-25 12:05:33 +09:00
];
if ( ! $this -> validate ( $rules )) {
return redirect () -> back ()
-> withInput ()
-> with ( 'errors' , $this -> validator -> getErrors ());
}
2026-04-14 00:14:53 +09:00
$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 ();
2026-03-25 12:05:33 +09:00
$data = [
2026-04-14 00:14:53 +09:00
'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_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' ),
2026-03-25 12:05:33 +09:00
];
$this -> shopModel -> update ( $id , $data );
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'success' , '지정판매소 정보가 수정되었습니다.' );
}
/**
* 지정판매소 삭제 ( 물리 삭제 , 효과 지자체 소속만 허용 )
* 문서 : docs / 기본 개발계획 / 23 - 지정판매소_수정_삭제_기능 . md
*/
public function delete ( int $id )
{
if ( ! $this -> isSuperAdmin () && ! $this -> isLocalAdmin ()) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '권한이 없습니다.' );
}
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx ();
if ( $lgIdx === null || $lgIdx <= 0 ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '작업할 지자체가 선택되지 않았습니다.' );
}
$shop = $this -> shopModel -> find ( $id );
if ( $shop === null || ( int ) $shop -> ds_lg_idx !== $lgIdx ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'error' , '해당 지정판매소를 찾을 수 없거나 삭제할 수 없습니다.' );
}
$this -> shopModel -> delete ( $id );
2026-04-08 00:19:00 +09:00
return redirect () -> to ( mgmt_url ( 'designated-shops' ))
2026-03-25 12:05:33 +09:00
-> with ( 'success' , '지정판매소가 삭제되었습니다.' );
}
2026-03-26 16:50:28 +09:00
/**
* P2 - 17 : 지정판매소 지도 표시
*/
public function map ()
{
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx ();
if ( $lgIdx === null || $lgIdx <= 0 ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( work_area_home_url ())
2026-03-26 16:50:28 +09:00
-> with ( 'error' , '작업할 지자체가 선택되지 않았습니다.' );
}
$shops = $this -> shopModel
-> where ( 'ds_lg_idx' , $lgIdx )
-> where ( 'ds_state' , 1 )
-> findAll ();
2026-04-08 00:19:00 +09:00
return $this -> renderWorkPage ( '지정판매소 지도' , 'admin/designated_shop/map' , [
2026-04-14 00:14:53 +09:00
'shops' => $shops ,
'kakaoJavascriptKey' => $this -> kakaoJavascriptKey (),
2026-03-26 16:50:28 +09:00
]);
}
/**
2026-04-14 00:14:53 +09:00
* 구·군 코드 → 표시명 ( 코드종류 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 : 지정판매소 현황 ( 연도별 신규 / 취소 + 구·군·동 집계 )
2026-03-26 16:50:28 +09:00
*/
public function status ()
{
helper ( 'admin' );
$lgIdx = admin_effective_lg_idx ();
if ( $lgIdx === null || $lgIdx <= 0 ) {
2026-04-08 00:19:00 +09:00
return redirect () -> to ( work_area_home_url ())
2026-03-26 16:50:28 +09:00
-> with ( 'error' , '작업할 지자체가 선택되지 않았습니다.' );
}
2026-04-14 00:14:53 +09:00
$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 ? ? '' ));
}
2026-03-26 16:50:28 +09:00
$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 );
2026-04-14 00:14:53 +09:00
$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 ;
}
2026-04-08 00:19:00 +09:00
return $this -> renderWorkPage ( '지정판매소 현황' , 'admin/designated_shop/status' , [
2026-04-14 00:14:53 +09:00
'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' ],
2026-03-26 16:50:28 +09:00
]);
}
2026-03-25 12:05:33 +09:00
/**
2026-04-14 00:14:53 +09:00
* 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 ) 와 무관하게 일련이 이어지게 한다 .
2026-03-25 12:05:33 +09:00
*/
2026-04-14 00:14:53 +09:00
private function maxDesignatedShopThreeDigitSerialForLocalGovernment ( int $lgIdx ) : int
2026-03-25 12:05:33 +09:00
{
2026-04-14 00:14:53 +09:00
$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' ]);
2026-03-25 12:05:33 +09:00
2026-04-14 00:14:53 +09:00
$dCode = null ;
foreach ( $dCandidates as $cand ) {
if ( $this -> addressHaystackContainsRegionName ( $blob , $cand [ 'nm' ])) {
$dCode = $cand [ 'cd' ];
break ;
2026-03-25 12:05:33 +09:00
}
}
2026-04-14 00:14:53 +09:00
if ( $dCode === null || $dCode === '' ) {
return [
'ok' => false ,
'error' => '주소에서 동 기본코드(D)를 찾을 수 없습니다. 지번·도로명에 법정동명이 포함되는지, 기본코드(D)에 해당 동이 등록되어 있는지 확인해 주세요.' ,
];
}
2026-03-25 12:05:33 +09:00
2026-04-14 00:14:53 +09:00
$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 ,
];
}
/** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
private function kakaoJavascriptKey () : string
{
return ( string ) ( config ( \Config\Kakao :: class ) -> javascriptKey ? ? '' );
2026-03-25 12:05:33 +09:00
}
}