diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 9bebfda..c3cc3e9 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -83,7 +83,13 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void $routes->get('designated-shops/export', 'Admin\DesignatedShop::export'); $routes->get('designated-shops/map', 'Admin\DesignatedShop::map'); + $routes->get('designated-shops/status/export', 'Admin\DesignatedShop::statusExport'); $routes->get('designated-shops/status', 'Admin\DesignatedShop::status'); + $routes->get('designated-shops/barcode', 'Admin\DesignatedShop::barcode'); + $routes->post('designated-shops/barcode/print', 'Admin\DesignatedShop::barcodePrint'); + $routes->get('designated-shops/district-new-cancel/export', 'Admin\DesignatedShop::districtNewCancelExport'); + $routes->get('designated-shops/district-new-cancel', 'Admin\DesignatedShop::districtNewCancel'); + $routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse'); $routes->get('designated-shops', 'Admin\DesignatedShop::index'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); diff --git a/app/Controllers/Admin/DesignatedShop.php b/app/Controllers/Admin/DesignatedShop.php index d29d8ba..ed11247 100644 --- a/app/Controllers/Admin/DesignatedShop.php +++ b/app/Controllers/Admin/DesignatedShop.php @@ -3,6 +3,8 @@ 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; @@ -31,25 +33,168 @@ class DesignatedShop extends BaseController } /** - * 지정판매소 목록 (효과 지자체 기준: super admin = 선택 지자체, 지자체관리자 = mb_lg_idx) + * DB 행에서 컬럼이 없을 수 있는 환경(구 스키마)까지 고려한 문자열 읽기. */ - public function index() + private function designatedShopScalar(object $row, string $field): string { - helper('admin'); - $lgIdx = admin_effective_lg_idx(); + return isset($row->{$field}) ? (string) $row->{$field} : ''; + } - if ($lgIdx === null || $lgIdx <= 0) { - return redirect()->to(work_area_home_url()) - ->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); + /** + * 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 ''; } - $builder = $this->shopModel->where('ds_lg_idx', $lgIdx); + return (string) $da; + } - // 다조건 검색 (P2-15) - $dsName = $this->request->getGet('ds_name'); - $dsGugunCode = $this->request->getGet('ds_gugun_code'); - $dsState = $this->request->getGet('ds_state'); + /** + * 가상계좌: 은행·계좌번호 분리 입력 + 구 단일 필드(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): 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); if ($dsName !== null && $dsName !== '') { $builder->like('ds_name', $dsName); } @@ -59,8 +204,99 @@ class DesignatedShop extends BaseController if ($dsState !== null && $dsState !== '') { $builder->where('ds_state', (int) $dsState); } + $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]; - $list = $builder->orderBy('ds_idx', 'DESC')->paginate(20); + return $counts; + } + + /** + * @param list $list + * @param array $lgMap + * @return list> + */ + private function buildDesignatedShopDetailPayload(array $list, array $lgMap): array + { + $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 ?? ''), + '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)] ?? '', + ]; + } + + return $payload; + } + + /** + * 목록·검색·상세 표시에 공통으로 쓰는 뷰 데이터 (지자체 미선택 시 null). + * + * @return array|null + */ + private function designatedShopIndexViewData(): ?array + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + + if ($lgIdx === null || $lgIdx <= 0) { + return null; + } + + // 다조건 검색 (P2-15) + $dsName = $this->request->getGet('ds_name'); + $dsGugunCode = $this->request->getGet('ds_gugun_code'); + $dsState = $this->request->getGet('ds_state'); + $this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState); + + $list = $this->shopModel->orderBy('ds_idx', 'DESC')->paginate(20); $pager = $this->shopModel->pager; // 지자체 이름 매핑용 @@ -69,18 +305,158 @@ class DesignatedShop extends BaseController $lgMap[$lg->lg_idx] = $lg->lg_name; } + $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState); + $detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap); + // 구군코드 목록 (검색 필터용) - $db = \Config\Database::connect(); + $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 $this->renderWorkPage('지정판매소 관리', 'admin/designated_shop/index', [ - 'list' => $list, - 'lgMap' => $lgMap, - 'pager' => $pager, - 'dsName' => $dsName ?? '', - 'dsGugunCode' => $dsGugunCode ?? '', - 'dsState' => $dsState ?? '', - 'gugunCodes' => $gugunCodes, + return [ + 'list' => $list, + 'lgMap' => $lgMap, + 'pager' => $pager, + 'dsName' => $dsName ?? '', + 'dsGugunCode' => $dsGugunCode ?? '', + 'dsState' => $dsState ?? '', + 'gugunCodes' => $gugunCodes, + 'stateCounts' => $stateCounts, + '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', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.'); + } + + 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/index', $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), ]); } @@ -103,9 +479,16 @@ class DesignatedShop extends BaseController $row->ds_name, $row->ds_rep_name, $row->ds_biz_no, - $row->ds_va_number, + $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 ?? ''), $row->ds_tel ?? '', $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 ?? '', ]; @@ -113,7 +496,11 @@ class DesignatedShop extends BaseController export_csv( '지정판매소_' . date('Ymd') . '.csv', - ['번호', '판매소번호', '상호명', '대표자', '사업자번호', '가상계좌', '전화번호', '주소', '상태', '등록일'], + [ + '번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종', + '가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호', + '변경일자', '변경사유', '상태', '등록일', + ], $rows ); } @@ -137,8 +524,13 @@ class DesignatedShop extends BaseController } return $this->renderWorkPage('지정판매소 등록', 'admin/designated_shop/create', [ - 'localGovs' => [], - 'currentLg' => $currentLg, + 'localGovs' => [], + 'currentLg' => $currentLg, + 'addrTenantScope' => [ + 'lg_sido' => (string) ($currentLg->lg_sido ?? ''), + 'lg_gugun' => (string) ($currentLg->lg_gugun ?? ''), + ], + 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), ]); } @@ -153,11 +545,22 @@ class DesignatedShop extends BaseController } $rules = [ - 'ds_name' => 'required|max_length[100]', - 'ds_biz_no' => 'required|max_length[20]', - 'ds_rep_name' => 'required|max_length[50]', - 'ds_va_number' => 'permit_empty|max_length[50]', - 'ds_email' => 'permit_empty|valid_email|max_length[100]', + '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)) { @@ -181,26 +584,66 @@ class DesignatedShop extends BaseController ->with('error', '지자체 코드 정보를 찾을 수 없습니다.'); } - $dsShopNo = $this->generateNextShopNo($lgIdx, (string) $lg->lg_code); + $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' => $dsShopNo, - '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_va_number' => (string) $this->request->getPost('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_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' => (string) $lg->lg_code, - 'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null, - 'ds_state' => 1, - 'ds_regdate' => date('Y-m-d H:i:s'), + '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'), ]; $this->shopModel->insert($data); @@ -236,8 +679,13 @@ class DesignatedShop extends BaseController $currentLg = $this->lgModel->find($lgIdx); return $this->renderWorkPage('지정판매소 수정', 'admin/designated_shop/edit', [ - 'shop' => $shop, - 'currentLg' => $currentLg, + '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(), ]); } @@ -265,12 +713,23 @@ class DesignatedShop extends BaseController } $rules = [ - 'ds_name' => 'required|max_length[100]', - 'ds_biz_no' => 'required|max_length[20]', - 'ds_rep_name' => 'required|max_length[50]', - 'ds_va_number' => 'permit_empty|max_length[50]', - 'ds_email' => 'permit_empty|valid_email|max_length[100]', - 'ds_state' => 'permit_empty|in_list[1,2,3]', + '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)) { @@ -279,19 +738,53 @@ class DesignatedShop extends BaseController ->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(); + $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_va_number' => (string) $this->request->getPost('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_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_designated_at' => $this->request->getPost('ds_designated_at') ?: null, - 'ds_state' => (int) ($this->request->getPost('ds_state') ?: 1), + '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'), ]; $this->shopModel->update($id, $data); @@ -348,12 +841,266 @@ class DesignatedShop extends BaseController ->findAll(); return $this->renderWorkPage('지정판매소 지도', 'admin/designated_shop/map', [ - 'shops' => $shops, + 'shops' => $shops, + 'kakaoJavascriptKey' => $this->kakaoJavascriptKey(), ]); } /** - * P2-18: 지정판매소 현황 (연도별 신규/취소) + * 구·군 코드 → 표시명 (코드종류 C, 플랫폼+지자체 범위). + * + * @return array + */ + private function gugunCodeNameMap(int $lgIdx): array + { + $ckIdx = $this->codeKindIdxByCkCode('C'); + if ($ckIdx === null) { + return []; + } + $rows = model(CodeDetailModel::class)->getByKind($ckIdx, true, $lgIdx); + $map = []; + foreach ($rows as $r) { + $map[(string) $r->cd_code] = (string) $r->cd_name; + } + + return $map; + } + + /** + * 효과 지자체 기준 구·군 마스터 행 (기본코드 C → 없으면 지정판매소에 실제 입력된 코드). + * + * @return list + */ + private function gugunMasterRowsForLg(int $lgIdx): array + { + $map = $this->gugunCodeNameMap($lgIdx); + $rows = []; + foreach ($map as $code => $name) { + $code = trim((string) $code); + if ($code === '') { + continue; + } + $rows[] = ['code' => $code, 'name' => trim((string) $name)]; + } + usort($rows, static fn (array $a, array $b): int => strcmp($a['code'], $b['code'])); + if ($rows !== []) { + return $rows; + } + $db = \Config\Database::connect(); + $q = $db->query( + "SELECT DISTINCT TRIM(ds_gugun_code) AS c FROM designated_shop WHERE ds_lg_idx = ? AND TRIM(ds_gugun_code) != '' ORDER BY c", + [$lgIdx] + )->getResult(); + foreach ($q as $o) { + $c = trim((string) ($o->c ?? '')); + if ($c === '') { + continue; + } + $rows[] = ['code' => $c, 'name' => $c]; + } + + return $rows; + } + + /** + * GBMS형: 마스터 구·군 순서로 행을 채우고, 집계는 designatedShopDistrictStatusRows(구군 전체)와 병합. + * + * @return array{rows: list, total: object} + */ + private function buildGbmsNewCancelRows(int $lgIdx, int $year): array + { + $byCode = []; + foreach ($this->designatedShopDistrictStatusRows($lgIdx, $year, '', 'gugun') as $r) { + $byCode[(string) $r->gugun_code] = $r; + } + $master = $this->gugunMasterRowsForLg($lgIdx); + $out = []; + $seen = []; + foreach ($master as $m) { + $c = $m['code']; + $seen[$c] = true; + $st = $byCode[$c] ?? null; + $out[] = $this->makeGbmsDistrictRow($m['name'], $c, $st); + } + foreach ($byCode as $c => $st) { + if ($c === '' || isset($seen[$c])) { + continue; + } + $out[] = $this->makeGbmsDistrictRow((string) $st->region_label, $c, $st); + $seen[$c] = true; + } + if (isset($byCode[''])) { + $st = $byCode['']; + $out[] = $this->makeGbmsDistrictRow('(구·군 미입력)', '', $st); + } + if ($out === [] && $byCode !== []) { + foreach ($byCode as $c => $st) { + $out[] = $this->makeGbmsDistrictRow((string) $st->region_label, $c, $st); + } + } + $sumP = $sumD = $sumC = $sumCur = 0; + foreach ($out as $o) { + $sumP += (int) $o->prev_end; + $sumD += (int) $o->designated_y; + $sumC += (int) $o->cancelled_y; + $sumCur += (int) $o->curr_end; + } + + return [ + 'rows' => $out, + 'total' => (object) [ + 'region_label' => '계', + 'prev_end' => $sumP, + 'designated_y' => $sumD, + 'cancelled_y' => $sumC, + 'curr_end' => $sumCur, + ], + ]; + } + + /** + * @param object|null $st designatedShopDistrictStatusRows 한 행 또는 null + */ + private function makeGbmsDistrictRow(string $displayLabel, string $gugunCode, ?object $st): object + { + return (object) [ + 'region_label' => $displayLabel, + 'gugun_code' => $gugunCode, + 'prev_end' => $st !== null ? (int) $st->prev_end : 0, + 'designated_y' => $st !== null ? (int) $st->designated_y : 0, + 'cancelled_y' => $st !== null ? (int) $st->cancelled_y : 0, + 'curr_end' => $st !== null ? (int) $st->curr_end : 0, + ]; + } + + /** + * 연도·구군·집계 단위별 신규/취소 현황 (종전=전년말, 지정·취소=당해, 현행=금년말). + * + * @return list + */ + private function designatedShopDistrictStatusRows( + int $lgIdx, + int $year, + string $filterGugunCode, + string $granularity + ): array { + $year = max(1990, min(2100, $year)); + $prevEnd = sprintf('%04d-12-31', $year - 1); + $currEnd = sprintf('%04d-12-31', $year); + $granularity = $granularity === 'dong' ? 'dong' : 'gugun'; + + $db = \Config\Database::connect(); + $gugunMap = $this->gugunCodeNameMap($lgIdx); + + $filterGugunCode = trim($filterGugunCode); + $bind = [ + $prevEnd, + $prevEnd, + $year, + $year, + $currEnd, + $currEnd, + $lgIdx, + $filterGugunCode, + $filterGugunCode, + ]; + + if ($granularity === 'dong') { + $sql = " + SELECT + IFNULL(NULLIF(TRIM(ds_gugun_code), ''), '') AS gugun_key, + IFNULL(NULLIF(TRIM(ds_zone_code), ''), '') AS zone_key, + SUM( + CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? + AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) + THEN 1 ELSE 0 END + ) AS prev_end, + SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END) AS designated_y, + SUM( + CASE WHEN ds_state IN (2,3) + AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = ? + THEN 1 ELSE 0 END + ) AS cancelled_y, + SUM( + CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? + AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) + THEN 1 ELSE 0 END + ) AS curr_end + FROM designated_shop + WHERE ds_lg_idx = ? + AND (? = '' OR ds_gugun_code = ?) + GROUP BY gugun_key, zone_key + ORDER BY gugun_key, zone_key + "; + } else { + $sql = " + SELECT + IFNULL(NULLIF(TRIM(ds_gugun_code), ''), '') AS gugun_key, + SUM( + CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? + AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) + THEN 1 ELSE 0 END + ) AS prev_end, + SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = ? THEN 1 ELSE 0 END) AS designated_y, + SUM( + CASE WHEN ds_state IN (2,3) + AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = ? + THEN 1 ELSE 0 END + ) AS cancelled_y, + SUM( + CASE WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= ? + AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > ?)) + THEN 1 ELSE 0 END + ) AS curr_end + FROM designated_shop + WHERE ds_lg_idx = ? + AND (? = '' OR ds_gugun_code = ?) + GROUP BY gugun_key + ORDER BY gugun_key + "; + } + + $raw = $db->query($sql, $bind)->getResult(); + $out = []; + foreach ($raw as $row) { + $gk = (string) ($row->gugun_key ?? ''); + $gn = $gk !== '' ? ($gugunMap[$gk] ?? $gk) : '(구·군 미입력)'; + + if ($granularity === 'dong') { + $zk = (string) ($row->zone_key ?? ''); + $zn = $zk !== '' ? $zk : '(구역 미입력)'; + $label = $gn . ' / ' . $zn; + $rkey = $gk . "\t" . $zk; + } else { + $label = $gn; + $rkey = $gk; + } + + $prev = (int) ($row->prev_end ?? 0); + $curr = (int) ($row->curr_end ?? 0); + $des = (int) ($row->designated_y ?? 0); + $can = (int) ($row->cancelled_y ?? 0); + $zk = $granularity === 'dong' ? (string) ($row->zone_key ?? '') : ''; + + $out[] = (object) [ + 'region_key' => $rkey, + 'region_label' => $label, + 'gugun_code' => $gk, + 'zone_code' => $zk, + 'prev_end' => $prev, + 'designated_y' => $des, + 'cancelled_y' => $can, + 'curr_end' => $curr, + 'delta_curr_prev' => $curr - $prev, + 'delta_des_cancel' => $des - $can, + ]; + } + + return $out; + } + + /** + * P2-18: 지정판매소 현황 (연도별 신규/취소 + 구·군·동 집계) */ public function status() { @@ -364,6 +1111,15 @@ class DesignatedShop extends BaseController ->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 기준) @@ -388,35 +1144,419 @@ class DesignatedShop extends BaseController $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, + 'newByYear' => $newByYear, + 'cancelByYear' => $cancelByYear, + 'totalActive' => $totalActive, + 'totalInactive' => $totalInactive, + 'districtRows' => $districtRows, + 'districtTotal' => $districtTotal, + 'zoneSummaryRows'=> $zoneSummaryRows, + 'reportYear' => $year, + 'fixedGugunLabel'=> $fixedGugunLabel, + 'yearChoices' => $yearChoices, ]); } /** - * 지자체별 다음 판매소번호 생성 (lg_code + 3자리 일련번호) - * 문서: docs/기본 개발계획/22-판매소번호_일련번호_결정.md §3 + * 구·군(·동) 신규/취소 현황 CSV (status 화면과 동일 조건) */ - private function generateNextShopNo(int $lgIdx, string $lgCode): string + public function statusExport() { - $prefixLen = strlen($lgCode); - $existing = $this->shopModel->where('ds_lg_idx', $lgIdx)->findAll(); + helper(['admin', 'export']); + $lgIdx = admin_effective_lg_idx(); + if ($lgIdx === null || $lgIdx <= 0) { + return redirect()->to(work_area_home_url()) + ->with('error', '작업할 지자체가 선택되지 않았습니다.'); + } - $maxSerial = 0; - foreach ($existing as $row) { - $no = $row->ds_shop_no; - if (strlen($no) === $prefixLen + 3 && str_starts_with($no, $lgCode)) { - $n = (int) substr($no, -3); - if ($n > $maxSerial) { - $maxSerial = $n; - } + $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 $lgCode . sprintf('%03d', $maxSerial + 1); + 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; + foreach ($dCandidates as $cand) { + if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) { + $dCode = $cand['cd']; + 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, + ]; + } + + /** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */ + private function kakaoJavascriptKey(): string + { + return (string) (config(\Config\Kakao::class)->javascriptKey ?? ''); } } diff --git a/app/Views/admin/designated_shop/barcode.php b/app/Views/admin/designated_shop/barcode.php new file mode 100644 index 0000000..2f91807 --- /dev/null +++ b/app/Views/admin/designated_shop/barcode.php @@ -0,0 +1,137 @@ + '지정판매소 바코드 출력']) ?> + + +
+
+ 지정판매소 바코드 출력 +
+ +
+
+
+ +
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+ + +
+ + 선택 건수: 0 +
+
+ + + + + + + + + + + + + + + ds_state ?? 1); + $stLabel = $st === 1 ? '사용' : '정지'; + ?> + + + + + + + + + + + + + + +
출력판매소 코드판매소명대표자명사업자번호사업장 주소상태
ds_shop_no ?? '')) ?>ds_name ?? '')) ?>ds_rep_name ?? '')) ?>ds_biz_no ?? '')) ?>ds_addr ?? '')) ?>
조회된 지정판매소가 없습니다.
+
+
+ +
+ + +
links() ?>
+ + + diff --git a/app/Views/admin/designated_shop/barcode_print.php b/app/Views/admin/designated_shop/barcode_print.php new file mode 100644 index 0000000..d370f94 --- /dev/null +++ b/app/Views/admin/designated_shop/barcode_print.php @@ -0,0 +1,94 @@ + + + + + + + 지정판매소 바코드 + + + + +
+

