From ab40a90f69f503024a93c7f2d81b27496d2735c1 Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Mon, 30 Mar 2026 15:07:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?bag=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20CRUD=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /bag/code-kinds, /bag/code-details/{ck_idx} 조회 (LoginAuthFilter, Roles::canManageCodeMaster) - admin에서는 종류·세부 목록 제거, 등록·수정·삭제만 유지 후 bag으로 리다이렉트 - 사이트 메뉴·기본코드 링크 SQL, CSV 동기화 스크립트·README 보강 - 관리자 대시보드: 발주·판매 테이블 미존재 시 통계 비활성화 - 회원 로그인 잠금(mb_login_fail_count, mb_locked_until) 및 관리자 잠금 해제 Made-with: Cursor --- README.md | 513 +++--------------- app/Config/Filters.php | 1 + app/Config/Roles.php | 8 + app/Config/Routes.php | 10 +- app/Controllers/Admin/CodeDetail.php | 61 ++- app/Controllers/Admin/CodeKind.php | 64 ++- app/Controllers/Admin/Dashboard.php | 105 ++-- app/Controllers/Admin/User.php | 17 + app/Controllers/Bag.php | 67 ++- app/Filters/LoginAuthFilter.php | 29 + app/Views/admin/code_detail/create.php | 4 +- app/Views/admin/code_detail/edit.php | 4 +- app/Views/admin/code_detail/index.php | 49 -- app/Views/admin/code_kind/create.php | 2 +- app/Views/admin/code_kind/edit.php | 2 +- app/Views/admin/code_kind/index.php | 48 -- app/Views/admin/dashboard/index.php | 9 + app/Views/admin/user/edit.php | 13 + app/Views/admin/user/index.php | 19 + app/Views/bag/basic_info.php | 30 +- app/Views/bag/code_details.php | 65 +++ app/Views/bag/code_kinds.php | 62 +++ app/Views/bag/layout/main.php | 37 +- e2e/bag-site.spec.js | 9 +- e2e/code-management.spec.js | 59 +- .../database/code_master_sync_from_csv.sql | 232 ++++++++ writable/database/login_tables.sql | 2 + .../database/menu_fix_basic_code_link.sql | 9 + .../menu_site_add_basic_code_child.sql | 19 + writable/database/menu_tables.sql | 18 + writable/database/menu_type_add_site.sql | 18 + writable/tools/sync_basic_codes_from_csv.py | 145 +++++ 32 files changed, 1026 insertions(+), 704 deletions(-) create mode 100644 app/Filters/LoginAuthFilter.php delete mode 100644 app/Views/admin/code_detail/index.php delete mode 100644 app/Views/admin/code_kind/index.php create mode 100644 app/Views/bag/code_details.php create mode 100644 app/Views/bag/code_kinds.php create mode 100644 writable/database/code_master_sync_from_csv.sql create mode 100644 writable/database/menu_fix_basic_code_link.sql create mode 100644 writable/database/menu_site_add_basic_code_child.sql create mode 100644 writable/tools/sync_basic_codes_from_csv.py diff --git a/README.md b/README.md index c4160c4..30f71bf 100644 --- a/README.md +++ b/README.md @@ -1,486 +1,97 @@ -# 종량제 -- 쓰레기봉투 물류시스템 (jongryangje) +# 종량제 — 쓰레기봉투 물류시스템 (jongryangje) -**[종량제 개발목록 (엑셀 다운로드)](https://github.com/wixon-associates/jongryangje/raw/main/assets/종량제_개발목록_20260127.xlsx)** -- 로컬 복제 후에는 [`assets/종량제_개발목록_20260127.xlsx`](./assets/종량제_개발목록_20260127.xlsx) +**[종량제 개발목록](https://github.com/wixon-associates/jongryangje/raw/main/assets/종량제_개발목록_20260127.xlsx)** — [`assets/종량제_개발목록_20260127.xlsx`](./assets/종량제_개발목록_20260127.xlsx) -지자체/지정판매소 등을 대상으로 하는 **종량제 쓰레기봉투 물류/업무 웹 애플리케이션**입니다. +지자체·지정판매소 등을 대상으로 하는 **종량제 쓰레기봉투 물류·업무 웹 애플리케이션**입니다. 백엔드는 **[CodeIgniter 4](https://codeigniter.com/)** 기반입니다. **저장소:** [wixon-associates/jongryangje](https://github.com/wixon-associates/jongryangje) -| **[구현 화면 스크린샷](./docs/SCREENSHOTS.md)** | **[Notion 진행상황](https://www.notion.so/31b42b987c3780baba32ded04a1d41bb)** | **[서버/배포 가이드](./docs/server.md)** | - -### 운영 환경 - -| 서비스 | URL | -|--------|-----| -| 웹 서비스 | https://trash.wxn.co.kr | -| Gitea (Git) | https://gitea.wxn.co.kr | -| GitHub | https://github.com/wixon-associates/jongryangje | --- -## 기술 스택 +## 요구 사항 -| 항목 | 기술 | -|------|------| -| Framework | CodeIgniter 4.7+ | -| Language | PHP 8.2+ (strict types) | -| Database | MySQL / MariaDB (MySQLi) | -| 의존성 관리 | Composer 2.x | -| E2E 테스트 | Playwright (Chromium) | -| 세션 | 파일 기반 (`writable/session/`) | -| 프론트엔드 | Tailwind CSS (CDN), Vanilla JS | +- **PHP** 8.2 이상 (`composer.json` 기준) +- **Composer** 2.x +- **MySQL / MariaDB** (프로젝트는 `MySQLi` 드라이버 사용) +- 권장 PHP 확장: `intl`, `mbstring`, MySQL 사용 시 `mysqlnd` --- -## 프로젝트 구조 +## 빠른 시작 (로컬) -``` -app/ -├── Config/ # Routes, Database, Roles, Filters, Session 등 (45개) -├── Controllers/ # 28개 컨트롤러 -│ ├── Auth.php # 로그인/로그아웃/회원가입 -│ ├── Bag.php # 사이트 메뉴 페이지 (10개 메뉴) -│ ├── Home.php # 홈/대시보드 -│ └── Admin/ # 관리자 컨트롤러 24개 -│ ├── BagOrder.php # 발주 관리 -│ ├── BagReceiving.php # 입고 관리 -│ ├── BagInventory.php # 재고 현황 -│ ├── BagSale.php # 판매/반품 관리 -│ ├── BagIssue.php # 무료용 불출 관리 -│ ├── ShopOrder.php # 주문 접수 관리 -│ ├── BagPrice.php # 봉투 단가 관리 -│ ├── PackagingUnit.php # 포장 단위 관리 -│ ├── CodeKind.php # 기본코드 종류 -│ ├── CodeDetail.php # 세부코드 -│ ├── SalesAgency.php # 판매 대행소 -│ ├── Manager.php # 담당자 -│ ├── Company.php # 업체 (제작/협회/회수) -│ ├── FreeRecipient.php # 무료용 대상자 -│ ├── SalesReport.php # 리포트 (판매대장/일계표/수불) -│ ├── User.php # 회원 관리 -│ ├── DesignatedShop.php # 지정판매소 -│ ├── LocalGovernment.php # 지자체 -│ ├── Menu.php # 메뉴 관리 -│ ├── PasswordChange.php # 비밀번호 변경 -│ └── ... -├── Models/ # 25개 모델 -├── Views/ # 88개 뷰 템플릿 -│ ├── admin/ # 관리자 뷰 (59개, 엔티티별 하위 디렉토리) -│ ├── bag/ # 사이트 메뉴 뷰 (17개 + 레이아웃) -│ ├── auth/ # 로그인/회원가입 (2개) -│ └── home/ # 대시보드 (1개) -├── Filters/ # AdminAuthFilter (관리자 접근 제어) -├── Helpers/ # admin_helper, pii_encryption_helper -└── Database/ # Migrations, Seeds -public/ # 웹 루트 -writable/database/ # SQL 초기화/시드 스크립트 (21개) -e2e/ # Playwright E2E 테스트 (84개 테스트) -assets/ # 기획 문서 (엑셀) -``` - ---- - -## 데이터베이스 (25개 테이블) - -### 회원/인증 - -| 테이블 | 용도 | -|--------|------| -| `member` | 회원 (mb_id, mb_level, mb_state, PII 암호화, 로그인 실패 lock) | -| `member_log` | 로그인/로그아웃 감사 로그 (IP, User-Agent) | -| `member_approval_request` | 회원가입 역할 승인 요청 (pending/approved/rejected) | - -### 지자체/판매소 - -| 테이블 | 용도 | -|--------|------| -| `local_government` | 지자체 (테넌트 루트, lg_code 기반) | -| `designated_shop` | 지정판매소 (지자체별, 판매소번호 자동생성) | - -### 메뉴 시스템 - -| 테이블 | 용도 | -|--------|------| -| `menu_type` | 메뉴 유형 (admin, site) | -| `menu` | 메뉴 항목 (트리 구조, 역할별 노출, 지자체별) | - -### 기본코드 마스터 - -| 테이블 | 용도 | -|--------|------| -| `code_kind` | 코드 종류 (A~T, 20종) | -| `code_detail` | 세부코드 (행정구역, 봉투구분, 재질, 용량 등) | - -### 단가/포장 - -| 테이블 | 용도 | -|--------|------| -| `bag_price` | 봉투 단가 (발주/도매/소비자가, 적용기간) | -| `bag_price_history` | 단가 변경 이력 | -| `packaging_unit` | 포장 단위 (박스/팩/낱장) | -| `packaging_unit_history` | 포장 단위 변경 이력 | - -### 업체/담당자/대상자 - -| 테이블 | 용도 | -|--------|------| -| `sales_agency` | 판매 대행소 | -| `company` | 업체 (manufacturer/association/collector) | -| `manager` | 담당자 (소속/직위) | -| `free_recipient` | 무료용 대상자 (생보자/시설/수훈자) | - -### 발주/입고/재고 - -| 테이블 | 용도 | -|--------|------| -| `bag_order` | 발주 (UUID, LOT번호, SHA-256 해시) | -| `bag_order_item` | 발주 품목 (봉투코드별 수량/금액) | -| `bag_receiving` | 입고 (발주 연계, 박스/낱장 수량) | -| `bag_inventory` | 재고 현황 (봉투코드별 현재 재고) | - -### 판매/주문/불출 - -| 테이블 | 용도 | -|--------|------| -| `bag_sale` | 판매/반품 (판매소, 봉투코드, 수량/금액) | -| `shop_order` | 주문 접수 (배달일, 결제/입금/수령 상태) | -| `shop_order_item` | 주문 품목 (박스/팩/낱장 단위) | -| `bag_issue` | 무료용 불출 (연도/분기, 불출처, 상태) | - ---- - -## 역할 체계 (RBAC) - -| Level | 역할 | 설명 | -|-------|------|------| -| 4 | Super Admin | 전체 시스템 관리, 작업 지자체 선택 필수 | -| 3 | 지자체관리자 | 소속 지자체 범위 내 관리 | -| 2 | 지정판매소 | 봉투 판매/재고 관리 | -| 1 | 일반 사용자 | 기본 조회 (시민) | - -- 역할 상수: `Config\Roles` -- `LEVEL_SUPER_ADMIN(4)`, `LEVEL_LOCAL_ADMIN(3)`, `LEVEL_SHOP(2)`, `LEVEL_CITIZEN(1)` -- `AdminAuthFilter`가 로그인 + 레벨 3/4 + 지자체 선택 여부 검증 - -## 멀티테넌시 - -- `local_government.lg_idx`가 테넌트 루트 -- 관리자 필터에서 `admin_effective_lg_idx()` 기반 테넌트 분리 -- Super Admin은 `/admin/select-local-government`에서 작업 지자체 선택 -- 지자체관리자는 소속 `mb_lg_idx` 자동 적용 - ---- - -## 라우트 구조 - -### 공개 페이지 - -| 경로 | 설명 | -|------|------| -| `/` | 홈 (비로그인: 환영, 로그인: 대시보드) | -| `/login`, `/logout` | 로그인/로그아웃 | -| `/register` | 회원가입 (역할 승인 플로우) | -| `/dashboard/*` | 대시보드 시안 (classic/modern/dense/charts) | - -### 사이트 메뉴 (`/bag/*`) - -| 경로 | 설명 | 기능 | -|------|------|------| -| `/bag/basic-info` | 기본정보관리 | 코드/단가/포장단위 조회 + 관리 링크 | -| `/bag/purchase-inbound` | 발주 입고 관리 | 발주/입고 목록 + 등록 버튼 | -| `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 | -| `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 | -| `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 | -| `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 | -| `/bag/flow` | 봉투 수불 관리 | 봉투코드별 입출고 수불 요약 | -| `/bag/analytics` | 통계 분석 관리 | Phase 6 예정 | -| `/bag/window` | 창 | Phase 6 예정 | -| `/bag/help` | 도움말 | 시스템 안내 | - -### 관리자 (`/admin/*`, adminAuth 필터) - -**시스템 관리** - -| 경로 | 기능 | -|------|------| -| `/admin` | 관리자 대시보드 | -| `/admin/users/*` | 회원 관리 (CRUD) | -| `/admin/access/login-history` | 로그인 이력 | -| `/admin/access/approvals` | 회원 승인 대기 처리 | -| `/admin/roles` | 역할 목록 | -| `/admin/menus/*` | 메뉴 관리 (트리 CRUD) | -| `/admin/local-governments/*` | 지자체 관리 (CRUD) | -| `/admin/select-local-government` | 작업 지자체 선택 (Super Admin) | -| `/admin/password-change` | 비밀번호 변경 | -| `/admin/designated-shops/*` | 지정판매소 관리 (CRUD) | - -**기본정보관리 (Phase 2)** - -| 경로 | 기능 | -|------|------| -| `/admin/code-kinds/*` | 기본코드 종류 (CRUD) | -| `/admin/code-details/*` | 세부코드 (CRUD) | -| `/admin/bag-prices/*` | 봉투 단가 (CRUD + 이력) | -| `/admin/packaging-units/*` | 포장 단위 (CRUD + 이력) | -| `/admin/sales-agencies/*` | 판매 대행소 (CRUD) | -| `/admin/managers/*` | 담당자 (CRUD) | -| `/admin/companies/*` | 업체 (CRUD) | -| `/admin/free-recipients/*` | 무료용 대상자 (CRUD) | - -**발주/입고/재고 (Phase 3)** - -| 경로 | 기능 | -|------|------| -| `/admin/bag-orders/*` | 발주 관리 (등록/상세/취소/삭제) | -| `/admin/bag-receivings/*` | 입고 관리 (등록, 재고 자동 반영) | -| `/admin/bag-inventory` | 재고 현황 조회 | - -**판매/주문/불출 (Phase 4)** - -| 경로 | 기능 | -|------|------| -| `/admin/shop-orders/*` | 주문 접수 (등록/취소) | -| `/admin/bag-sales/*` | 판매/반품 (등록) | -| `/admin/bag-issues/*` | 무료용 불출 (등록/취소, 재고 연동) | - -**리포트 (Phase 5)** - -| 경로 | 기능 | -|------|------| -| `/admin/reports/sales-ledger` | 판매 대장 (일자별/기간별) | -| `/admin/reports/daily-summary` | 일계표 (일계 + 월간 누계) | -| `/admin/reports/period-sales` | 기간별 판매현황 | -| `/admin/reports/supply-demand` | 봉투 수불 현황 | - ---- - -## 모델 (25개) - -| 모델 | 테이블 | 용도 | -|------|--------|------| -| MemberModel | member | 회원 계정 | -| MemberLogModel | member_log | 로그인 이력 | -| MemberApprovalRequestModel | member_approval_request | 승인 요청 | -| LocalGovernmentModel | local_government | 지자체 | -| DesignatedShopModel | designated_shop | 지정판매소 | -| MenuModel | menu | 메뉴 항목 | -| MenuTypeModel | menu_type | 메뉴 유형 | -| CodeKindModel | code_kind | 코드 종류 | -| CodeDetailModel | code_detail | 세부코드 | -| BagPriceModel | bag_price | 봉투 단가 | -| BagPriceHistoryModel | bag_price_history | 단가 변경 이력 | -| PackagingUnitModel | packaging_unit | 포장 단위 | -| PackagingUnitHistoryModel | packaging_unit_history | 포장 단위 이력 | -| SalesAgencyModel | sales_agency | 판매 대행소 | -| CompanyModel | company | 업체 | -| ManagerModel | manager | 담당자 | -| FreeRecipientModel | free_recipient | 무료 대상자 | -| BagOrderModel | bag_order | 발주 | -| BagOrderItemModel | bag_order_item | 발주 품목 | -| BagReceivingModel | bag_receiving | 입고 | -| BagInventoryModel | bag_inventory | 재고 | -| BagSaleModel | bag_sale | 판매/반품 | -| ShopOrderModel | shop_order | 주문 접수 | -| ShopOrderItemModel | shop_order_item | 주문 품목 | -| BagIssueModel | bag_issue | 무료 불출 | - ---- - -## 보안 - -| 항목 | 구현 | -|------|------| -| 인증 | 세션 기반 로그인, AdminAuthFilter로 관리자 접근 제어 | -| RBAC | 4단계 역할 (Config\Roles), 메뉴별 역할 노출 | -| PII 암호화 | `pii_encryption_helper` (AES, `ENC:` prefix) - 이메일/전화번호 | -| 비밀번호 | `password_hash()` + `password_verify()` (bcrypt) | -| 로그인 보호 | 5회 실패 시 계정 잠금 (mb_login_fail_count, mb_locked_until) | -| CSRF | CodeIgniter 내장 CSRF 필터 | -| 멀티테넌시 | lg_idx 기반 데이터 격리, 세션에서 테넌트 관리 | - ---- - -## 빠른 시작 - -### 1) 저장소 복제 및 의존성 설치 +### 1) 저장소 복제 ```bash git clone https://github.com/wixon-associates/jongryangje.git cd jongryangje -composer install -npm install # Playwright E2E 테스트용 ``` -### 2) 환경 설정 +### 2) 의존성 설치 + +```bash +composer install +``` + +### 3) 환경 설정 + +루트에 있는 샘플 파일을 복사해 `.env`를 만듭니다. ```bash cp env .env ``` -`.env`에서 설정: +`.env`에서 최소한 다음을 설정합니다. | 항목 | 설명 | |------|------| -| `app.baseURL` | 예: `http://localhost:8045/` | -| `database.default.*` | DB 호스트/DB명/사용자/비밀번호 | -| `encryption.key` | PII 암호화용 64자리 hex | +| `app.baseURL` | 예: `http://localhost:8080/` (끝에 `/`) | +| `database.default.*` | DB 호스트·DB명·사용자·비밀번호 | +| `encryption.key` | 개인정보(이메일·연락처) 암호화용. **64자리 hex** (예: `php -r "echo bin2hex(random_bytes(32));"`) | -### 3) 데이터베이스 준비 -SQL 스크립트 실행 순서: -| 순서 | 파일 | 용도 | -|------|------|------| -| 1 | `init_jongryangje_dev.sql` | DB/사용자 생성 | -| 2 | `login_tables.sql` | 회원/로그인/지자체/지정판매소 테이블 | -| 3 | `member_approval_request_add.sql` | 승인 요청 테이블 | -| 4 | `member_login_lock_add.sql` | 로그인 잠금 컬럼 | -| 5 | `menu_tables.sql` | 메뉴 시스템 + 시드 | -| 6 | `menu_type_add_site.sql` | 사이트 메뉴 타입 | -| 7 | `local_government_init_daegu.sql` | 대구 8개 구군 지자체 | -| 8 | `code_master_init_daegu.sql` | 기본코드 마스터 (20종) | -| 9 | `bag_price_tables.sql` | 단가 + 단가 이력 테이블 | -| 10 | `packaging_unit_tables.sql` | 포장 단위 + 이력 테이블 | -| 11 | `sales_agency_tables.sql` | 판매 대행소 테이블 | -| 12 | `manager_tables.sql` | 담당자 테이블 | -| 13 | `company_tables.sql` | 업체 테이블 | -| 14 | `free_recipient_tables.sql` | 무료 대상자 테이블 | -| 15 | `order_tables.sql` | 발주/발주품목 테이블 | -| 16 | `sales_tables.sql` | 판매/입고/재고/불출/주문 테이블 | -| 17 | `seed_test_accounts.sql` | 테스터 계정 4개 | -| 18 | `seed_realistic_data.sql` | 실제형 시범 데이터 | +### 4) 데이터베이스 준비 -### 4) 개발 서버 실행 -```bash -php spark serve --port=8045 -``` - -### 5) 시드 데이터 (선택) - -```bash -node e2e/helpers/db-seed.js # 테스터 계정 생성 -node e2e/helpers/db-seed-realistic.js # 실제형 시범 데이터 -``` - ---- - -## E2E 테스트 (Playwright) - -```bash -# 전체 테스트 -npm test - -# headed 모드 (브라우저 표시) -npm run test:headed - -# 특정 파일 -npx playwright test e2e/auth.spec.js - -# 특정 테스트 -npx playwright test -g "로그인 페이지" -``` - -### 테스터 계정 (비밀번호: `test1234!`) - -| ID | 역할 | Level | -|----|------|-------| -| `tester_admin` | Super Admin | 4 | -| `tester_local` | 지자체관리자 (중구청) | 3 | -| `tester_shop` | 지정판매소 | 2 | -| `tester_user` | 일반 사용자 | 1 | - -### 테스트 파일 (84개 테스트) - -| 파일 | 테스트 수 | 대상 | -|------|-----------|------| -| auth.spec.js | 9 | 로그인/로그아웃/회원가입 | -| admin.spec.js | 10 | 관리자 패널 접근 | -| public.spec.js | 4 | 공개 페이지 | -| bag-site.spec.js | 11 | 사이트 메뉴 /bag/* | -| code-management.spec.js | 7 | 기본코드 CRUD | -| bag-price.spec.js | 6 | 봉투 단가 | -| packaging-unit.spec.js | 3 | 포장 단위 | -| phase2-entities.spec.js | 8 | 대행소/담당자/업체/무료대상자 | -| phase2-extra.spec.js | 4 | 지자체 수정/비밀번호/로그인 lock | -| phase3-order.spec.js | 8 | 발주/입고/재고 | -| phase4-sales.spec.js | 10 | 주문/판매/불출 | -| phase5-reports.spec.js | 4 | 리포트 | - ---- - -## 기본코드 체계 - -A~T 총 20종의 코드 체계 (`code_kind` + `code_detail`): - -| 코드 | 코드명 | 코드 | 코드명 | -|------|--------|------|--------| -| A | 도/특별시/광역시 구분 | K | 반품사유 | -| B | 특별시/광역시/시/군코드 | L | 지정판매소 변경사유 | -| C | 구코드 | M | 수불구분 | -| D | 동코드 | N | 동판종류 | -| E | 봉투구분 | O | 봉투명 (상세 봉투코드) | -| F | 봉투재질 | P | 작업권한 | -| G | 용량별 | Q | 예산과목 | -| H | 무상지급 대상 | R | 은행목록 | -| I | 판매형태 | S | 소속 | -| J | 반품형태 | T | 직위 | - ---- - -## 개발 진행 현황 - -### Phase별 완료 현황 - -| Phase | 내용 | 상태 | -|-------|------|------| -| Phase 1 | 프로젝트 초기 세팅, 로그인/회원가입, RBAC, 멀티테넌시, 메뉴 관리, PII 암호화 | **완료** | -| Phase 2 | 기본정보관리 (코드/단가/포장/대행소/담당자/업체/무료대상자/지자체수정/비밀번호/로그인lock) | **완료** | -| Phase 3 | 발주/입고/재고 (발주등록/LOT/취소/삭제/현황/입고처리/재고현황) | **완료** | -| Phase 4 | 주문/판매/불출 (주문접수/판매/반품/불출처리/취소) | **완료** | -| Phase 5 | 리포트 (판매대장/일계표/기간별현황/수불현황) | **완료** | -| Phase 6 | 모바일앱 + 고급기능 (바코드/통계/엑셀/인쇄) | 대기 | - -### Phase 6 이후 대기 작업 - -- 지정판매소 다조건 조회 + 엑셀 + 인쇄 + 바코드 출력 -- 지정판매소 지도 표시 / 현황 (신규/취소) -- 카카오 주소 검색 API 연동 -- 년 판매 현황 (월별/분기별) -- 지정판매소별 판매현황 -- 홈택스 세금계산서 엑셀 생성 -- 반품/파기 현황, LOT 수불 조회 -- 바코드 스캐너 연동 (Electron + serialport) -- 실사 선별/등록/조회 -- 페이지네이션/엑셀/인쇄 공통 컴포넌트 -- CRUD 로깅 (전체 데이터 변경 이력) -- 2차 인증 적용 -- 대시보드 실 데이터 연동 -- 모바일앱 (15개 기능) - ---- - -## SQL 스크립트 목록 (writable/database/) | 파일 | 용도 | |------|------| -| `init_jongryangje_dev.sql` | DB/사용자 생성 | -| `login_tables.sql` | member, member_log, local_government, designated_shop | -| `member_approval_request_add.sql` | 승인 요청 테이블 | -| `member_login_lock_add.sql` | 로그인 실패 잠금 컬럼 | -| `menu_tables.sql` | menu_type, menu + admin/site 시드 | -| `menu_type_add_site.sql` | 사이트 메뉴 타입 추가 | -| `menu_add_lg_idx.sql` | 메뉴에 지자체 컬럼 추가 | -| `menu_site_seed_from_csv.sql` | 사이트 네비게이션 시드 | -| `local_government_init_daegu.sql` | 대구 8개 구군 지자체 | -| `code_master_init_daegu.sql` | 기본코드 20종 + 세부코드 | -| `bag_price_tables.sql` | bag_price, bag_price_history | -| `packaging_unit_tables.sql` | packaging_unit, packaging_unit_history | -| `sales_agency_tables.sql` | sales_agency | -| `manager_tables.sql` | manager | -| `company_tables.sql` | company | -| `free_recipient_tables.sql` | free_recipient | -| `order_tables.sql` | bag_order, bag_order_item | -| `sales_tables.sql` | bag_sale, bag_receiving, bag_inventory, bag_issue, shop_order, shop_order_item | -| `seed_test_accounts.sql` | 테스터 계정 4개 | -| `seed_realistic_data.sql` | 실제형 시범 데이터 (대구 남구청 기준) | -| `fix_double_encoding.sql` | UTF-8 이중인코딩 수정 | +| `writable/database/init_jongryangje_dev.sql` | DB·DB 사용자 생성 예시 | +| `writable/database/login_tables.sql` | 회원·로그인·지자체 등 기본 테이블 | +| `writable/database/member_approval_request_add.sql` | 회원가입 역할 승인 요청 테이블 (별도 추가) | +| `writable/database/menu_tables.sql` 등 | 메뉴·시드 관련 SQL | +| `writable/database/order_tables.sql` | 발주 (`bag_order`, `bag_order_item` 등) | +| `writable/database/sales_tables.sql` | 판매·입고·재고·불출·주문 등 (`bag_sale`, `bag_receiving`, `bag_inventory` …) | +| `writable/database/code_master_init_daegu.sql` | 기본코드 종류·세부코드 시드 | +| `writable/database/code_master_sync_from_csv.sql` | 개발목록 CSV와 DB 보강용 (선택) | + +개발목록 **기본코드 종류** CSV와 DB를 맞출 때는 `writable/tools/sync_basic_codes_from_csv.py`로 SQL을 생성하거나, 위 `code_master_sync_from_csv.sql`을 참고해 실행할 수 있습니다. + +--- + +## 주요 URL (참고) + +| 경로 | 설명 | +|------|------| +| `/` | 홈 (비로그인 시 환영 화면 등) | +| `/login`, `/logout` | 로그인·로그아웃 | +| `/register` | 회원가입 (역할 승인 플로우 연동) | +| `/dashboard` | 로그인 후 사이트형 메인 흐름 | +| `/dashboard/classic-mock` 등 | UI 시안용 라우트 | +| `/bag/basic-info` | 기본정보 (단가·포장단위 등 링크 허브) | +| `/bag/code-kinds` | **기본코드 종류** 목록 (로그인 사용자 조회; 시민·판매소는 조회만) | +| `/bag/code-details/{ck_idx}` | **기본코드 세부** 목록 (종류별) | +| `/admin` | 관리자 (권한·필터 적용) | +| `/admin/access/approvals` | 회원가입 역할 **승인 대기** 처리 | + +### 기본코드 CRUD (관리자) + +- **목록 화면은 `/bag/code-kinds`, `/bag/code-details/{ck_idx}`만 사용**합니다. `/admin` 아래에는 종류·세부 **목록 페이지가 없고**, 등록·수정·삭제 등 **CRUD만** `admin` 라우트로 열립니다. +- 예: `/admin/code-kinds/create`, `edit/{id}`, `store`/`update`/`delete`; `/admin/code-details/{ck_idx}/create`, `code-details/edit/{id}` 등 (전체는 `Routes.php` 참고). +- 예전 주소 `/admin/code-details/{ck_idx}`는 **`/bag/code-details/{ck_idx}`로 리다이렉트**됩니다. + +E2E: 기본코드·사이트 메뉴 경로는 `e2e/code-management.spec.js`, `e2e/bag-site.spec.js`를 참고하세요. + +정확한 라우트는 `app/Config/Routes.php`를 확인하세요. + diff --git a/app/Config/Filters.php b/app/Config/Filters.php index e134470..204ec53 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -26,6 +26,7 @@ class Filters extends BaseFilters */ public array $aliases = [ 'adminAuth' => \App\Filters\AdminAuthFilter::class, + 'loginAuth' => \App\Filters\LoginAuthFilter::class, 'csrf' => CSRF::class, 'toolbar' => DebugToolbar::class, 'honeypot' => Honeypot::class, diff --git a/app/Config/Roles.php b/app/Config/Roles.php index f0c1c14..8f04fcf 100644 --- a/app/Config/Roles.php +++ b/app/Config/Roles.php @@ -42,6 +42,14 @@ class Roles extends BaseConfig return $level === self::LEVEL_SUPER_ADMIN || $level === self::LEVEL_HEADQUARTERS_ADMIN; } + /** + * 기본코드(종류·세부) 등록·수정·삭제 가능 (지자체·super·본부 관리자) + */ + public static function canManageCodeMaster(int $level): bool + { + return $level === self::LEVEL_LOCAL_ADMIN || self::isSuperAdminEquivalent($level); + } + /** * TOTP 2차 인증 적용 대상 (지자체·super·본부 관리자) */ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index eb04c25..3994ec1 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -17,6 +17,11 @@ $routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise'); // 사이트 메뉴 (/bag/*) $routes->get('bag/basic-info', 'Bag::basicInfo'); +$routes->get('bag/code-kinds', 'Bag::codeKinds'); +$routes->get('bag/code-details/(:num)', 'Bag::codeDetails/$1'); + +// 옛 주소 호환: 세부 목록만 사이트로 이동 +$routes->get('admin/code-details/(:num)', 'Admin\CodeDetail::index/$1'); $routes->get('bag/purchase-inbound', 'Bag::purchaseInbound'); $routes->get('bag/issue', 'Bag::issue'); $routes->get('bag/inventory', 'Bag::inventory'); @@ -63,6 +68,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo $routes->post('users/store', 'Admin\User::store'); $routes->get('users/edit/(:num)', 'Admin\User::edit/$1'); $routes->post('users/update/(:num)', 'Admin\User::update/$1'); + $routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1'); $routes->post('users/delete/(:num)', 'Admin\User::delete/$1'); $routes->get('access/login-history', 'Admin\Access::loginHistory'); $routes->get('access/approvals', 'Admin\Access::approvals'); @@ -88,8 +94,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo $routes->get('password-change', 'Admin\PasswordChange::index'); $routes->post('password-change', 'Admin\PasswordChange::update'); - // 기본코드 종류 관리 (P2-01) - $routes->get('code-kinds', 'Admin\CodeKind::index'); + // 기본코드 종류 관리 (P2-01) — 등록·수정·삭제는 관리자 전용 $routes->get('code-kinds/create', 'Admin\CodeKind::create'); $routes->post('code-kinds/store', 'Admin\CodeKind::store'); $routes->get('code-kinds/edit/(:num)', 'Admin\CodeKind::edit/$1'); @@ -97,7 +102,6 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo $routes->post('code-kinds/delete/(:num)', 'Admin\CodeKind::delete/$1'); // 세부코드 관리 (P2-02) - $routes->get('code-details/(:num)', 'Admin\CodeDetail::index/$1'); $routes->get('code-details/(:num)/create', 'Admin\CodeDetail::create/$1'); $routes->post('code-details/store', 'Admin\CodeDetail::store'); $routes->get('code-details/edit/(:num)', 'Admin\CodeDetail::edit/$1'); diff --git a/app/Controllers/Admin/CodeDetail.php b/app/Controllers/Admin/CodeDetail.php index e28200d..919c5d0 100644 --- a/app/Controllers/Admin/CodeDetail.php +++ b/app/Controllers/Admin/CodeDetail.php @@ -1,10 +1,14 @@ detailModel = model(CodeDetailModel::class); } - public function index(int $ckIdx) + private function redirectIfCannotManageCodeMaster(): ?RedirectResponse { - $kind = $this->kindModel->find($ckIdx); - if ($kind === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); + if (! Roles::canManageCodeMaster((int) session()->get('mb_level'))) { + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 관리 권한이 없습니다.'); } - $list = $this->detailModel->where('cd_ck_idx', $ckIdx)->orderBy('cd_sort', 'ASC')->paginate(20); - $pager = $this->detailModel->pager; + return null; + } - return view('admin/layout', [ - 'title' => '세부코드 관리 — ' . $kind->ck_name . ' (' . $kind->ck_code . ')', - 'content' => view('admin/code_detail/index', [ - 'kind' => $kind, - 'list' => $list, - 'pager' => $pager, - ]), - ]); + /** @deprecated 사이트 URL 유지용 — 세부 목록은 /bag/code-details/{ck_idx} */ + public function index(int $ckIdx): RedirectResponse + { + return redirect()->to(site_url('bag/code-details/' . $ckIdx)); } public function create(int $ckIdx) { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $kind = $this->kindModel->find($ckIdx); if ($kind === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); } return view('admin/layout', [ @@ -52,6 +55,10 @@ class CodeDetail extends BaseController public function store() { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $rules = [ 'cd_ck_idx' => 'required|is_natural_no_zero', 'cd_code' => 'required|max_length[50]', @@ -74,14 +81,18 @@ class CodeDetail extends BaseController 'cd_regdate' => date('Y-m-d H:i:s'), ]); - return redirect()->to(site_url('admin/code-details/' . $ckIdx))->with('success', '세부코드가 등록되었습니다.'); + return redirect()->to(site_url('bag/code-details/' . $ckIdx))->with('success', '세부코드가 등록되었습니다.'); } public function edit(int $id) { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $item = $this->detailModel->find($id); if ($item === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.'); } $kind = $this->kindModel->find($item->cd_ck_idx); @@ -97,9 +108,13 @@ class CodeDetail extends BaseController public function update(int $id) { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $item = $this->detailModel->find($id); if ($item === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.'); } $rules = [ @@ -118,19 +133,23 @@ class CodeDetail extends BaseController 'cd_state' => (int) $this->request->getPost('cd_state'), ]); - return redirect()->to(site_url('admin/code-details/' . $item->cd_ck_idx))->with('success', '세부코드가 수정되었습니다.'); + return redirect()->to(site_url('bag/code-details/' . $item->cd_ck_idx))->with('success', '세부코드가 수정되었습니다.'); } public function delete(int $id) { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $item = $this->detailModel->find($id); if ($item === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.'); } $ckIdx = $item->cd_ck_idx; $this->detailModel->delete($id); - return redirect()->to(site_url('admin/code-details/' . $ckIdx))->with('success', '세부코드가 삭제되었습니다.'); + return redirect()->to(site_url('bag/code-details/' . $ckIdx))->with('success', '세부코드가 삭제되었습니다.'); } } diff --git a/app/Controllers/Admin/CodeKind.php b/app/Controllers/Admin/CodeKind.php index 9ca623d..5a128a8 100644 --- a/app/Controllers/Admin/CodeKind.php +++ b/app/Controllers/Admin/CodeKind.php @@ -1,10 +1,13 @@ kindModel = model(CodeKindModel::class); } - public function index() + private function redirectIfCannotManageCodeMaster(): ?RedirectResponse { - $list = $this->kindModel->orderBy('ck_code', 'ASC')->paginate(20); - $pager = $this->kindModel->pager; - - // 세부코드 수 매핑 - $detailModel = model(CodeDetailModel::class); - $countMap = []; - foreach ($list as $row) { - $countMap[$row->ck_idx] = $detailModel->where('cd_ck_idx', $row->ck_idx)->countAllResults(false); + if (! Roles::canManageCodeMaster((int) session()->get('mb_level'))) { + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 관리 권한이 없습니다.'); } - return view('admin/layout', [ - 'title' => '기본코드 종류 관리', - 'content' => view('admin/code_kind/index', [ - 'list' => $list, - 'countMap' => $countMap, - 'pager' => $pager, - ]), - ]); + return null; } public function create() { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + return view('admin/layout', [ 'title' => '기본코드 종류 등록', 'content' => view('admin/code_kind/create'), @@ -48,6 +42,10 @@ class CodeKind extends BaseController public function store() { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $rules = [ 'ck_code' => 'required|max_length[20]|is_unique[code_kind.ck_code]', 'ck_name' => 'required|max_length[100]', @@ -64,14 +62,18 @@ class CodeKind extends BaseController 'ck_regdate' => date('Y-m-d H:i:s'), ]); - return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 등록되었습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 등록되었습니다.'); } public function edit(int $id) { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $item = $this->kindModel->find($id); if ($item === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); } return view('admin/layout', [ @@ -82,9 +84,13 @@ class CodeKind extends BaseController public function update(int $id) { + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + $item = $this->kindModel->find($id); if ($item === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); } $rules = [ @@ -101,24 +107,28 @@ class CodeKind extends BaseController 'ck_state' => (int) $this->request->getPost('ck_state'), ]); - return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 수정되었습니다.'); + return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 수정되었습니다.'); } public function delete(int $id) { - $item = $this->kindModel->find($id); - if ($item === null) { - return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); + if ($r = $this->redirectIfCannotManageCodeMaster()) { + return $r; + } + + $item = $this->kindModel->find($id); + if ($item === null) { + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); } - // 세부코드가 있으면 삭제 불가 $detailCount = model(CodeDetailModel::class)->where('cd_ck_idx', $id)->countAllResults(); if ($detailCount > 0) { - return redirect()->to(site_url('admin/code-kinds')) + return redirect()->to(site_url('bag/code-kinds')) ->with('error', '세부코드가 ' . $detailCount . '개 존재하여 삭제할 수 없습니다. 먼저 세부코드를 삭제해 주세요.'); } $this->kindModel->delete($id); - return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.'); + + return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.'); } } diff --git a/app/Controllers/Admin/Dashboard.php b/app/Controllers/Admin/Dashboard.php index a1002bb..afa3467 100644 --- a/app/Controllers/Admin/Dashboard.php +++ b/app/Controllers/Admin/Dashboard.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controllers\Admin; use App\Controllers\BaseController; +use CodeIgniter\Database\Exceptions\DatabaseException; class Dashboard extends BaseController { @@ -22,65 +23,71 @@ class Dashboard extends BaseController 'issue_count_month'=> 0, 'recent_orders' => [], 'recent_sales' => [], + 'stats_unavailable'=> false, ]; if ($lgIdx) { $db = \Config\Database::connect(); - // 총 발주 건수/금액 - $orderStats = $db->query(" - SELECT COUNT(*) as cnt, - COALESCE(SUM(sub.total_amt), 0) as total_amount - FROM bag_order bo - LEFT JOIN ( - SELECT boi_bo_idx, SUM(boi_amount) as total_amt - FROM bag_order_item GROUP BY boi_bo_idx - ) sub ON sub.boi_bo_idx = bo.bo_idx - WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' - ", [$lgIdx])->getRow(); - $stats['order_count'] = (int) ($orderStats->cnt ?? 0); - $stats['order_amount'] = (int) ($orderStats->total_amount ?? 0); + try { + // 총 발주 건수/금액 + $orderStats = $db->query(" + SELECT COUNT(*) as cnt, + COALESCE(SUM(sub.total_amt), 0) as total_amount + FROM bag_order bo + LEFT JOIN ( + SELECT boi_bo_idx, SUM(boi_amount) as total_amt + FROM bag_order_item GROUP BY boi_bo_idx + ) sub ON sub.boi_bo_idx = bo.bo_idx + WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' + ", [$lgIdx])->getRow(); + $stats['order_count'] = (int) ($orderStats->cnt ?? 0); + $stats['order_amount'] = (int) ($orderStats->total_amount ?? 0); - // 총 판매 건수/금액 - $saleStats = $db->query(" - SELECT COUNT(*) as cnt, COALESCE(SUM(bs_amount), 0) as total_amount - FROM bag_sale - WHERE bs_lg_idx = ? AND bs_type = 'sale' - ", [$lgIdx])->getRow(); - $stats['sale_count'] = (int) ($saleStats->cnt ?? 0); - $stats['sale_amount'] = (int) ($saleStats->total_amount ?? 0); + // 총 판매 건수/금액 + $saleStats = $db->query(" + SELECT COUNT(*) as cnt, COALESCE(SUM(bs_amount), 0) as total_amount + FROM bag_sale + WHERE bs_lg_idx = ? AND bs_type = 'sale' + ", [$lgIdx])->getRow(); + $stats['sale_count'] = (int) ($saleStats->cnt ?? 0); + $stats['sale_amount'] = (int) ($saleStats->total_amount ?? 0); - // 현재 재고 품목 수 - $invCount = $db->query(" - SELECT COUNT(*) as cnt FROM bag_inventory WHERE bi_lg_idx = ? AND bi_qty > 0 - ", [$lgIdx])->getRow(); - $stats['inventory_count'] = (int) ($invCount->cnt ?? 0); + // 현재 재고 품목 수 + $invCount = $db->query(" + SELECT COUNT(*) as cnt FROM bag_inventory WHERE bi_lg_idx = ? AND bi_qty > 0 + ", [$lgIdx])->getRow(); + $stats['inventory_count'] = (int) ($invCount->cnt ?? 0); - // 이번 달 불출 건수 - $monthStart = date('Y-m-01'); - $issueCount = $db->query(" - SELECT COUNT(*) as cnt FROM bag_issue - WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_issue_date >= ? - ", [$lgIdx, $monthStart])->getRow(); - $stats['issue_count_month'] = (int) ($issueCount->cnt ?? 0); + // 이번 달 불출 건수 + $monthStart = date('Y-m-01'); + $issueCount = $db->query(" + SELECT COUNT(*) as cnt FROM bag_issue + WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_issue_date >= ? + ", [$lgIdx, $monthStart])->getRow(); + $stats['issue_count_month'] = (int) ($issueCount->cnt ?? 0); - // 최근 발주 5건 - $stats['recent_orders'] = $db->query(" - SELECT bo_idx, bo_lot_no, bo_order_date, bo_status - FROM bag_order - WHERE bo_lg_idx = ? - ORDER BY bo_order_date DESC, bo_idx DESC - LIMIT 5 - ", [$lgIdx])->getResult(); + // 최근 발주 5건 + $stats['recent_orders'] = $db->query(" + SELECT bo_idx, bo_lot_no, bo_order_date, bo_status + FROM bag_order + WHERE bo_lg_idx = ? + ORDER BY bo_order_date DESC, bo_idx DESC + LIMIT 5 + ", [$lgIdx])->getResult(); - // 최근 판매 5건 - $stats['recent_sales'] = $db->query(" - SELECT bs_idx, bs_ds_name, bs_bag_name, bs_qty, bs_amount, bs_sale_date, bs_type - FROM bag_sale - WHERE bs_lg_idx = ? - ORDER BY bs_sale_date DESC, bs_idx DESC - LIMIT 5 - ", [$lgIdx])->getResult(); + // 최근 판매 5건 + $stats['recent_sales'] = $db->query(" + SELECT bs_idx, bs_ds_name, bs_bag_name, bs_qty, bs_amount, bs_sale_date, bs_type + FROM bag_sale + WHERE bs_lg_idx = ? + ORDER BY bs_sale_date DESC, bs_idx DESC + LIMIT 5 + ", [$lgIdx])->getResult(); + } catch (DatabaseException $e) { + $stats['stats_unavailable'] = true; + log_message('error', '[Dashboard] 통계 조회 실패(테이블 미생성 등): ' . $e->getMessage()); + } } return view('admin/layout', [ diff --git a/app/Controllers/Admin/User.php b/app/Controllers/Admin/User.php index 672cc92..87db294 100644 --- a/app/Controllers/Admin/User.php +++ b/app/Controllers/Admin/User.php @@ -177,6 +177,23 @@ class User extends BaseController return redirect()->to(site_url('admin/users'))->with('success', '회원 정보가 수정되었습니다.'); } + /** + * 로그인 실패 누적 잠금(mb_locked_until) 해제 — 비밀번호는 그대로 두고 재시도만 가능하게 함 + */ + public function unlockLogin(int $id) + { + $member = $this->memberModel->find($id); + if (! $member) { + return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.'); + } + $this->memberModel->update($id, [ + 'mb_login_fail_count' => 0, + 'mb_locked_until' => null, + ]); + + return redirect()->back()->with('success', '로그인 잠금이 해제되었습니다.'); + } + /** * 현재 로그인한 관리자가 부여 가능한 역할 목록. * super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만. diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 196937c..117f5d8 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Controllers; +use CodeIgniter\Database\Exceptions\DatabaseException; use App\Models\BagInventoryModel; use App\Models\BagIssueModel; use App\Models\BagOrderModel; @@ -18,6 +19,7 @@ use App\Models\PackagingUnitModel; use App\Models\SalesAgencyModel; use App\Models\ShopOrderModel; use App\Models\DesignatedShopModel; +use Config\Roles; class Bag extends BaseController { @@ -44,17 +46,74 @@ class Bag extends BaseController public function basicInfo(): string { $lgIdx = $this->lgIdx(); - $data = []; + $data = [ + 'bagPrices' => [], + 'packagingUnits' => [], + ]; if ($lgIdx) { - $data['codeKinds'] = model(CodeKindModel::class)->orderBy('ck_code', 'ASC')->findAll(); - $data['bagPrices'] = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->orderBy('bp_bag_code', 'ASC')->findAll(); - $data['packagingUnits'] = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->orderBy('pu_bag_code', 'ASC')->findAll(); + try { + $data['bagPrices'] = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->orderBy('bp_bag_code', 'ASC')->findAll(); + } catch (DatabaseException $e) { + log_message('error', '[basicInfo] bag_price 조회 실패(테이블 미생성 등): ' . $e->getMessage()); + } + try { + $data['packagingUnits'] = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->orderBy('pu_bag_code', 'ASC')->findAll(); + } catch (DatabaseException $e) { + log_message('error', '[basicInfo] packaging_unit 조회 실패: ' . $e->getMessage()); + } } return $this->render('기본정보관리', 'bag/basic_info', $data); } + /** + * 기본코드 종류·세부코드 조회 전용 (사이트 메뉴 기본코드관리) + */ + public function codeKinds(): string + { + $kindModel = model(CodeKindModel::class); + $detailModel = model(CodeDetailModel::class); + $kinds = $kindModel->orderBy('ck_code', 'ASC')->findAll(); + $countMap = []; + foreach ($kinds as $row) { + // countAllResults() 기본값(true)으로 매번 빌더 초기화 — false 시 where 누적되어 2번째부터 0건으로 보임 + $countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx)->countAllResults(); + } + + return $this->render('기본코드관리', 'bag/code_kinds', [ + 'codeKinds' => $kinds, + 'countMap' => $countMap, + 'canManage' => Roles::canManageCodeMaster((int) session()->get('mb_level')), + ]); + } + + /** + * 기본코드 세부 목록 (사이트 레이아웃). 등록·수정·삭제 폼은 /admin/code-details/* 유지. + */ + public function codeDetails(int $ckIdx) + { + $kindModel = model(CodeKindModel::class); + $detailModel = model(CodeDetailModel::class); + $kind = $kindModel->find($ckIdx); + if ($kind === null) { + return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); + } + + $list = $detailModel->where('cd_ck_idx', $ckIdx)->orderBy('cd_sort', 'ASC')->orderBy('cd_idx', 'ASC')->paginate(20); + $pager = $detailModel->pager; + + $canManage = Roles::canManageCodeMaster((int) session()->get('mb_level')); + $title = ($canManage ? '세부코드 관리' : '세부코드 조회') . ' — ' . $kind->ck_name . ' (' . $kind->ck_code . ')'; + + return $this->render($title, 'bag/code_details', [ + 'kind' => $kind, + 'list' => $list, + 'pager' => $pager, + 'canManage' => $canManage, + ]); + } + // ────────────────────────────────────────────── // 발주 입고 관리 // ────────────────────────────────────────────── diff --git a/app/Filters/LoginAuthFilter.php b/app/Filters/LoginAuthFilter.php new file mode 100644 index 0000000..184acf8 --- /dev/null +++ b/app/Filters/LoginAuthFilter.php @@ -0,0 +1,29 @@ +get('logged_in')) { + return redirect()->to(site_url('login'))->with('error', '로그인이 필요합니다.'); + } + + return null; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + return $response; + } +} diff --git a/app/Views/admin/code_detail/create.php b/app/Views/admin/code_detail/create.php index c6ce871..20b035c 100644 --- a/app/Views/admin/code_detail/create.php +++ b/app/Views/admin/code_detail/create.php @@ -1,6 +1,6 @@
- ck_name) ?> + ck_name) ?> | 세부코드 등록
@@ -32,7 +32,7 @@
- 취소 + 취소
diff --git a/app/Views/admin/code_detail/edit.php b/app/Views/admin/code_detail/edit.php index 0244fcf..f5375ad 100644 --- a/app/Views/admin/code_detail/edit.php +++ b/app/Views/admin/code_detail/edit.php @@ -1,6 +1,6 @@
- ck_name) ?> + ck_name) ?> | 세부코드 수정
@@ -39,7 +39,7 @@
- 취소 + 취소
diff --git a/app/Views/admin/code_detail/index.php b/app/Views/admin/code_detail/index.php deleted file mode 100644 index 536cade..0000000 --- a/app/Views/admin/code_detail/index.php +++ /dev/null @@ -1,49 +0,0 @@ - '세부코드 관리 - ' . esc($kind->ck_name)]) ?> -
-
-
- ← 코드 종류 - | - 세부코드 — ck_name) ?> (ck_code) ?>) -
-
- - 세부코드 등록 -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
번호코드코드명정렬순서상태등록일작업
cd_idx) ?>cd_code) ?>cd_name) ?>cd_sort ?>cd_state === 1 ? '사용' : '미사용' ?>cd_regdate ?? '') ?> - 수정 -
- - -
-
-
-
links() ?>
diff --git a/app/Views/admin/code_kind/create.php b/app/Views/admin/code_kind/create.php index a1ff7b1..03622bb 100644 --- a/app/Views/admin/code_kind/create.php +++ b/app/Views/admin/code_kind/create.php @@ -17,7 +17,7 @@
- 취소 + 취소
diff --git a/app/Views/admin/code_kind/edit.php b/app/Views/admin/code_kind/edit.php index 7825d9e..a529423 100644 --- a/app/Views/admin/code_kind/edit.php +++ b/app/Views/admin/code_kind/edit.php @@ -25,7 +25,7 @@
- 취소 + 취소
diff --git a/app/Views/admin/code_kind/index.php b/app/Views/admin/code_kind/index.php deleted file mode 100644 index 166a16a..0000000 --- a/app/Views/admin/code_kind/index.php +++ /dev/null @@ -1,48 +0,0 @@ - '기본코드 종류 관리']) ?> -
-
- 기본코드 종류 관리 -
- - 코드 종류 등록 -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
번호코드코드명세부코드 수상태등록일작업
ck_idx) ?>ck_code) ?>ck_name) ?> - ck_idx] ?? 0) ?>개 - ck_state === 1 ? '사용' : '미사용' ?>ck_regdate ?? '') ?> - 세부코드 - 수정 -
- - -
-
-
-
links() ?>
diff --git a/app/Views/admin/dashboard/index.php b/app/Views/admin/dashboard/index.php index 5e28c56..4171523 100644 --- a/app/Views/admin/dashboard/index.php +++ b/app/Views/admin/dashboard/index.php @@ -6,6 +6,15 @@ + +
+ 발주·판매·재고·불출 통계 테이블이 아직 없거나 조회에 실패했습니다. MySQL에서 + writable/database/order_tables.sql과 + writable/database/sales_tables.sql을 + jongryangje_dev DB에 실행해 주세요. +
+ +
diff --git a/app/Views/admin/user/edit.php b/app/Views/admin/user/edit.php index 818ac90..ca97607 100644 --- a/app/Views/admin/user/edit.php +++ b/app/Views/admin/user/edit.php @@ -1,6 +1,19 @@
회원 수정
+mb_locked_until ?? null; +$editLoginLocked = $editLockUntil !== null && $editLockUntil !== '' && strtotime((string) $editLockUntil) > time(); +?> + +
+ 비밀번호 오류로 로그인이 잠겨 있습니다. (~ ) +
+ + +
+
+
diff --git a/app/Views/admin/user/index.php b/app/Views/admin/user/index.php index 64a8bad..8fd494d 100644 --- a/app/Views/admin/user/index.php +++ b/app/Views/admin/user/index.php @@ -18,6 +18,7 @@ 이메일 역할 상태 + 로그인 잠금 가입일 관리 @@ -42,9 +43,27 @@ } ?> + + mb_locked_until ?? null; + $loginLocked = $until !== null && $until !== '' && strtotime((string) $until) > time(); + if ($loginLocked) { + echo '잠금~' . esc(date('Y-m-d H:i', strtotime((string) $until))); + } else { + $fail = (int) ($row->mb_login_fail_count ?? 0); + echo $fail > 0 ? '실패 ' . $fail . '회' : '—'; + } + ?> + mb_regdate ?? '') ?> mb_state !== 0): ?> + + + + +
+ 수정
diff --git a/app/Views/bag/basic_info.php b/app/Views/bag/basic_info.php index 761eaaa..8838d29 100644 --- a/app/Views/bag/basic_info.php +++ b/app/Views/bag/basic_info.php @@ -1,30 +1,8 @@
- -
-
-

