diff --git a/.cursor/rules/dependency-security.mdc b/.cursor/rules/dependency-security.mdc
new file mode 100644
index 0000000..bc9a571
--- /dev/null
+++ b/.cursor/rules/dependency-security.mdc
@@ -0,0 +1,10 @@
+---
+description: 패키지 설치 전 승인·안정성 확인 및 공급망 보안 습관
+alwaysApply: true
+---
+
+# 의존성·패키지 보안
+
+- **새 패키지(npm, Composer 등)를 설치·추가하기 전에 반드시 사용자에게 먼저 물어본다.** 자동으로 `npm install`, `composer require` 등을 실행하지 않는다(사용자가 명시적으로 요청한 경우만).
+- 새 버전을 제안할 때는 **공식 레지스트리(npmjs.org, packagist.org) 출처**인지 확인하고, **출시된 지 최소 며칠(가이드: 7일) 이상 지난 안정(stable) 버전**을 우선 제안한다. 방금 출시된 버전은 typosquat·피싱 패키지 위험이 있어 사용자에게 그 점을 짚어 준다.
+- 락 파일(`package-lock.json`, `composer.lock`)을 대량 수정하거나 생소한 패키지를 넣지 않는다. 이상하면 사용자에게 중단하고 확인을 요청한다.
diff --git a/app/Config/Kakao.php b/app/Config/Kakao.php
new file mode 100644
index 0000000..40d59c6
--- /dev/null
+++ b/app/Config/Kakao.php
@@ -0,0 +1,26 @@
+javascriptKey = $v;
+ }
+ }
+}
diff --git a/app/Models/DesignatedShopModel.php b/app/Models/DesignatedShopModel.php
index 2b8394a..a43c1a6 100644
--- a/app/Models/DesignatedShopModel.php
+++ b/app/Models/DesignatedShopModel.php
@@ -17,16 +17,25 @@ class DesignatedShopModel extends Model
'ds_name',
'ds_biz_no',
'ds_rep_name',
+ 'ds_biz_type',
+ 'ds_biz_kind',
'ds_va_number',
+ 'ds_va_bank',
+ 'ds_va_account',
'ds_zip',
'ds_addr',
'ds_addr_jibun',
+ 'ds_addr_detail',
'ds_tel',
'ds_rep_phone',
'ds_email',
'ds_gugun_code',
+ 'ds_zone_code',
+ 'ds_branch_no',
'ds_designated_at',
'ds_state',
+ 'ds_state_changed_at',
+ 'ds_change_reason',
'ds_regdate',
];
}
diff --git a/app/Views/admin/designated_shop/create.php b/app/Views/admin/designated_shop/create.php
index 1dcd33d..2b2fe46 100644
--- a/app/Views/admin/designated_shop/create.php
+++ b/app/Views/admin/designated_shop/create.php
@@ -2,7 +2,7 @@
지정판매소 등록
+
+
+
판매소번호
-
등록 시 자동 부여 (지자체코드 + 일련번호 3자리)
+
등록 시 자동 부여 (주소 기준 기본코드 B·C·D 조합 + 일련번호 3자리)
@@ -49,23 +53,50 @@
- 가상계좌
-
+ 업태
+
+
+
+
+ 업종
+
+
+
+
+ 가상계좌(은행)
+
+
+
+
+ 계좌번호
+
+
+
+
+
주소
+
우편번호 옆 주소 검색 으로만 지정합니다(직접 입력 불가). 지도 는 카카오맵에서 현재 입력된 주소를 검색해 엽니다. 관할이 아니면 검색 직후 안내 후 반영되지 않습니다. 동·호 등은 아래 상세주소 에 입력하세요.
우편번호
-
+
+ 주소 검색
+ = view('components/kakao_map_link_button', ['buttonId' => 'btn-ds-kakao-map-create']) ?>
도로명주소
-
+
지번주소
-
+
+
+
+
+ 상세주소
+
@@ -88,11 +119,31 @@
해당 지자체(구·군) 코드로 등록 시 자동 설정
+
+ 구역
+
+
+
+
+ 종사업장번호
+
+
+
지정일자
+
+ 변경일자
+
+
+
+
+ 변경사유
+
+
+
등록
목록
@@ -100,3 +151,17 @@
+= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
+
+= view('components/kakao_address_search', [
+ 'buttonId' => 'btn-ds-kakao-postcode',
+ 'zipName' => 'ds_zip',
+ 'roadName' => 'ds_addr',
+ 'jibunName' => 'ds_addr_jibun',
+ 'sidoFieldName' => 'addr_search_sido',
+ 'sigunguFieldName' => 'addr_search_sigungu',
+ 'tenantScope' => $addrTenantScope ?? ['lg_sido' => '', 'lg_gugun' => ''],
+ 'roadBaseOnly' => true,
+ 'detailFieldName' => 'ds_addr_detail',
+]) ?>
+
diff --git a/app/Views/admin/designated_shop/edit.php b/app/Views/admin/designated_shop/edit.php
index b93a954..4069f3e 100644
--- a/app/Views/admin/designated_shop/edit.php
+++ b/app/Views/admin/designated_shop/edit.php
@@ -5,20 +5,35 @@ if ($shop === null) {
return;
}
$v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($key) : ($shop->{$key} ?? $default);
+$vaAccountDefault = (isset($shop->ds_va_account) && (string) $shop->ds_va_account !== '')
+ ? (string) $shop->ds_va_account
+ : (string) ($shop->ds_va_number ?? '');
+$dateField = static function (string $key) use ($shop, $v): string {
+ $s = (string) $v($key);
+ if ($s === '' || str_starts_with($s, '0000')) {
+ return '';
+ }
+
+ return $s;
+};
?>
- 가상계좌
-
+ 업태
+
+
+
+
+ 업종
+
+
+
+
+ 가상계좌(은행)
+
+
+
+
+ 계좌번호
+
+
+
+
+
주소
+
우편·도로명·지번은 주소 검색 으로만 바꿀 수 있습니다(직접 입력 불가). 지도 는 카카오맵에서 현재 입력된 주소를 검색해 엽니다. 관할이 아니면 검색 직후 안내 후 반영되지 않습니다. 동·호 등은 상세주소 에 입력하세요.
우편번호
-
+
+ 주소 검색
+ = view('components/kakao_map_link_button', ['buttonId' => 'btn-ds-kakao-map-edit']) ?>
도로명주소
-
+
지번주소
-
+
+
+
+
+ 상세주소
+
@@ -83,6 +125,16 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
+
+ 구역
+
+
+
+
+ 종사업장번호
+
+
+
지정일자
@@ -97,9 +149,33 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
+
+ 변경일자
+
+
+
+
+ 변경사유
+
+
+
+
+= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
+
+= view('components/kakao_address_search', [
+ 'buttonId' => 'btn-ds-kakao-postcode-edit',
+ 'zipName' => 'ds_zip',
+ 'roadName' => 'ds_addr',
+ 'jibunName' => 'ds_addr_jibun',
+ 'sidoFieldName' => 'addr_search_sido',
+ 'sigunguFieldName' => 'addr_search_sigungu',
+ 'tenantScope' => $addrTenantScope ?? ['lg_sido' => '', 'lg_gugun' => ''],
+ 'roadBaseOnly' => true,
+ 'detailFieldName' => 'ds_addr_detail',
+]) ?>
diff --git a/app/Views/admin/designated_shop/index.php b/app/Views/admin/designated_shop/index.php
index 1bb609f..90e2fe9 100644
--- a/app/Views/admin/designated_shop/index.php
+++ b/app/Views/admin/designated_shop/index.php
@@ -1,19 +1,186 @@
-= view('components/print_header', ['printTitle' => '지정판매소 목록']) ?>
+
+= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
+
+
+
-
지정판매소 목록
+
= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?>
-
+
-
-
+
+ 0, 1 => 0, 2 => 0, 3 => 0];
+?>
+
+ 건수 : = (int) ($sc['total'] ?? 0) ?>
+ (정상 : = (int) ($sc[1] ?? 0) ?> / 폐업 : = (int) ($sc[2] ?? 0) ?> / 해지 : = (int) ($sc[3] ?? 0) ?>)
+
+
+
+
+
지정판매소 리스트
+
+
+
+
+ 번호
+ 구·군
+ 지정일
+ 구역
+ 대표자명
+ 상호명
+ 우편번호
+ 주소
+ 상세주소
+ 사업자번호
+ 전화
+ 상태
+
+
+
+ $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);
+ $stLabel = $st === 1 ? '' : ($st === 2 ? '폐업' : '해지');
+ $ggLabel = (string) ($row->ds_gugun_code ?? '');
+ $da = $row->ds_designated_at ?? null;
+ $daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
+ $zone = (string) ($row->ds_zone_code ?? '');
+ $zipList = trim((string) ($row->ds_zip ?? ''));
+ $roadL = trim((string) ($row->ds_addr ?? ''));
+ $jibunL = trim((string) ($row->ds_addr_jibun ?? ''));
+ $addrMainList = $roadL !== '' ? $roadL : $jibunL;
+ $addrDetailList = trim((string) ($row->ds_addr_detail ?? ''));
+ ?>
+
+ = esc($shortNo) ?>
+ = esc($ggLabel) ?>
+ = esc($daDisp) ?>
+ = esc($zone) ?>
+ = esc($row->ds_rep_name ?? '') ?>
+ = esc($row->ds_name ?? '') ?>
+ = esc($zipList) ?>
+ = esc($addrMainList) ?>
+ = esc($addrDetailList) ?>
+ = esc($row->ds_biz_no ?? '') ?>
+ = esc($row->ds_tel ?? '') ?>
+ = esc($stLabel) ?>
+
+
+
+
+
+
+
+
+
지정판매소 정보
+
+
위 목록에서 행을 선택하세요.
+
+
+
+
+
+ 판매소번호
+ 상호명
+ 우편번호
+ 사업자번호
+ 일반전화
+ 대표자명
+ 이메일
+ 업태
+ 업종
+ 지정일자
+ 지자체
+ 도로명주소
+ 지번주소
+ 상세주소
+ 개인전화
+ 구코드
+ 구역
+ 가상계좌(은행)
+ 계좌번호
+ 종사업장번호
+ 변경일자
+ 영업상태
+ 등록일시
+ 변경사유
+ 지도
+
+
+
+
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+ —
+
+ 지도
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+= $pager->links() ?>
+
+
+= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
+
+
+
+
+
+
+
- 번호
+ 번호
지자체
- 판매소번호
+ 구·군
+ 지정일
+ 구역
+ 대표자명
상호명
- 대표자
+ 우편번호
+ 주소
+ 상세주소
사업자번호
+ 전화
+ 판매소번호
가상계좌
상태
등록일
- 작업
-
+
+ ds_shop_no ?? '');
+ if (preg_match('/(\d{3})$/', $snP, $mP)) {
+ $shortNoP = $mP[1];
+ } elseif ($snP !== '' && strlen($snP) >= 3) {
+ $shortNoP = substr($snP, -3);
+ } else {
+ $shortNoP = $snP;
+ }
+ $daP = $row->ds_designated_at ?? null;
+ $daDispP = ($daP !== null && $daP !== '' && (string) $daP !== '0000-00-00') ? substr((string) $daP, 0, 10) : '';
+ $stP = (int) ($row->ds_state ?? 1);
+ $stLabP = $stP === 1 ? '정상' : ($stP === 2 ? '폐업' : '직권해지');
+ $zipP = trim((string) ($row->ds_zip ?? ''));
+ $roadP = trim((string) ($row->ds_addr ?? ''));
+ $jibP = trim((string) ($row->ds_addr_jibun ?? ''));
+ $addrP = $roadP !== '' ? $roadP : $jibP;
+ $detP = trim((string) ($row->ds_addr_detail ?? ''));
+ ?>
- = esc($row->ds_idx) ?>
- = esc($lgMap[$row->ds_lg_idx] ?? '') ?>
- = esc($row->ds_shop_no) ?>
- = esc($row->ds_name) ?>
- = esc($row->ds_rep_name) ?>
- = esc($row->ds_biz_no) ?>
- = esc($row->ds_va_number) ?>
- = (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?>
- = esc($row->ds_regdate ?? '') ?>
-
- 수정
-
-
+ = esc($shortNoP) ?>
+ = esc($lgMap[$row->ds_lg_idx] ?? '') ?>
+ = esc($row->ds_gugun_code ?? '') ?>
+ = esc($daDispP) ?>
+ = esc($row->ds_zone_code ?? '') ?>
+ = esc($row->ds_rep_name ?? '') ?>
+ = esc($row->ds_name ?? '') ?>
+ = esc($zipP) ?>
+ = esc($addrP) ?>
+ = esc($detP) ?>
+ = esc($row->ds_biz_no ?? '') ?>
+ = esc($row->ds_tel ?? '') ?>
+ = esc($row->ds_shop_no) ?>
+ = esc($row->ds_va_number) ?>
+ = esc($stLabP) ?>
+ = esc($row->ds_regdate ?? '') ?>
-= $pager->links() ?>
-
diff --git a/app/Views/admin/designated_shop/map.php b/app/Views/admin/designated_shop/map.php
index 8dcea39..6fec1ea 100644
--- a/app/Views/admin/designated_shop/map.php
+++ b/app/Views/admin/designated_shop/map.php
@@ -8,12 +8,12 @@
총 = count($shops) ?>개 판매소 표시
-
+
+
diff --git a/app/Views/components/kakao_map_link_button.php b/app/Views/components/kakao_map_link_button.php
new file mode 100644
index 0000000..148ff1d
--- /dev/null
+++ b/app/Views/components/kakao_map_link_button.php
@@ -0,0 +1,47 @@
+
+= esc($label) ?>
+
diff --git a/app/Views/components/kakao_map_modal.php b/app/Views/components/kakao_map_modal.php
new file mode 100644
index 0000000..860ba3d
--- /dev/null
+++ b/app/Views/components/kakao_map_modal.php
@@ -0,0 +1,153 @@
+
+
+
diff --git a/playwright.config.js b/playwright.config.js
index f57c4d8..975c29b 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -11,7 +11,7 @@ module.exports = defineConfig({
timeout: 60000,
use: {
- baseURL: 'http://localhost:8045',
+ baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8045',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
locale: 'ko-KR',
diff --git a/writable/database/after_company_feature_tables.sql b/writable/database/after_company_feature_tables.sql
index dedeeb1..9ab1302 100644
--- a/writable/database/after_company_feature_tables.sql
+++ b/writable/database/after_company_feature_tables.sql
@@ -29,16 +29,25 @@ CREATE TABLE IF NOT EXISTS `designated_shop` (
`ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '상호명',
`ds_biz_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '사업자번호',
`ds_rep_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '대표자명',
- `ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '고정 가상계좌 번호',
+ `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업태',
+ `ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업종',
+ `ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '가상계좌(표시용 번호, 계좌번호와 동기화 가능)',
+ `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '가상계좌(은행)',
+ `ds_va_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '계좌번호',
`ds_zip` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '우편번호',
`ds_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '도로명주소',
`ds_addr_jibun` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '지번주소',
+ `ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '상세주소(동·호 등)',
`ds_tel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '일반전화',
`ds_rep_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '개인전화',
`ds_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
`ds_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구코드',
+ `ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '구역',
+ `ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '종사업장번호',
`ds_designated_at` DATE NULL DEFAULT NULL COMMENT '지정일자',
`ds_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=폐업, 3=직권해지',
+ `ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT '변경일자',
+ `ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '변경사유',
`ds_regdate` DATETIME NOT NULL COMMENT '등록일시',
PRIMARY KEY (`ds_idx`),
KEY `idx_ds_lg_idx` (`ds_lg_idx`),
diff --git a/writable/database/designated_shop_addr_detail.sql b/writable/database/designated_shop_addr_detail.sql
new file mode 100644
index 0000000..a05e498
--- /dev/null
+++ b/writable/database/designated_shop_addr_detail.sql
@@ -0,0 +1,5 @@
+-- 지정판매소 상세주소(건물명·동·호 등) — 주소 검색으로 채운 도로명/지번과 별도 입력
+SET NAMES utf8mb4;
+
+ALTER TABLE `designated_shop`
+ ADD COLUMN `ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '상세주소(동·호 등)' AFTER `ds_addr_jibun`;
diff --git a/writable/database/designated_shop_ensure_app_columns.sql b/writable/database/designated_shop_ensure_app_columns.sql
new file mode 100644
index 0000000..114d0e6
--- /dev/null
+++ b/writable/database/designated_shop_ensure_app_columns.sql
@@ -0,0 +1,108 @@
+-- 지정판매소: 앱(DesignatedShopModel / Admin\DesignatedShop)이 기대하는 컬럼을
+-- 없을 때만 추가합니다. 기존 DB를 login_tables.sql 최신 정의와 맞출 때 사용.
+-- 실행 예: mysql -h 127.0.0.1 -u USER -p DBNAME < writable/database/designated_shop_ensure_app_columns.sql
+--
+-- kr_address 등 외부 테이블 불필요. INFORMATION_SCHEMA 로 존재 여부만 확인합니다.
+
+SET NAMES utf8mb4;
+
+SET @db = DATABASE();
+
+-- ds_biz_type
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_biz_type') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '''' COMMENT ''업태'' AFTER `ds_rep_name`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_biz_kind
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_biz_kind') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '''' COMMENT ''업종'' AFTER `ds_biz_type`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_va_bank (ds_va_number 뒤 — 없으면 ds_biz_kind 뒤에 붙임)
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_bank') > 0,
+ 'SELECT 1',
+ IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_number') > 0,
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '''' COMMENT ''가상계좌(은행)'' AFTER `ds_va_number`',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '''' COMMENT ''가상계좌(은행)'' AFTER `ds_biz_kind`'
+ )
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_va_account
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_account') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_va_account` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''계좌번호'' AFTER `ds_va_bank`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_addr_detail
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_addr_detail') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '''' COMMENT ''상세주소(동·호 등)'' AFTER `ds_addr_jibun`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_zone_code
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_zone_code') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '''' COMMENT ''구역'' AFTER `ds_gugun_code`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_branch_no
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_branch_no') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''종사업장번호'' AFTER `ds_zone_code`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_state_changed_at
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_state_changed_at') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT ''변경일자'' AFTER `ds_state`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_change_reason
+SET @s = (SELECT IF(
+ (SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_change_reason') > 0,
+ 'SELECT 1',
+ 'ALTER TABLE `designated_shop` ADD COLUMN `ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''변경사유'' AFTER `ds_state_changed_at`'
+));
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ds_va_number 뒤에 va_bank를 넣었을 수 있음 — 구 스키마에 ds_designated_at 등만 있는 경우
+UPDATE `designated_shop`
+SET `ds_va_account` = `ds_va_number`
+WHERE EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_account'
+)
+AND EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_number'
+)
+AND (`ds_va_account` = '' OR `ds_va_account` IS NULL)
+AND `ds_va_number` IS NOT NULL
+AND `ds_va_number` != '';
diff --git a/writable/database/designated_shop_extended_columns.sql b/writable/database/designated_shop_extended_columns.sql
new file mode 100644
index 0000000..db0ca16
--- /dev/null
+++ b/writable/database/designated_shop_extended_columns.sql
@@ -0,0 +1,25 @@
+-- 지정판매소 확장 컬럼 (업태·업종·구역·종사업장·가상계좌 은행/계좌·변경일자·변경사유)
+-- 기존 DB: mysql ... < writable/database/designated_shop_extended_columns.sql
+-- 컬럼이 이미 있으면 수동으로 스킵하거나 에러 무시 후 진행
+--
+-- 권장: 컬럼 유무를 자동 판별하려면 대신
+-- writable/database/designated_shop_ensure_app_columns.sql
+-- 를 실행하세요(여러 번 실행해도 안전).
+
+SET NAMES utf8mb4;
+
+ALTER TABLE `designated_shop`
+ ADD COLUMN `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업태' AFTER `ds_rep_name`,
+ ADD COLUMN `ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업종' AFTER `ds_biz_type`,
+ ADD COLUMN `ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '구역' AFTER `ds_gugun_code`,
+ ADD COLUMN `ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '종사업장번호' AFTER `ds_zone_code`,
+ ADD COLUMN `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '가상계좌(은행)' AFTER `ds_va_number`,
+ ADD COLUMN `ds_va_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '계좌번호' AFTER `ds_va_bank`,
+ ADD COLUMN `ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT '변경일자' AFTER `ds_state`,
+ ADD COLUMN `ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '변경사유' AFTER `ds_state_changed_at`;
+
+UPDATE `designated_shop`
+SET `ds_va_account` = `ds_va_number`
+WHERE (`ds_va_account` = '' OR `ds_va_account` IS NULL)
+ AND `ds_va_number` IS NOT NULL
+ AND `ds_va_number` != '';
diff --git a/writable/database/login_tables.sql b/writable/database/login_tables.sql
index 591e569..ff23343 100644
--- a/writable/database/login_tables.sql
+++ b/writable/database/login_tables.sql
@@ -106,16 +106,25 @@ CREATE TABLE IF NOT EXISTS `designated_shop` (
`ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '상호명',
`ds_biz_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '사업자번호',
`ds_rep_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '대표자명',
- `ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '고정 가상계좌 번호',
+ `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업태',
+ `ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업종',
+ `ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '가상계좌(표시용 번호, 계좌번호와 동기화 가능)',
+ `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '가상계좌(은행)',
+ `ds_va_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '계좌번호',
`ds_zip` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '우편번호',
`ds_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '도로명주소',
`ds_addr_jibun` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '지번주소',
+ `ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '상세주소(동·호 등)',
`ds_tel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '일반전화',
`ds_rep_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '개인전화',
`ds_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
`ds_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구코드',
+ `ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '구역',
+ `ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '종사업장번호',
`ds_designated_at` DATE NULL DEFAULT NULL COMMENT '지정일자',
`ds_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=폐업, 3=직권해지',
+ `ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT '변경일자',
+ `ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '변경사유',
`ds_regdate` DATETIME NOT NULL COMMENT '등록일시',
PRIMARY KEY (`ds_idx`),
KEY `idx_ds_lg_idx` (`ds_lg_idx`),
diff --git a/writable/database/seed_designated_shops_test_30.sql b/writable/database/seed_designated_shops_test_30.sql
new file mode 100644
index 0000000..e9e1b73
--- /dev/null
+++ b/writable/database/seed_designated_shops_test_30.sql
@@ -0,0 +1,43 @@
+-- 테스트 지정판매소 30건 (동일 스크립트 재실행 시 기존 ZZTEST 행 삭제 후 삽입)
+-- 기본: ds_lg_idx = 1 (북구청), ds_gugun_code = 110209 — 환경에 맞게 아래 DELETE/INSERT의 1, 110209만 조정하세요.
+-- 실행: mysql -h 127.0.0.1 -u jongryangje -p jongryangje_dev < writable/database/seed_designated_shops_test_30.sql
+
+SET NAMES utf8mb4;
+
+DELETE FROM `designated_shop` WHERE `ds_shop_no` LIKE 'ZZTEST-%';
+
+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_tel`, `ds_rep_phone`, `ds_email`,
+ `ds_gugun_code`, `ds_designated_at`, `ds_state`, `ds_regdate`
+) VALUES
+(1, NULL, 'ZZTEST-00001', '테스트편의점 01', '784-12-00001', '김테01', '', '41590', '대구광역시 북구 테스트로 1', '대구 북구 테스트동 1', '053-000-0001', '01010000001', 'seed01@test.local', '110209', '2026-01-01', 1, NOW()),
+(1, NULL, 'ZZTEST-00002', '테스트편의점 02', '784-12-00002', '김테02', '', '41590', '대구광역시 북구 테스트로 2', '대구 북구 테스트동 2', '053-000-0002', '01010000002', 'seed02@test.local', '110209', '2026-01-02', 1, NOW()),
+(1, NULL, 'ZZTEST-00003', '테스트편의점 03', '784-12-00003', '김테03', '', '41590', '대구광역시 북구 테스트로 3', '대구 북구 테스트동 3', '053-000-0003', '01010000003', 'seed03@test.local', '110209', '2026-01-03', 1, NOW()),
+(1, NULL, 'ZZTEST-00004', '테스트편의점 04', '784-12-00004', '김테04', '', '41590', '대구광역시 북구 테스트로 4', '대구 북구 테스트동 4', '053-000-0004', '01010000004', 'seed04@test.local', '110209', '2026-01-04', 1, NOW()),
+(1, NULL, 'ZZTEST-00005', '테스트편의점 05', '784-12-00005', '김테05', '', '41590', '대구광역시 북구 테스트로 5', '대구 북구 테스트동 5', '053-000-0005', '01010000005', 'seed05@test.local', '110209', '2026-01-05', 1, NOW()),
+(1, NULL, 'ZZTEST-00006', '테스트편의점 06', '784-12-00006', '김테06', '', '41590', '대구광역시 북구 테스트로 6', '대구 북구 테스트동 6', '053-000-0006', '01010000006', 'seed06@test.local', '110209', '2026-01-06', 1, NOW()),
+(1, NULL, 'ZZTEST-00007', '테스트편의점 07', '784-12-00007', '김테07', '', '41590', '대구광역시 북구 테스트로 7', '대구 북구 테스트동 7', '053-000-0007', '01010000007', 'seed07@test.local', '110209', '2026-01-07', 1, NOW()),
+(1, NULL, 'ZZTEST-00008', '테스트편의점 08', '784-12-00008', '김테08', '', '41590', '대구광역시 북구 테스트로 8', '대구 북구 테스트동 8', '053-000-0008', '01010000008', 'seed08@test.local', '110209', '2026-01-08', 1, NOW()),
+(1, NULL, 'ZZTEST-00009', '테스트편의점 09', '784-12-00009', '김테09', '', '41590', '대구광역시 북구 테스트로 9', '대구 북구 테스트동 9', '053-000-0009', '01010000009', 'seed09@test.local', '110209', '2026-01-09', 1, NOW()),
+(1, NULL, 'ZZTEST-00010', '테스트편의점 10', '784-12-00010', '김테10', '', '41590', '대구광역시 북구 테스트로 10', '대구 북구 테스트동 10', '053-000-0010', '01010000010', 'seed10@test.local', '110209', '2026-01-10', 1, NOW()),
+(1, NULL, 'ZZTEST-00011', '테스트마트 11', '784-12-00011', '이테11', '', '41590', '대구광역시 북구 테스트로 11', '대구 북구 테스트동 11', '053-000-0011', '01010000011', 'seed11@test.local', '110209', '2026-01-11', 2, NOW()),
+(1, NULL, 'ZZTEST-00012', '테스트마트 12', '784-12-00012', '이테12', '', '41590', '대구광역시 북구 테스트로 12', '대구 북구 테스트동 12', '053-000-0012', '01010000012', 'seed12@test.local', '110209', '2026-01-12', 1, NOW()),
+(1, NULL, 'ZZTEST-00013', '테스트마트 13', '784-12-00013', '이테13', '', '41590', '대구광역시 북구 테스트로 13', '대구 북구 테스트동 13', '053-000-0013', '01010000013', 'seed13@test.local', '110209', '2026-01-13', 1, NOW()),
+(1, NULL, 'ZZTEST-00014', '테스트마트 14', '784-12-00014', '이테14', '', '41590', '대구광역시 북구 테스트로 14', '대구 북구 테스트동 14', '053-000-0014', '01010000014', 'seed14@test.local', '110209', '2026-01-14', 1, NOW()),
+(1, NULL, 'ZZTEST-00015', '테스트마트 15', '784-12-00015', '이테15', '', '41590', '대구광역시 북구 테스트로 15', '대구 북구 테스트동 15', '053-000-0015', '01010000015', 'seed15@test.local', '110209', '2026-01-15', 1, NOW()),
+(1, NULL, 'ZZTEST-00016', '테스트슈퍼 16', '784-12-00016', '박테16', '', '41590', '대구광역시 북구 테스트로 16', '대구 북구 테스트동 16', '053-000-0016', '01010000016', 'seed16@test.local', '110209', '2026-01-16', 1, NOW()),
+(1, NULL, 'ZZTEST-00017', '테스트슈퍼 17', '784-12-00017', '박테17', '', '41590', '대구광역시 북구 테스트로 17', '대구 북구 테스트동 17', '053-000-0017', '01010000017', 'seed17@test.local', '110209', '2026-01-17', 1, NOW()),
+(1, NULL, 'ZZTEST-00018', '테스트슈퍼 18', '784-12-00018', '박테18', '', '41590', '대구광역시 북구 테스트로 18', '대구 북구 테스트동 18', '053-000-0018', '01010000018', 'seed18@test.local', '110209', '2026-01-18', 1, NOW()),
+(1, NULL, 'ZZTEST-00019', '테스트슈퍼 19', '784-12-00019', '박테19', '', '41590', '대구광역시 북구 테스트로 19', '대구 북구 테스트동 19', '053-000-0019', '01010000019', 'seed19@test.local', '110209', '2026-01-19', 1, NOW()),
+(1, NULL, 'ZZTEST-00020', '테스트슈퍼 20', '784-12-00020', '박테20', '', '41590', '대구광역시 북구 테스트로 20', '대구 북구 테스트동 20', '053-000-0020', '01010000020', 'seed20@test.local', '110209', '2026-01-20', 1, NOW()),
+(1, NULL, 'ZZTEST-00021', '테스트상회 21', '784-12-00021', '최테21', '', '41590', '대구광역시 북구 테스트로 21', '대구 북구 테스트동 21', '053-000-0021', '01010000021', 'seed21@test.local', '110209', '2026-01-21', 1, NOW()),
+(1, NULL, 'ZZTEST-00022', '테스트상회 22', '784-12-00022', '최테22', '', '41590', '대구광역시 북구 테스트로 22', '대구 북구 테스트동 22', '053-000-0022', '01010000022', 'seed22@test.local', '110209', '2026-01-22', 1, NOW()),
+(1, NULL, 'ZZTEST-00023', '테스트상회 23', '784-12-00023', '최테23', '', '41590', '대구광역시 북구 테스트로 23', '대구 북구 테스트동 23', '053-000-0023', '01010000023', 'seed23@test.local', '110209', '2026-01-23', 1, NOW()),
+(1, NULL, 'ZZTEST-00024', '테스트상회 24', '784-12-00024', '최테24', '', '41590', '대구광역시 북구 테스트로 24', '대구 북구 테스트동 24', '053-000-0024', '01010000024', 'seed24@test.local', '110209', '2026-01-24', 1, NOW()),
+(1, NULL, 'ZZTEST-00025', '테스트상회 25', '784-12-00025', '최테25', '', '41590', '대구광역시 북구 테스트로 25', '대구 북구 테스트동 25', '053-000-0025', '01010000025', 'seed25@test.local', '110209', '2026-01-25', 1, NOW()),
+(1, NULL, 'ZZTEST-00026', '테스트복합 26', '784-12-00026', '정테26', '', '41590', '대구광역시 북구 테스트로 26', '대구 북구 테스트동 26', '053-000-0026', '01010000026', 'seed26@test.local', '110209', '2026-01-26', 3, NOW()),
+(1, NULL, 'ZZTEST-00027', '테스트복합 27', '784-12-00027', '정테27', '', '41590', '대구광역시 북구 테스트로 27', '대구 북구 테스트동 27', '053-000-0027', '01010000027', 'seed27@test.local', '110209', '2026-01-27', 1, NOW()),
+(1, NULL, 'ZZTEST-00028', '테스트복합 28', '784-12-00028', '정테28', '', '41590', '대구광역시 북구 테스트로 28', '대구 북구 테스트동 28', '053-000-0028', '01010000028', 'seed28@test.local', '110209', '2026-01-28', 1, NOW()),
+(1, NULL, 'ZZTEST-00029', '테스트복합 29', '784-12-00029', '정테29', '', '41590', '대구광역시 북구 테스트로 29', '대구 북구 테스트동 29', '053-000-0029', '01010000029', 'seed29@test.local', '110209', '2026-01-29', 1, NOW()),
+(1, NULL, 'ZZTEST-00030', '테스트복합 30', '784-12-00030', '정테30', '', '41590', '대구광역시 북구 테스트로 30', '대구 북구 테스트동 30', '053-000-0030', '01010000030', 'seed30@test.local', '110209', '2026-01-30', 1, NOW());
diff --git a/writable/database/seed_tester_accounts_trash_host.sql b/writable/database/seed_tester_accounts_trash_host.sql
new file mode 100644
index 0000000..72f9464
--- /dev/null
+++ b/writable/database/seed_tester_accounts_trash_host.sql
@@ -0,0 +1,56 @@
+-- 테스터 계정 (비밀번호: test1234!) — 관리자 회원 등록과 동일 필드 구성
+-- 비밀번호 해시: PHP password_hash('test1234!', PASSWORD_DEFAULT)
+-- tester_local → local_government 중구청 (lg_idx=10, lg_code=110201)
+-- 실행 예: mysql -h 116.122.157.166 -P 3306 -u jongryangje -p jongryangje_dev < writable/database/seed_tester_accounts_trash_host.sql
+
+SET NAMES utf8mb4;
+SET @pw := '$2y$10$D.rk9Dtce7qitSCaPO0W2.DROcEwpe3otxE.QF0qWPb63bCBhtE5u';
+
+START TRANSACTION;
+
+DELETE mar FROM member_approval_request mar
+INNER JOIN member m ON m.mb_idx = mar.mb_idx
+WHERE m.mb_id IN ('tester_badmin', 'tester_admin', 'tester_local', 'tester_shop', 'tester_user');
+
+INSERT INTO `member` (
+ `mb_id`, `mb_passwd`, `mb_totp_secret`, `mb_totp_enabled`,
+ `mb_name`, `mb_email`, `mb_phone`, `mb_lang`,
+ `mb_level`, `mb_group`, `mb_lg_idx`, `mb_state`, `mb_regdate`
+) VALUES
+ ('tester_badmin', @pw, NULL, 0, '테스터본부', 'tester_badmin@test.com', '010-0000-0005', 'ko', 5, '', NULL, 1, NOW()),
+ ('tester_admin', @pw, NULL, 0, '테스터관리자', 'tester_admin@test.com', '010-0000-0001', 'ko', 4, '', NULL, 1, NOW()),
+ ('tester_local', @pw, NULL, 0, '테스터지자체(중구)', 'tester_local@test.com', '010-0000-0002', 'ko', 3, '', 10, 1, NOW()),
+ ('tester_shop', @pw, NULL, 0, '테스터판매소', 'tester_shop@test.com', '010-0000-0003', 'ko', 2, '', NULL, 1, NOW()),
+ ('tester_user', @pw, NULL, 0, '테스터사용자', 'tester_user@test.com', '010-0000-0004', 'ko', 1, '', NULL, 1, NOW())
+AS new
+ON DUPLICATE KEY UPDATE
+ `mb_passwd` = new.`mb_passwd`,
+ `mb_totp_secret` = NULL,
+ `mb_totp_enabled` = 0,
+ `mb_name` = new.`mb_name`,
+ `mb_email` = new.`mb_email`,
+ `mb_phone` = new.`mb_phone`,
+ `mb_level` = new.`mb_level`,
+ `mb_group` = new.`mb_group`,
+ `mb_lg_idx` = new.`mb_lg_idx`,
+ `mb_state` = 1;
+
+INSERT INTO `member_approval_request` (
+ `mb_idx`, `mar_requested_level`, `mar_status`, `mar_request_note`,
+ `mar_reject_reason`, `mar_requested_at`, `mar_requested_by`,
+ `mar_processed_at`, `mar_processed_by`
+)
+SELECT
+ m.`mb_idx`,
+ m.`mb_level`,
+ 'approved',
+ '테스트 계정 시드',
+ NULL,
+ NOW(),
+ m.`mb_idx`,
+ NOW(),
+ m.`mb_idx`
+FROM `member` m
+WHERE m.`mb_id` IN ('tester_badmin', 'tester_admin', 'tester_local', 'tester_shop', 'tester_user');
+
+COMMIT;