지정판매소 바코드

+

출력할 지정판매소가 없습니다.

+
+ + $pageRows): ?> +
+

지정판매소 바코드

+
+ 출 력 일 자: + + 페  이  지: / +
+
+ + ds_shop_no ?? '')); + $nm = trim((string) ($row->ds_name ?? '')); + $rep = trim((string) ($row->ds_rep_name ?? '')); + $label = trim($nm . ($rep !== '' ? ('-' . $rep) : '')); + ?> +
+
+ +
+
+
+
+ +
+
+ + + + + + + diff --git a/app/Views/admin/designated_shop/district_new_cancel.php b/app/Views/admin/designated_shop/district_new_cancel.php new file mode 100644 index 0000000..0da108c --- /dev/null +++ b/app/Views/admin/designated_shop/district_new_cancel.php @@ -0,0 +1,210 @@ +lg_sido ?? '')) : ''; +$lgGugun = $lg !== null ? trim((string) ($lg->lg_gugun ?? '')) : ''; +$lgName = $lg !== null ? trim((string) ($lg->lg_name ?? '')) : ''; +$scopeLabel = $lgSido !== '' && $lgGugun !== '' + ? $lgSido . ' ' . $lgGugun + : ($lgName !== '' ? $lgName : '—'); +$exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . http_build_query(['year' => $ry]); +?> + '지정 판매소 신규/취소 현황 (' . $ry . '년)']) ?> + + +
+
+ [지정 판매소 신규/취소 현황] +
+ 엑셀저장 + + 목록 +
+
+
+ +
+
+
+ + +
+
+ 군·구 (소속 지자체) +
+ +
+
+ 단위: 판매소 + +
+