기본코드 종류

- 관리 → -
- - - - - - - $row): ?> - - - - - - - - - - - -
번호코드코드명상태
ck_code) ?>ck_name) ?>ck_status ?? 'active') === 'active' ? '사용' : '미사용' ?>
등록된 코드 종류가 없습니다.
-
+

+ 기본코드 종류·세부코드는 상단 메뉴 기본정보관리 → + 기본코드관리에서 확인할 수 있습니다. +

diff --git a/app/Views/bag/code_details.php b/app/Views/bag/code_details.php new file mode 100644 index 0000000..c4f335d --- /dev/null +++ b/app/Views/bag/code_details.php @@ -0,0 +1,65 @@ + $list */ +/** @var bool $canManage */ +$canManage = ! empty($canManage); +?> +
+ '세부코드 - ' . esc($kind->ck_name)]) ?> +
+
+
+ ← 기본코드 종류 + | + ck_name) ?> (ck_code) ?>) +
+
+ + + 세부코드 등록 + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호코드코드명정렬순서상태등록일작업
cd_idx) ?>cd_code) ?>cd_name) ?>cd_sort ?>cd_state === 1 ? '사용' : '미사용' ?>cd_regdate ?? '') ?> + 수정 + + + + +
+
+ +
links() ?>
+ +
diff --git a/app/Views/bag/code_kinds.php b/app/Views/bag/code_kinds.php new file mode 100644 index 0000000..36ca822 --- /dev/null +++ b/app/Views/bag/code_kinds.php @@ -0,0 +1,62 @@ + $codeKinds */ +/** @var array $countMap */ +/** @var bool $canManage */ +$canManage = ! empty($canManage); +$colCount = $canManage ? 7 : 6; +?> +
+
+
+

기본코드 종류

+
+ + 코드 종류 등록 + + 세부코드는 행의 링크에서 조회할 수 있습니다. + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
코드코드명세부코드상태등록일작업
ck_idx) : (string) $i ?>ck_code) ?>ck_name) ?> + ck_idx] ?? 0) ?>개 보기 + ck_state ?? 0) === 1 ? '사용' : '미사용' ?>ck_regdate ?? '') ?> + 세부코드 + 수정 +
+ + +
+
등록된 코드 종류가 없습니다.
+
+
diff --git a/app/Views/bag/layout/main.php b/app/Views/bag/layout/main.php index 6d3d424..71a851b 100644 --- a/app/Views/bag/layout/main.php +++ b/app/Views/bag/layout/main.php @@ -64,7 +64,7 @@ body { overflow: hidden; } -
+
@@ -78,20 +78,49 @@ body { overflow: hidden; }