diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 16f19ab..8edc38e 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -65,6 +65,15 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo $routes->post('code-details/update/(:num)', 'Admin\CodeDetail::update/$1'); $routes->post('code-details/delete/(:num)', 'Admin\CodeDetail::delete/$1'); + // 봉투 단가 관리 (P2-03/04) + $routes->get('bag-prices', 'Admin\BagPrice::index'); + $routes->get('bag-prices/create', 'Admin\BagPrice::create'); + $routes->post('bag-prices/store', 'Admin\BagPrice::store'); + $routes->get('bag-prices/edit/(:num)', 'Admin\BagPrice::edit/$1'); + $routes->post('bag-prices/update/(:num)', 'Admin\BagPrice::update/$1'); + $routes->post('bag-prices/delete/(:num)', 'Admin\BagPrice::delete/$1'); + $routes->get('bag-prices/history/(:num)', 'Admin\BagPrice::history/$1'); + $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/BagPrice.php b/app/Controllers/Admin/BagPrice.php new file mode 100644 index 0000000..d0f5d3b --- /dev/null +++ b/app/Controllers/Admin/BagPrice.php @@ -0,0 +1,228 @@ +priceModel = model(BagPriceModel::class); + $this->historyModel = model(BagPriceHistoryModel::class); + } + + public function index() + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + if (!$lgIdx) { + return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.'); + } + + $builder = $this->priceModel->where('bp_lg_idx', $lgIdx); + + // 기간 필터 (P2-04) + $startDate = $this->request->getGet('start_date'); + $endDate = $this->request->getGet('end_date'); + if ($startDate) { + $builder->where('bp_start_date >=', $startDate); + } + if ($endDate) { + $builder->groupStart() + ->where('bp_end_date IS NULL') + ->orWhere('bp_end_date <=', $endDate) + ->groupEnd(); + } + + $list = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->findAll(); + + return view('admin/layout', [ + 'title' => '봉투 단가 관리', + 'content' => view('admin/bag_price/index', [ + 'list' => $list, + 'startDate' => $startDate, + 'endDate' => $endDate, + ]), + ]); + } + + public function create() + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + if (!$lgIdx) { + return redirect()->to(site_url('admin/bag-prices'))->with('error', '지자체를 선택해 주세요.'); + } + + // 봉투명 코드(O) 목록 + $kindModel = model(CodeKindModel::class); + $kind = $kindModel->where('ck_code', 'O')->first(); + $bagCodes = []; + if ($kind) { + $bagCodes = model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true); + } + + return view('admin/layout', [ + 'title' => '봉투 단가 등록', + 'content' => view('admin/bag_price/create', ['bagCodes' => $bagCodes]), + ]); + } + + public function store() + { + helper('admin'); + $lgIdx = admin_effective_lg_idx(); + + $rules = [ + 'bp_bag_code' => 'required|max_length[50]', + 'bp_order_price' => 'required|decimal', + 'bp_wholesale' => 'required|decimal', + 'bp_consumer' => 'required|decimal', + 'bp_start_date' => 'required|valid_date[Y-m-d]', + 'bp_end_date' => 'permit_empty|valid_date[Y-m-d]', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + // 봉투명 스냅샷 + $bagCode = $this->request->getPost('bp_bag_code'); + $kindModel = model(CodeKindModel::class); + $kind = $kindModel->where('ck_code', 'O')->first(); + $bagName = ''; + if ($kind) { + $detail = model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_code', $bagCode)->first(); + $bagName = $detail ? $detail->cd_name : ''; + } + + $this->priceModel->insert([ + 'bp_lg_idx' => $lgIdx, + 'bp_bag_code' => $bagCode, + 'bp_bag_name' => $bagName, + 'bp_order_price' => $this->request->getPost('bp_order_price'), + 'bp_wholesale' => $this->request->getPost('bp_wholesale'), + 'bp_consumer' => $this->request->getPost('bp_consumer'), + 'bp_start_date' => $this->request->getPost('bp_start_date'), + 'bp_end_date' => $this->request->getPost('bp_end_date') ?: null, + 'bp_state' => 1, + 'bp_regdate' => date('Y-m-d H:i:s'), + 'bp_reg_mb_idx' => session()->get('mb_idx'), + ]); + + return redirect()->to(site_url('admin/bag-prices'))->with('success', '봉투 단가가 등록되었습니다.'); + } + + public function edit(int $id) + { + helper('admin'); + $item = $this->priceModel->find($id); + if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) { + return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.'); + } + + $kindModel = model(CodeKindModel::class); + $kind = $kindModel->where('ck_code', 'O')->first(); + $bagCodes = []; + if ($kind) { + $bagCodes = model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true); + } + + return view('admin/layout', [ + 'title' => '봉투 단가 수정', + 'content' => view('admin/bag_price/edit', ['item' => $item, 'bagCodes' => $bagCodes]), + ]); + } + + public function update(int $id) + { + helper('admin'); + $item = $this->priceModel->find($id); + if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) { + return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.'); + } + + $rules = [ + 'bp_order_price' => 'required|decimal', + 'bp_wholesale' => 'required|decimal', + 'bp_consumer' => 'required|decimal', + 'bp_start_date' => 'required|valid_date[Y-m-d]', + 'bp_end_date' => 'permit_empty|valid_date[Y-m-d]', + 'bp_state' => 'required|in_list[0,1]', + ]; + + if (! $this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + // 이력 기록 + $db = \Config\Database::connect(); + $db->transStart(); + + $priceFields = ['bp_order_price', 'bp_wholesale', 'bp_consumer']; + foreach ($priceFields as $field) { + $oldVal = (string) $item->$field; + $newVal = (string) $this->request->getPost($field); + if ($oldVal !== $newVal) { + $this->historyModel->insert([ + 'bph_bp_idx' => $id, + 'bph_field' => $field, + 'bph_old_value' => $oldVal, + 'bph_new_value' => $newVal, + 'bph_changed_at'=> date('Y-m-d H:i:s'), + 'bph_changed_by'=> session()->get('mb_idx'), + ]); + } + } + + $this->priceModel->update($id, [ + 'bp_order_price' => $this->request->getPost('bp_order_price'), + 'bp_wholesale' => $this->request->getPost('bp_wholesale'), + 'bp_consumer' => $this->request->getPost('bp_consumer'), + 'bp_start_date' => $this->request->getPost('bp_start_date'), + 'bp_end_date' => $this->request->getPost('bp_end_date') ?: null, + 'bp_state' => (int) $this->request->getPost('bp_state'), + 'bp_moddate' => date('Y-m-d H:i:s'), + ]); + + $db->transComplete(); + + return redirect()->to(site_url('admin/bag-prices'))->with('success', '봉투 단가가 수정되었습니다.'); + } + + public function delete(int $id) + { + helper('admin'); + $item = $this->priceModel->find($id); + if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) { + return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.'); + } + + $this->priceModel->delete($id); + return redirect()->to(site_url('admin/bag-prices'))->with('success', '봉투 단가가 삭제되었습니다.'); + } + + public function history(int $bpIdx) + { + helper('admin'); + $item = $this->priceModel->find($bpIdx); + if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) { + return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.'); + } + + $list = $this->historyModel->where('bph_bp_idx', $bpIdx)->orderBy('bph_changed_at', 'DESC')->findAll(); + + return view('admin/layout', [ + 'title' => '단가 변경 이력 — ' . $item->bp_bag_name, + 'content' => view('admin/bag_price/history', ['item' => $item, 'list' => $list]), + ]); + } +} diff --git a/app/Models/BagPriceHistoryModel.php b/app/Models/BagPriceHistoryModel.php new file mode 100644 index 0000000..e886cac --- /dev/null +++ b/app/Models/BagPriceHistoryModel.php @@ -0,0 +1,17 @@ + + 봉투 단가 등록 + +
+
+ + +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + + 비워두면 현재 적용중 +
+ +
+ + 취소 +
+
+
diff --git a/app/Views/admin/bag_price/edit.php b/app/Views/admin/bag_price/edit.php new file mode 100644 index 0000000..3f1181a --- /dev/null +++ b/app/Views/admin/bag_price/edit.php @@ -0,0 +1,55 @@ +
+ 봉투 단가 수정 +
+
+
+ + +
+ + bp_bag_code) ?> + bp_bag_name) ?> +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + 취소 +
+
+
diff --git a/app/Views/admin/bag_price/history.php b/app/Views/admin/bag_price/history.php new file mode 100644 index 0000000..9d92f10 --- /dev/null +++ b/app/Views/admin/bag_price/history.php @@ -0,0 +1,34 @@ +
+
+ ← 단가 목록 + | + 단가 변경 이력 — bp_bag_name) ?> (bp_bag_code) ?>) +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
번호변경 필드이전 값변경 값변경일시
bph_idx) ?>bph_field) ?>bph_old_value) ?>bph_new_value) ?>bph_changed_at) ?>
변경 이력이 없습니다.
+
diff --git a/app/Views/admin/bag_price/index.php b/app/Views/admin/bag_price/index.php new file mode 100644 index 0000000..a4d3c3a --- /dev/null +++ b/app/Views/admin/bag_price/index.php @@ -0,0 +1,60 @@ +
+
+ 봉투 단가 관리 + 단가 등록 +
+
+
+
+ + + + + + 초기화 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호봉투코드봉투명발주단가도매가소비자가적용시작적용종료상태작업
bp_idx) ?>bp_bag_code) ?>bp_bag_name) ?>bp_order_price) ?>bp_wholesale) ?>bp_consumer) ?>bp_start_date) ?>bp_end_date ?? '현재') ?>bp_state === 1 ? '사용' : '미사용' ?> + 이력 + 수정 +
+ + +
+
등록된 단가가 없습니다.
+
diff --git a/e2e/bag-price.spec.js b/e2e/bag-price.spec.js new file mode 100644 index 0000000..7df91b9 --- /dev/null +++ b/e2e/bag-price.spec.js @@ -0,0 +1,48 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { login } = require('./helpers/auth'); + +async function loginAsAdmin(page) { + await login(page, 'admin'); + await page.locator('input[name="lg_idx"]').first().check(); + await page.click('button[type="submit"]'); + await page.waitForURL(url => !url.pathname.includes('select-local-government'), { timeout: 15000 }); +} + +test.describe('P2-03/04: 봉투 단가 관리', () => { + + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('단가 목록 접근', async ({ page }) => { + await page.goto('/admin/bag-prices'); + await expect(page).toHaveURL(/\/admin\/bag-prices/); + }); + + test('단가 등록 폼 표시', async ({ page }) => { + await page.goto('/admin/bag-prices/create'); + await expect(page.locator('select[name="bp_bag_code"]')).toBeVisible(); + await expect(page.locator('input[name="bp_order_price"]')).toBeVisible(); + }); + + test('기간 필터 조회', async ({ page }) => { + await page.goto('/admin/bag-prices?start_date=2026-01-01&end_date=2026-12-31'); + await expect(page).toHaveURL(/start_date/); + }); + + test('단가 변경 이력 페이지 (빈 상태)', async ({ page }) => { + // 먼저 데이터가 있어야 하므로, 목록 페이지만 접근 확인 + await page.goto('/admin/bag-prices'); + await expect(page).toHaveURL(/\/admin\/bag-prices/); + }); +}); + +test.describe('P2-03: 지자체관리자 접근', () => { + + test('지자체관리자도 단가 목록 접근 가능', async ({ page }) => { + await login(page, 'local'); + await page.goto('/admin/bag-prices'); + await expect(page).toHaveURL(/\/admin\/bag-prices/); + }); +}); diff --git a/screenshots/25_admin_bag_prices.png b/screenshots/25_admin_bag_prices.png new file mode 100644 index 0000000..3c3729e Binary files /dev/null and b/screenshots/25_admin_bag_prices.png differ diff --git a/screenshots/26_admin_bag_price_create.png b/screenshots/26_admin_bag_price_create.png new file mode 100644 index 0000000..cb9255d Binary files /dev/null and b/screenshots/26_admin_bag_price_create.png differ diff --git a/writable/database/bag_price_tables.sql b/writable/database/bag_price_tables.sql new file mode 100644 index 0000000..09c7679 --- /dev/null +++ b/writable/database/bag_price_tables.sql @@ -0,0 +1,34 @@ +-- ============================================ +-- 봉투 단가 관리 테이블 (P2-03, P2-04) +-- ============================================ + +CREATE TABLE IF NOT EXISTS `bag_price` ( + `bp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `bp_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체 FK', + `bp_bag_code` VARCHAR(50) NOT NULL COMMENT '봉투코드(code_detail cd_code, ck=O)', + `bp_bag_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '봉투명(등록시점 스냅샷)', + `bp_order_price` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '발주단가', + `bp_wholesale` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '도매가', + `bp_consumer` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '소비자가', + `bp_start_date` DATE NOT NULL COMMENT '적용 시작일', + `bp_end_date` DATE NULL DEFAULT NULL COMMENT '적용 종료일(NULL=현재 적용중)', + `bp_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=사용, 0=미사용', + `bp_regdate` DATETIME NOT NULL, + `bp_moddate` DATETIME NULL DEFAULT NULL, + `bp_reg_mb_idx` INT UNSIGNED NULL COMMENT '등록자', + PRIMARY KEY (`bp_idx`), + KEY `idx_bp_lg_bag` (`bp_lg_idx`, `bp_bag_code`), + KEY `idx_bp_dates` (`bp_start_date`, `bp_end_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='지자체별 봉투 단가'; + +CREATE TABLE IF NOT EXISTS `bag_price_history` ( + `bph_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `bph_bp_idx` INT UNSIGNED NOT NULL COMMENT 'bag_price FK', + `bph_field` VARCHAR(30) NOT NULL COMMENT '변경 필드명', + `bph_old_value` VARCHAR(100) NOT NULL DEFAULT '', + `bph_new_value` VARCHAR(100) NOT NULL DEFAULT '', + `bph_changed_at` DATETIME NOT NULL, + `bph_changed_by` INT UNSIGNED NULL COMMENT '변경자 mb_idx', + PRIMARY KEY (`bph_idx`), + KEY `idx_bph_bp_idx` (`bph_bp_idx`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='봉투 단가 변경 이력';