+ 종전·현행은 각각 -12-31·-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 년 달력연도 기준입니다. 구·군 행은 효과 지자체의 기본코드(구 코드) 순서로 표시됩니다. +

+
+ +
+
지정 판매소 신규/취소 현황 조회 내역
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
군·구 + + 종전 + ? + +
(전년도말) +
사용 + + 현행 + ? + +
(금년도말) +
+ + 지정 + ? + + + + 취소 + ? + +
region_label) ?>prev_end) ?>designated_y) ?>cancelled_y) ?>curr_end) ?>
표시할 구·군 또는 지정판매소 데이터가 없습니다.
region_label) ?>prev_end) ?>designated_y) ?>cancelled_y) ?>curr_end) ?>
+
+
diff --git a/app/Views/admin/designated_shop/status.php b/app/Views/admin/designated_shop/status.php index 947b523..f0871e9 100644 --- a/app/Views/admin/designated_shop/status.php +++ b/app/Views/admin/designated_shop/status.php @@ -1,80 +1,387 @@ - '지정판매소 현황']) ?> -
+ $ry, +]); +$fixedGugunLabel = trim((string) ($fixedGugunLabel ?? '')); +$regionColLabel = '군·구'; +$sumCurrForPct = (int) ($districtTotal->curr_end ?? 0); +?> + + '지정판매소 신규·취소 현황 (' . $ry . '년)']) ?> +
지정판매소 현황 (신규/취소)
- + 엑셀저장 + 목록으로
- -
-
-
활성 판매소
-
-
-
-
비활성/취소 판매소
-
-
-
-
전체
-
-
+
+
+
+ + +
+
+ +
+ +
+
+ +
+

+ 종전·현행은 각각 -12-31·-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 년 달력연도 기준입니다. 군·구는 현재 로그인 사용자의 지자체 기준으로 고정 표시됩니다. +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + curr_end; + $prev = (int) $row->prev_end; + $pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0; + $yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
순번 + 구코드 ? + + 종전 ?(전년도말) + + 지정 ?(년) + + 취소 ?(년) + + 현행 ?(금년도말) + + 증감 ? +
(현행−종전) +
+ 지정−취소 ? +
(년) +
+ 현행비중 ? +
(%) +
+ 전년대비 ? +
증감률(%) +
region_label) ?>gugun_code ?? '')) ?>designated_y) ?>cancelled_y) ?>delta_curr_prev ?? 0)) ?>delta_des_cancel ?? 0)) ?>
조건에 맞는 데이터가 없습니다.
region_label) ?>prev_end) ?>designated_y) ?>cancelled_y) ?>curr_end) ?>delta_curr_prev ?? 0)) ?>delta_des_cancel ?? 0)) ?>100 + prev_end; + $tCurr = (int) $districtTotal->curr_end; + echo $tPrev > 0 ? round((($tCurr - $tPrev) / $tPrev) * 100, 1) : '—'; + ?> +
-
- -
-

연도별 신규등록 건수

-
- - - - - - - - - - - - - - - - - - -
연도신규등록 건수
yr) ?>년cnt) ?>
데이터가 없습니다.
-
+ +
+
동별 현행 요약 ()
+ +
+ + + zone_label) ?> curr_end) ?> + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
종전지정취소현행증감
zone_label) ?>prev_end) ?>designated_y) ?>cancelled_y) ?>curr_end) ?>delta_curr_prev) ?>
+
+ +

동별 집계 데이터가 없습니다.

+ +
- -
-

연도별 취소/비활성 건수

-
- - - - - - - - - - - - - - - - - - -
연도취소/비활성 건수
yr) ?>년cnt) ?>
데이터가 없습니다.
+ + + + +
+ 연도별 요약 (참고) +
+
+
활성 / 비활성 / 전체
+
활성 · 비활성 · 합
-
+
+
+

연도별 신규등록 (지정일)

+
+ + + + + + + + + + +
연도건수
yr) ?>년cnt) ?>
없음
+
+
+
+

연도별 취소/비활성 (등록일 기준)

+
+ + + + + + + + + + +
연도건수
yr) ?>년cnt) ?>
없음
+
+
+
+ diff --git a/e2e/menu-links-smoke.spec.js b/e2e/menu-links-smoke.spec.js index d6d2df8..cd94ba1 100644 --- a/e2e/menu-links-smoke.spec.js +++ b/e2e/menu-links-smoke.spec.js @@ -60,7 +60,12 @@ const BAG_PATHS = [ '/bag/free-recipients', '/bag/free-recipients/create', '/bag/designated-shops', + '/bag/designated-shops/browse', '/bag/designated-shops/status', + '/bag/designated-shops/status/export?year=2026&ds_gugun_code=&granularity=gugun', + '/bag/designated-shops/district-new-cancel', + '/bag/designated-shops/district-new-cancel/export?year=2026', + '/bag/designated-shops/barcode', '/bag/bag-prices', '/bag/bag-prices/create', '/bag/packaging-units/manage', diff --git a/e2e/new-features.spec.js b/e2e/new-features.spec.js index 698e442..834c6e0 100644 --- a/e2e/new-features.spec.js +++ b/e2e/new-features.spec.js @@ -85,13 +85,15 @@ test.describe('P2-15: 지정판매소 다조건 조회', () => { await loginAsLocal(page); await page.goto('/bag/designated-shops?ds_name=CU'); await expect(page).toHaveURL(/ds_name=CU/); - await expect(page.locator('table.data-table')).toBeVisible(); + await expect(page.locator('input[name="ds_name"]')).toHaveValue('CU'); + await expect(page.locator('#ds-list-body')).toBeAttached(); }); test('상태 필터', async ({ page }) => { await loginAsLocal(page); await page.goto('/bag/designated-shops?ds_state=1'); - await expect(page.locator('table.data-table')).toBeVisible(); + await expect(page.locator('select[name="ds_state"]')).toHaveValue('1'); + await expect(page.locator('#ds-list-body')).toBeAttached(); }); test('검색 폼에서 이름 입력 후 조회', async ({ page }) => { @@ -126,6 +128,17 @@ test.describe('P2-18: 지정판매소 현황', () => { await page.goto('/bag/designated-shops/status'); await expect(page).toHaveURL(/\/status/); await expect(page.locator('table.data-table').first()).toBeVisible(); + await expect(page.getByRole('columnheader', { name: '종전(전년도말)' })).toBeVisible(); + await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible(); + }); + + test('GBMS형 신규/취소 현황(구·군 고정)', async ({ page }) => { + await loginAsLocal(page); + await page.goto('/bag/designated-shops/district-new-cancel'); + await expect(page).toHaveURL(/district-new-cancel/); + await expect(page.locator('.gbms-dnc-table')).toBeVisible(); + await expect(page.getByRole('columnheader', { name: '군·구' })).toBeVisible(); + await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible(); }); }); @@ -279,9 +292,9 @@ test.describe('사이트 메뉴 CRUD 동작', () => { test.describe('엑셀 내보내기 다운로드', () => { test('지정판매소 엑셀', async ({ page }) => { await loginAsLocal(page); - await page.goto('/bag/designated-shops'); + await page.goto('/bag/designated-shops/browse'); const downloadPromise = page.waitForEvent('download', { timeout: 10000 }); - await page.locator('a[href*="export"]').first().click(); + await page.locator('a[href*="designated-shops/export"]').first().click(); const download = await downloadPromise; expect(download.suggestedFilename()).toContain('.csv'); }); diff --git a/writable/database/menu_link_designated_shop_barcode.sql b/writable/database/menu_link_designated_shop_barcode.sql new file mode 100644 index 0000000..cba6635 --- /dev/null +++ b/writable/database/menu_link_designated_shop_barcode.sql @@ -0,0 +1,7 @@ +-- 기존 DB: '지정판매소 바코드 출력' 메뉴를 전용 URL로 변경 +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +UPDATE `menu` m +INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site' +SET m.mm_link = 'bag/designated-shops/barcode' +WHERE m.mm_name = '지정판매소 바코드 출력'; diff --git a/writable/database/menu_link_district_new_cancel.sql b/writable/database/menu_link_district_new_cancel.sql new file mode 100644 index 0000000..f63d66c --- /dev/null +++ b/writable/database/menu_link_district_new_cancel.sql @@ -0,0 +1,10 @@ +-- 기존 DB: 지정 판매소 신규/취소 현황 메뉴를 GBMS형 전용 URL로 변경 +-- UTF-8: mysql --default-character-set=utf8mb4 ... + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +UPDATE `menu` m +INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site' +SET m.mm_link = 'bag/designated-shops/district-new-cancel' +WHERE m.mm_name IN ('지정 판매소 신규/취소 현황', '지정 판매소 현황') + AND m.mm_link IN ('bag/designated-shops/status', ''); diff --git a/writable/database/menu_site_fill_empty_second_level_links.sql b/writable/database/menu_site_fill_empty_second_level_links.sql index bf44bab..125f119 100644 --- a/writable/database/menu_site_fill_empty_second_level_links.sql +++ b/writable/database/menu_site_fill_empty_second_level_links.sql @@ -19,12 +19,12 @@ SET m.mm_link = CASE m.mm_name WHEN '업체 관리' THEN 'bag/companies' WHEN '무료용 대상자 관리' THEN 'bag/free-recipients' WHEN '지정 판매소 관리' THEN 'bag/designated-shops' - WHEN '지정 판매소 조회' THEN 'bag/designated-shops' - WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/status' - WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops' + WHEN '지정 판매소 조회' THEN 'bag/designated-shops/browse' + WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/district-new-cancel' + WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops/barcode' WHEN 'PASSWORD 변경' THEN 'bag/password-change' WHEN '환경 설정' THEN 'dashboard' - WHEN '지정 판매소 현황' THEN 'bag/designated-shops/status' + WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel' WHEN '발주 등록' THEN 'bag/order/create' WHEN '발주 변경' THEN 'bag/bag-orders' WHEN '발주 현황' THEN 'bag/bag-orders' diff --git a/writable/database/menu_site_seed_from_csv.sql b/writable/database/menu_site_seed_from_csv.sql index 85a9a60..ff3bc57 100644 --- a/writable/database/menu_site_seed_from_csv.sql +++ b/writable/database/menu_site_seed_from_csv.sql @@ -33,12 +33,12 @@ SELECT @mt_site, 1, t.mm_name, WHEN '업체 관리' THEN 'bag/companies' WHEN '무료용 대상자 관리' THEN 'bag/free-recipients' WHEN '지정 판매소 관리' THEN 'bag/designated-shops' - WHEN '지정 판매소 조회' THEN 'bag/designated-shops' - WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/status' - WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops' + WHEN '지정 판매소 조회' THEN 'bag/designated-shops/browse' + WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/district-new-cancel' + WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops/barcode' WHEN 'PASSWORD 변경' THEN 'bag/password-change' WHEN '환경 설정' THEN 'dashboard' - WHEN '지정 판매소 현황' THEN 'bag/designated-shops/status' + WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel' ELSE '' END, @parent_basic, 1, t.mm_num, 0, '', 'Y' diff --git a/writable/database/seed_designated_shops_status_multi_years.sql b/writable/database/seed_designated_shops_status_multi_years.sql new file mode 100644 index 0000000..a717bae --- /dev/null +++ b/writable/database/seed_designated_shops_status_multi_years.sql @@ -0,0 +1,64 @@ +-- 지정판매소 신규/취소 현황(연도별) 테스트 데이터 +-- 목적: district-new-cancel/status 화면에서 2022~2025 연도 전환 테스트 +-- 기본값: ds_lg_idx=1, ds_gugun_code=110209(북구). 환경에 맞게 값 변경 후 실행하세요. +-- 실행 예: +-- mysql -h 127.0.0.1 -u jongryangje -p jongryangje_dev < writable/database/seed_designated_shops_status_multi_years.sql + +SET NAMES utf8mb4; + +-- 재실행 가능하도록 테스트 prefix 데이터만 정리 +DELETE FROM `designated_shop` WHERE `ds_shop_no` LIKE 'ZZSTAT-%'; + +INSERT INTO `designated_shop` ( + `ds_lg_idx`, `ds_mb_idx`, `ds_shop_no`, `ds_name`, `ds_biz_no`, `ds_rep_name`, + `ds_va_number`, `ds_zip`, `ds_addr`, `ds_addr_jibun`, `ds_addr_detail`, + `ds_tel`, `ds_rep_phone`, `ds_email`, + `ds_gugun_code`, `ds_zone_code`, + `ds_designated_at`, `ds_state`, `ds_state_changed_at`, `ds_change_reason`, `ds_regdate` +) VALUES +-- 2022 지정 2건 +(1, NULL, 'ZZSTAT-2201', '현황테스트 A', '901-22-00001', '홍길동', '', '41590', '대구광역시 북구 테스트로 2201', '대구 북구 테스트동 2201', '101호', '053-220-0001', '01022000001', 'zzstat2201@test.local', '110209', '북구-A', '2022-03-01', 1, NULL, '', '2022-03-01 09:00:00'), +(1, NULL, 'ZZSTAT-2202', '현황테스트 J', '901-22-00002', '김현황', '', '41590', '대구광역시 북구 테스트로 2202', '대구 북구 테스트동 2202', '102호', '053-220-0002', '01022000002', 'zzstat2202@test.local', '110209', '북구-A', '2022-08-08', 2, '2023-12-20', '테스트 취소(2023)', '2022-08-08 09:00:00'), + +-- 2023 지정 2건 (이 중 1건은 2024에 취소) +(1, NULL, 'ZZSTAT-2301', '현황테스트 B', '901-23-00001', '이현황', '', '41590', '대구광역시 북구 테스트로 2301', '대구 북구 테스트동 2301', '201호', '053-230-0001', '01023000001', 'zzstat2301@test.local', '110209', '북구-B', '2023-04-10', 1, NULL, '', '2023-04-10 09:00:00'), +(1, NULL, 'ZZSTAT-2302', '현황테스트 C', '901-23-00002', '박현황', '', '41590', '대구광역시 북구 테스트로 2302', '대구 북구 테스트동 2302', '202호', '053-230-0002', '01023000002', 'zzstat2302@test.local', '110209', '북구-B', '2023-07-01', 2, '2024-05-02', '테스트 취소(2024)', '2023-07-01 09:00:00'), + +-- 2024 지정 4건 (이 중 1건은 2024에 취소) +(1, NULL, 'ZZSTAT-2401', '현황테스트 D', '901-24-00001', '최현황', '', '41590', '대구광역시 북구 테스트로 2401', '대구 북구 테스트동 2401', '301호', '053-240-0001', '01024000001', 'zzstat2401@test.local', '110209', '북구-C', '2024-01-15', 1, NULL, '', '2024-01-15 09:00:00'), +(1, NULL, 'ZZSTAT-2402', '현황테스트 E', '901-24-00002', '정현황', '', '41590', '대구광역시 북구 테스트로 2402', '대구 북구 테스트동 2402', '302호', '053-240-0002', '01024000002', 'zzstat2402@test.local', '110209', '북구-C', '2024-06-20', 1, NULL, '', '2024-06-20 09:00:00'), +(1, NULL, 'ZZSTAT-2403', '현황테스트 F', '901-24-00003', '강현황', '', '41590', '대구광역시 북구 테스트로 2403', '대구 북구 테스트동 2403', '303호', '053-240-0003', '01024000003', 'zzstat2403@test.local', '110209', '북구-D', '2024-09-01', 3, '2024-12-01', '테스트 해지(2024)', '2024-09-01 09:00:00'), +(1, NULL, 'ZZSTAT-2404', '현황테스트 G', '901-24-00004', '신현황', '', '41590', '대구광역시 북구 테스트로 2404', '대구 북구 테스트동 2404', '304호', '053-240-0004', '01024000004', 'zzstat2404@test.local', '110209', '북구-D', '2024-11-12', 1, NULL, '', '2024-11-12 09:00:00'), + +-- 2025 지정 2건 (이 중 1건은 2025에 취소) +(1, NULL, 'ZZSTAT-2501', '현황테스트 H', '901-25-00001', '오현황', '', '41590', '대구광역시 북구 테스트로 2501', '대구 북구 테스트동 2501', '401호', '053-250-0001', '01025000001', 'zzstat2501@test.local', '110209', '북구-E', '2025-02-01', 1, NULL, '', '2025-02-01 09:00:00'), +(1, NULL, 'ZZSTAT-2502', '현황테스트 I', '901-25-00002', '유현황', '', '41590', '대구광역시 북구 테스트로 2502', '대구 북구 테스트동 2502', '402호', '053-250-0002', '01025000002', 'zzstat2502@test.local', '110209', '북구-E', '2025-03-20', 2, '2025-10-15', '테스트 취소(2025)', '2025-03-20 09:00:00'); + +-- 참고: 추가된 "지정" 건수(연도별) +-- 2022: 2건 / 2023: 2건 / 2024: 4건 / 2025: 2건 + +-- 검증 1) prefix 데이터의 연도별 지정/취소 건수 +-- SELECT +-- YEAR(ds_designated_at) AS yr, +-- COUNT(*) AS designated_cnt, +-- SUM(CASE WHEN ds_state IN (2,3) AND ds_state_changed_at IS NOT NULL THEN YEAR(ds_state_changed_at)=YEAR(ds_designated_at) ELSE 0 END) AS same_year_cancel_cnt +-- FROM designated_shop +-- WHERE ds_shop_no LIKE 'ZZSTAT-%' +-- GROUP BY YEAR(ds_designated_at) +-- ORDER BY yr; + +-- 검증 2) 2024 현황 기대값(prefix 데이터만) +-- 종전(2023말)=3, 지정(2024)=4, 취소(2024)=2, 현행(2024말)=5 +-- SELECT +-- SUM(CASE +-- WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= '2023-12-31' +-- AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > '2023-12-31')) +-- THEN 1 ELSE 0 END) AS prev_end_2023, +-- SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = 2024 THEN 1 ELSE 0 END) AS designated_2024, +-- SUM(CASE WHEN ds_state IN (2,3) AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = 2024 THEN 1 ELSE 0 END) AS cancelled_2024, +-- SUM(CASE +-- WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= '2024-12-31' +-- AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > '2024-12-31')) +-- THEN 1 ELSE 0 END) AS curr_end_2024 +-- FROM designated_shop +-- WHERE ds_shop_no LIKE 'ZZSTAT-%';