Compare commits
64 Commits
7af4f082c9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
912ffdbe23 | ||
|
|
4d9343e980 | ||
|
|
7e32f579e8 | ||
|
|
1a443de02e | ||
|
|
e8d58b5837 | ||
|
|
c15e01bfa7 | ||
|
|
600a79788e | ||
|
|
6b1c118651 | ||
|
|
abc8a405e1 | ||
|
|
ec3119799c | ||
|
|
707182ad2d | ||
|
|
8763876f19 | ||
|
|
0f1d414f37 | ||
|
|
21e7b91871 | ||
|
|
c708d30660 | ||
|
|
215d4d2c7c | ||
|
|
6db9d119c1 | ||
|
|
5c89c963ee | ||
|
|
05c479397b | ||
|
|
647d5f919d | ||
|
|
0b4c622b99 | ||
|
|
40db578e85 | ||
|
|
5d733ac0d8 | ||
|
|
2629644f90 | ||
|
|
c8d1612f0e | ||
|
|
48e5578611 | ||
|
|
078fa5d0c2 | ||
|
|
734a55833b | ||
|
|
72578f200c | ||
|
|
8e859f420d | ||
|
|
cd2d41b3d7 | ||
|
|
f22b1480a3 | ||
|
|
7580c31ab0 | ||
|
|
6fddf15000 | ||
|
|
b99c108aeb | ||
|
|
f68f135446 | ||
|
|
0d512bd21d | ||
|
|
12cd052c40 | ||
|
|
aaf7b4c66e | ||
|
|
d551dfa87e | ||
|
|
84026f8072 | ||
|
|
1a8d4bb9da | ||
|
|
06aa401048 | ||
|
|
06fedc866a | ||
|
|
b5eed31b94 | ||
|
|
c2dc2fd38a | ||
|
|
984ddb403e | ||
|
|
89f80edc5d | ||
|
|
5b0c3fac97 | ||
|
|
c4d30b204b | ||
|
|
ab40a90f69 | ||
|
|
de8f631ca8 | ||
|
|
ac88ebdedb | ||
|
|
6fdd040d4d | ||
|
|
1e8bf1eeeb | ||
|
|
704141a1f0 | ||
|
|
35561b414b | ||
|
|
39ee71cc80 | ||
|
|
f6a64e07b8 | ||
|
|
56661ed5dc | ||
|
|
a0c75a4a31 | ||
|
|
9193fc587e | ||
|
|
c3c731cda0 | ||
|
|
a3f92cd322 |
10
.cursor/rules/dependency-security.mdc
Normal file
10
.cursor/rules/dependency-security.mdc
Normal file
@@ -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`)을 대량 수정하거나 생소한 패키지를 넣지 않는다. 이상하면 사용자에게 중단하고 확인을 요청한다.
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -102,6 +102,7 @@ writable/debugbar/*
|
|||||||
!writable/debugbar/index.html
|
!writable/debugbar/index.html
|
||||||
|
|
||||||
php_errors.log
|
php_errors.log
|
||||||
|
deploy.log
|
||||||
|
|
||||||
#-------------------------
|
#-------------------------
|
||||||
# User Guide Temp Files
|
# User Guide Temp Files
|
||||||
@@ -173,3 +174,8 @@ blob-report/
|
|||||||
|
|
||||||
/results/
|
/results/
|
||||||
/phpunit*.xml
|
/phpunit*.xml
|
||||||
|
# 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
|
||||||
|
/docs/
|
||||||
|
|
||||||
|
# Claude Code 개인 권한 설정(비밀 포함) — 커밋 금지
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ assets/ # 기획 문서 (엑셀)
|
|||||||
php spark serve --port=8045
|
php spark serve --port=8045
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 로컬에서 **Apache + 여러 VirtualHost + PHP-FPM**을 쓰면 `localhost`·포트·FPM(예: 9001) 설정에 따라 500/503이 나기 쉽습니다. **일상 개발은 위 내장 서버를 기본**으로 두고, `.env`의 `app.baseURL`을 `http://localhost:8045/` 등과 맞추는 것을 권장합니다.
|
||||||
|
|
||||||
## 테스트 (Playwright E2E)
|
## 테스트 (Playwright E2E)
|
||||||
|
|
||||||
모든 작업 완료 후 반드시 Playwright 브라우저 테스트를 수행합니다.
|
모든 작업 완료 후 반드시 Playwright 브라우저 테스트를 수행합니다.
|
||||||
|
|||||||
122
README.md
122
README.md
@@ -6,7 +6,15 @@
|
|||||||
백엔드는 **[CodeIgniter 4](https://codeigniter.com/)** 기반입니다.
|
백엔드는 **[CodeIgniter 4](https://codeigniter.com/)** 기반입니다.
|
||||||
|
|
||||||
**저장소:** [wixon-associates/jongryangje](https://github.com/wixon-associates/jongryangje)
|
**저장소:** [wixon-associates/jongryangje](https://github.com/wixon-associates/jongryangje)
|
||||||
| **[구현 화면 스크린샷](./docs/SCREENSHOTS.md)** | **[Notion 진행상황](https://www.notion.so/31b42b987c3780baba32ded04a1d41bb)** |
|
| **[구현 화면 스크린샷](./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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,7 +39,7 @@ app/
|
|||||||
├── Config/ # Routes, Database, Roles, Filters, Session 등 (45개)
|
├── Config/ # Routes, Database, Roles, Filters, Session 등 (45개)
|
||||||
├── Controllers/ # 28개 컨트롤러
|
├── Controllers/ # 28개 컨트롤러
|
||||||
│ ├── Auth.php # 로그인/로그아웃/회원가입
|
│ ├── Auth.php # 로그인/로그아웃/회원가입
|
||||||
│ ├── Bag.php # 사이트 메뉴 페이지 (10개 메뉴)
|
│ ├── Bag.php # 사이트 메뉴 페이지 (기본정보·기본코드 목록 등)
|
||||||
│ ├── Home.php # 홈/대시보드
|
│ ├── Home.php # 홈/대시보드
|
||||||
│ └── Admin/ # 관리자 컨트롤러 24개
|
│ └── Admin/ # 관리자 컨트롤러 24개
|
||||||
│ ├── BagOrder.php # 발주 관리
|
│ ├── BagOrder.php # 발주 관리
|
||||||
@@ -61,7 +69,7 @@ app/
|
|||||||
│ ├── bag/ # 사이트 메뉴 뷰 (17개 + 레이아웃)
|
│ ├── bag/ # 사이트 메뉴 뷰 (17개 + 레이아웃)
|
||||||
│ ├── auth/ # 로그인/회원가입 (2개)
|
│ ├── auth/ # 로그인/회원가입 (2개)
|
||||||
│ └── home/ # 대시보드 (1개)
|
│ └── home/ # 대시보드 (1개)
|
||||||
├── Filters/ # AdminAuthFilter (관리자 접근 제어)
|
├── Filters/ # AdminAuthFilter, LoginAuthFilter (`/bag/code-kinds` 등)
|
||||||
├── Helpers/ # admin_helper, pii_encryption_helper
|
├── Helpers/ # admin_helper, pii_encryption_helper
|
||||||
└── Database/ # Migrations, Seeds
|
└── Database/ # Migrations, Seeds
|
||||||
public/ # 웹 루트
|
public/ # 웹 루트
|
||||||
@@ -152,6 +160,7 @@ assets/ # 기획 문서 (엑셀)
|
|||||||
|
|
||||||
- 역할 상수: `Config\Roles` -- `LEVEL_SUPER_ADMIN(4)`, `LEVEL_LOCAL_ADMIN(3)`, `LEVEL_SHOP(2)`, `LEVEL_CITIZEN(1)`
|
- 역할 상수: `Config\Roles` -- `LEVEL_SUPER_ADMIN(4)`, `LEVEL_LOCAL_ADMIN(3)`, `LEVEL_SHOP(2)`, `LEVEL_CITIZEN(1)`
|
||||||
- `AdminAuthFilter`가 로그인 + 레벨 3/4 + 지자체 선택 여부 검증
|
- `AdminAuthFilter`가 로그인 + 레벨 3/4 + 지자체 선택 여부 검증
|
||||||
|
- **기본코드 마스터 CRUD**는 `Roles::canManageCodeMaster()`(지자체관리자·Super Admin 등)로 제한. **종류·세부 목록 조회**는 로그인 사용자 전원 (`/bag/code-kinds`, `/bag/code-details/{ck_idx}`, `loginAuth` 필터)
|
||||||
|
|
||||||
## 멀티테넌시
|
## 멀티테넌시
|
||||||
|
|
||||||
@@ -177,14 +186,27 @@ assets/ # 기획 문서 (엑셀)
|
|||||||
|
|
||||||
| 경로 | 설명 | 기능 |
|
| 경로 | 설명 | 기능 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/bag/basic-info` | 기본정보관리 | 코드/단가/포장단위 조회 + 관리 링크 |
|
| `/bag/basic-info` | 기본정보관리 | 단가·포장단위 등 링크 허브 (`/bag/code-kinds`로 기본코드 조회) |
|
||||||
|
| `/bag/code-kinds` | 기본코드 종류 | 종류 목록·세부코드 링크 (조회; CRUD는 관리자만) |
|
||||||
|
| `/bag/code-details/{ck_idx}` | 기본코드 세부 | 해당 종류의 세부코드 목록 (조회; CRUD는 관리자만) |
|
||||||
| `/bag/purchase-inbound` | 발주 입고 관리 | 발주/입고 목록 + 등록 버튼 |
|
| `/bag/purchase-inbound` | 발주 입고 관리 | 발주/입고 목록 + 등록 버튼 |
|
||||||
|
| `/bag/order/create` | 발주 등록 | 발주서 신규 작성 |
|
||||||
|
| `/bag/order/change` | 발주 변경 | 발주 변경 목록/수정 진입 |
|
||||||
|
| `/bag/order/revise/{bo_idx}` | 발주 수정 | 선택 발주 수정 화면 |
|
||||||
|
| `/bag/order/lot-seed` | LOT-No 디스켓 불출 | 발주 LOT 기준 seed 생성/다운로드 |
|
||||||
| `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 |
|
| `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 |
|
||||||
| `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 |
|
| `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 |
|
||||||
| `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 |
|
| `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 |
|
||||||
|
| `/bag/order/phone` | 전화 주문 접수 | 전화 주문 접수표 작성/저장 |
|
||||||
|
| `/bag/order/phone/manage` | 전화 주문 접수 관리 | 접수 리스트 선택 후 품목 수량 수정/취소 |
|
||||||
|
| `/bag/sale/designated` | 지정판매소 판매 | 주문 선택 + 바코드 스캔 + 판매 저장 |
|
||||||
|
| `/bag/receiving/batch` | 일괄 입고 | 미입고 건 선택 일괄 입고 |
|
||||||
| `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 |
|
| `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 |
|
||||||
| `/bag/flow` | 봉투 수불 관리 | 봉투코드별 입출고 수불 요약 |
|
| `/bag/flow` | 봉투 수불 관리 | 봉투코드별 입출고 수불 요약 |
|
||||||
| `/bag/analytics` | 통계 분석 관리 | Phase 6 예정 |
|
| `/bag/analytics` | 통계 분석 (→ 전년 대비로 리다이렉트) | |
|
||||||
|
| `/bag/analytics/year-over-year` | 전년 대비 판매 분석 (w_gm604r) | |
|
||||||
|
| `/bag/analytics/monthly-trend` | 월별 판매 추이 분석 (w_gm606r) | |
|
||||||
|
| `/bag/analytics/seasonal-trend` | 계절별 판매 추이 분석 (w_gm607r) | |
|
||||||
| `/bag/window` | 창 | Phase 6 예정 |
|
| `/bag/window` | 창 | Phase 6 예정 |
|
||||||
| `/bag/help` | 도움말 | 시스템 안내 |
|
| `/bag/help` | 도움말 | 시스템 안내 |
|
||||||
|
|
||||||
@@ -202,46 +224,62 @@ assets/ # 기획 문서 (엑셀)
|
|||||||
| `/admin/menus/*` | 메뉴 관리 (트리 CRUD) |
|
| `/admin/menus/*` | 메뉴 관리 (트리 CRUD) |
|
||||||
| `/admin/local-governments/*` | 지자체 관리 (CRUD) |
|
| `/admin/local-governments/*` | 지자체 관리 (CRUD) |
|
||||||
| `/admin/select-local-government` | 작업 지자체 선택 (Super Admin) |
|
| `/admin/select-local-government` | 작업 지자체 선택 (Super Admin) |
|
||||||
| `/admin/password-change` | 비밀번호 변경 |
|
|
||||||
| `/admin/designated-shops/*` | 지정판매소 관리 (CRUD) |
|
|
||||||
|
|
||||||
**기본정보관리 (Phase 2)**
|
**기본코드 CRUD만 관리자 경로 (목록·조회는 `/bag/*`)**
|
||||||
|
|
||||||
| 경로 | 기능 |
|
| 경로 | 기능 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `/admin/code-kinds/*` | 기본코드 종류 (CRUD) |
|
| `/admin/code-kinds/*` | 기본코드 종류 **CRUD만** (create/edit/store/update/delete; **목록 없음** — 조회는 `/bag/code-kinds`) |
|
||||||
| `/admin/code-details/*` | 세부코드 (CRUD) |
|
| `/admin/code-details/*` | 세부코드 **CRUD만** (**목록 없음** — 조회는 `/bag/code-details/{ck_idx}`) |
|
||||||
| `/admin/bag-prices/*` | 봉투 단가 (CRUD + 이력) |
|
| (호환) `GET /admin/code-details/{ck_idx}` | `/bag/code-details/{ck_idx}` 로 리다이렉트 |
|
||||||
| `/admin/packaging-units/*` | 포장 단위 (CRUD + 이력) |
|
|
||||||
| `/admin/sales-agencies/*` | 판매 대행소 (CRUD) |
|
|
||||||
| `/admin/managers/*` | 담당자 (CRUD) |
|
|
||||||
| `/admin/companies/*` | 업체 (CRUD) |
|
|
||||||
| `/admin/free-recipients/*` | 무료용 대상자 (CRUD) |
|
|
||||||
|
|
||||||
**발주/입고/재고 (Phase 3)**
|
### 업무 화면 (`/bag/*`, adminAuth 필터)
|
||||||
|
|
||||||
|
동일 `Admin\*` 컨트롤러·뷰를 쓰며 메인 사이트 레이아웃으로 렌더된다. `GET /admin/managers` 등 옛 업무 URL은 `301`·`POST` 는 `307`로 `/bag/...`에 리다이렉트된다.
|
||||||
|
|
||||||
| 경로 | 기능 |
|
| 경로 | 기능 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `/admin/bag-orders/*` | 발주 관리 (등록/상세/취소/삭제) |
|
| `/bag/password-change` | 비밀번호 변경 |
|
||||||
| `/admin/bag-receivings/*` | 입고 관리 (등록, 재고 자동 반영) |
|
| `/bag/designated-shops/*` | 지정판매소 관리 (CRUD) |
|
||||||
| `/admin/bag-inventory` | 재고 현황 조회 |
|
| `/bag/bag-prices/*` | 봉투 단가 (CRUD + 이력) |
|
||||||
|
| `/bag/packaging-units/manage/*` | 포장 단위 (CRUD + 이력; 조회 전용은 `/bag/packaging-units`) |
|
||||||
|
| `/bag/sales-agencies/*` | 판매 대행소 (CRUD) |
|
||||||
|
| `/bag/managers/*` | 담당자 (CRUD) |
|
||||||
|
| `/bag/companies/*` | 업체 (CRUD) |
|
||||||
|
| `/bag/free-recipients/*` | 무료용 대상자 (CRUD) |
|
||||||
|
| `/bag/bag-orders/*` | 발주 관리 (등록/상세/취소/삭제) |
|
||||||
|
| `/bag/bag-receivings/*` | 입고 관리 (등록, 재고 자동 반영) |
|
||||||
|
| `/bag/bag-inventory` | 재고 현황 조회 |
|
||||||
|
| `/bag/shop-orders/*` | 주문 접수 (등록/취소) |
|
||||||
|
| `/bag/bag-sales/*` | 판매/반품 (등록) |
|
||||||
|
| `/bag/bag-issues/*` | 무료용 불출 (등록/취소, 재고 연동) |
|
||||||
|
| `/bag/reports/sales-ledger` | 판매 대장 (일자별/기간별) |
|
||||||
|
| `/bag/reports/daily-summary` | 일계표 (일계 + 월간 누계) |
|
||||||
|
| `/bag/reports/period-sales` | 기간별 판매현황 |
|
||||||
|
| `/bag/reports/supply-demand` | 봉투 수불 현황 |
|
||||||
|
|
||||||
**판매/주문/불출 (Phase 4)**
|
---
|
||||||
|
|
||||||
| 경로 | 기능 |
|
## 주문/판매 실무 흐름 (현재 구현)
|
||||||
|------|------|
|
|
||||||
| `/admin/shop-orders/*` | 주문 접수 (등록/취소) |
|
|
||||||
| `/admin/bag-sales/*` | 판매/반품 (등록) |
|
|
||||||
| `/admin/bag-issues/*` | 무료용 불출 (등록/취소, 재고 연동) |
|
|
||||||
|
|
||||||
**리포트 (Phase 5)**
|
1. 전화 주문 접수: `/bag/order/phone`
|
||||||
|
2. 전화 주문 관리(수정/취소): `/bag/order/phone/manage`
|
||||||
|
3. 지정판매소 판매 처리(바코드 스캔): `/bag/sale/designated`
|
||||||
|
4. 판매/재고 반영: `bag_sale` 기록 + `bag_inventory` 차감 + 주문 수령상태(`so_received`) 갱신
|
||||||
|
|
||||||
| 경로 | 기능 |
|
---
|
||||||
|------|------|
|
|
||||||
| `/admin/reports/sales-ledger` | 판매 대장 (일자별/기간별) |
|
## 바코드 생성/사용 시점 (현재 코드 기준)
|
||||||
| `/admin/reports/daily-summary` | 일계표 (일계 + 월간 누계) |
|
|
||||||
| `/admin/reports/period-sales` | 기간별 판매현황 |
|
상세 코드 체계(LOT·팩·낱장·품목코드·판매소번호): [`doc/봉투-LOT-바코드-코드체계.md`](doc/봉투-LOT-바코드-코드체계.md)
|
||||||
| `/admin/reports/supply-demand` | 봉투 수불 현황 |
|
|
||||||
|
- **현재 코드 구현**
|
||||||
|
- 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다.
|
||||||
|
- 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다.
|
||||||
|
- 판매 단계(`/bag/sale/designated`)에서는 생성된 코드를 스캔하여 `in_stock -> sold` 상태로 전환합니다.
|
||||||
|
- **요구사항 문서 관점**
|
||||||
|
- 노션 요구사항에는 발주 단계에서 바코드 원시데이터 생성 후 제작업체 인쇄 흐름이 명시되어 있습니다.
|
||||||
|
- 현재 구현과 요구사항 간 시점 차이가 존재하므로, 운영 정책 확정 후 발주 단계 생성으로 이관 검토가 필요합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -281,7 +319,7 @@ assets/ # 기획 문서 (엑셀)
|
|||||||
|
|
||||||
| 항목 | 구현 |
|
| 항목 | 구현 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 인증 | 세션 기반 로그인, AdminAuthFilter로 관리자 접근 제어 |
|
| 인증 | 세션 기반 로그인, AdminAuthFilter로 관리자 접근 제어, 기본코드 목록 경로는 LoginAuthFilter(`loginAuth`) |
|
||||||
| RBAC | 4단계 역할 (Config\Roles), 메뉴별 역할 노출 |
|
| RBAC | 4단계 역할 (Config\Roles), 메뉴별 역할 노출 |
|
||||||
| PII 암호화 | `pii_encryption_helper` (AES, `ENC:` prefix) - 이메일/전화번호 |
|
| PII 암호화 | `pii_encryption_helper` (AES, `ENC:` prefix) - 이메일/전화번호 |
|
||||||
| 비밀번호 | `password_hash()` + `password_verify()` (bcrypt) |
|
| 비밀번호 | `password_hash()` + `password_verify()` (bcrypt) |
|
||||||
@@ -341,6 +379,8 @@ SQL 스크립트 실행 순서:
|
|||||||
| 17 | `seed_test_accounts.sql` | 테스터 계정 4개 |
|
| 17 | `seed_test_accounts.sql` | 테스터 계정 4개 |
|
||||||
| 18 | `seed_realistic_data.sql` | 실제형 시범 데이터 |
|
| 18 | `seed_realistic_data.sql` | 실제형 시범 데이터 |
|
||||||
|
|
||||||
|
**기본코드 전용 보강 (선택·기존 DB):** `menu_fix_basic_code_link.sql`, `menu_site_add_basic_code_child.sql`, 개발목록 CSV 반영 시 `code_master_sync_from_csv.sql` 또는 `writable/tools/sync_basic_codes_from_csv.py`.
|
||||||
|
|
||||||
### 4) 개발 서버 실행
|
### 4) 개발 서버 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -376,6 +416,7 @@ npx playwright test -g "로그인 페이지"
|
|||||||
|
|
||||||
| ID | 역할 | Level |
|
| ID | 역할 | Level |
|
||||||
|----|------|-------|
|
|----|------|-------|
|
||||||
|
| `tester_badmin` | 본부 관리자 | 5 |
|
||||||
| `tester_admin` | Super Admin | 4 |
|
| `tester_admin` | Super Admin | 4 |
|
||||||
| `tester_local` | 지자체관리자 (중구청) | 3 |
|
| `tester_local` | 지자체관리자 (중구청) | 3 |
|
||||||
| `tester_shop` | 지정판매소 | 2 |
|
| `tester_shop` | 지정판매소 | 2 |
|
||||||
@@ -389,7 +430,7 @@ npx playwright test -g "로그인 페이지"
|
|||||||
| admin.spec.js | 10 | 관리자 패널 접근 |
|
| admin.spec.js | 10 | 관리자 패널 접근 |
|
||||||
| public.spec.js | 4 | 공개 페이지 |
|
| public.spec.js | 4 | 공개 페이지 |
|
||||||
| bag-site.spec.js | 11 | 사이트 메뉴 /bag/* |
|
| bag-site.spec.js | 11 | 사이트 메뉴 /bag/* |
|
||||||
| code-management.spec.js | 7 | 기본코드 CRUD |
|
| code-management.spec.js | 7 | 기본코드 CRUD (`/bag/*` 목록 + `/admin/*` 폼) |
|
||||||
| bag-price.spec.js | 6 | 봉투 단가 |
|
| bag-price.spec.js | 6 | 봉투 단가 |
|
||||||
| packaging-unit.spec.js | 3 | 포장 단위 |
|
| packaging-unit.spec.js | 3 | 포장 단위 |
|
||||||
| phase2-entities.spec.js | 8 | 대행소/담당자/업체/무료대상자 |
|
| phase2-entities.spec.js | 8 | 대행소/담당자/업체/무료대상자 |
|
||||||
@@ -402,6 +443,14 @@ npx playwright test -g "로그인 페이지"
|
|||||||
|
|
||||||
## 기본코드 체계
|
## 기본코드 체계
|
||||||
|
|
||||||
|
### 코드 관리 URL·동작
|
||||||
|
|
||||||
|
| 구분 | 경로 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 목록·조회 | `/bag/code-kinds`, `/bag/code-details/{ck_idx}` | 사이트(`bag`) 레이아웃. 시민·판매소는 열람만; 코드 마스터 관리 권한이 있으면 CRUD용 링크(관리자 화면) 노출 |
|
||||||
|
| 등록·수정·삭제 | `/admin/code-kinds/*`, `/admin/code-details/*` | `adminAuth` + `canManageCodeMaster`. 처리 후 flash 메시지와 함께 위 `bag` 목록으로 되돌아감 |
|
||||||
|
| 데이터 보강 (선택) | `writable/tools/sync_basic_codes_from_csv.py` → 생성 SQL 또는 `code_master_sync_from_csv.sql` | 개발목록 CSV와 DB 종류·세부코드 맞출 때 참고 |
|
||||||
|
|
||||||
A~T 총 20종의 코드 체계 (`code_kind` + `code_detail`):
|
A~T 총 20종의 코드 체계 (`code_kind` + `code_detail`):
|
||||||
|
|
||||||
| 코드 | 코드명 | 코드 | 코드명 |
|
| 코드 | 코드명 | 코드 | 코드명 |
|
||||||
@@ -465,6 +514,9 @@ A~T 총 20종의 코드 체계 (`code_kind` + `code_detail`):
|
|||||||
| `menu_site_seed_from_csv.sql` | 사이트 네비게이션 시드 |
|
| `menu_site_seed_from_csv.sql` | 사이트 네비게이션 시드 |
|
||||||
| `local_government_init_daegu.sql` | 대구 8개 구군 지자체 |
|
| `local_government_init_daegu.sql` | 대구 8개 구군 지자체 |
|
||||||
| `code_master_init_daegu.sql` | 기본코드 20종 + 세부코드 |
|
| `code_master_init_daegu.sql` | 기본코드 20종 + 세부코드 |
|
||||||
|
| `menu_fix_basic_code_link.sql` | 사이트 메뉴에서 기본코드 링크 보정 (기존 DB용, 선택) |
|
||||||
|
| `menu_site_add_basic_code_child.sql` | 사이트 메뉴에 기본코드 하위 항목 (선택) |
|
||||||
|
| `code_master_sync_from_csv.sql` | CSV 기준 기본코드 보강 (선택) |
|
||||||
| `bag_price_tables.sql` | bag_price, bag_price_history |
|
| `bag_price_tables.sql` | bag_price, bag_price_history |
|
||||||
| `packaging_unit_tables.sql` | packaging_unit, packaging_unit_history |
|
| `packaging_unit_tables.sql` | packaging_unit, packaging_unit_history |
|
||||||
| `sales_agency_tables.sql` | sales_agency |
|
| `sales_agency_tables.sql` | sales_agency |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class App extends BaseConfig
|
|||||||
* something else. If you have configured your web server to remove this file
|
* something else. If you have configured your web server to remove this file
|
||||||
* from your site URIs, set this variable to an empty string.
|
* from your site URIs, set this variable to an empty string.
|
||||||
*/
|
*/
|
||||||
public string $indexPage = 'index.php';
|
public string $indexPage = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
|
|||||||
54
app/Config/Auth.php
Normal file
54
app/Config/Auth.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Config\BaseConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인·2차 인증(TOTP) 관련 설정
|
||||||
|
*
|
||||||
|
* .env 의 auth.requireTotp 가 Config 기본값보다 우선합니다. 끄려면 반드시 false 로 두세요.
|
||||||
|
* 예:
|
||||||
|
* auth.requireTotp = false
|
||||||
|
* auth.requireTotp = true # 운영에서 2FA 켤 때
|
||||||
|
* auth.totpIssuer = "종량제 시스템"
|
||||||
|
*/
|
||||||
|
class Auth extends BaseConfig
|
||||||
|
{
|
||||||
|
/** false 이면 로그인 시 TOTP·등록 유도 없음. 운영에서 켤 때 .env 에 auth.requireTotp = true */
|
||||||
|
public bool $requireTotp = false;
|
||||||
|
|
||||||
|
/** 인증 앱에 표시되는 발급자(issuer) */
|
||||||
|
public string $totpIssuer = '종량제 시스템';
|
||||||
|
|
||||||
|
/** TOTP 연속 실패 시 세션 종료 전 허용 횟수 */
|
||||||
|
public int $totpMaxAttempts = 5;
|
||||||
|
|
||||||
|
/** 비밀번호 통과 후 2단계 완료까지 허용 시간(초) */
|
||||||
|
public int $pending2faTtlSeconds = 600;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$require = env('auth.requireTotp');
|
||||||
|
if ($require !== null && $require !== '') {
|
||||||
|
$this->requireTotp = filter_var($require, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuer = env('auth.totpIssuer');
|
||||||
|
if (is_string($issuer) && $issuer !== '') {
|
||||||
|
$this->totpIssuer = $issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = env('auth.totpMaxAttempts');
|
||||||
|
if ($max !== null && $max !== '' && is_numeric($max)) {
|
||||||
|
$this->totpMaxAttempts = max(1, (int) $max);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ttl = env('auth.pending2faTtlSeconds');
|
||||||
|
if ($ttl !== null && $ttl !== '' && is_numeric($ttl)) {
|
||||||
|
$this->pending2faTtlSeconds = max(60, (int) $ttl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,40 @@ class Encryption extends BaseConfig
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$hex = (string) env('encryption.key', '');
|
$hex = trim((string) env('encryption.key', ''));
|
||||||
|
if (
|
||||||
|
(str_starts_with($hex, "'") && str_ends_with($hex, "'"))
|
||||||
|
|| (str_starts_with($hex, '"') && str_ends_with($hex, '"'))
|
||||||
|
) {
|
||||||
|
$hex = substr($hex, 1, -1);
|
||||||
|
}
|
||||||
$this->key = (strlen($hex) === 64 && ctype_xdigit($hex)) ? hex2bin($hex) : '';
|
$this->key = (strlen($hex) === 64 && ctype_xdigit($hex)) ? hex2bin($hex) : '';
|
||||||
|
|
||||||
|
$prev = trim((string) env('encryption.previousKeys', ''));
|
||||||
|
if ($prev !== '') {
|
||||||
|
$parsed = [];
|
||||||
|
$parts = array_map('trim', explode(',', $prev));
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if ($part === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($part, 'hex2bin:')) {
|
||||||
|
$part = substr($part, 8);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(str_starts_with($part, "'") && str_ends_with($part, "'"))
|
||||||
|
|| (str_starts_with($part, '"') && str_ends_with($part, '"'))
|
||||||
|
) {
|
||||||
|
$part = substr($part, 1, -1);
|
||||||
|
}
|
||||||
|
if (strlen($part) === 64 && ctype_xdigit($part)) {
|
||||||
|
$parsed[] = 'hex2bin:' . $part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! empty($parsed)) {
|
||||||
|
$this->previousKeys = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Filters extends BaseFilters
|
|||||||
*/
|
*/
|
||||||
public array $aliases = [
|
public array $aliases = [
|
||||||
'adminAuth' => \App\Filters\AdminAuthFilter::class,
|
'adminAuth' => \App\Filters\AdminAuthFilter::class,
|
||||||
|
'loginAuth' => \App\Filters\LoginAuthFilter::class,
|
||||||
'csrf' => CSRF::class,
|
'csrf' => CSRF::class,
|
||||||
'toolbar' => DebugToolbar::class,
|
'toolbar' => DebugToolbar::class,
|
||||||
'honeypot' => Honeypot::class,
|
'honeypot' => Honeypot::class,
|
||||||
|
|||||||
26
app/Config/Kakao.php
Normal file
26
app/Config/Kakao.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Config\BaseConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카카오 Developers — 내 애플리케이션 — 앱 키 — JavaScript 키
|
||||||
|
* .env 예: kakao.javascriptKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
*/
|
||||||
|
class Kakao extends BaseConfig
|
||||||
|
{
|
||||||
|
public string $javascriptKey = '';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$v = env('kakao.javascriptKey');
|
||||||
|
if (is_string($v) && $v !== '') {
|
||||||
|
$this->javascriptKey = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Config/Manual.php
Normal file
73
app/Config/Manual.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Config\BaseConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 매뉴얼(설명서) 목차 정의.
|
||||||
|
*
|
||||||
|
* - 배열 순서가 곧 목차(사이드바) 노출 순서입니다.
|
||||||
|
* - slug 는 URL 세그먼트이자 화이트리스트입니다. 여기에 없는 slug 는 404 입니다.
|
||||||
|
* - file 은 $dir 하위의 실제 마크다운 파일명입니다(사용자 입력으로 조합하지 않음).
|
||||||
|
*/
|
||||||
|
class Manual extends BaseConfig
|
||||||
|
{
|
||||||
|
/** 마크다운 콘텐츠 디렉터리 (웹 루트 밖). */
|
||||||
|
public string $dir = APPPATH . 'Docs/manual/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{title: string, file: string}>
|
||||||
|
*/
|
||||||
|
public array $pages = [
|
||||||
|
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
|
||||||
|
'account' => ['title' => '로그인·회원가입·계정', 'file' => '01_account.md'],
|
||||||
|
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
|
||||||
|
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
|
||||||
|
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
|
||||||
|
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
|
||||||
|
'sales' => ['title' => '판매·반품·불출·주문', 'file' => '40_sales_issue.md'],
|
||||||
|
'reports' => ['title' => '현황·리포트·수불', 'file' => '50_reports.md'],
|
||||||
|
'basic' => ['title' => '기본정보(판매소·단가·코드)', 'file' => '60_basic_info.md'],
|
||||||
|
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
|
||||||
|
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 경로(접두) → 그 화면을 설명하는 매뉴얼 slug.
|
||||||
|
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
|
||||||
|
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
public array $screenHelp = [
|
||||||
|
'bag/order/phone' => 'sales',
|
||||||
|
'bag/order' => 'order',
|
||||||
|
'bag/bag-orders' => 'order',
|
||||||
|
'bag/receiving' => 'order',
|
||||||
|
'bag/bag-receivings' => 'order',
|
||||||
|
'bag/inventory' => 'inventory',
|
||||||
|
'bag/sale' => 'sales',
|
||||||
|
'bag/sales' => 'sales',
|
||||||
|
'bag/issue' => 'sales',
|
||||||
|
'bag/bag-issues' => 'sales',
|
||||||
|
'bag/bag-sales' => 'sales',
|
||||||
|
'bag/shop-orders' => 'sales',
|
||||||
|
'bag/flow' => 'reports',
|
||||||
|
'bag/reports' => 'reports',
|
||||||
|
'bag/analytics' => 'reports',
|
||||||
|
'bag/designated-shops' => 'basic',
|
||||||
|
'bag/bag-prices' => 'basic',
|
||||||
|
'bag/prices' => 'basic',
|
||||||
|
'bag/packaging-units' => 'basic',
|
||||||
|
'bag/code-kinds' => 'basic',
|
||||||
|
'bag/code-details' => 'basic',
|
||||||
|
'bag/managers' => 'basic',
|
||||||
|
'bag/companies' => 'basic',
|
||||||
|
'bag/sales-agencies' => 'basic',
|
||||||
|
'bag/free-recipients' => 'basic',
|
||||||
|
'bag/number-lookup' => 'codes',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@ class Pager extends BaseConfig
|
|||||||
* @var array<string, string>
|
* @var array<string, string>
|
||||||
*/
|
*/
|
||||||
public array $templates = [
|
public array $templates = [
|
||||||
'default_full' => 'CodeIgniter\Pager\Views\default_full',
|
'default_full' => 'App\Views\components\pager',
|
||||||
'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
|
'default_simple' => 'App\Views\components\pager',
|
||||||
'default_head' => 'CodeIgniter\Pager\Views\default_head',
|
'default_head' => 'CodeIgniter\Pager\Views\default_head',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class Roles extends BaseConfig
|
|||||||
* mb_level 상수 (member.mb_level)
|
* mb_level 상수 (member.mb_level)
|
||||||
*/
|
*/
|
||||||
public const LEVEL_SUPER_ADMIN = 4;
|
public const LEVEL_SUPER_ADMIN = 4;
|
||||||
|
/** 본부 관리자 — 현재는 super admin과 동일한 관리자 권한(지자체 선택 후 작업). 추후 super 전용 기능 분리 시 여기만 조정 */
|
||||||
|
public const LEVEL_HEADQUARTERS_ADMIN = 5;
|
||||||
public const LEVEL_LOCAL_ADMIN = 3; // 지자체관리자
|
public const LEVEL_LOCAL_ADMIN = 3; // 지자체관리자
|
||||||
public const LEVEL_SHOP = 2; // 지정판매소
|
public const LEVEL_SHOP = 2; // 지정판매소
|
||||||
public const LEVEL_CITIZEN = 1; // 일반 사용자(시민)
|
public const LEVEL_CITIZEN = 1; // 일반 사용자(시민)
|
||||||
@@ -29,8 +31,73 @@ class Roles extends BaseConfig
|
|||||||
self::LEVEL_SHOP => '지정판매소',
|
self::LEVEL_SHOP => '지정판매소',
|
||||||
self::LEVEL_LOCAL_ADMIN => '지자체관리자',
|
self::LEVEL_LOCAL_ADMIN => '지자체관리자',
|
||||||
self::LEVEL_SUPER_ADMIN => 'super admin',
|
self::LEVEL_SUPER_ADMIN => 'super admin',
|
||||||
|
self::LEVEL_HEADQUARTERS_ADMIN => '본부 관리자',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* super admin(4) 또는 본부 관리자(5) — 동일 관리자 UX(지자체 선택 등)에 사용
|
||||||
|
*/
|
||||||
|
public static function isSuperAdminEquivalent(int $level): bool
|
||||||
|
{
|
||||||
|
return $level === self::LEVEL_SUPER_ADMIN || $level === self::LEVEL_HEADQUARTERS_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본코드(종류·세부) 등록·수정·삭제 가능 (super admin(4) · 본부 관리자(5)만)
|
||||||
|
*/
|
||||||
|
public static function canManageCodeMaster(int $level): bool
|
||||||
|
{
|
||||||
|
return self::isSuperAdminEquivalent($level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본코드 종류(code_kind) CRUD — super·본부만
|
||||||
|
*/
|
||||||
|
public static function canManageCodeKindMaster(int $level): bool
|
||||||
|
{
|
||||||
|
return self::isSuperAdminEquivalent($level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫폼 공통 세부코드(CSV·시드) 수정·삭제 — super·본부만
|
||||||
|
*/
|
||||||
|
public static function canEditPlatformCodeDetail(int $level): bool
|
||||||
|
{
|
||||||
|
return self::isSuperAdminEquivalent($level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세부코드 행 단위 수정/삭제 가능 여부
|
||||||
|
*
|
||||||
|
* @param object $row code_detail (cd_source, cd_lg_idx)
|
||||||
|
*/
|
||||||
|
public static function canEditCodeDetailRow(int $level, object $row, ?int $adminEffectiveLgIdx): bool
|
||||||
|
{
|
||||||
|
if (! self::canManageCodeMaster($level)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$src = $row->cd_source ?? 'platform';
|
||||||
|
$lg = (int) ($row->cd_lg_idx ?? 0);
|
||||||
|
if ($src === 'platform' && $lg === 0) {
|
||||||
|
return self::canEditPlatformCodeDetail($level);
|
||||||
|
}
|
||||||
|
if (self::isSuperAdminEquivalent($level)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $adminEffectiveLgIdx !== null && $adminEffectiveLgIdx > 0 && $lg === $adminEffectiveLgIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP 2차 인증 적용 대상 (지자체·super·본부 관리자)
|
||||||
|
*/
|
||||||
|
public static function requiresTotp(int $level): bool
|
||||||
|
{
|
||||||
|
return $level === self::LEVEL_LOCAL_ADMIN
|
||||||
|
|| $level === self::LEVEL_SUPER_ADMIN
|
||||||
|
|| $level === self::LEVEL_HEADQUARTERS_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자체 회원가입 시 기본 역할 (mb_level)
|
* 자체 회원가입 시 기본 역할 (mb_level)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,29 +6,228 @@ use CodeIgniter\Router\RouteCollection;
|
|||||||
* @var RouteCollection $routes
|
* @var RouteCollection $routes
|
||||||
*/
|
*/
|
||||||
$routes->get('/', 'Home::index');
|
$routes->get('/', 'Home::index');
|
||||||
|
$routes->get('workspace', 'Home::workspace');
|
||||||
$routes->get('dashboard', 'Home::dashboard');
|
$routes->get('dashboard', 'Home::dashboard');
|
||||||
|
$routes->get('dashboard/simple', 'Home::dashboardSimple');
|
||||||
|
$routes->get('dashboard/compact', 'Home::dashboardCompact');
|
||||||
$routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock');
|
$routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock');
|
||||||
$routes->get('dashboard/modern', 'Home::dashboardModern');
|
$routes->get('dashboard/modern', 'Home::dashboardModern');
|
||||||
$routes->get('dashboard/dense', 'Home::dashboardDense');
|
$routes->get('dashboard/dense', 'Home::dashboardDense');
|
||||||
$routes->get('dashboard/charts', 'Home::dashboardCharts');
|
$routes->get('dashboard/charts', 'Home::dashboardCharts');
|
||||||
|
$routes->get('dashboard/blend', 'Home::dashboardBlend');
|
||||||
|
$routes->get('dashboard/lite', 'Home::dashboardLite');
|
||||||
|
$routes->get('dashboard/gov-portal', 'Home::dashboardGovPortal');
|
||||||
|
$routes->get('dashboard/gov-portal/code-kinds', 'Home::dashboardGovPortalCodeKinds');
|
||||||
|
$routes->get('dashboard/gov-portal-strip', 'Home::dashboardGovPortalStrip');
|
||||||
|
$routes->get('dashboard/gov-portal-strip/code-kinds', 'Home::dashboardGovPortalStripCodeKinds');
|
||||||
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
|
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
|
||||||
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
|
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
|
||||||
|
|
||||||
// 사이트 메뉴 (/bag/*)
|
// 사이트 메뉴 (/bag/*)
|
||||||
$routes->get('bag/basic-info', 'Bag::basicInfo');
|
$routes->get('bag/basic-info', 'Bag::basicInfo');
|
||||||
|
$routes->get('bag/prices', 'Bag::prices');
|
||||||
|
$routes->post('bag/prices', 'Bag::prices');
|
||||||
|
$routes->get('bag/packaging-units', 'Bag::packagingUnits');
|
||||||
|
$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/purchase-inbound', 'Bag::purchaseInbound');
|
||||||
$routes->get('bag/issue', 'Bag::issue');
|
$routes->get('bag/issue', 'Bag::issueLegacy');
|
||||||
|
$routes->get('bag/issue/cancel', 'Bag::issue');
|
||||||
$routes->get('bag/inventory', 'Bag::inventory');
|
$routes->get('bag/inventory', 'Bag::inventory');
|
||||||
|
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
|
||||||
|
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
|
||||||
|
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
|
||||||
|
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
|
||||||
|
$routes->post('bag/inventory/inspection-select/save', 'Bag::inspectionSelectSave');
|
||||||
|
$routes->post('bag/inventory/inspection-select/confirm', 'Bag::inspectionSelectConfirm');
|
||||||
|
$routes->get('bag/inventory/inspection/(:num)', 'Bag::inspectionDetail/$1');
|
||||||
|
$routes->post('bag/inventory/inspection/(:num)/save', 'Bag::inspectionSave/$1');
|
||||||
|
$routes->post('bag/inventory/inspection/(:num)/apply', 'Bag::inspectionApply/$1');
|
||||||
$routes->get('bag/sales', 'Bag::sales');
|
$routes->get('bag/sales', 'Bag::sales');
|
||||||
$routes->get('bag/sales-stats', 'Bag::salesStats');
|
$routes->get('bag/sales-stats', 'Bag::salesStats');
|
||||||
$routes->get('bag/flow', 'Bag::flow');
|
$routes->get('bag/flow', 'Bag::flow');
|
||||||
|
$routes->get('bag/flow/export', 'Bag::flowExport');
|
||||||
$routes->get('bag/analytics', 'Bag::analytics');
|
$routes->get('bag/analytics', 'Bag::analytics');
|
||||||
|
$routes->get('bag/analytics/year-over-year', 'Bag::analyticsYearOverYear');
|
||||||
|
$routes->get('bag/analytics/monthly-trend', 'Bag::analyticsMonthlyTrend');
|
||||||
|
$routes->get('bag/analytics/seasonal-trend', 'Bag::analyticsSeasonalTrend');
|
||||||
$routes->get('bag/window', 'Bag::window');
|
$routes->get('bag/window', 'Bag::window');
|
||||||
$routes->get('bag/help', 'Bag::help');
|
$routes->get('bag/help', 'Bag::help');
|
||||||
|
|
||||||
|
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
|
||||||
|
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
|
||||||
|
$routes->get('manual', 'Bag::manual');
|
||||||
|
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
|
||||||
|
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
||||||
|
});
|
||||||
|
|
||||||
|
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
||||||
|
$routes->post('bag/number-lookup/resolve', 'Bag::numberLookupResolve');
|
||||||
|
|
||||||
|
// 사이트 메뉴 CRUD (사이트 레이아웃)
|
||||||
|
$routes->get('bag/issue/create', 'Bag::issueCreate');
|
||||||
|
$routes->post('bag/issue/store', 'Bag::issueStore');
|
||||||
|
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
|
||||||
|
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
|
||||||
|
$routes->get('bag/order/create', 'Bag::orderCreate');
|
||||||
|
$routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
|
||||||
|
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
||||||
|
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
||||||
|
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
|
||||||
|
$routes->get('bag/order/change', 'Bag::orderChange');
|
||||||
|
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
|
||||||
|
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
|
||||||
|
$routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate');
|
||||||
|
$routes->post('bag/order/store', 'Bag::orderStore');
|
||||||
|
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');
|
||||||
|
$routes->post('bag/order/delete', 'Bag::orderDeletePost');
|
||||||
|
$routes->post('bag/order/delete/(:num)', 'Bag::orderDelete/$1');
|
||||||
|
$routes->get('bag/receiving/create', 'Bag::receivingCreate');
|
||||||
|
$routes->post('bag/receiving/store', 'Bag::receivingStore');
|
||||||
|
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
|
||||||
|
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
|
||||||
|
$routes->get('bag/receiving/batch', 'Bag::receivingBatch');
|
||||||
|
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
|
||||||
|
$routes->get('bag/receiving/status', 'Bag::receivingStatus');
|
||||||
|
$routes->get('bag/receiving/status/export', 'Bag::receivingStatusExport');
|
||||||
|
$routes->get('bag/sale/create', 'Bag::saleCreate');
|
||||||
|
$routes->post('bag/sale/store', 'Bag::saleStore');
|
||||||
|
$routes->get('bag/sale/designated', 'Bag::designatedShopSaleCreate');
|
||||||
|
$routes->get('bag/sale/designated/dev-saleable-barcodes', 'Bag::designatedShopDevSaleableBarcodes');
|
||||||
|
$routes->get('bag/sale/dev-all-sales-history', 'Bag::devAllSalesHistory');
|
||||||
|
$routes->post('bag/sale/designated/scan', 'Bag::designatedShopSaleScan');
|
||||||
|
$routes->post('bag/sale/designated/save', 'Bag::designatedShopSaleSave');
|
||||||
|
$routes->get('bag/sale/designated-return', 'Bag::designatedShopSaleReturnCreate');
|
||||||
|
$routes->post('bag/sale/designated-return/scan', 'Bag::designatedShopSaleReturnScan');
|
||||||
|
$routes->post('bag/sale/designated-return/save', 'Bag::designatedShopSaleReturnSave');
|
||||||
|
$routes->get('bag/sale/designated-return-cancel', 'Bag::designatedShopSaleReturnCancelCreate');
|
||||||
|
$routes->post('bag/sale/designated-return-cancel/save', 'Bag::designatedShopSaleReturnCancelSave');
|
||||||
|
$routes->get('bag/sale/designated-cancel', 'Bag::designatedShopReturnCreate');
|
||||||
|
$routes->post('bag/sale/designated-cancel/submit', 'Bag::designatedShopReturnCancel');
|
||||||
|
$routes->get('bag/shop-order/create', 'Bag::shopOrderCreate');
|
||||||
|
$routes->post('bag/shop-order/store', 'Bag::shopOrderStore');
|
||||||
|
|
||||||
|
// 메인 사이트 메뉴용 업무 URL (관리자 권한). 동일 컨트롤러가 URI 가 bag 이면 메인 사이트 레이아웃으로 렌더.
|
||||||
|
$routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void {
|
||||||
|
$routes->get('managers', 'Admin\Manager::index');
|
||||||
|
$routes->get('managers/create', 'Admin\Manager::create');
|
||||||
|
$routes->post('managers/store', 'Admin\Manager::store');
|
||||||
|
$routes->get('managers/edit/(:num)', 'Admin\Manager::edit/$1');
|
||||||
|
$routes->post('managers/update/(:num)', 'Admin\Manager::update/$1');
|
||||||
|
$routes->post('managers/delete/(:num)', 'Admin\Manager::delete/$1');
|
||||||
|
|
||||||
|
$routes->get('sales-agencies', 'Admin\SalesAgency::index');
|
||||||
|
$routes->get('sales-agencies/create', 'Admin\SalesAgency::create');
|
||||||
|
$routes->post('sales-agencies/store', 'Admin\SalesAgency::store');
|
||||||
|
$routes->get('sales-agencies/edit/(:num)', 'Admin\SalesAgency::edit/$1');
|
||||||
|
$routes->post('sales-agencies/update/(:num)', 'Admin\SalesAgency::update/$1');
|
||||||
|
$routes->post('sales-agencies/delete/(:num)', 'Admin\SalesAgency::delete/$1');
|
||||||
|
|
||||||
|
$routes->get('companies', 'Admin\Company::index');
|
||||||
|
$routes->get('companies/create', 'Admin\Company::create');
|
||||||
|
$routes->post('companies/store', 'Admin\Company::store');
|
||||||
|
$routes->get('companies/edit/(:num)', 'Admin\Company::edit/$1');
|
||||||
|
$routes->post('companies/update/(:num)', 'Admin\Company::update/$1');
|
||||||
|
$routes->post('companies/delete/(:num)', 'Admin\Company::delete/$1');
|
||||||
|
|
||||||
|
$routes->get('free-recipients', 'Admin\FreeRecipient::index');
|
||||||
|
$routes->get('free-recipients/create', 'Admin\FreeRecipient::create');
|
||||||
|
$routes->post('free-recipients/store', 'Admin\FreeRecipient::store');
|
||||||
|
$routes->get('free-recipients/edit/(:num)', 'Admin\FreeRecipient::edit/$1');
|
||||||
|
$routes->post('free-recipients/update/(:num)', 'Admin\FreeRecipient::update/$1');
|
||||||
|
$routes->post('free-recipients/delete/(:num)', 'Admin\FreeRecipient::delete/$1');
|
||||||
|
|
||||||
|
$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');
|
||||||
|
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
||||||
|
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
||||||
|
$routes->post('designated-shops/delete/(:num)', 'Admin\DesignatedShop::delete/$1');
|
||||||
|
|
||||||
|
$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('bag-orders/export', 'Admin\BagOrder::export');
|
||||||
|
$routes->get('bag-orders', 'Admin\BagOrder::index');
|
||||||
|
$routes->get('bag-orders/create', 'Admin\BagOrder::create');
|
||||||
|
$routes->get('bag-orders/revise/(:num)', 'Admin\BagOrder::revise/$1');
|
||||||
|
$routes->post('bag-orders/store', 'Admin\BagOrder::store');
|
||||||
|
$routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1');
|
||||||
|
$routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1');
|
||||||
|
$routes->post('bag-orders/delete/(:num)', 'Admin\BagOrder::delete/$1');
|
||||||
|
|
||||||
|
$routes->get('bag-receivings', 'Admin\BagReceiving::index');
|
||||||
|
$routes->get('bag-receivings/create', 'Admin\BagReceiving::create');
|
||||||
|
$routes->post('bag-receivings/store', 'Admin\BagReceiving::store');
|
||||||
|
|
||||||
|
$routes->get('bag-inventory/export', 'Admin\BagInventory::export');
|
||||||
|
$routes->get('bag-inventory', 'Admin\BagInventory::index');
|
||||||
|
|
||||||
|
$routes->get('shop-orders', 'Admin\ShopOrder::index');
|
||||||
|
$routes->get('shop-orders/create', 'Admin\ShopOrder::create');
|
||||||
|
$routes->post('shop-orders/store', 'Admin\ShopOrder::store');
|
||||||
|
$routes->post('shop-orders/cancel/(:num)', 'Admin\ShopOrder::cancel/$1');
|
||||||
|
|
||||||
|
$routes->get('bag-sales/export', 'Admin\BagSale::export');
|
||||||
|
$routes->get('bag-sales', 'Admin\BagSale::index');
|
||||||
|
$routes->get('bag-sales/create', 'Admin\BagSale::create');
|
||||||
|
$routes->post('bag-sales/store', 'Admin\BagSale::store');
|
||||||
|
|
||||||
|
$routes->get('bag-issues', 'Admin\BagIssue::index');
|
||||||
|
$routes->get('bag-issues/create', 'Admin\BagIssue::create');
|
||||||
|
$routes->post('bag-issues/store', 'Admin\BagIssue::store');
|
||||||
|
$routes->post('bag-issues/cancel/(:num)', 'Admin\BagIssue::cancel/$1');
|
||||||
|
|
||||||
|
$routes->get('packaging-units/manage', 'Admin\PackagingUnit::index');
|
||||||
|
$routes->get('packaging-units/manage/create', 'Admin\PackagingUnit::create');
|
||||||
|
$routes->post('packaging-units/manage/store', 'Admin\PackagingUnit::store');
|
||||||
|
$routes->get('packaging-units/manage/edit/(:num)', 'Admin\PackagingUnit::edit/$1');
|
||||||
|
$routes->post('packaging-units/manage/update/(:num)', 'Admin\PackagingUnit::update/$1');
|
||||||
|
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
|
||||||
|
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
|
||||||
|
|
||||||
|
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
|
||||||
|
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
|
||||||
|
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
|
||||||
|
$routes->get('reports/supply-demand', 'Admin\SalesReport::supplyDemand');
|
||||||
|
$routes->get('reports/yearly-sales', 'Admin\SalesReport::yearlySales');
|
||||||
|
$routes->get('reports/shop-sales', 'Admin\SalesReport::shopSales');
|
||||||
|
$routes->get('reports/hometax-export', 'Admin\SalesReport::hometaxExport');
|
||||||
|
$routes->get('reports/returns', 'Admin\SalesReport::returns');
|
||||||
|
$routes->get('reports/returns/export', 'Admin\SalesReport::returnsExport');
|
||||||
|
$routes->get('reports/lot-flow', 'Admin\SalesReport::lotFlow');
|
||||||
|
$routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow');
|
||||||
|
$routes->post('reports/misc-flow', 'Admin\SalesReport::miscFlowStore');
|
||||||
|
$routes->post('reports/misc-flow/delete', 'Admin\SalesReport::miscFlowDelete');
|
||||||
|
|
||||||
|
$routes->get('password-change', 'Admin\PasswordChange::index');
|
||||||
|
$routes->post('password-change', 'Admin\PasswordChange::update');
|
||||||
|
});
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
$routes->get('login', 'Auth::showLoginForm');
|
$routes->get('login', 'Auth::showLoginForm');
|
||||||
$routes->post('login', 'Auth::login');
|
$routes->post('login', 'Auth::login');
|
||||||
|
$routes->get('login/two-factor', 'Auth::showTwoFactor');
|
||||||
|
$routes->post('login/two-factor', 'Auth::verifyTwoFactor');
|
||||||
|
$routes->get('login/totp-setup', 'Auth::showTotpSetup');
|
||||||
|
$routes->post('login/totp-setup', 'Auth::completeTotpSetup');
|
||||||
$routes->get('logout', 'Auth::logout');
|
$routes->get('logout', 'Auth::logout');
|
||||||
$routes->get('register', 'Auth::showRegisterForm');
|
$routes->get('register', 'Auth::showRegisterForm');
|
||||||
$routes->post('register', 'Auth::register');
|
$routes->post('register', 'Auth::register');
|
||||||
@@ -43,6 +242,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
|||||||
$routes->post('users/store', 'Admin\User::store');
|
$routes->post('users/store', 'Admin\User::store');
|
||||||
$routes->get('users/edit/(:num)', 'Admin\User::edit/$1');
|
$routes->get('users/edit/(:num)', 'Admin\User::edit/$1');
|
||||||
$routes->post('users/update/(:num)', 'Admin\User::update/$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->post('users/delete/(:num)', 'Admin\User::delete/$1');
|
||||||
$routes->get('access/login-history', 'Admin\Access::loginHistory');
|
$routes->get('access/login-history', 'Admin\Access::loginHistory');
|
||||||
$routes->get('access/approvals', 'Admin\Access::approvals');
|
$routes->get('access/approvals', 'Admin\Access::approvals');
|
||||||
@@ -64,12 +264,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
|||||||
$routes->post('local-governments/update/(:num)', 'Admin\LocalGovernment::update/$1');
|
$routes->post('local-governments/update/(:num)', 'Admin\LocalGovernment::update/$1');
|
||||||
$routes->post('local-governments/delete/(:num)', 'Admin\LocalGovernment::delete/$1');
|
$routes->post('local-governments/delete/(:num)', 'Admin\LocalGovernment::delete/$1');
|
||||||
|
|
||||||
// 비밀번호 변경 (P2-20)
|
// 기본코드 종류 관리 (P2-01) — 등록·수정·삭제는 관리자 전용
|
||||||
$routes->get('password-change', 'Admin\PasswordChange::index');
|
|
||||||
$routes->post('password-change', 'Admin\PasswordChange::update');
|
|
||||||
|
|
||||||
// 기본코드 종류 관리 (P2-01)
|
|
||||||
$routes->get('code-kinds', 'Admin\CodeKind::index');
|
|
||||||
$routes->get('code-kinds/create', 'Admin\CodeKind::create');
|
$routes->get('code-kinds/create', 'Admin\CodeKind::create');
|
||||||
$routes->post('code-kinds/store', 'Admin\CodeKind::store');
|
$routes->post('code-kinds/store', 'Admin\CodeKind::store');
|
||||||
$routes->get('code-kinds/edit/(:num)', 'Admin\CodeKind::edit/$1');
|
$routes->get('code-kinds/edit/(:num)', 'Admin\CodeKind::edit/$1');
|
||||||
@@ -77,106 +272,32 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
|||||||
$routes->post('code-kinds/delete/(:num)', 'Admin\CodeKind::delete/$1');
|
$routes->post('code-kinds/delete/(:num)', 'Admin\CodeKind::delete/$1');
|
||||||
|
|
||||||
// 세부코드 관리 (P2-02)
|
// 세부코드 관리 (P2-02)
|
||||||
$routes->get('code-details/(:num)', 'Admin\CodeDetail::index/$1');
|
|
||||||
$routes->get('code-details/(:num)/create', 'Admin\CodeDetail::create/$1');
|
$routes->get('code-details/(:num)/create', 'Admin\CodeDetail::create/$1');
|
||||||
$routes->post('code-details/store', 'Admin\CodeDetail::store');
|
$routes->post('code-details/store', 'Admin\CodeDetail::store');
|
||||||
$routes->get('code-details/edit/(:num)', 'Admin\CodeDetail::edit/$1');
|
$routes->get('code-details/edit/(:num)', 'Admin\CodeDetail::edit/$1');
|
||||||
$routes->post('code-details/update/(:num)', 'Admin\CodeDetail::update/$1');
|
$routes->post('code-details/update/(:num)', 'Admin\CodeDetail::update/$1');
|
||||||
$routes->post('code-details/delete/(:num)', 'Admin\CodeDetail::delete/$1');
|
$routes->post('code-details/delete/(:num)', 'Admin\CodeDetail::delete/$1');
|
||||||
|
|
||||||
// 봉투 단가 관리 (P2-03/04)
|
// 구 업무 URL → /bag/* (실제 처리는 bag 그룹). GET 301, POST 307.
|
||||||
$routes->get('bag-prices', 'Admin\BagPrice::index');
|
$adminToBagPrefixes = [
|
||||||
$routes->get('bag-prices/create', 'Admin\BagPrice::create');
|
'managers',
|
||||||
$routes->post('bag-prices/store', 'Admin\BagPrice::store');
|
'sales-agencies',
|
||||||
$routes->get('bag-prices/edit/(:num)', 'Admin\BagPrice::edit/$1');
|
'companies',
|
||||||
$routes->post('bag-prices/update/(:num)', 'Admin\BagPrice::update/$1');
|
'free-recipients',
|
||||||
$routes->post('bag-prices/delete/(:num)', 'Admin\BagPrice::delete/$1');
|
'designated-shops',
|
||||||
$routes->get('bag-prices/history/(:num)', 'Admin\BagPrice::history/$1');
|
'bag-prices',
|
||||||
|
'bag-orders',
|
||||||
// 발주 관리 (P3-01~05)
|
'bag-receivings',
|
||||||
$routes->get('bag-orders', 'Admin\BagOrder::index');
|
'bag-inventory',
|
||||||
$routes->get('bag-orders/create', 'Admin\BagOrder::create');
|
'shop-orders',
|
||||||
$routes->post('bag-orders/store', 'Admin\BagOrder::store');
|
'bag-sales',
|
||||||
$routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1');
|
'bag-issues',
|
||||||
$routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1');
|
'packaging-units',
|
||||||
$routes->post('bag-orders/delete/(:num)', 'Admin\BagOrder::delete/$1');
|
'reports',
|
||||||
|
'password-change',
|
||||||
// 입고 관리 (P3-06~09)
|
];
|
||||||
$routes->get('bag-receivings', 'Admin\BagReceiving::index');
|
foreach ($adminToBagPrefixes as $p) {
|
||||||
$routes->get('bag-receivings/create', 'Admin\BagReceiving::create');
|
$routes->match(['get', 'post'], $p, 'Admin\WorkMovedToBag::toBag/' . $p);
|
||||||
$routes->post('bag-receivings/store', 'Admin\BagReceiving::store');
|
$routes->match(['get', 'post'], $p . '/(:any)', 'Admin\WorkMovedToBag::toBag/' . $p . '/$1');
|
||||||
|
}
|
||||||
// 재고 현황 (P3-10)
|
|
||||||
$routes->get('bag-inventory', 'Admin\BagInventory::index');
|
|
||||||
|
|
||||||
// 주문 접수 관리 (P4-01~03)
|
|
||||||
$routes->get('shop-orders', 'Admin\ShopOrder::index');
|
|
||||||
$routes->get('shop-orders/create', 'Admin\ShopOrder::create');
|
|
||||||
$routes->post('shop-orders/store', 'Admin\ShopOrder::store');
|
|
||||||
$routes->post('shop-orders/cancel/(:num)', 'Admin\ShopOrder::cancel/$1');
|
|
||||||
|
|
||||||
// 판매/반품 관리 (P4-04~07)
|
|
||||||
$routes->get('bag-sales', 'Admin\BagSale::index');
|
|
||||||
$routes->get('bag-sales/create', 'Admin\BagSale::create');
|
|
||||||
$routes->post('bag-sales/store', 'Admin\BagSale::store');
|
|
||||||
|
|
||||||
// 무료용 불출 관리 (P4-08~10)
|
|
||||||
$routes->get('bag-issues', 'Admin\BagIssue::index');
|
|
||||||
$routes->get('bag-issues/create', 'Admin\BagIssue::create');
|
|
||||||
$routes->post('bag-issues/store', 'Admin\BagIssue::store');
|
|
||||||
$routes->post('bag-issues/cancel/(:num)', 'Admin\BagIssue::cancel/$1');
|
|
||||||
|
|
||||||
// 포장 단위 관리 (P2-05/06)
|
|
||||||
$routes->get('packaging-units', 'Admin\PackagingUnit::index');
|
|
||||||
$routes->get('packaging-units/create', 'Admin\PackagingUnit::create');
|
|
||||||
$routes->post('packaging-units/store', 'Admin\PackagingUnit::store');
|
|
||||||
$routes->get('packaging-units/edit/(:num)', 'Admin\PackagingUnit::edit/$1');
|
|
||||||
$routes->post('packaging-units/update/(:num)', 'Admin\PackagingUnit::update/$1');
|
|
||||||
$routes->post('packaging-units/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
|
|
||||||
$routes->get('packaging-units/history/(:num)', 'Admin\PackagingUnit::history/$1');
|
|
||||||
|
|
||||||
// 현황/리포트 (Phase 5)
|
|
||||||
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
|
|
||||||
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
|
|
||||||
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
|
|
||||||
$routes->get('reports/supply-demand', 'Admin\SalesReport::supplyDemand');
|
|
||||||
|
|
||||||
// 판매 대행소 관리 (P2-07/08)
|
|
||||||
$routes->get('sales-agencies', 'Admin\SalesAgency::index');
|
|
||||||
$routes->get('sales-agencies/create', 'Admin\SalesAgency::create');
|
|
||||||
$routes->post('sales-agencies/store', 'Admin\SalesAgency::store');
|
|
||||||
$routes->get('sales-agencies/edit/(:num)', 'Admin\SalesAgency::edit/$1');
|
|
||||||
$routes->post('sales-agencies/update/(:num)', 'Admin\SalesAgency::update/$1');
|
|
||||||
$routes->post('sales-agencies/delete/(:num)', 'Admin\SalesAgency::delete/$1');
|
|
||||||
|
|
||||||
// 담당자 관리 (P2-09/10)
|
|
||||||
$routes->get('managers', 'Admin\Manager::index');
|
|
||||||
$routes->get('managers/create', 'Admin\Manager::create');
|
|
||||||
$routes->post('managers/store', 'Admin\Manager::store');
|
|
||||||
$routes->get('managers/edit/(:num)', 'Admin\Manager::edit/$1');
|
|
||||||
$routes->post('managers/update/(:num)', 'Admin\Manager::update/$1');
|
|
||||||
$routes->post('managers/delete/(:num)', 'Admin\Manager::delete/$1');
|
|
||||||
|
|
||||||
// 업체 관리 (P2-11/12)
|
|
||||||
$routes->get('companies', 'Admin\Company::index');
|
|
||||||
$routes->get('companies/create', 'Admin\Company::create');
|
|
||||||
$routes->post('companies/store', 'Admin\Company::store');
|
|
||||||
$routes->get('companies/edit/(:num)', 'Admin\Company::edit/$1');
|
|
||||||
$routes->post('companies/update/(:num)', 'Admin\Company::update/$1');
|
|
||||||
$routes->post('companies/delete/(:num)', 'Admin\Company::delete/$1');
|
|
||||||
|
|
||||||
// 무료용 대상자 관리 (P2-13/14)
|
|
||||||
$routes->get('free-recipients', 'Admin\FreeRecipient::index');
|
|
||||||
$routes->get('free-recipients/create', 'Admin\FreeRecipient::create');
|
|
||||||
$routes->post('free-recipients/store', 'Admin\FreeRecipient::store');
|
|
||||||
$routes->get('free-recipients/edit/(:num)', 'Admin\FreeRecipient::edit/$1');
|
|
||||||
$routes->post('free-recipients/update/(:num)', 'Admin\FreeRecipient::update/$1');
|
|
||||||
$routes->post('free-recipients/delete/(:num)', 'Admin\FreeRecipient::delete/$1');
|
|
||||||
|
|
||||||
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
|
||||||
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
|
||||||
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
|
||||||
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
|
||||||
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
|
||||||
$routes->post('designated-shops/delete/(:num)', 'Admin\DesignatedShop::delete/$1');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ class Access extends BaseController
|
|||||||
{
|
{
|
||||||
$start = $this->request->getGet('start');
|
$start = $this->request->getGet('start');
|
||||||
$end = $this->request->getGet('end');
|
$end = $this->request->getGet('end');
|
||||||
$builder = $this->memberLogModel->builder();
|
$builder = $this->memberLogModel;
|
||||||
$builder->select('member_log.*');
|
|
||||||
$builder->orderBy('mll_regdate', 'DESC');
|
$builder->orderBy('mll_regdate', 'DESC');
|
||||||
if ($start !== null && $start !== '') {
|
if ($start !== null && $start !== '') {
|
||||||
$builder->where('mll_regdate >=', $start . ' 00:00:00');
|
$builder->where('mll_regdate >=', $start . ' 00:00:00');
|
||||||
@@ -40,10 +39,11 @@ class Access extends BaseController
|
|||||||
if ($end !== null && $end !== '') {
|
if ($end !== null && $end !== '') {
|
||||||
$builder->where('mll_regdate <=', $end . ' 23:59:59');
|
$builder->where('mll_regdate <=', $end . ' 23:59:59');
|
||||||
}
|
}
|
||||||
$list = $builder->get()->getResult();
|
$list = $builder->paginate(20);
|
||||||
|
$pager = $this->memberLogModel->pager;
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '로그인 이력',
|
'title' => '로그인 이력',
|
||||||
'content' => view('admin/access/login_history', ['list' => $list, 'start' => $start, 'end' => $end]),
|
'content' => view('admin/access/login_history', ['list' => $list, 'start' => $start, 'end' => $end, 'pager' => $pager]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +59,14 @@ class Access extends BaseController
|
|||||||
$status = MemberApprovalRequestModel::STATUS_PENDING;
|
$status = MemberApprovalRequestModel::STATUS_PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = $this->approvalModel->builder();
|
$list = $this->approvalModel
|
||||||
$builder->select(
|
->select('member_approval_request.*, member.mb_id, member.mb_name, member.mb_lg_idx, local_government.lg_name')
|
||||||
'member_approval_request.*, member.mb_id, member.mb_name, member.mb_lg_idx, local_government.lg_name'
|
->join('member', 'member.mb_idx = member_approval_request.mb_idx', 'left')
|
||||||
);
|
->join('local_government', 'local_government.lg_idx = member.mb_lg_idx', 'left')
|
||||||
$builder->join('member', 'member.mb_idx = member_approval_request.mb_idx', 'left');
|
->where('member_approval_request.mar_status', $status)
|
||||||
$builder->join('local_government', 'local_government.lg_idx = member.mb_lg_idx', 'left');
|
->orderBy('member_approval_request.mar_requested_at', 'DESC')
|
||||||
$builder->where('member_approval_request.mar_status', $status);
|
->paginate(20);
|
||||||
$builder->orderBy('member_approval_request.mar_requested_at', 'DESC');
|
$pager = $this->approvalModel->pager;
|
||||||
$list = $builder->get()->getResult();
|
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '승인 대기',
|
'title' => '승인 대기',
|
||||||
@@ -75,6 +74,7 @@ class Access extends BaseController
|
|||||||
'list' => $list,
|
'list' => $list,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'roles' => $this->roles,
|
'roles' => $this->roles,
|
||||||
|
'pager' => $pager,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -90,8 +90,8 @@ class Access extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requestedLevel = (int) $requestRow->mar_requested_level;
|
$requestedLevel = (int) $requestRow->mar_requested_level;
|
||||||
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN) {
|
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN || $requestedLevel === Roles::LEVEL_HEADQUARTERS_ADMIN) {
|
||||||
return redirect()->to(site_url('admin/access/approvals'))->with('error', 'super admin 역할 요청은 승인할 수 없습니다.');
|
return redirect()->to(site_url('admin/access/approvals'))->with('error', '상위 관리자 역할 요청은 승인할 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = db_connect();
|
$db = db_connect();
|
||||||
|
|||||||
@@ -11,13 +11,43 @@ class BagInventory extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invModel = model(BagInventoryModel::class);
|
||||||
|
$list = $invModel->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->paginate(20);
|
||||||
|
$pager = $invModel->pager;
|
||||||
|
|
||||||
|
return $this->renderWorkPage('재고 현황', 'admin/bag_inventory/index', ['list' => $list, 'pager' => $pager]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export()
|
||||||
|
{
|
||||||
|
helper(['admin', 'export']);
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-inventory'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$list = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->findAll();
|
$list = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->findAll();
|
||||||
|
|
||||||
return view('admin/layout', [
|
$rows = [];
|
||||||
'title' => '재고 현황',
|
foreach ($list as $row) {
|
||||||
'content' => view('admin/bag_inventory/index', ['list' => $list]),
|
$rows[] = [
|
||||||
]);
|
$row->bi_idx,
|
||||||
|
$row->bi_bag_code,
|
||||||
|
$row->bi_bag_name,
|
||||||
|
(int) $row->bi_qty,
|
||||||
|
$row->bi_updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export_xlsx(
|
||||||
|
'재고현황_' . date('Ymd') . '.xlsx',
|
||||||
|
'재고현황',
|
||||||
|
['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,49 +4,90 @@ namespace App\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
use App\Models\BagIssueModel;
|
use App\Models\BagIssueModel;
|
||||||
|
use App\Models\BagIssueItemCodeModel;
|
||||||
use App\Models\BagInventoryModel;
|
use App\Models\BagInventoryModel;
|
||||||
use App\Models\CodeKindModel;
|
use App\Models\CodeKindModel;
|
||||||
use App\Models\CodeDetailModel;
|
use App\Models\CodeDetailModel;
|
||||||
|
use App\Models\FreeRecipientModel;
|
||||||
|
use App\Models\PackagingUnitModel;
|
||||||
|
|
||||||
class BagIssue extends BaseController
|
class BagIssue extends BaseController
|
||||||
{
|
{
|
||||||
private BagIssueModel $issueModel;
|
private BagIssueModel $issueModel;
|
||||||
|
private BagIssueItemCodeModel $issueItemCodeModel;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->issueModel = model(BagIssueModel::class);
|
$this->issueModel = model(BagIssueModel::class);
|
||||||
|
$this->issueItemCodeModel = model(BagIssueItemCodeModel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 낱장 수량을 품목코드 단위로 분해한다.
|
||||||
|
*
|
||||||
|
* @return array<int,array{issueCode:string,qty:int}>
|
||||||
|
*/
|
||||||
|
private function buildIssueCodeRows(int $bi2Idx, int $sheetQty, array $packUnit): array
|
||||||
|
{
|
||||||
|
$sheetQty = max(0, $sheetQty);
|
||||||
|
if ($sheetQty <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$chunkSize = max(
|
||||||
|
1,
|
||||||
|
(int) ($packUnit['totalPerBox'] ?? 0),
|
||||||
|
(int) ($packUnit['packPerSheet'] ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$remaining = $sheetQty;
|
||||||
|
$seq = 1;
|
||||||
|
while ($remaining > 0) {
|
||||||
|
$qty = min($chunkSize, $remaining);
|
||||||
|
$rows[] = [
|
||||||
|
'issueCode' => sprintf('%d-%06d-%03d', (int) date('y'), $bi2Idx, $seq),
|
||||||
|
'qty' => $qty,
|
||||||
|
];
|
||||||
|
$remaining -= $qty;
|
||||||
|
$seq++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$builder = $this->issueModel->where('bi2_lg_idx', $lgIdx);
|
$builder = $this->issueModel->where('bi2_lg_idx', $lgIdx);
|
||||||
$startDate = $this->request->getGet('start_date');
|
$startDate = $this->request->getGet('start_date');
|
||||||
$endDate = $this->request->getGet('end_date');
|
$endDate = $this->request->getGet('end_date');
|
||||||
if ($startDate) $builder->where('bi2_issue_date >=', $startDate);
|
if ($startDate) {
|
||||||
if ($endDate) $builder->where('bi2_issue_date <=', $endDate);
|
$builder->where('bi2_issue_date >=', $startDate);
|
||||||
|
}
|
||||||
|
if ($endDate) {
|
||||||
|
$builder->where('bi2_issue_date <=', $endDate);
|
||||||
|
}
|
||||||
|
|
||||||
$list = $builder->orderBy('bi2_issue_date', 'DESC')->orderBy('bi2_idx', 'DESC')->findAll();
|
$list = $builder->orderBy('bi2_issue_date', 'DESC')->orderBy('bi2_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->issueModel->pager;
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('무료용 불출 관리', 'admin/bag_issue/index', compact('list', 'startDate', 'endDate', 'pager'));
|
||||||
'title' => '무료용 불출 관리',
|
|
||||||
'content' => view('admin/bag_issue/index', compact('list', 'startDate', 'endDate')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('무료용 불출 처리', 'admin/bag_issue/create', compact('bagCodes'));
|
||||||
'title' => '무료용 불출 처리',
|
|
||||||
'content' => view('admin/bag_issue/create', compact('bagCodes')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -60,63 +101,266 @@ class BagIssue extends BaseController
|
|||||||
'bi2_issue_type' => 'required|max_length[20]',
|
'bi2_issue_type' => 'required|max_length[20]',
|
||||||
'bi2_issue_date' => 'required|valid_date[Y-m-d]',
|
'bi2_issue_date' => 'required|valid_date[Y-m-d]',
|
||||||
'bi2_dest_name' => 'required|max_length[100]',
|
'bi2_dest_name' => 'required|max_length[100]',
|
||||||
'bi2_bag_code' => 'required|max_length[50]',
|
// 사이트 다건 입력(item_bag_code/item_qty)과 기존 관리자 단건 입력을 함께 허용
|
||||||
'bi2_qty' => 'required|is_natural_no_zero',
|
'bi2_bag_code' => 'permit_empty|max_length[50]',
|
||||||
|
'bi2_qty' => 'permit_empty|is_natural_no_zero',
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules)) {
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
$bagCode = $this->request->getPost('bi2_bag_code');
|
|
||||||
$qty = (int) $this->request->getPost('bi2_qty');
|
|
||||||
|
|
||||||
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$detail = $kindO ? model(CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
|
$issueType = trim((string) $this->request->getPost('bi2_issue_type'));
|
||||||
$bagName = $detail ? $detail->cd_name : '';
|
$destType = trim((string) ($this->request->getPost('bi2_dest_type') ?? ''));
|
||||||
|
$destName = trim((string) ($this->request->getPost('bi2_dest_name') ?? ''));
|
||||||
|
$destDongCode = trim((string) ($this->request->getPost('bi2_dest_dong_code') ?? ''));
|
||||||
|
|
||||||
|
if ($destType === '') {
|
||||||
|
$destType = '동사무소';
|
||||||
|
}
|
||||||
|
if ($issueType === '공공용' && $destType === '동사무소') {
|
||||||
|
$destType = '구청';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issueType === '무료용' && $destDongCode !== '') {
|
||||||
|
$existsFreeDong = model(FreeRecipientModel::class)
|
||||||
|
->where('fr_lg_idx', $lgIdx)
|
||||||
|
->where('fr_state', 1)
|
||||||
|
->where('fr_dong_code', $destDongCode)
|
||||||
|
->first();
|
||||||
|
if (! $existsFreeDong) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '선택한 불출처는 무료용 대상이 아닙니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$invRows = model(BagInventoryModel::class)
|
||||||
|
->where('bi_lg_idx', $lgIdx)
|
||||||
|
->where('bi_qty >', 0)
|
||||||
|
->findAll();
|
||||||
|
$inventoryMap = [];
|
||||||
|
foreach ($invRows as $inv) {
|
||||||
|
$code = (string) ($inv->bi_bag_code ?? '');
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$inventoryMap[$code] = [
|
||||||
|
'qty' => (int) ($inv->bi_qty ?? 0),
|
||||||
|
'name' => (string) ($inv->bi_bag_name ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unitRows = model(PackagingUnitModel::class)
|
||||||
|
->where('pu_lg_idx', $lgIdx)
|
||||||
|
->where('pu_state', 1)
|
||||||
|
->findAll();
|
||||||
|
$packMap = [];
|
||||||
|
foreach ($unitRows as $unit) {
|
||||||
|
$code = (string) ($unit->pu_bag_code ?? '');
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$packMap[$code] = [
|
||||||
|
'packPerSheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
|
||||||
|
'totalPerBox' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
$itemCodes = $this->request->getPost('item_bag_code');
|
||||||
|
$itemQtys = $this->request->getPost('item_qty');
|
||||||
|
$itemPacks = $this->request->getPost('item_pack');
|
||||||
|
$itemCodes = is_array($itemCodes) ? $itemCodes : [];
|
||||||
|
$itemQtys = is_array($itemQtys) ? $itemQtys : [];
|
||||||
|
$itemPacks = is_array($itemPacks) ? $itemPacks : [];
|
||||||
|
|
||||||
|
$count = max(count($itemCodes), count($itemQtys), count($itemPacks));
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$bagCode = trim((string) ($itemCodes[$i] ?? ''));
|
||||||
|
$qtyRaw = (int) ($itemQtys[$i] ?? 0);
|
||||||
|
$pack = trim((string) ($itemPacks[$i] ?? 'sheet'));
|
||||||
|
if ($bagCode === '' || $qtyRaw <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! in_array($pack, ['box', 'pack', 'sheet'], true)) {
|
||||||
|
$pack = 'sheet';
|
||||||
|
}
|
||||||
|
$packUnit = $packMap[$bagCode] ?? ['packPerSheet' => 1, 'totalPerBox' => 1];
|
||||||
|
$sheetQty = $qtyRaw;
|
||||||
|
if ($pack === 'box') {
|
||||||
|
$sheetQty = $qtyRaw * (int) $packUnit['totalPerBox'];
|
||||||
|
} elseif ($pack === 'pack') {
|
||||||
|
$sheetQty = $qtyRaw * (int) $packUnit['packPerSheet'];
|
||||||
|
}
|
||||||
|
$sheetQty = max(1, (int) $sheetQty);
|
||||||
|
|
||||||
|
$detail = $kindO
|
||||||
|
? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx)
|
||||||
|
: null;
|
||||||
|
$bagName = $detail ? (string) ($detail->cd_name ?? '') : (string) ($inventoryMap[$bagCode]['name'] ?? '');
|
||||||
|
if ($bagName === '') {
|
||||||
|
$bagName = (string) $bagCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'bagCode' => $bagCode,
|
||||||
|
'bagName' => $bagName,
|
||||||
|
'pack' => $pack,
|
||||||
|
'rawQty' => $qtyRaw,
|
||||||
|
'sheetQty' => $sheetQty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 관리자 단건 폼과의 호환
|
||||||
|
if ($items === []) {
|
||||||
|
$singleBagCode = trim((string) $this->request->getPost('bi2_bag_code'));
|
||||||
|
$singleQty = (int) $this->request->getPost('bi2_qty');
|
||||||
|
if ($singleBagCode !== '' && $singleQty > 0) {
|
||||||
|
$detail = $kindO
|
||||||
|
? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $singleBagCode, $lgIdx)
|
||||||
|
: null;
|
||||||
|
$bagName = $detail ? (string) ($detail->cd_name ?? '') : (string) ($inventoryMap[$singleBagCode]['name'] ?? '');
|
||||||
|
if ($bagName === '') {
|
||||||
|
$bagName = (string) $singleBagCode;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'bagCode' => $singleBagCode,
|
||||||
|
'bagName' => $bagName,
|
||||||
|
'pack' => 'sheet',
|
||||||
|
'rawQty' => $singleQty,
|
||||||
|
'sheetQty' => $singleQty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($items === []) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '불출 품목을 1건 이상 입력해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$requiredByBag = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$code = (string) $item['bagCode'];
|
||||||
|
if (! isset($requiredByBag[$code])) {
|
||||||
|
$requiredByBag[$code] = 0;
|
||||||
|
}
|
||||||
|
$requiredByBag[$code] += (int) $item['sheetQty'];
|
||||||
|
}
|
||||||
|
foreach ($requiredByBag as $code => $requiredQty) {
|
||||||
|
$available = (int) ($inventoryMap[$code]['qty'] ?? 0);
|
||||||
|
if ($available <= 0) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '입고 재고가 없는 봉투코드는 불출할 수 없습니다: ' . $code);
|
||||||
|
}
|
||||||
|
if ($available < $requiredQty) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '재고가 부족합니다: ' . $code . ' (재고 ' . number_format($available) . ', 요청 ' . number_format($requiredQty) . ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
$hasIssueCodeTable = $db->tableExists('bag_issue_item_code');
|
||||||
|
|
||||||
$this->issueModel->insert([
|
$issueYear = (int) $this->request->getPost('bi2_year');
|
||||||
|
$issueQuarter = (int) $this->request->getPost('bi2_quarter');
|
||||||
|
$issueDate = (string) $this->request->getPost('bi2_issue_date');
|
||||||
|
$createdCount = 0;
|
||||||
|
helper('audit');
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$issueData = [
|
||||||
'bi2_lg_idx' => $lgIdx,
|
'bi2_lg_idx' => $lgIdx,
|
||||||
'bi2_year' => (int) $this->request->getPost('bi2_year'),
|
'bi2_year' => $issueYear,
|
||||||
'bi2_quarter' => (int) $this->request->getPost('bi2_quarter'),
|
'bi2_quarter' => $issueQuarter,
|
||||||
'bi2_issue_type' => $this->request->getPost('bi2_issue_type'),
|
'bi2_issue_type' => $issueType,
|
||||||
'bi2_issue_date' => $this->request->getPost('bi2_issue_date'),
|
'bi2_issue_date' => $issueDate,
|
||||||
'bi2_dest_type' => $this->request->getPost('bi2_dest_type') ?? '',
|
'bi2_dest_type' => $destType,
|
||||||
'bi2_dest_name' => $this->request->getPost('bi2_dest_name'),
|
'bi2_dest_name' => $destName,
|
||||||
'bi2_bag_code' => $bagCode,
|
'bi2_bag_code' => (string) $item['bagCode'],
|
||||||
'bi2_bag_name' => $bagName,
|
'bi2_bag_name' => (string) $item['bagName'],
|
||||||
'bi2_qty' => $qty,
|
'bi2_qty' => (int) $item['sheetQty'],
|
||||||
'bi2_status' => 'normal',
|
'bi2_status' => 'normal',
|
||||||
'bi2_regdate' => date('Y-m-d H:i:s'),
|
'bi2_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
];
|
||||||
|
$this->issueModel->insert($issueData);
|
||||||
|
$bi2Idx = (int) $this->issueModel->getInsertID();
|
||||||
|
audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx]));
|
||||||
|
|
||||||
// 재고 감산
|
if ($hasIssueCodeTable) {
|
||||||
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
|
$codeRows = $this->buildIssueCodeRows($bi2Idx, (int) $item['sheetQty'], $packMap[(string) $item['bagCode']] ?? []);
|
||||||
|
foreach ($codeRows as $codeRow) {
|
||||||
|
$this->issueItemCodeModel->insert([
|
||||||
|
'bic_lg_idx' => $lgIdx,
|
||||||
|
'bic_bi2_idx' => $bi2Idx,
|
||||||
|
'bic_bag_code' => (string) $item['bagCode'],
|
||||||
|
'bic_issue_code' => (string) $codeRow['issueCode'],
|
||||||
|
'bic_qty' => (int) $codeRow['qty'],
|
||||||
|
'bic_cancel_qty' => 0,
|
||||||
|
'bic_state' => 'normal',
|
||||||
|
'bic_regdate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model(BagInventoryModel::class)->adjustQty(
|
||||||
|
$lgIdx,
|
||||||
|
(string) $item['bagCode'],
|
||||||
|
(string) $item['bagName'],
|
||||||
|
-((int) $item['sheetQty'])
|
||||||
|
);
|
||||||
|
$createdCount++;
|
||||||
|
}
|
||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/bag-issues'))->with('success', '불출 처리되었습니다.');
|
if (! $db->transStatus()) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '불출 처리 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(mgmt_url('bag-issues'))->with('success', $createdCount . '건 불출 처리되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(int $id)
|
public function cancel(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->issueModel->find($id);
|
$item = $this->issueModel->find($id);
|
||||||
if (!$item || (int) $item->bi2_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->bi2_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-issues'))->with('error', '불출 내역을 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-issues'))->with('error', '불출 내역을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
$hasIssueCodeTable = $db->tableExists('bag_issue_item_code');
|
||||||
|
|
||||||
|
$before = (array) $item;
|
||||||
$this->issueModel->update($id, ['bi2_status' => 'cancelled']);
|
$this->issueModel->update($id, ['bi2_status' => 'cancelled']);
|
||||||
// 재고 복원
|
helper('audit');
|
||||||
model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, (int) $item->bi2_qty);
|
audit_log('update', 'bag_issue', $id, $before, ['bi2_status' => 'cancelled']);
|
||||||
|
|
||||||
|
$restoreQty = (int) $item->bi2_qty;
|
||||||
|
if ($hasIssueCodeTable) {
|
||||||
|
$codeRows = $db->table('bag_issue_item_code')
|
||||||
|
->select('bic_idx, bic_qty, bic_cancel_qty')
|
||||||
|
->where('bic_lg_idx', (int) $item->bi2_lg_idx)
|
||||||
|
->where('bic_bi2_idx', $id)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
$restoreQty = 0;
|
||||||
|
foreach ($codeRows as $codeRow) {
|
||||||
|
$bicIdx = (int) ($codeRow['bic_idx'] ?? 0);
|
||||||
|
$qty = (int) ($codeRow['bic_qty'] ?? 0);
|
||||||
|
$oldCancel = (int) ($codeRow['bic_cancel_qty'] ?? 0);
|
||||||
|
$restoreQty += max(0, $qty - $oldCancel);
|
||||||
|
$db->table('bag_issue_item_code')
|
||||||
|
->where('bic_idx', $bicIdx)
|
||||||
|
->update([
|
||||||
|
'bic_cancel_qty' => $qty,
|
||||||
|
'bic_state' => 'cancelled',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, $restoreQty);
|
||||||
|
$this->issueModel->update($id, ['bi2_qty' => 0, 'bi2_status' => 'cancelled']);
|
||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/bag-issues'))->with('success', '불출이 취소되었습니다.');
|
return redirect()->to(mgmt_url('bag-issues'))->with('success', '불출이 취소되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ use App\Models\BagPriceModel;
|
|||||||
use App\Models\PackagingUnitModel;
|
use App\Models\PackagingUnitModel;
|
||||||
use App\Models\CompanyModel;
|
use App\Models\CompanyModel;
|
||||||
use App\Models\SalesAgencyModel;
|
use App\Models\SalesAgencyModel;
|
||||||
|
use App\Models\BagReceivingModel;
|
||||||
use App\Models\CodeKindModel;
|
use App\Models\CodeKindModel;
|
||||||
use App\Models\CodeDetailModel;
|
use App\Models\CodeDetailModel;
|
||||||
use Ramsey\Uuid\Uuid;
|
use App\Models\LocalGovernmentModel;
|
||||||
|
use App\Libraries\Blockchain\SqlLedger;
|
||||||
class BagOrder extends BaseController
|
class BagOrder extends BaseController
|
||||||
{
|
{
|
||||||
private BagOrderModel $orderModel;
|
private BagOrderModel $orderModel;
|
||||||
@@ -28,66 +29,434 @@ class BagOrder extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (! $lgIdx) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = $this->orderModel->where('bo_lg_idx', $lgIdx);
|
$startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
|
||||||
|
$endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
|
||||||
// 기간 필터
|
if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
|
||||||
$startDate = $this->request->getGet('start_date');
|
$startMonth = date('Y-m');
|
||||||
$endDate = $this->request->getGet('end_date');
|
}
|
||||||
$status = $this->request->getGet('status');
|
if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
|
||||||
if ($startDate) $builder->where('bo_order_date >=', $startDate);
|
$endMonth = $startMonth;
|
||||||
if ($endDate) $builder->where('bo_order_date <=', $endDate);
|
}
|
||||||
if ($status) $builder->where('bo_status', $status);
|
if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
|
||||||
|
[$startMonth, $endMonth] = [$endMonth, $startMonth];
|
||||||
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll();
|
|
||||||
|
|
||||||
// 발주별 품목 합계
|
|
||||||
$itemSummary = [];
|
|
||||||
foreach ($list as $order) {
|
|
||||||
$items = $this->itemModel->where('boi_bo_idx', $order->bo_idx)->findAll();
|
|
||||||
$totalQty = 0; $totalAmt = 0;
|
|
||||||
foreach ($items as $it) { $totalQty += (int) $it->boi_qty_sheet; $totalAmt += (float) $it->boi_amount; }
|
|
||||||
$itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 제작업체/대행소 이름 매핑
|
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
|
||||||
$companyMap = []; $agencyMap = [];
|
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
|
||||||
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $c) $companyMap[$c->cp_idx] = $c->cp_name;
|
$receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
|
||||||
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->findAll() as $a) $agencyMap[$a->sa_idx] = $a->sa_name;
|
if (! in_array($receiveType, ['all', 'received', 'pending'], true)) {
|
||||||
|
$receiveType = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
$companies = model(CompanyModel::class)
|
||||||
'title' => '발주 현황',
|
->where('cp_lg_idx', $lgIdx)
|
||||||
'content' => view('admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status')),
|
->where('cp_type', '제작업체')
|
||||||
]);
|
->where('cp_state', 1)
|
||||||
|
->orderBy('cp_name', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
$companyMap = [];
|
||||||
|
foreach ($companies as $company) {
|
||||||
|
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agencyMap = [];
|
||||||
|
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
|
||||||
|
$agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
$bagNameMap = [];
|
||||||
|
foreach ($bagCodes as $code) {
|
||||||
|
$bagNameMap[(string) $code->cd_code] = (string) $code->cd_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportData = $this->buildOrderStatusRows(
|
||||||
|
$lgIdx,
|
||||||
|
$startMonth,
|
||||||
|
$endMonth,
|
||||||
|
$companyIdx,
|
||||||
|
$bagCode,
|
||||||
|
$receiveType,
|
||||||
|
$companyMap,
|
||||||
|
$agencyMap,
|
||||||
|
$bagNameMap
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->renderWorkPage(
|
||||||
|
'발주 현황',
|
||||||
|
'admin/bag_order/index',
|
||||||
|
[
|
||||||
|
'startMonth' => $startMonth,
|
||||||
|
'endMonth' => $endMonth,
|
||||||
|
'companyIdx' => $companyIdx,
|
||||||
|
'bagCode' => $bagCode,
|
||||||
|
'receiveType' => $receiveType,
|
||||||
|
'companyOptions' => $companies,
|
||||||
|
'bagCodeOptions' => $bagCodes,
|
||||||
|
'rows' => $reportData['rows'],
|
||||||
|
'groupRows' => $reportData['groupRows'],
|
||||||
|
'grandTotals' => $reportData['grandTotals'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export()
|
||||||
|
{
|
||||||
|
helper(['admin', 'export']);
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
|
||||||
|
$endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
|
||||||
|
if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
|
||||||
|
$startMonth = date('Y-m');
|
||||||
|
}
|
||||||
|
if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
|
||||||
|
$endMonth = $startMonth;
|
||||||
|
}
|
||||||
|
if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
|
||||||
|
[$startMonth, $endMonth] = [$endMonth, $startMonth];
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
|
||||||
|
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
|
||||||
|
$receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
|
||||||
|
if (! in_array($receiveType, ['all', 'received', 'pending'], true)) {
|
||||||
|
$receiveType = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyMap = [];
|
||||||
|
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) {
|
||||||
|
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
|
||||||
|
}
|
||||||
|
$agencyMap = [];
|
||||||
|
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
|
||||||
|
$agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? '');
|
||||||
|
}
|
||||||
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
$bagNameMap = [];
|
||||||
|
foreach ($bagCodes as $code) {
|
||||||
|
$bagNameMap[(string) $code->cd_code] = (string) $code->cd_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reportData = $this->buildOrderStatusRows(
|
||||||
|
$lgIdx,
|
||||||
|
$startMonth,
|
||||||
|
$endMonth,
|
||||||
|
$companyIdx,
|
||||||
|
$bagCode,
|
||||||
|
$receiveType,
|
||||||
|
$companyMap,
|
||||||
|
$agencyMap,
|
||||||
|
$bagNameMap
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($reportData['rows'] as $row) {
|
||||||
|
if (! empty($row['is_subtotal'])) {
|
||||||
|
$rows[] = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
(string) ($row['label'] ?? '소계'),
|
||||||
|
(int) ($row['order_qty'] ?? 0),
|
||||||
|
(int) ($row['received_qty'] ?? 0),
|
||||||
|
(int) ($row['pending_qty'] ?? 0),
|
||||||
|
(float) ($row['amount'] ?? 0),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
(string) ($row['order_date'] ?? ''),
|
||||||
|
(string) ($row['company_name'] ?? ''),
|
||||||
|
(string) ($row['bag_name'] ?? ''),
|
||||||
|
(int) ($row['order_qty'] ?? 0),
|
||||||
|
(int) ($row['received_qty'] ?? 0),
|
||||||
|
(int) ($row['pending_qty'] ?? 0),
|
||||||
|
(float) ($row['amount'] ?? 0),
|
||||||
|
(string) ($row['agency_name'] ?? ''),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$gt = $reportData['grandTotals'] ?? [];
|
||||||
|
$rows[] = [
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'총계',
|
||||||
|
(int) ($gt['order_qty'] ?? 0),
|
||||||
|
(int) ($gt['received_qty'] ?? 0),
|
||||||
|
(int) ($gt['pending_qty'] ?? 0),
|
||||||
|
(float) ($gt['amount'] ?? 0),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
export_xlsx(
|
||||||
|
'발주현황_' . date('Ymd'),
|
||||||
|
'발주현황',
|
||||||
|
['발주일자', '제작업체', '품명', '발주수량', '입고수량', '미입고수량', '발주금액', '입고처', '비고'],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발주 현황(품목 기준) 행 및 소계를 만든다.
|
||||||
|
*/
|
||||||
|
private function buildOrderStatusRows(
|
||||||
|
int $lgIdx,
|
||||||
|
string $startMonth,
|
||||||
|
string $endMonth,
|
||||||
|
int $companyIdx,
|
||||||
|
string $bagCode,
|
||||||
|
string $receiveType,
|
||||||
|
array $companyMap,
|
||||||
|
array $agencyMap,
|
||||||
|
array $bagNameMap
|
||||||
|
): array {
|
||||||
|
$startDate = $startMonth . '-01';
|
||||||
|
$endDate = date('Y-m-t', strtotime($endMonth . '-01 00:00:00'));
|
||||||
|
|
||||||
|
$builder = $this->orderModel
|
||||||
|
->where('bo_lg_idx', $lgIdx)
|
||||||
|
->whereLatestHead($lgIdx)
|
||||||
|
->where('bo_order_date >=', $startDate)
|
||||||
|
->where('bo_order_date <=', $endDate)
|
||||||
|
->whereIn('bo_status', ['normal', 'cancelled'])
|
||||||
|
->orderBy('bo_order_date', 'DESC')
|
||||||
|
->orderBy('bo_idx', 'DESC');
|
||||||
|
if ($companyIdx > 0) {
|
||||||
|
$builder->where('bo_company_idx', $companyIdx);
|
||||||
|
}
|
||||||
|
$orders = $builder->findAll();
|
||||||
|
|
||||||
|
if (empty($orders)) {
|
||||||
|
return ['rows' => [], 'groupRows' => [], 'grandTotals' => ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderIds = array_map(static fn($order) => (int) $order->bo_idx, $orders);
|
||||||
|
|
||||||
|
$itemsByOrder = [];
|
||||||
|
if (! empty($orderIds)) {
|
||||||
|
$allItems = $this->itemModel
|
||||||
|
->whereIn('boi_bo_idx', $orderIds)
|
||||||
|
->orderBy('boi_bo_idx', 'DESC')
|
||||||
|
->orderBy('boi_idx', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
foreach ($allItems as $item) {
|
||||||
|
$boIdx = (int) ($item->boi_bo_idx ?? 0);
|
||||||
|
if (! isset($itemsByOrder[$boIdx])) {
|
||||||
|
$itemsByOrder[$boIdx] = [];
|
||||||
|
}
|
||||||
|
$itemsByOrder[$boIdx][] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$receivedMap = [];
|
||||||
|
$receivingRows = model(BagReceivingModel::class)
|
||||||
|
->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty')
|
||||||
|
->where('br_lg_idx', $lgIdx)
|
||||||
|
->whereIn('br_bo_idx', $orderIds)
|
||||||
|
->groupBy('br_bo_idx, br_bag_code')
|
||||||
|
->findAll();
|
||||||
|
foreach ($receivingRows as $received) {
|
||||||
|
$key = (int) ($received->br_bo_idx ?? 0) . '|' . (string) ($received->br_bag_code ?? '');
|
||||||
|
$receivedMap[$key] = (int) ($received->recv_qty ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$groupRows = [];
|
||||||
|
$grandTotals = ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0.0];
|
||||||
|
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
$boIdx = (int) ($order->bo_idx ?? 0);
|
||||||
|
$items = $itemsByOrder[$boIdx] ?? [];
|
||||||
|
$groupCount = 0;
|
||||||
|
$groupTotalOrder = 0;
|
||||||
|
$groupTotalReceived = 0;
|
||||||
|
$groupTotalPending = 0;
|
||||||
|
$groupTotalAmount = 0.0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$itemBagCode = (string) ($item->boi_bag_code ?? '');
|
||||||
|
if ($bagCode !== '' && $itemBagCode !== $bagCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderQty = (int) ($item->boi_qty_sheet ?? 0);
|
||||||
|
$recvQty = (int) ($receivedMap[$boIdx . '|' . $itemBagCode] ?? 0);
|
||||||
|
if ($recvQty > $orderQty) {
|
||||||
|
$recvQty = $orderQty;
|
||||||
|
}
|
||||||
|
$pendingQty = max(0, $orderQty - $recvQty);
|
||||||
|
|
||||||
|
if ($receiveType === 'received' && $recvQty <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($receiveType === 'pending' && $pendingQty <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = (float) ($item->boi_amount ?? 0);
|
||||||
|
$rows[] = [
|
||||||
|
'bo_idx' => $boIdx,
|
||||||
|
'order_date' => (string) ($order->bo_order_date ?? ''),
|
||||||
|
'company_name' => (string) ($companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ''),
|
||||||
|
'bag_name' => (string) ($item->boi_bag_name ?? ($bagNameMap[$itemBagCode] ?? $itemBagCode)),
|
||||||
|
'order_qty' => $orderQty,
|
||||||
|
'received_qty' => $recvQty,
|
||||||
|
'pending_qty' => $pendingQty,
|
||||||
|
'amount' => $amount,
|
||||||
|
'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$groupCount++;
|
||||||
|
$groupTotalOrder += $orderQty;
|
||||||
|
$groupTotalReceived += $recvQty;
|
||||||
|
$groupTotalPending += $pendingQty;
|
||||||
|
$groupTotalAmount += $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($groupCount > 0) {
|
||||||
|
$groupRows[$boIdx] = $groupCount;
|
||||||
|
$rows[] = [
|
||||||
|
'bo_idx' => $boIdx,
|
||||||
|
'is_subtotal' => true,
|
||||||
|
'label' => '소계',
|
||||||
|
'order_qty' => $groupTotalOrder,
|
||||||
|
'received_qty' => $groupTotalReceived,
|
||||||
|
'pending_qty' => $groupTotalPending,
|
||||||
|
'amount' => $groupTotalAmount,
|
||||||
|
];
|
||||||
|
$grandTotals['order_qty'] += $groupTotalOrder;
|
||||||
|
$grandTotals['received_qty'] += $groupTotalReceived;
|
||||||
|
$grandTotals['pending_qty'] += $groupTotalPending;
|
||||||
|
$grandTotals['amount'] += $groupTotalAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'groupRows' => $groupRows, 'grandTotals' => $grandTotals];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin/bag-orders'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
// 봉투 종류 + 단가 + 포장단위
|
// 봉투 종류 + 단가 + 포장단위
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
$prices = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_state', 1)->findAll();
|
$priceMapRows = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx);
|
||||||
$units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll();
|
$units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll();
|
||||||
$companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
|
|
||||||
$agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->where('sa_state', 1)->findAll();
|
|
||||||
|
|
||||||
return view('admin/layout', [
|
$companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
|
||||||
'title' => '발주 등록',
|
$associations = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll();
|
||||||
'content' => view('admin/bag_order/create', compact('bagCodes', 'prices', 'units', 'companies', 'agencies')),
|
$agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
|
||||||
]);
|
|
||||||
|
$companyMap = [];
|
||||||
|
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $company) {
|
||||||
|
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
|
||||||
|
}
|
||||||
|
$agencyMap = [];
|
||||||
|
foreach ($agencies as $agency) {
|
||||||
|
$agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$recentOrders = $this->orderModel
|
||||||
|
->where('bo_lg_idx', $lgIdx)
|
||||||
|
->whereLatestHead($lgIdx)
|
||||||
|
->orderBy('bo_order_date', 'DESC')
|
||||||
|
->orderBy('bo_idx', 'DESC')
|
||||||
|
->findAll(12);
|
||||||
|
|
||||||
|
$bagNameMap = [];
|
||||||
|
foreach ($bagCodes as $codeDetail) {
|
||||||
|
$bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
|
||||||
|
}
|
||||||
|
$priceMap = [];
|
||||||
|
foreach ($priceMapRows as $bagCode => $price) {
|
||||||
|
$priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
|
||||||
|
}
|
||||||
|
$unitMap = [];
|
||||||
|
foreach ($units as $unit) {
|
||||||
|
$unitMap[(string) $unit->pu_bag_code] = [
|
||||||
|
'boxPerPack' => (int) $unit->pu_box_per_pack,
|
||||||
|
'packPerSheet' => (int) $unit->pu_pack_per_sheet,
|
||||||
|
'totalPerBox' => (int) $unit->pu_total_per_box,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bagReferenceRows = [];
|
||||||
|
foreach ($bagCodes as $codeDetail) {
|
||||||
|
$bagCode = (string) $codeDetail->cd_code;
|
||||||
|
$unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
|
||||||
|
$bagReferenceRows[] = [
|
||||||
|
'code' => $bagCode,
|
||||||
|
'name' => (string) ($bagNameMap[$bagCode] ?? ''),
|
||||||
|
'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
|
||||||
|
'boxPerPack' => (int) $unit['boxPerPack'],
|
||||||
|
'packPerSheet' => (int) $unit['packPerSheet'],
|
||||||
|
'totalPerBox' => (int) $unit['totalPerBox'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderWorkPage(
|
||||||
|
'발주 등록',
|
||||||
|
'admin/bag_order/create',
|
||||||
|
compact(
|
||||||
|
'bagCodes',
|
||||||
|
'units',
|
||||||
|
'companies',
|
||||||
|
'associations',
|
||||||
|
'agencies',
|
||||||
|
'recentOrders',
|
||||||
|
'companyMap',
|
||||||
|
'agencyMap',
|
||||||
|
'bagReferenceRows'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revise(int $id)
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
$order = $this->orderModel->find($id);
|
||||||
|
if (! $order || (int) $order->bo_lg_idx !== $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url('bag/order/revise/' . $id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if ($lgIdx === null || $lgIdx <= 0) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0);
|
||||||
|
$sourceOrder = null;
|
||||||
|
if ($sourceIdx > 0) {
|
||||||
|
$sourceOrder = $this->orderModel->find($sourceIdx);
|
||||||
|
if (! $sourceOrder || (int) $sourceOrder->bo_lg_idx !== $lgIdx) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '수정 대상 발주를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'bo_order_date' => 'required|valid_date[Y-m-d]',
|
'bo_order_date' => 'required|valid_date[Y-m-d]',
|
||||||
@@ -98,61 +467,114 @@ class BagOrder extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
|
||||||
|
$qtySheets = $this->request->getPost('item_qty_sheet') ?? [];
|
||||||
|
$qtyBoxes = $this->request->getPost('item_qty_box') ?? []; // 구 화면 호환
|
||||||
|
$postedUnitPrices = $this->request->getPost('item_unit_price');
|
||||||
|
$changeKind = (string) ($this->request->getPost('bo_change_mode') ?? 'meta');
|
||||||
|
if (! in_array($changeKind, ['price', 'meta', 'delete'], true)) {
|
||||||
|
$changeKind = 'meta';
|
||||||
|
}
|
||||||
|
$itemCount = count($bagCodes);
|
||||||
|
$normalizedItems = [];
|
||||||
|
for ($i = 0; $i < $itemCount; $i++) {
|
||||||
|
$code = trim((string) ($bagCodes[$i] ?? ''));
|
||||||
|
$qtySheet = (int) ($qtySheets[$i] ?? 0);
|
||||||
|
$qtyBox = (int) ($qtyBoxes[$i] ?? 0);
|
||||||
|
if ($code === '' || ($qtySheet <= 0 && $qtyBox <= 0)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalizedItems[] = ['code' => $code, 'qtySheet' => $qtySheet, 'qtyBox' => $qtyBox];
|
||||||
|
}
|
||||||
|
if (empty($normalizedItems)) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '최소 1개 이상의 봉투 수량을 입력해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceByCode = [];
|
||||||
|
if ($sourceOrder !== null && $changeKind === 'price' && is_array($postedUnitPrices)) {
|
||||||
|
for ($pi = 0; $pi < count($bagCodes); $pi++) {
|
||||||
|
$c = trim((string) ($bagCodes[$pi] ?? ''));
|
||||||
|
if ($c === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$raw = $postedUnitPrices[$pi] ?? null;
|
||||||
|
if ($raw !== null && $raw !== '' && is_numeric($raw)) {
|
||||||
|
$priceByCode[$c] = round((float) $raw, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
|
||||||
// UUID 생성
|
try {
|
||||||
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
if ($sourceOrder) {
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
$uuid = (string) $sourceOrder->bo_uuid;
|
||||||
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000,
|
$maxVerRow = $this->orderModel->selectMax('bo_version')->where('bo_uuid', $uuid)->first();
|
||||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
$latestVersion = ($maxVerRow !== null && isset($maxVerRow->bo_version)) ? (int) $maxVerRow->bo_version : 0;
|
||||||
|
$version = $latestVersion + 1;
|
||||||
// LOT 번호 생성
|
$lotNo = (string) $sourceOrder->bo_lot_no;
|
||||||
$lotNo = 'LOT-' . date('Ymd') . '-' . strtoupper(substr(md5($uuid), 0, 6));
|
} else {
|
||||||
|
$uuid = $this->generateUuidV4();
|
||||||
|
$version = 1;
|
||||||
|
$lotNo = $this->generateLotNo6();
|
||||||
|
}
|
||||||
|
|
||||||
$orderData = [
|
$orderData = [
|
||||||
'bo_uuid' => $uuid,
|
'bo_uuid' => $uuid,
|
||||||
'bo_version' => 1,
|
'bo_version' => $version,
|
||||||
'bo_lg_idx' => $lgIdx,
|
'bo_lg_idx' => $lgIdx,
|
||||||
'bo_gugun_code' => $this->request->getPost('bo_gugun_code') ?? '',
|
'bo_gugun_code' => $this->resolveGugunCodeFromLg($lgIdx),
|
||||||
'bo_dong_code' => $this->request->getPost('bo_dong_code') ?? '',
|
'bo_dong_code' => '',
|
||||||
'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null,
|
'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null,
|
||||||
'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null,
|
'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null,
|
||||||
'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0),
|
'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0),
|
||||||
'bo_order_date' => $this->request->getPost('bo_order_date'),
|
'bo_order_date' => $this->request->getPost('bo_order_date'),
|
||||||
|
'bo_bag_types' => '',
|
||||||
|
'bo_unit_prices' => '',
|
||||||
|
'bo_qty_boxes' => '',
|
||||||
'bo_lot_no' => $lotNo,
|
'bo_lot_no' => $lotNo,
|
||||||
'bo_status' => 'normal',
|
'bo_status' => 'normal',
|
||||||
'bo_orderer_idx' => session()->get('mb_idx'),
|
'bo_orderer_idx' => session()->get('mb_idx'),
|
||||||
'bo_regdate' => date('Y-m-d H:i:s'),
|
'bo_regdate' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// SHA-256 해시
|
// 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
|
||||||
$orderData['bo_hash'] = hash('sha256', json_encode($orderData));
|
$orderData['bo_hash'] = '';
|
||||||
|
|
||||||
$this->orderModel->insert($orderData);
|
$this->orderModel->insert($orderData);
|
||||||
$boIdx = (int) $this->orderModel->getInsertID();
|
$boIdx = (int) $this->orderModel->getInsertID();
|
||||||
|
|
||||||
// 품목 저장
|
// 품목 저장
|
||||||
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
|
$hashItems = [];
|
||||||
$qtyBoxes = $this->request->getPost('item_qty_box') ?? [];
|
$bagTypesForHeader = [];
|
||||||
foreach ($bagCodes as $i => $code) {
|
$unitPricesForHeader = [];
|
||||||
if (empty($code) || empty($qtyBoxes[$i])) continue;
|
$qtyBoxesForHeader = [];
|
||||||
$qtyBox = (int) $qtyBoxes[$i];
|
foreach ($normalizedItems as $item) {
|
||||||
|
$code = $item['code'];
|
||||||
|
$qtySheetInput = (int) ($item['qtySheet'] ?? 0);
|
||||||
|
$qtyBoxInput = (int) ($item['qtyBox'] ?? 0);
|
||||||
// 포장단위에서 낱장 환산
|
// 포장단위에서 낱장 환산
|
||||||
$unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
|
$unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
|
||||||
$totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1;
|
$totalPerBox = $unit ? max(1, (int) $unit->pu_total_per_box) : 1;
|
||||||
$qtySheet = $qtyBox * $totalPerBox;
|
$qtySheet = $qtySheetInput > 0 ? $qtySheetInput : ($qtyBoxInput * $totalPerBox);
|
||||||
|
if ($qtySheet <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$qtyBox = intdiv($qtySheet, $totalPerBox);
|
||||||
|
|
||||||
// 단가
|
// 단가 (발주 변경·단가 구분 시 POST 단가 우선)
|
||||||
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first();
|
$price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, $code);
|
||||||
$unitPrice = $price ? (float) $price->bp_order_price : 0;
|
$unitPrice = $price ? (float) $price->bp_order_price : 0;
|
||||||
|
if ($sourceOrder !== null && isset($priceByCode[$code])) {
|
||||||
|
$unitPrice = $priceByCode[$code];
|
||||||
|
}
|
||||||
|
|
||||||
// 봉투명
|
// 봉투명
|
||||||
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$detail = $kindO ? model(CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $code)->first() : null;
|
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null;
|
||||||
|
|
||||||
$this->itemModel->insert([
|
$itemData = [
|
||||||
'boi_bo_idx' => $boIdx,
|
'boi_bo_idx' => $boIdx,
|
||||||
'boi_bag_code' => $code,
|
'boi_bag_code' => $code,
|
||||||
'boi_bag_name' => $detail ? $detail->cd_name : '',
|
'boi_bag_name' => $detail ? $detail->cd_name : '',
|
||||||
@@ -160,12 +582,202 @@ class BagOrder extends BaseController
|
|||||||
'boi_qty_box' => $qtyBox,
|
'boi_qty_box' => $qtyBox,
|
||||||
'boi_qty_sheet' => $qtySheet,
|
'boi_qty_sheet' => $qtySheet,
|
||||||
'boi_amount' => $unitPrice * $qtySheet,
|
'boi_amount' => $unitPrice * $qtySheet,
|
||||||
]);
|
];
|
||||||
|
$this->itemModel->insert($itemData);
|
||||||
|
$hashItems[] = $itemData;
|
||||||
|
|
||||||
|
$bagTypesForHeader[] = [
|
||||||
|
'code' => $itemData['boi_bag_code'],
|
||||||
|
'name' => $itemData['boi_bag_name'],
|
||||||
|
];
|
||||||
|
$unitPricesForHeader[] = [
|
||||||
|
'code' => $itemData['boi_bag_code'],
|
||||||
|
'unit_price' => $itemData['boi_unit_price'],
|
||||||
|
];
|
||||||
|
$qtyBoxesForHeader[] = [
|
||||||
|
'code' => $itemData['boi_bag_code'],
|
||||||
|
'qty_box' => $itemData['boi_qty_box'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$db->transComplete();
|
$orderData['bo_bag_types'] = json_encode($bagTypesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||||
|
$orderData['bo_unit_prices'] = json_encode($unitPricesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||||
|
$orderData['bo_qty_boxes'] = json_encode($qtyBoxesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo);
|
// 최종 발주 데이터(헤더+품목) 해시
|
||||||
|
$hashPayload = $orderData;
|
||||||
|
$hashPayload['bo_idx'] = $boIdx;
|
||||||
|
$hashPayload['items'] = $hashItems;
|
||||||
|
$hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$orderHash = hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx);
|
||||||
|
$this->orderModel->update($boIdx, [
|
||||||
|
'bo_bag_types' => $orderData['bo_bag_types'],
|
||||||
|
'bo_unit_prices' => $orderData['bo_unit_prices'],
|
||||||
|
'bo_qty_boxes' => $orderData['bo_qty_boxes'],
|
||||||
|
'bo_hash' => $orderHash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$beforeHash = $sourceOrder ? (string) ($sourceOrder->bo_hash ?? '') : '';
|
||||||
|
$seedFilePath = $this->generateBarcodeSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash);
|
||||||
|
$blockPayload = [
|
||||||
|
'bo_idx' => $boIdx,
|
||||||
|
'bo_uuid' => $uuid,
|
||||||
|
'bo_version' => $version,
|
||||||
|
'bo_lot_no' => $lotNo,
|
||||||
|
'bo_hash' => $orderHash,
|
||||||
|
'seed_file' => $seedFilePath,
|
||||||
|
'hash_chain' => $beforeHash !== '' ? [$beforeHash, $orderHash] : [$orderHash],
|
||||||
|
'order' => $orderData,
|
||||||
|
'items' => $hashItems,
|
||||||
|
];
|
||||||
|
$ledger = new SqlLedger();
|
||||||
|
$ledger->appendBlock(
|
||||||
|
$sourceOrder ? 'ORDER_UPDATE' : 'ORDER_CREATE',
|
||||||
|
$blockPayload,
|
||||||
|
$uuid,
|
||||||
|
$version,
|
||||||
|
session()->get('mb_idx') ? (int) session()->get('mb_idx') : null,
|
||||||
|
$lgIdx
|
||||||
|
);
|
||||||
|
|
||||||
|
// CT-05: 감사 로그
|
||||||
|
helper('audit');
|
||||||
|
if ($sourceOrder) {
|
||||||
|
audit_log(
|
||||||
|
'update',
|
||||||
|
'bag_order',
|
||||||
|
$boIdx,
|
||||||
|
['bo_idx' => (int) $sourceOrder->bo_idx, 'bo_hash' => $beforeHash, 'bo_version' => (int) $sourceOrder->bo_version],
|
||||||
|
array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $db->transComplete()) {
|
||||||
|
throw new \RuntimeException('Transaction did not complete');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
|
log_message('error', 'BagOrder::store: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
|
||||||
|
return redirect()->back()->withInput()->with('error', '발주 저장 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 효과 지자체(`local_government`)의 행정 구·군 코드(lg_code) */
|
||||||
|
private function resolveGugunCodeFromLg(int $lgIdx): string
|
||||||
|
{
|
||||||
|
$lg = model(LocalGovernmentModel::class)->find($lgIdx);
|
||||||
|
|
||||||
|
return $lg ? trim((string) ($lg->lg_code ?? '')) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateUuidV4(): string
|
||||||
|
{
|
||||||
|
$bytes = random_bytes(16);
|
||||||
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||||
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||||
|
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateLotNo6(): string
|
||||||
|
{
|
||||||
|
// 문서의 "LOT 번호 6 Byte" 요구를 맞추기 위해 영숫자 6자리로 생성한다.
|
||||||
|
// 충돌 가능성을 낮추기 위해 최대 20회 재시도 후 timestamp 기반으로 fallback.
|
||||||
|
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||||
|
$lot = '';
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
$lot .= $chars[random_int(0, strlen($chars) - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $this->orderModel->where('bo_lot_no', $lot)->countAllResults() > 0;
|
||||||
|
if (! $exists) {
|
||||||
|
return $lot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtoupper(substr(base_convert((string) time(), 10, 36), -6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $orderData
|
||||||
|
* @param array<int,array<string,mixed>> $items
|
||||||
|
*/
|
||||||
|
private function generateBarcodeSeedFile(string $uuid, int $version, string $lotNo, array $orderData, array $items, string $orderHash): string
|
||||||
|
{
|
||||||
|
$baseDir = WRITEPATH . 'barcode-seeds';
|
||||||
|
if (! is_dir($baseDir)) {
|
||||||
|
mkdir($baseDir, 0775, true);
|
||||||
|
}
|
||||||
|
$keyDir = WRITEPATH . 'keys';
|
||||||
|
if (! is_dir($keyDir)) {
|
||||||
|
mkdir($keyDir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$privateKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_private.pem';
|
||||||
|
$publicKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_public.pem';
|
||||||
|
if (! is_file($privateKeyPath) || ! is_file($publicKeyPath)) {
|
||||||
|
$config = [
|
||||||
|
'private_key_bits' => 2048,
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
|
];
|
||||||
|
$resource = openssl_pkey_new($config);
|
||||||
|
if ($resource !== false) {
|
||||||
|
$privatePem = '';
|
||||||
|
openssl_pkey_export($resource, $privatePem);
|
||||||
|
$details = openssl_pkey_get_details($resource);
|
||||||
|
$publicPem = $details['key'] ?? '';
|
||||||
|
if ($privatePem !== '' && $publicPem !== '') {
|
||||||
|
file_put_contents($privateKeyPath, $privatePem);
|
||||||
|
file_put_contents($publicKeyPath, $publicPem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'version' => $version,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'order_hash' => $orderHash,
|
||||||
|
'order' => $orderData,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
|
||||||
|
|
||||||
|
$aesKey = random_bytes(32);
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipherRaw = openssl_encrypt($payloadJson, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($cipherRaw === false) {
|
||||||
|
$cipherRaw = $payloadJson;
|
||||||
|
}
|
||||||
|
$encryptedKey = '';
|
||||||
|
$publicPem = is_file($publicKeyPath) ? file_get_contents($publicKeyPath) : '';
|
||||||
|
if (is_string($publicPem) && $publicPem !== '') {
|
||||||
|
openssl_public_encrypt($aesKey, $encryptedKey, $publicPem, OPENSSL_PKCS1_OAEP_PADDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seed = [
|
||||||
|
'algorithm' => ['symmetric' => 'AES-256-CBC', 'asymmetric' => 'RSA-2048'],
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'version' => $version,
|
||||||
|
'iv_b64' => base64_encode($iv),
|
||||||
|
'key_b64' => $encryptedKey !== '' ? base64_encode($encryptedKey) : '',
|
||||||
|
'cipher_b64' => base64_encode((string) $cipherRaw),
|
||||||
|
'payload_hash' => hash('sha256', $payloadJson),
|
||||||
|
'created_at' => date('c'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$fileName = $lotNo . '_v' . $version . '.seed.json';
|
||||||
|
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $fileName;
|
||||||
|
file_put_contents($fullPath, json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return $fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function detail(int $id)
|
public function detail(int $id)
|
||||||
@@ -173,7 +785,7 @@ class BagOrder extends BaseController
|
|||||||
helper('admin');
|
helper('admin');
|
||||||
$order = $this->orderModel->find($id);
|
$order = $this->orderModel->find($id);
|
||||||
if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
|
if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $this->itemModel->where('boi_bo_idx', $id)->findAll();
|
$items = $this->itemModel->where('boi_bo_idx', $id)->findAll();
|
||||||
@@ -186,13 +798,12 @@ class BagOrder extends BaseController
|
|||||||
$agencyName = '';
|
$agencyName = '';
|
||||||
if ($order->bo_agency_idx) {
|
if ($order->bo_agency_idx) {
|
||||||
$a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
|
$a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
|
||||||
$agencyName = $a ? $a->sa_name : '';
|
if ($a) {
|
||||||
|
$agencyName = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('발주 상세 — ' . $order->bo_lot_no, 'admin/bag_order/detail', compact('order', 'items', 'companyName', 'agencyName'));
|
||||||
'title' => '발주 상세 — ' . $order->bo_lot_no,
|
|
||||||
'content' => view('admin/bag_order/detail', compact('order', 'items', 'companyName', 'agencyName')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(int $id)
|
public function cancel(int $id)
|
||||||
@@ -200,11 +811,17 @@ class BagOrder extends BaseController
|
|||||||
helper('admin');
|
helper('admin');
|
||||||
$order = $this->orderModel->find($id);
|
$order = $this->orderModel->find($id);
|
||||||
if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
|
if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]);
|
$before = (array) $order;
|
||||||
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 취소되었습니다.');
|
$beforeHash = (string) ($order->bo_hash ?? '');
|
||||||
|
$this->appendLedgerForStatusChange($order, $id, 'ORDER_CANCEL', 'cancelled', $beforeHash);
|
||||||
|
$after = (array) $this->orderModel->find($id);
|
||||||
|
helper('audit');
|
||||||
|
audit_log('update', 'bag_order', $id, $before, $after);
|
||||||
|
|
||||||
|
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
@@ -212,10 +829,121 @@ class BagOrder extends BaseController
|
|||||||
helper('admin');
|
helper('admin');
|
||||||
$order = $this->orderModel->find($id);
|
$order = $this->orderModel->find($id);
|
||||||
if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
|
if (!$order || (int) $order->bo_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]);
|
$before = (array) $order;
|
||||||
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
|
$beforeHash = (string) ($order->bo_hash ?? '');
|
||||||
|
$this->appendLedgerForStatusChange($order, $id, 'ORDER_DELETE', 'deleted', $beforeHash);
|
||||||
|
$after = (array) $this->orderModel->find($id);
|
||||||
|
helper('audit');
|
||||||
|
audit_log('delete', 'bag_order', $id, $before, $after);
|
||||||
|
|
||||||
|
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 변경 시(취소/삭제) 무결성 검증을 위해 bo_hash 재계산 후
|
||||||
|
* SQL-Ledger(append-only)에 블록을 추가한다.
|
||||||
|
*
|
||||||
|
* @param object $order
|
||||||
|
* @param int $boIdx
|
||||||
|
* @param string $txType ORDER_CANCEL|ORDER_DELETE
|
||||||
|
* @param string $newStatus cancelled|deleted
|
||||||
|
* @param string $previousHash
|
||||||
|
*/
|
||||||
|
private function appendLedgerForStatusChange(object $order, int $boIdx, string $txType, string $newStatus, string $previousHash): void
|
||||||
|
{
|
||||||
|
// 품목은 상태 변경 시 그대로이므로, 동일 payload 형태로 items array를 만든다.
|
||||||
|
$items = $this->itemModel->where('boi_bo_idx', $boIdx)->findAll();
|
||||||
|
$hashItems = [];
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$hashItems[] = [
|
||||||
|
'boi_bo_idx' => (int) $it->boi_bo_idx,
|
||||||
|
'boi_bag_code' => (string) $it->boi_bag_code,
|
||||||
|
'boi_bag_name' => (string) ($it->boi_bag_name ?? ''),
|
||||||
|
'boi_unit_price' => (float) $it->boi_unit_price,
|
||||||
|
'boi_qty_box' => (int) $it->boi_qty_box,
|
||||||
|
'boi_qty_sheet' => (int) $it->boi_qty_sheet,
|
||||||
|
'boi_amount' => (float) $it->boi_amount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newOrder = $order;
|
||||||
|
$newOrder->bo_status = $newStatus;
|
||||||
|
|
||||||
|
$newHash = $this->computeOrderHash($boIdx, $newOrder, $hashItems);
|
||||||
|
$actorIdx = session()->get('mb_idx') ? (int) session()->get('mb_idx') : null;
|
||||||
|
$lgIdx = (int) ($order->bo_lg_idx ?? 0);
|
||||||
|
|
||||||
|
$seedFilePath = '';
|
||||||
|
$ledgerPayload = [
|
||||||
|
'bo_idx' => $boIdx,
|
||||||
|
'bo_uuid' => (string) $order->bo_uuid,
|
||||||
|
'bo_version' => (int) $order->bo_version,
|
||||||
|
'bo_lot_no' => (string) $order->bo_lot_no,
|
||||||
|
'bo_hash' => $newHash,
|
||||||
|
'seed_file' => $seedFilePath,
|
||||||
|
'hash_chain' => [$previousHash, $newHash],
|
||||||
|
'order' => [
|
||||||
|
'bo_status' => $newStatus,
|
||||||
|
'bo_hash' => $newHash,
|
||||||
|
],
|
||||||
|
'items' => $hashItems,
|
||||||
|
];
|
||||||
|
|
||||||
|
$ledger = new SqlLedger();
|
||||||
|
$ledger->appendBlock(
|
||||||
|
$txType,
|
||||||
|
$ledgerPayload,
|
||||||
|
(string) $order->bo_uuid,
|
||||||
|
(int) $order->bo_version,
|
||||||
|
$actorIdx,
|
||||||
|
$lgIdx
|
||||||
|
);
|
||||||
|
|
||||||
|
// order row에 hash 반영
|
||||||
|
$this->orderModel->update($boIdx, [
|
||||||
|
'bo_status' => $newStatus,
|
||||||
|
'bo_moddate' => date('Y-m-d H:i:s'),
|
||||||
|
'bo_hash' => $newHash,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* store()에서 생성하는 bo_hash와 동일한 "헤더+items" 규격을 사용해 SHA-256을 계산한다.
|
||||||
|
*
|
||||||
|
* @param int $boIdx
|
||||||
|
* @param object $order
|
||||||
|
* @param array<int,array<string,mixed>> $hashItems
|
||||||
|
*/
|
||||||
|
private function computeOrderHash(int $boIdx, object $order, array $hashItems): string
|
||||||
|
{
|
||||||
|
$orderData = [
|
||||||
|
'bo_uuid' => (string) $order->bo_uuid,
|
||||||
|
'bo_version' => (int) $order->bo_version,
|
||||||
|
'bo_lg_idx' => (int) $order->bo_lg_idx,
|
||||||
|
'bo_gugun_code' => (string) ($order->bo_gugun_code ?? ''),
|
||||||
|
'bo_dong_code' => (string) ($order->bo_dong_code ?? ''),
|
||||||
|
'bo_company_idx' => $order->bo_company_idx !== null ? (int) $order->bo_company_idx : null,
|
||||||
|
'bo_agency_idx' => $order->bo_agency_idx !== null ? (int) $order->bo_agency_idx : null,
|
||||||
|
'bo_fee_rate' => (float) ($order->bo_fee_rate ?? 0),
|
||||||
|
'bo_order_date' => (string) $order->bo_order_date,
|
||||||
|
'bo_bag_types' => (string) ($order->bo_bag_types ?? ''),
|
||||||
|
'bo_unit_prices' => (string) ($order->bo_unit_prices ?? ''),
|
||||||
|
'bo_qty_boxes' => (string) ($order->bo_qty_boxes ?? ''),
|
||||||
|
'bo_lot_no' => (string) $order->bo_lot_no,
|
||||||
|
'bo_hash' => '',
|
||||||
|
'bo_status' => (string) $order->bo_status,
|
||||||
|
'bo_orderer_idx' => $order->bo_orderer_idx !== null ? (int) $order->bo_orderer_idx : null,
|
||||||
|
'bo_regdate' => (string) ($order->bo_regdate ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$hashPayload = $orderData;
|
||||||
|
$hashPayload['bo_idx'] = $boIdx;
|
||||||
|
$hashPayload['items'] = $hashItems;
|
||||||
|
|
||||||
|
$hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
return hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
namespace App\Controllers\Admin;
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
use App\Models\BagPriceModel;
|
|
||||||
use App\Models\BagPriceHistoryModel;
|
use App\Models\BagPriceHistoryModel;
|
||||||
use App\Models\CodeKindModel;
|
use App\Models\BagPriceModel;
|
||||||
use App\Models\CodeDetailModel;
|
use App\Models\CodeDetailModel;
|
||||||
|
use App\Models\CodeKindModel;
|
||||||
|
|
||||||
class BagPrice extends BaseController
|
class BagPrice extends BaseController
|
||||||
{
|
{
|
||||||
@@ -23,34 +23,143 @@ class BagPrice extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if ($lgIdx === null) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$get = $this->request->getGet();
|
||||||
|
$readSrc = static function (array $src, string $key): ?string {
|
||||||
|
if (! array_key_exists($key, $src)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$v = $src[$key];
|
||||||
|
if ($v === null || is_array($v)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$s = trim((string) $v);
|
||||||
|
|
||||||
|
return $s === '' ? null : $s;
|
||||||
|
};
|
||||||
|
|
||||||
|
$sy = $readSrc($get, 'start_y');
|
||||||
|
$sm = $readSrc($get, 'start_m');
|
||||||
|
$sd = $readSrc($get, 'start_d');
|
||||||
|
$ey = $readSrc($get, 'end_y');
|
||||||
|
$em = $readSrc($get, 'end_m');
|
||||||
|
$ed = $readSrc($get, 'end_d');
|
||||||
|
|
||||||
|
$startDate = null;
|
||||||
|
if ($sy !== null && $sy !== '' && $sm !== null && $sm !== '' && $sd !== null && $sd !== '') {
|
||||||
|
$startDate = parse_ymd_from_triple($sy, $sm, $sd);
|
||||||
|
}
|
||||||
|
if ($startDate === null) {
|
||||||
|
$legacyStart = $readSrc($get, 'start_date');
|
||||||
|
$startDate = ($legacyStart !== null && $legacyStart !== '') ? $legacyStart : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = null;
|
||||||
|
if ($ey !== null && $ey !== '' && $em !== null && $em !== '' && $ed !== null && $ed !== '') {
|
||||||
|
$endDate = parse_ymd_from_triple($ey, $em, $ed);
|
||||||
|
}
|
||||||
|
if ($endDate === null) {
|
||||||
|
$legacyEnd = $readSrc($get, 'end_date');
|
||||||
|
$endDate = ($legacyEnd !== null && $legacyEnd !== '') ? $legacyEnd : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startParts = ['y' => '', 'm' => '', 'd' => ''];
|
||||||
|
$endParts = ['y' => '', 'm' => '', 'd' => ''];
|
||||||
|
if ($startDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $startDate, $m)) {
|
||||||
|
$startParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
|
||||||
|
}
|
||||||
|
if ($endDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $endDate, $m)) {
|
||||||
|
$endParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bagKindE = $readSrc($get, 'bag_kind_e');
|
||||||
|
$bagCode = $readSrc($get, 'bag_code');
|
||||||
|
|
||||||
$builder = $this->priceModel->where('bp_lg_idx', $lgIdx);
|
$builder = $this->priceModel->where('bp_lg_idx', $lgIdx);
|
||||||
|
if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
|
||||||
// 기간 필터 (P2-04)
|
$qStart = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
|
||||||
$startDate = $this->request->getGet('start_date');
|
$qEnd = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
|
||||||
$endDate = $this->request->getGet('end_date');
|
if (strcmp((string) $qStart, (string) $qEnd) > 0) {
|
||||||
if ($startDate) {
|
[$qStart, $qEnd] = [$qEnd, $qStart];
|
||||||
$builder->where('bp_start_date >=', $startDate);
|
|
||||||
}
|
}
|
||||||
if ($endDate) {
|
$builder->where('bp_start_date <=', $qEnd);
|
||||||
$builder->groupStart()
|
$builder->groupStart()
|
||||||
->where('bp_end_date IS NULL')
|
->where('bp_end_date IS NULL')
|
||||||
->orWhere('bp_end_date <=', $endDate)
|
->orWhere('bp_end_date >=', $qStart)
|
||||||
->groupEnd();
|
->groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->findAll();
|
if ($bagKindE !== null && $bagKindE !== '') {
|
||||||
|
$kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
|
||||||
|
if ($kindE) {
|
||||||
|
$detailE = model(CodeDetailModel::class)
|
||||||
|
->where('cd_ck_idx', (int) $kindE->ck_idx)
|
||||||
|
->where('cd_code', $bagKindE)
|
||||||
|
->where('cd_state', 1)
|
||||||
|
->first();
|
||||||
|
if ($detailE !== null) {
|
||||||
|
$builder->like('bp_bag_code', $bagKindE, 'after');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
if ($bagCode !== null && $bagCode !== '') {
|
||||||
'title' => '봉투 단가 관리',
|
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
'content' => view('admin/bag_price/index', [
|
if ($kindO) {
|
||||||
|
$detailO = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx);
|
||||||
|
if ($detailO !== null) {
|
||||||
|
$builder->where('bp_bag_code', $bagCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder
|
||||||
|
->orderBy('bp_bag_code', 'ASC')
|
||||||
|
->orderBy('bp_start_date', 'DESC')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
$queryForPager = [];
|
||||||
|
if ($sy !== null && $sm !== null && $sd !== null && $sy !== '' && $sm !== '' && $sd !== '') {
|
||||||
|
$queryForPager['start_y'] = $sy;
|
||||||
|
$queryForPager['start_m'] = $sm;
|
||||||
|
$queryForPager['start_d'] = $sd;
|
||||||
|
}
|
||||||
|
if ($ey !== null && $em !== null && $ed !== null && $ey !== '' && $em !== '' && $ed !== '') {
|
||||||
|
$queryForPager['end_y'] = $ey;
|
||||||
|
$queryForPager['end_m'] = $em;
|
||||||
|
$queryForPager['end_d'] = $ed;
|
||||||
|
}
|
||||||
|
if ($bagKindE !== null && $bagKindE !== '') {
|
||||||
|
$queryForPager['bag_kind_e'] = $bagKindE;
|
||||||
|
}
|
||||||
|
if ($bagCode !== null && $bagCode !== '') {
|
||||||
|
$queryForPager['bag_code'] = $bagCode;
|
||||||
|
}
|
||||||
|
apply_pager_path($this->priceModel->pager, mgmt_path('bag-prices'), $queryForPager);
|
||||||
|
|
||||||
|
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
|
$bagCodes = $kindO
|
||||||
|
? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx)
|
||||||
|
: [];
|
||||||
|
$kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
|
||||||
|
$bagKindOptions = $kindE
|
||||||
|
? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return $this->renderWorkPage('봉투 단가 관리', 'admin/bag_price/index', [
|
||||||
'list' => $list,
|
'list' => $list,
|
||||||
'startDate' => $startDate,
|
'pager' => $this->priceModel->pager,
|
||||||
'endDate' => $endDate,
|
'startParts' => $startParts,
|
||||||
]),
|
'endParts' => $endParts,
|
||||||
|
'dateYearMin' => (int) date('Y') - 12,
|
||||||
|
'dateYearMax' => (int) date('Y') + 2,
|
||||||
|
'bag_kind_e' => $bagKindE,
|
||||||
|
'bag_code' => $bagCode,
|
||||||
|
'bag_codes' => $bagCodes,
|
||||||
|
'bag_kind_options' => $bagKindOptions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,22 +167,18 @@ class BagPrice extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (! $lgIdx) {
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봉투명 코드(O) 목록
|
|
||||||
$kindModel = model(CodeKindModel::class);
|
$kindModel = model(CodeKindModel::class);
|
||||||
$kind = $kindModel->where('ck_code', 'O')->first();
|
$kind = $kindModel->where('ck_code', 'O')->first();
|
||||||
$bagCodes = [];
|
$bagCodes = [];
|
||||||
if ($kind) {
|
if ($kind) {
|
||||||
$bagCodes = model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true);
|
$bagCodes = model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('봉투 단가 등록', 'admin/bag_price/create', ['bagCodes' => $bagCodes]);
|
||||||
'title' => '봉투 단가 등록',
|
|
||||||
'content' => view('admin/bag_price/create', ['bagCodes' => $bagCodes]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -94,13 +199,12 @@ class BagPrice extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 봉투명 스냅샷
|
|
||||||
$bagCode = $this->request->getPost('bp_bag_code');
|
$bagCode = $this->request->getPost('bp_bag_code');
|
||||||
$kindModel = model(CodeKindModel::class);
|
$kindModel = model(CodeKindModel::class);
|
||||||
$kind = $kindModel->where('ck_code', 'O')->first();
|
$kind = $kindModel->where('ck_code', 'O')->first();
|
||||||
$bagName = '';
|
$bagName = '';
|
||||||
if ($kind) {
|
if ($kind) {
|
||||||
$detail = model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_code', $bagCode)->first();
|
$detail = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kind->ck_idx, (string) $bagCode, $lgIdx);
|
||||||
$bagName = $detail ? $detail->cd_name : '';
|
$bagName = $detail ? $detail->cd_name : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,36 +222,34 @@ class BagPrice extends BaseController
|
|||||||
'bp_reg_mb_idx' => session()->get('mb_idx'),
|
'bp_reg_mb_idx' => session()->get('mb_idx'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('success', '봉투 단가가 등록되었습니다.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('success', '봉투 단가가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$item = $this->priceModel->find($id);
|
$item = $this->priceModel->find($id);
|
||||||
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->bp_lg_idx !== $lgIdx) {
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$kindModel = model(CodeKindModel::class);
|
$kindModel = model(CodeKindModel::class);
|
||||||
$kind = $kindModel->where('ck_code', 'O')->first();
|
$kind = $kindModel->where('ck_code', 'O')->first();
|
||||||
$bagCodes = [];
|
$bagCodes = [];
|
||||||
if ($kind) {
|
if ($kind) {
|
||||||
$bagCodes = model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true);
|
$bagCodes = model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('봉투 단가 수정', 'admin/bag_price/edit', ['item' => $item, 'bagCodes' => $bagCodes]);
|
||||||
'title' => '봉투 단가 수정',
|
|
||||||
'content' => view('admin/bag_price/edit', ['item' => $item, 'bagCodes' => $bagCodes]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->priceModel->find($id);
|
$item = $this->priceModel->find($id);
|
||||||
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -163,7 +265,6 @@ class BagPrice extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이력 기록
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
|
||||||
@@ -177,8 +278,8 @@ class BagPrice extends BaseController
|
|||||||
'bph_field' => $field,
|
'bph_field' => $field,
|
||||||
'bph_old_value' => $oldVal,
|
'bph_old_value' => $oldVal,
|
||||||
'bph_new_value' => $newVal,
|
'bph_new_value' => $newVal,
|
||||||
'bph_changed_at'=> date('Y-m-d H:i:s'),
|
'bph_changed_at' => date('Y-m-d H:i:s'),
|
||||||
'bph_changed_by'=> session()->get('mb_idx'),
|
'bph_changed_by' => session()->get('mb_idx'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,34 +296,32 @@ class BagPrice extends BaseController
|
|||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('success', '봉투 단가가 수정되었습니다.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('success', '봉투 단가가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->priceModel->find($id);
|
$item = $this->priceModel->find($id);
|
||||||
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->priceModel->delete($id);
|
$this->priceModel->delete($id);
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('success', '봉투 단가가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('bag-prices'))->with('success', '봉투 단가가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(int $bpIdx)
|
public function history(int $bpIdx)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->priceModel->find($bpIdx);
|
$item = $this->priceModel->find($bpIdx);
|
||||||
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->historyModel->where('bph_bp_idx', $bpIdx)->orderBy('bph_changed_at', 'DESC')->findAll();
|
$list = $this->historyModel->where('bph_bp_idx', $bpIdx)->orderBy('bph_changed_at', 'DESC')->findAll();
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('단가 변경 이력 — ' . $item->bp_bag_name, 'admin/bag_price/history', ['item' => $item, 'list' => $list]);
|
||||||
'title' => '단가 변경 이력 — ' . $item->bp_bag_name,
|
|
||||||
'content' => view('admin/bag_price/history', ['item' => $item, 'list' => $list]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,35 +22,37 @@ class BagReceiving extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$builder = $this->recvModel->where('br_lg_idx', $lgIdx);
|
$builder = $this->recvModel->where('br_lg_idx', $lgIdx);
|
||||||
$startDate = $this->request->getGet('start_date');
|
$startDate = $this->request->getGet('start_date');
|
||||||
$endDate = $this->request->getGet('end_date');
|
$endDate = $this->request->getGet('end_date');
|
||||||
if ($startDate) $builder->where('br_receive_date >=', $startDate);
|
if ($startDate) {
|
||||||
if ($endDate) $builder->where('br_receive_date <=', $endDate);
|
$builder->where('br_receive_date >=', $startDate);
|
||||||
|
}
|
||||||
|
if ($endDate) {
|
||||||
|
$builder->where('br_receive_date <=', $endDate);
|
||||||
|
}
|
||||||
|
|
||||||
$list = $builder->orderBy('br_receive_date', 'DESC')->orderBy('br_idx', 'DESC')->findAll();
|
$list = $builder->orderBy('br_receive_date', 'DESC')->orderBy('br_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->recvModel->pager;
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('입고 현황', 'admin/bag_receiving/index', compact('list', 'startDate', 'endDate', 'pager'));
|
||||||
'title' => '입고 현황',
|
|
||||||
'content' => view('admin/bag_receiving/index', compact('list', 'startDate', 'endDate')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin/bag-receivings'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-receivings'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
// 미입고 발주 목록
|
$orders = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll();
|
||||||
$orders = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll();
|
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('입고 처리', 'admin/bag_receiving/create', compact('orders'));
|
||||||
'title' => '입고 처리',
|
|
||||||
'content' => view('admin/bag_receiving/create', compact('orders')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -72,14 +74,12 @@ class BagReceiving extends BaseController
|
|||||||
$bagCode = $this->request->getPost('br_bag_code');
|
$bagCode = $this->request->getPost('br_bag_code');
|
||||||
$qtyBox = (int) $this->request->getPost('br_qty_box');
|
$qtyBox = (int) $this->request->getPost('br_qty_box');
|
||||||
|
|
||||||
// 포장단위로 낱장 환산
|
|
||||||
$unit = model(\App\Models\PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $bagCode)->where('pu_state', 1)->first();
|
$unit = model(\App\Models\PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $bagCode)->where('pu_state', 1)->first();
|
||||||
$totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1;
|
$totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1;
|
||||||
$qtySheet = $qtyBox * $totalPerBox;
|
$qtySheet = $qtyBox * $totalPerBox;
|
||||||
|
|
||||||
// 봉투명
|
|
||||||
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$detail = $kindO ? model(\App\Models\CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
|
$detail = $kindO ? model(\App\Models\CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null;
|
||||||
$bagName = $detail ? $detail->cd_name : '';
|
$bagName = $detail ? $detail->cd_name : '';
|
||||||
|
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
@@ -99,11 +99,10 @@ class BagReceiving extends BaseController
|
|||||||
'br_regdate' => date('Y-m-d H:i:s'),
|
'br_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 재고 가산
|
|
||||||
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $qtySheet);
|
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $qtySheet);
|
||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/bag-receivings'))->with('success', '입고 처리되었습니다. (' . $bagName . ' ' . $qtyBox . '박스)');
|
return redirect()->to(mgmt_url('bag-receivings'))->with('success', '입고 처리되었습니다. (' . $bagName . ' ' . $qtyBox . '박스)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,38 +23,90 @@ class BagSale extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$builder = $this->saleModel->where('bs_lg_idx', $lgIdx);
|
$builder = $this->saleModel->where('bs_lg_idx', $lgIdx);
|
||||||
$startDate = $this->request->getGet('start_date');
|
$startDate = $this->request->getGet('start_date');
|
||||||
$endDate = $this->request->getGet('end_date');
|
$endDate = $this->request->getGet('end_date');
|
||||||
$type = $this->request->getGet('type');
|
$type = $this->request->getGet('type');
|
||||||
if ($startDate) $builder->where('bs_sale_date >=', $startDate);
|
if ($startDate) {
|
||||||
if ($endDate) $builder->where('bs_sale_date <=', $endDate);
|
$builder->where('bs_sale_date >=', $startDate);
|
||||||
if ($type) $builder->where('bs_type', $type);
|
}
|
||||||
|
if ($endDate) {
|
||||||
|
$builder->where('bs_sale_date <=', $endDate);
|
||||||
|
}
|
||||||
|
if ($type) {
|
||||||
|
$builder->where('bs_type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->saleModel->pager;
|
||||||
|
|
||||||
|
return $this->renderWorkPage('판매/반품 관리', 'admin/bag_sale/index', compact('list', 'startDate', 'endDate', 'type', 'pager'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export()
|
||||||
|
{
|
||||||
|
helper(['admin', 'export']);
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-sales'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->saleModel->where('bs_lg_idx', $lgIdx);
|
||||||
|
$startDate = $this->request->getGet('start_date');
|
||||||
|
$endDate = $this->request->getGet('end_date');
|
||||||
|
$type = $this->request->getGet('type');
|
||||||
|
if ($startDate) {
|
||||||
|
$builder->where('bs_sale_date >=', $startDate);
|
||||||
|
}
|
||||||
|
if ($endDate) {
|
||||||
|
$builder->where('bs_sale_date <=', $endDate);
|
||||||
|
}
|
||||||
|
if ($type) {
|
||||||
|
$builder->where('bs_type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
$list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->findAll();
|
$list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->findAll();
|
||||||
|
|
||||||
return view('admin/layout', [
|
$typeMap = ['sale' => '판매', 'return' => '반품', 'cancel' => '취소'];
|
||||||
'title' => '판매/반품 관리',
|
$rows = [];
|
||||||
'content' => view('admin/bag_sale/index', compact('list', 'startDate', 'endDate', 'type')),
|
foreach ($list as $row) {
|
||||||
]);
|
$rows[] = [
|
||||||
|
$row->bs_idx,
|
||||||
|
$row->bs_ds_name,
|
||||||
|
$row->bs_sale_date,
|
||||||
|
$row->bs_bag_code,
|
||||||
|
$row->bs_bag_name,
|
||||||
|
(int) $row->bs_qty,
|
||||||
|
(int) $row->bs_unit_price,
|
||||||
|
(int) $row->bs_amount,
|
||||||
|
$typeMap[$row->bs_type] ?? $row->bs_type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export_csv(
|
||||||
|
'판매반품_' . date('Ymd') . '.csv',
|
||||||
|
['번호', '판매소', '판매일', '봉투코드', '봉투명', '수량', '단가', '금액', '구분'],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin/bag-sales'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('bag-sales'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll();
|
$shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('판매 등록', 'admin/bag_sale/create', compact('shops', 'bagCodes'));
|
||||||
'title' => '판매 등록',
|
|
||||||
'content' => view('admin/bag_sale/create', compact('shops', 'bagCodes')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -80,8 +132,8 @@ class BagSale extends BaseController
|
|||||||
|
|
||||||
$shop = model(DesignatedShopModel::class)->find($dsIdx);
|
$shop = model(DesignatedShopModel::class)->find($dsIdx);
|
||||||
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$detail = $kindO ? model(CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
|
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null;
|
||||||
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $bagCode)->where('bp_state', 1)->first();
|
$price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $bagCode);
|
||||||
$unitPrice = $price ? (float) $price->bp_consumer : 0;
|
$unitPrice = $price ? (float) $price->bp_consumer : 0;
|
||||||
|
|
||||||
$actualQty = ($type === 'return') ? -$qty : $qty;
|
$actualQty = ($type === 'return') ? -$qty : $qty;
|
||||||
@@ -89,7 +141,7 @@ class BagSale extends BaseController
|
|||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
|
||||||
$this->saleModel->insert([
|
$saleData = [
|
||||||
'bs_lg_idx' => $lgIdx,
|
'bs_lg_idx' => $lgIdx,
|
||||||
'bs_ds_idx' => $dsIdx,
|
'bs_ds_idx' => $dsIdx,
|
||||||
'bs_ds_name' => $shop ? $shop->ds_name : '',
|
'bs_ds_name' => $shop ? $shop->ds_name : '',
|
||||||
@@ -97,18 +149,23 @@ class BagSale extends BaseController
|
|||||||
'bs_bag_code' => $bagCode,
|
'bs_bag_code' => $bagCode,
|
||||||
'bs_bag_name' => $detail ? $detail->cd_name : '',
|
'bs_bag_name' => $detail ? $detail->cd_name : '',
|
||||||
'bs_qty' => $actualQty,
|
'bs_qty' => $actualQty,
|
||||||
'bs_unit_price'=> $unitPrice,
|
'bs_unit_price' => $unitPrice,
|
||||||
'bs_amount' => $unitPrice * abs($actualQty),
|
'bs_amount' => $unitPrice * abs($actualQty),
|
||||||
'bs_type' => $type,
|
'bs_type' => $type,
|
||||||
'bs_regdate' => date('Y-m-d H:i:s'),
|
'bs_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
];
|
||||||
|
$this->saleModel->insert($saleData);
|
||||||
|
$bsIdx = (int) $this->saleModel->getInsertID();
|
||||||
|
|
||||||
|
helper('audit');
|
||||||
|
audit_log('create', 'bag_sale', $bsIdx, null, array_merge($saleData, ['bs_idx' => $bsIdx]));
|
||||||
|
|
||||||
// 재고 감산(판매) / 가산(반품)
|
|
||||||
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $detail ? $detail->cd_name : '', -$actualQty);
|
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $detail ? $detail->cd_name : '', -$actualQty);
|
||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
|
|
||||||
$msg = ($type === 'sale') ? '판매 처리되었습니다.' : '반품 처리되었습니다.';
|
$msg = ($type === 'sale') ? '판매 처리되었습니다.' : '반품 처리되었습니다.';
|
||||||
return redirect()->to(site_url('admin/bag-sales'))->with('success', $msg);
|
|
||||||
|
return redirect()->to(mgmt_url('bag-sales'))->with('success', $msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers\Admin;
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
use App\Models\CodeKindModel;
|
use App\Models\CodeKindModel;
|
||||||
use App\Models\CodeDetailModel;
|
use App\Models\CodeDetailModel;
|
||||||
|
use App\Models\LocalGovernmentModel;
|
||||||
|
use CodeIgniter\HTTP\RedirectResponse;
|
||||||
|
use Config\Roles;
|
||||||
|
|
||||||
class CodeDetail extends BaseController
|
class CodeDetail extends BaseController
|
||||||
{
|
{
|
||||||
@@ -17,39 +22,57 @@ class CodeDetail extends BaseController
|
|||||||
$this->detailModel = model(CodeDetailModel::class);
|
$this->detailModel = model(CodeDetailModel::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(int $ckIdx)
|
private function redirectIfCannotManageCodeMaster(): ?RedirectResponse
|
||||||
{
|
{
|
||||||
$kind = $this->kindModel->find($ckIdx);
|
if (! Roles::canManageCodeMaster((int) session()->get('mb_level'))) {
|
||||||
if ($kind === null) {
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 관리 권한이 없습니다.');
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->detailModel->getByKind($ckIdx);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
/** @deprecated 사이트 URL 유지용 — 세부 목록은 /bag/code-details/{ck_idx} */
|
||||||
'title' => '세부코드 관리 — ' . $kind->ck_name . ' (' . $kind->ck_code . ')',
|
public function index(int $ckIdx): RedirectResponse
|
||||||
'content' => view('admin/code_detail/index', [
|
{
|
||||||
'kind' => $kind,
|
return redirect()->to(site_url('bag/code-details/' . $ckIdx));
|
||||||
'list' => $list,
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(int $ckIdx)
|
public function create(int $ckIdx)
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$kind = $this->kindModel->find($ckIdx);
|
$kind = $this->kindModel->find($ckIdx);
|
||||||
if ($kind === null) {
|
if ($kind === null) {
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
$canPlatformScope = Roles::isSuperAdminEquivalent($level);
|
||||||
|
$govs = $canPlatformScope
|
||||||
|
? model(LocalGovernmentModel::class)->where('lg_state', 1)->orderBy('lg_name', 'ASC')->findAll()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '세부코드 등록 — ' . $kind->ck_name,
|
'title' => '세부코드 등록 — ' . $kind->ck_name,
|
||||||
'content' => view('admin/code_detail/create', ['kind' => $kind]),
|
'content' => view('admin/code_detail/create', [
|
||||||
|
'kind' => $kind,
|
||||||
|
'canPlatformScope' => $canPlatformScope,
|
||||||
|
'localGovernments' => $govs,
|
||||||
|
'effectiveLgIdx' => admin_effective_lg_idx(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'cd_ck_idx' => 'required|is_natural_no_zero',
|
'cd_ck_idx' => 'required|is_natural_no_zero',
|
||||||
'cd_code' => 'required|max_length[50]',
|
'cd_code' => 'required|max_length[50]',
|
||||||
@@ -62,24 +85,73 @@ class CodeDetail extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$ckIdx = (int) $this->request->getPost('cd_ck_idx');
|
$ckIdx = (int) $this->request->getPost('cd_ck_idx');
|
||||||
|
$kind = $this->kindModel->find($ckIdx);
|
||||||
|
if ($kind === null) {
|
||||||
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
|
||||||
|
if (Roles::isSuperAdminEquivalent($level)) {
|
||||||
|
$scope = $this->request->getPost('cd_scope') === 'local' ? 'local' : 'platform';
|
||||||
|
if ($scope === 'platform') {
|
||||||
|
$cdSource = 'platform';
|
||||||
|
$cdLgIdx = 0;
|
||||||
|
} else {
|
||||||
|
$cdLgIdx = (int) $this->request->getPost('cd_lg_idx');
|
||||||
|
if ($cdLgIdx < 1) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '지자체 전용인 경우 소속 지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
$gov = model(LocalGovernmentModel::class)->find($cdLgIdx);
|
||||||
|
if ($gov === null) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '유효하지 않은 지자체입니다.');
|
||||||
|
}
|
||||||
|
$cdSource = 'local';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$lg = admin_effective_lg_idx();
|
||||||
|
if ($lg === null || (int) $lg < 1) {
|
||||||
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '지자체를 선택한 뒤 등록해 주세요.');
|
||||||
|
}
|
||||||
|
$cdSource = 'local';
|
||||||
|
$cdLgIdx = (int) $lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cdCode = (string) $this->request->getPost('cd_code');
|
||||||
|
$dup = $this->detailModel->where('cd_ck_idx', $ckIdx)->where('cd_code', $cdCode)->where('cd_lg_idx', $cdLgIdx)->first();
|
||||||
|
if ($dup !== null) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '같은 종류·코드값·소속 범위에 이미 등록된 행이 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->detailModel->insert([
|
$this->detailModel->insert([
|
||||||
'cd_ck_idx' => $ckIdx,
|
'cd_ck_idx' => $ckIdx,
|
||||||
'cd_code' => $this->request->getPost('cd_code'),
|
'cd_source' => $cdSource,
|
||||||
|
'cd_lg_idx' => $cdLgIdx,
|
||||||
|
'cd_code' => $cdCode,
|
||||||
'cd_name' => $this->request->getPost('cd_name'),
|
'cd_name' => $this->request->getPost('cd_name'),
|
||||||
'cd_sort' => (int) ($this->request->getPost('cd_sort') ?: 0),
|
'cd_sort' => (int) ($this->request->getPost('cd_sort') ?: 0),
|
||||||
'cd_state' => 1,
|
'cd_state' => 1,
|
||||||
'cd_regdate' => date('Y-m-d H:i:s'),
|
'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)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$item = $this->detailModel->find($id);
|
$item = $this->detailModel->find($id);
|
||||||
if ($item === null) {
|
if ($item === null) {
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
if (! Roles::canEditCodeDetailRow((int) session()->get('mb_level'), $item, admin_effective_lg_idx())) {
|
||||||
|
return redirect()->to(site_url('bag/code-details/' . $item->cd_ck_idx))->with('error', '이 세부코드를 수정할 권한이 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$kind = $this->kindModel->find($item->cd_ck_idx);
|
$kind = $this->kindModel->find($item->cd_ck_idx);
|
||||||
@@ -95,9 +167,18 @@ class CodeDetail extends BaseController
|
|||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$item = $this->detailModel->find($id);
|
$item = $this->detailModel->find($id);
|
||||||
if ($item === null) {
|
if ($item === null) {
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
if (! Roles::canEditCodeDetailRow((int) session()->get('mb_level'), $item, admin_effective_lg_idx())) {
|
||||||
|
return redirect()->to(site_url('bag/code-details/' . $item->cd_ck_idx))->with('error', '이 세부코드를 수정할 권한이 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -116,19 +197,28 @@ class CodeDetail extends BaseController
|
|||||||
'cd_state' => (int) $this->request->getPost('cd_state'),
|
'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)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$item = $this->detailModel->find($id);
|
$item = $this->detailModel->find($id);
|
||||||
if ($item === null) {
|
if ($item === null) {
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
if (! Roles::canEditCodeDetailRow((int) session()->get('mb_level'), $item, admin_effective_lg_idx())) {
|
||||||
|
return redirect()->to(site_url('bag/code-details/' . $item->cd_ck_idx))->with('error', '이 세부코드를 삭제할 권한이 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$ckIdx = $item->cd_ck_idx;
|
$ckIdx = $item->cd_ck_idx;
|
||||||
$this->detailModel->delete($id);
|
$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', '세부코드가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers\Admin;
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
use App\Models\CodeKindModel;
|
use App\Models\CodeKindModel;
|
||||||
use App\Models\CodeDetailModel;
|
use App\Models\CodeDetailModel;
|
||||||
|
use CodeIgniter\HTTP\RedirectResponse;
|
||||||
use Config\Roles;
|
use Config\Roles;
|
||||||
|
|
||||||
class CodeKind extends BaseController
|
class CodeKind extends BaseController
|
||||||
@@ -16,28 +19,21 @@ class CodeKind extends BaseController
|
|||||||
$this->kindModel = model(CodeKindModel::class);
|
$this->kindModel = model(CodeKindModel::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
private function redirectIfCannotManageCodeKindMaster(): ?RedirectResponse
|
||||||
{
|
{
|
||||||
$list = $this->kindModel->orderBy('ck_code', 'ASC')->findAll();
|
if (! Roles::canManageCodeKindMaster((int) session()->get('mb_level'))) {
|
||||||
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류는 super admin·본부 관리자만 관리할 수 있습니다.');
|
||||||
// 세부코드 수 매핑
|
|
||||||
$detailModel = model(CodeDetailModel::class);
|
|
||||||
$countMap = [];
|
|
||||||
foreach ($list as $row) {
|
|
||||||
$countMap[$row->ck_idx] = $detailModel->where('cd_ck_idx', $row->ck_idx)->countAllResults(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return null;
|
||||||
'title' => '기본코드 종류 관리',
|
|
||||||
'content' => view('admin/code_kind/index', [
|
|
||||||
'list' => $list,
|
|
||||||
'countMap' => $countMap,
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '기본코드 종류 등록',
|
'title' => '기본코드 종류 등록',
|
||||||
'content' => view('admin/code_kind/create'),
|
'content' => view('admin/code_kind/create'),
|
||||||
@@ -46,6 +42,10 @@ class CodeKind extends BaseController
|
|||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'ck_code' => 'required|max_length[20]|is_unique[code_kind.ck_code]',
|
'ck_code' => 'required|max_length[20]|is_unique[code_kind.ck_code]',
|
||||||
'ck_name' => 'required|max_length[100]',
|
'ck_name' => 'required|max_length[100]',
|
||||||
@@ -62,14 +62,18 @@ class CodeKind extends BaseController
|
|||||||
'ck_regdate' => date('Y-m-d H:i:s'),
|
'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)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$item = $this->kindModel->find($id);
|
$item = $this->kindModel->find($id);
|
||||||
if ($item === null) {
|
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', [
|
return view('admin/layout', [
|
||||||
@@ -80,9 +84,13 @@ class CodeKind extends BaseController
|
|||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
|
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
$item = $this->kindModel->find($id);
|
$item = $this->kindModel->find($id);
|
||||||
if ($item === null) {
|
if ($item === null) {
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -99,24 +107,28 @@ class CodeKind extends BaseController
|
|||||||
'ck_state' => (int) $this->request->getPost('ck_state'),
|
'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)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
$item = $this->kindModel->find($id);
|
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
|
||||||
if ($item === null) {
|
return $r;
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
}
|
||||||
|
|
||||||
|
$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();
|
$detailCount = model(CodeDetailModel::class)->where('cd_ck_idx', $id)->countAllResults();
|
||||||
if ($detailCount > 0) {
|
if ($detailCount > 0) {
|
||||||
return redirect()->to(site_url('admin/code-kinds'))
|
return redirect()->to(site_url('bag/code-kinds'))
|
||||||
->with('error', '세부코드가 ' . $detailCount . '개 존재하여 삭제할 수 없습니다. 먼저 세부코드를 삭제해 주세요.');
|
->with('error', '세부코드가 ' . $detailCount . '개 존재하여 삭제할 수 없습니다. 먼저 세부코드를 삭제해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->kindModel->delete($id);
|
$this->kindModel->delete($id);
|
||||||
return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ class Company extends BaseController
|
|||||||
{
|
{
|
||||||
private CompanyModel $model;
|
private CompanyModel $model;
|
||||||
|
|
||||||
|
private function companyTypeOptions(): array
|
||||||
|
{
|
||||||
|
return ['협회', '제작업체', '회수업체'];
|
||||||
|
}
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->model = model(CompanyModel::class);
|
$this->model = model(CompanyModel::class);
|
||||||
@@ -18,24 +23,38 @@ class Company extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (! $lgIdx) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->model->where('cp_lg_idx', $lgIdx)->orderBy('cp_idx', 'DESC')->findAll();
|
$companyType = trim((string) ($this->request->getGet('cp_type') ?? ''));
|
||||||
|
$typeOptions = $this->companyTypeOptions();
|
||||||
|
|
||||||
return view('admin/layout', [
|
$builder = $this->model->where('cp_lg_idx', $lgIdx);
|
||||||
'title' => '업체 관리',
|
if ($companyType !== '' && in_array($companyType, $typeOptions, true)) {
|
||||||
'content' => view('admin/company/index', ['list' => $list]),
|
$builder->where('cp_type', $companyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->orderBy('cp_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->model->pager;
|
||||||
|
|
||||||
|
$queryForPager = [];
|
||||||
|
if ($companyType !== '' && in_array($companyType, $typeOptions, true)) {
|
||||||
|
$queryForPager['cp_type'] = $companyType;
|
||||||
|
}
|
||||||
|
apply_pager_path($pager, mgmt_path('companies'), $queryForPager);
|
||||||
|
|
||||||
|
return $this->renderWorkPage('업체 관리', 'admin/company/index', [
|
||||||
|
'list' => $list,
|
||||||
|
'pager' => $pager,
|
||||||
|
'cpType' => $companyType,
|
||||||
|
'typeOptions' => $typeOptions,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('업체 등록', 'admin/company/create');
|
||||||
'title' => '업체 등록',
|
|
||||||
'content' => view('admin/company/create'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -65,29 +84,26 @@ class Company extends BaseController
|
|||||||
'cp_regdate' => date('Y-m-d H:i:s'),
|
'cp_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/companies'))->with('success', '업체가 등록되었습니다.');
|
return redirect()->to(mgmt_url('companies'))->with('success', '업체가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/companies'))->with('error', '업체를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('companies'))->with('error', '업체를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('업체 수정', 'admin/company/edit', ['item' => $item]);
|
||||||
'title' => '업체 수정',
|
|
||||||
'content' => view('admin/company/edit', ['item' => $item]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/companies'))->with('error', '업체를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('companies'))->with('error', '업체를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -109,18 +125,19 @@ class Company extends BaseController
|
|||||||
'cp_state' => (int) $this->request->getPost('cp_state'),
|
'cp_state' => (int) $this->request->getPost('cp_state'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/companies'))->with('success', '업체가 수정되었습니다.');
|
return redirect()->to(mgmt_url('companies'))->with('success', '업체가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/companies'))->with('error', '업체를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('companies'))->with('error', '업체를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->model->delete($id);
|
$this->model->delete($id);
|
||||||
return redirect()->to(site_url('admin/companies'))->with('success', '업체가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('companies'))->with('success', '업체가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,104 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers\Admin;
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
|
use CodeIgniter\Database\Exceptions\DatabaseException;
|
||||||
|
|
||||||
class Dashboard extends BaseController
|
class Dashboard extends BaseController
|
||||||
{
|
{
|
||||||
public function index(): string
|
public function index(): string
|
||||||
{
|
{
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'order_count' => 0,
|
||||||
|
'order_amount' => 0,
|
||||||
|
'sale_count' => 0,
|
||||||
|
'sale_amount' => 0,
|
||||||
|
'inventory_count' => 0,
|
||||||
|
'issue_count_month'=> 0,
|
||||||
|
'recent_orders' => [],
|
||||||
|
'recent_sales' => [],
|
||||||
|
'stats_unavailable'=> false,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($lgIdx) {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
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'
|
||||||
|
AND (bo.bo_uuid, bo.bo_version) IN (
|
||||||
|
SELECT bo_uuid, MAX(bo_version) FROM bag_order WHERE bo_lg_idx = ? GROUP BY bo_uuid
|
||||||
|
)
|
||||||
|
", [$lgIdx, $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);
|
||||||
|
|
||||||
|
// 현재 재고 품목 수
|
||||||
|
$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);
|
||||||
|
|
||||||
|
// 최근 발주 5건
|
||||||
|
$stats['recent_orders'] = $db->query("
|
||||||
|
SELECT bo_idx, bo_lot_no, bo_order_date, bo_status
|
||||||
|
FROM bag_order
|
||||||
|
WHERE bo_lg_idx = ?
|
||||||
|
AND (bo_uuid, bo_version) IN (
|
||||||
|
SELECT bo_uuid, MAX(bo_version) FROM bag_order WHERE bo_lg_idx = ? GROUP BY bo_uuid
|
||||||
|
)
|
||||||
|
ORDER BY bo_order_date DESC, bo_idx DESC
|
||||||
|
LIMIT 5
|
||||||
|
", [$lgIdx, $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', [
|
return view('admin/layout', [
|
||||||
'title' => '대시보드',
|
'title' => '대시보드',
|
||||||
'content' => view('admin/dashboard/index'),
|
'content' => view('admin/dashboard/index', ['stats' => $stats, 'lgIdx' => $lgIdx]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,36 +16,73 @@ class FreeRecipient extends BaseController
|
|||||||
$this->model = model(FreeRecipientModel::class);
|
$this->model = model(FreeRecipientModel::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 무료용 대상 구분(스크린샷 기준): 사람뿐 아니라 동사무소 자체도 등록 가능.
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function recipientTypeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'office' => '읍.면.동 사무소',
|
||||||
|
'target' => '무료 대상자',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function getCodeOptions(string $ckCode): array
|
private function getCodeOptions(string $ckCode): array
|
||||||
{
|
{
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
|
||||||
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
|
||||||
|
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (! $lgIdx) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->model->where('fr_lg_idx', $lgIdx)->orderBy('fr_idx', 'DESC')->findAll();
|
$list = $this->model
|
||||||
|
->where('fr_lg_idx', $lgIdx)
|
||||||
|
->orderBy('fr_type_code', 'ASC')
|
||||||
|
->orderBy('fr_name', 'ASC')
|
||||||
|
->orderBy('fr_idx', 'DESC')
|
||||||
|
->paginate(20);
|
||||||
|
$pager = $this->model->pager;
|
||||||
|
$perPage = 20;
|
||||||
|
$currentPage = (int) ($pager->getCurrentPage() ?: 1);
|
||||||
|
$totalCount = (int) $this->model
|
||||||
|
->where('fr_lg_idx', $lgIdx)
|
||||||
|
->countAllResults();
|
||||||
|
$dongNameMap = [];
|
||||||
|
foreach ($this->getCodeOptions('D') as $dong) {
|
||||||
|
$code = (string) ($dong->cd_code ?? '');
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$dongNameMap[$code] = (string) ($dong->cd_name ?? $code);
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('무료용 대상자 관리', 'admin/free_recipient/index', [
|
||||||
'title' => '무료용 대상자 관리',
|
'list' => $list,
|
||||||
'content' => view('admin/free_recipient/index', ['list' => $list]),
|
'pager' => $pager,
|
||||||
|
'recipientTypeOptions' => $this->recipientTypeOptions(),
|
||||||
|
'dongNameMap' => $dongNameMap,
|
||||||
|
'totalCount' => $totalCount,
|
||||||
|
'currentPage' => $currentPage,
|
||||||
|
'perPage' => $perPage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('무료용 대상자 등록', 'admin/free_recipient/create', [
|
||||||
'title' => '무료용 대상자 등록',
|
'recipientTypeOptions' => $this->recipientTypeOptions(),
|
||||||
'content' => view('admin/free_recipient/create', [
|
|
||||||
'typeCodes' => $this->getCodeOptions('H'),
|
|
||||||
'dongCodes' => $this->getCodeOptions('D'),
|
'dongCodes' => $this->getCodeOptions('D'),
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,24 +111,21 @@ class FreeRecipient extends BaseController
|
|||||||
'fr_regdate' => date('Y-m-d H:i:s'),
|
'fr_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/free-recipients'))->with('success', '무료용 대상자가 등록되었습니다.');
|
return redirect()->to(mgmt_url('free-recipients'))->with('success', '무료용 대상자가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('무료용 대상자 수정', 'admin/free_recipient/edit', [
|
||||||
'title' => '무료용 대상자 수정',
|
|
||||||
'content' => view('admin/free_recipient/edit', [
|
|
||||||
'item' => $item,
|
'item' => $item,
|
||||||
'typeCodes' => $this->getCodeOptions('H'),
|
'recipientTypeOptions' => $this->recipientTypeOptions(),
|
||||||
'dongCodes' => $this->getCodeOptions('D'),
|
'dongCodes' => $this->getCodeOptions('D'),
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +133,8 @@ class FreeRecipient extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -122,18 +156,19 @@ class FreeRecipient extends BaseController
|
|||||||
'fr_state' => (int) $this->request->getPost('fr_state'),
|
'fr_state' => (int) $this->request->getPost('fr_state'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/free-recipients'))->with('success', '무료용 대상자가 수정되었습니다.');
|
return redirect()->to(mgmt_url('free-recipients'))->with('success', '무료용 대상자가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->model->delete($id);
|
$this->model->delete($id);
|
||||||
return redirect()->to(site_url('admin/free-recipients'))->with('success', '무료용 대상자가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('free-recipients'))->with('success', '무료용 대상자가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class LocalGovernment extends BaseController
|
|||||||
|
|
||||||
private function isSuperAdmin(): bool
|
private function isSuperAdmin(): bool
|
||||||
{
|
{
|
||||||
return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN;
|
return Roles::isSuperAdminEquivalent((int) session()->get('mb_level'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,14 +29,15 @@ class LocalGovernment extends BaseController
|
|||||||
{
|
{
|
||||||
if (! $this->isSuperAdmin()) {
|
if (! $this->isSuperAdmin()) {
|
||||||
return redirect()->to(site_url('admin'))
|
return redirect()->to(site_url('admin'))
|
||||||
->with('error', '지자체 관리는 super admin만 접근할 수 있습니다.');
|
->with('error', '지자체 관리는 상위 관리자만 접근할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->lgModel->orderBy('lg_idx', 'DESC')->findAll();
|
$list = $this->lgModel->orderBy('lg_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->lgModel->pager;
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '지자체 관리',
|
'title' => '지자체 관리',
|
||||||
'content' => view('admin/local_government/index', ['list' => $list]),
|
'content' => view('admin/local_government/index', ['list' => $list, 'pager' => $pager]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class LocalGovernment extends BaseController
|
|||||||
{
|
{
|
||||||
if (! $this->isSuperAdmin()) {
|
if (! $this->isSuperAdmin()) {
|
||||||
return redirect()->to(site_url('admin/local-governments'))
|
return redirect()->to(site_url('admin/local-governments'))
|
||||||
->with('error', '지자체 등록은 super admin만 가능합니다.');
|
->with('error', '지자체 등록은 상위 관리자만 가능합니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
@@ -63,7 +64,7 @@ class LocalGovernment extends BaseController
|
|||||||
{
|
{
|
||||||
if (! $this->isSuperAdmin()) {
|
if (! $this->isSuperAdmin()) {
|
||||||
return redirect()->to(site_url('admin/local-governments'))
|
return redirect()->to(site_url('admin/local-governments'))
|
||||||
->with('error', '지자체 등록은 super admin만 가능합니다.');
|
->with('error', '지자체 등록은 상위 관리자만 가능합니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
|
|||||||
@@ -18,8 +18,20 @@ class Manager extends BaseController
|
|||||||
|
|
||||||
private function getCodeOptions(string $ckCode): array
|
private function getCodeOptions(string $ckCode): array
|
||||||
{
|
{
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
|
||||||
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
|
||||||
|
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function managerCategoryOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'company' => '제작업체',
|
||||||
|
'district' => '구·군',
|
||||||
|
'agency' => '대행소',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
@@ -27,33 +39,44 @@ class Manager extends BaseController
|
|||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (!$lgIdx) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
helper('admin');
|
||||||
|
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->model->where('mg_lg_idx', $lgIdx)->orderBy('mg_idx', 'DESC')->findAll();
|
$category = (string) ($this->request->getGet('category') ?? '');
|
||||||
|
$categories = $this->managerCategoryOptions();
|
||||||
|
|
||||||
return view('admin/layout', [
|
$builder = $this->model->where('mg_lg_idx', $lgIdx);
|
||||||
'title' => '담당자 관리',
|
if ($category !== '' && isset($categories[$category])) {
|
||||||
'content' => view('admin/manager/index', ['list' => $list]),
|
$builder->where('mg_dept_code', $category);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->orderBy('mg_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->model->pager;
|
||||||
|
|
||||||
|
return $this->renderWorkPage('담당자 관리', 'admin/manager/index', [
|
||||||
|
'list' => $list,
|
||||||
|
'pager' => $pager,
|
||||||
|
'categories' => $categories,
|
||||||
|
'category' => $category,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('담당자 등록', 'admin/manager/create', [
|
||||||
'title' => '담당자 등록',
|
'categories' => $this->managerCategoryOptions(),
|
||||||
'content' => view('admin/manager/create', [
|
|
||||||
'deptCodes' => $this->getCodeOptions('S'),
|
|
||||||
'positionCodes' => $this->getCodeOptions('T'),
|
'positionCodes' => $this->getCodeOptions('T'),
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper(['admin', 'url']);
|
||||||
$rules = [
|
$rules = [
|
||||||
'mg_name' => 'required|max_length[50]',
|
'mg_name' => 'required|max_length[50]',
|
||||||
|
'mg_category' => 'required|in_list[company,district,agency]',
|
||||||
'mg_tel' => 'permit_empty|max_length[20]',
|
'mg_tel' => 'permit_empty|max_length[20]',
|
||||||
'mg_phone' => 'permit_empty|max_length[20]',
|
'mg_phone' => 'permit_empty|max_length[20]',
|
||||||
'mg_email' => 'permit_empty|valid_email|max_length[100]',
|
'mg_email' => 'permit_empty|valid_email|max_length[100]',
|
||||||
@@ -65,7 +88,7 @@ class Manager extends BaseController
|
|||||||
$this->model->insert([
|
$this->model->insert([
|
||||||
'mg_lg_idx' => admin_effective_lg_idx(),
|
'mg_lg_idx' => admin_effective_lg_idx(),
|
||||||
'mg_name' => $this->request->getPost('mg_name'),
|
'mg_name' => $this->request->getPost('mg_name'),
|
||||||
'mg_dept_code' => $this->request->getPost('mg_dept_code') ?? '',
|
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
|
||||||
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
||||||
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
||||||
'mg_phone' => $this->request->getPost('mg_phone') ?? '',
|
'mg_phone' => $this->request->getPost('mg_phone') ?? '',
|
||||||
@@ -74,37 +97,35 @@ class Manager extends BaseController
|
|||||||
'mg_regdate' => date('Y-m-d H:i:s'),
|
'mg_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/managers'))->with('success', '담당자가 등록되었습니다.');
|
return redirect()->to(mgmt_url('managers'))->with('success', '담당자가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper(['admin', 'url']);
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->mg_lg_idx !== admin_effective_lg_idx()) {
|
if (!$item || (int) $item->mg_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/managers'))->with('error', '담당자를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('managers'))->with('error', '담당자를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('담당자 수정', 'admin/manager/edit', [
|
||||||
'title' => '담당자 수정',
|
|
||||||
'content' => view('admin/manager/edit', [
|
|
||||||
'item' => $item,
|
'item' => $item,
|
||||||
'deptCodes' => $this->getCodeOptions('S'),
|
'categories' => $this->managerCategoryOptions(),
|
||||||
'positionCodes' => $this->getCodeOptions('T'),
|
'positionCodes' => $this->getCodeOptions('T'),
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper(['admin', 'url']);
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->mg_lg_idx !== admin_effective_lg_idx()) {
|
if (!$item || (int) $item->mg_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/managers'))->with('error', '담당자를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('managers'))->with('error', '담당자를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'mg_name' => 'required|max_length[50]',
|
'mg_name' => 'required|max_length[50]',
|
||||||
|
'mg_category' => 'required|in_list[company,district,agency]',
|
||||||
'mg_state' => 'required|in_list[0,1]',
|
'mg_state' => 'required|in_list[0,1]',
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules)) {
|
||||||
@@ -113,7 +134,7 @@ class Manager extends BaseController
|
|||||||
|
|
||||||
$this->model->update($id, [
|
$this->model->update($id, [
|
||||||
'mg_name' => $this->request->getPost('mg_name'),
|
'mg_name' => $this->request->getPost('mg_name'),
|
||||||
'mg_dept_code' => $this->request->getPost('mg_dept_code') ?? '',
|
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
|
||||||
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
||||||
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
||||||
'mg_phone' => $this->request->getPost('mg_phone') ?? '',
|
'mg_phone' => $this->request->getPost('mg_phone') ?? '',
|
||||||
@@ -121,18 +142,19 @@ class Manager extends BaseController
|
|||||||
'mg_state' => (int) $this->request->getPost('mg_state'),
|
'mg_state' => (int) $this->request->getPost('mg_state'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/managers'))->with('success', '담당자가 수정되었습니다.');
|
return redirect()->to(mgmt_url('managers'))->with('success', '담당자가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper(['admin', 'url']);
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->mg_lg_idx !== admin_effective_lg_idx()) {
|
if (!$item || (int) $item->mg_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/managers'))->with('error', '담당자를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('managers'))->with('error', '담당자를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->model->delete($id);
|
$this->model->delete($id);
|
||||||
return redirect()->to(site_url('admin/managers'))->with('success', '담당자가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('managers'))->with('success', '담당자가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,29 @@ class Menu extends BaseController
|
|||||||
$this->typeModel = model(MenuTypeModel::class);
|
$this->typeModel = model(MenuTypeModel::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 등록·수정·삭제·순서변경 후 항상 같은 메뉴 관리 화면(mt_idx 유지)으로 돌아간다.
|
||||||
|
* redirect()->back() 은 목록의 새 탭(target="_blank") 링크 클릭으로 세션 직전 URL(_ci_previous_url)이
|
||||||
|
* 메뉴 대상 페이지로 덮어써지면 그 페이지로 이탈하므로, 명시적으로 메뉴 화면 URL 을 사용한다.
|
||||||
|
*/
|
||||||
|
private function menusRedirect(int $mtIdx): \CodeIgniter\HTTP\RedirectResponse
|
||||||
|
{
|
||||||
|
$url = base_url('admin/menus');
|
||||||
|
if ($mtIdx > 0) {
|
||||||
|
$url .= '?mt_idx=' . $mtIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($url);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리.
|
* 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리.
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
|
if ($deny = $this->denyUnlessLevel4Plus()) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if ($lgIdx === null) {
|
if ($lgIdx === null) {
|
||||||
@@ -30,18 +48,39 @@ class Menu extends BaseController
|
|||||||
->with('error', '메뉴를 관리하려면 먼저 지자체를 선택하세요.');
|
->with('error', '메뉴를 관리하려면 먼저 지자체를 선택하세요.');
|
||||||
}
|
}
|
||||||
$types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
|
$types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
|
||||||
$mtIdx = (int) ($this->request->getGet('mt_idx') ?? 0);
|
$requestedMtIdx = (int) ($this->request->getGet('mt_idx') ?? 0);
|
||||||
if ($mtIdx <= 0 && ! empty($types)) {
|
$mtIdx = $this->resolveMtIdx($requestedMtIdx, $types);
|
||||||
// 기본 선택: 사이트 메뉴(mt_code=site), 없으면 첫 번째 타입
|
$effectiveMtIdx = $mtIdx;
|
||||||
$siteType = $this->typeModel->where('mt_code', 'site')->first();
|
$debugMode = $this->request->getGet('debug') === '1';
|
||||||
$mtIdx = $siteType ? (int) $siteType->mt_idx : (int) $types[0]->mt_idx;
|
$fallbackApplied = false;
|
||||||
}
|
$list = $effectiveMtIdx > 0 ? $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx) : [];
|
||||||
$list = $mtIdx > 0 ? $this->menuModel->getAllByType($mtIdx, $lgIdx) : [];
|
$currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
|
||||||
|
$currentTypeCode = (string) ($currentType->mt_code ?? '');
|
||||||
|
|
||||||
// 현재 지자체에 메뉴가 없으면, mt_idx별로 기본 지자체(lg_idx=1)의 메뉴를 한 번 복사한다.
|
// 현재 지자체에 메뉴가 없으면, mt_idx별로 기본 지자체(lg_idx=1)의 메뉴를 한 번 복사한다.
|
||||||
if ($mtIdx > 0 && empty($list)) {
|
if ($effectiveMtIdx > 0 && empty($list)) {
|
||||||
$this->menuModel->copyDefaultsFromLg($mtIdx, 1, $lgIdx);
|
$this->menuModel->copyDefaultsFromLg($effectiveMtIdx, 1, $lgIdx);
|
||||||
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
|
$list = $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 운영 DB 불일치 대응: site 타입인데 mt_idx 매핑이 어긋난 경우(예: menu_type=2, menu는 4 사용)
|
||||||
|
if (empty($list) && $currentTypeCode === 'site' && $effectiveMtIdx !== 4) {
|
||||||
|
$fallbackMtIdx = 4;
|
||||||
|
$fallbackList = $this->menuModel->getAllByType($fallbackMtIdx, $lgIdx);
|
||||||
|
if (empty($fallbackList)) {
|
||||||
|
$this->menuModel->copyDefaultsFromLg($fallbackMtIdx, 1, $lgIdx);
|
||||||
|
$fallbackList = $this->menuModel->getAllByType($fallbackMtIdx, $lgIdx);
|
||||||
|
}
|
||||||
|
if (! empty($fallbackList)) {
|
||||||
|
$effectiveMtIdx = $fallbackMtIdx;
|
||||||
|
$list = $fallbackList;
|
||||||
|
$fallbackApplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($effectiveMtIdx > 0 && $currentTypeCode === 'site') {
|
||||||
|
$this->menuModel->pruneInventoryManagementMenus($effectiveMtIdx, $lgIdx);
|
||||||
|
$list = $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화
|
// 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화
|
||||||
@@ -50,16 +89,24 @@ class Menu extends BaseController
|
|||||||
$list = flatten_menu_tree($tree);
|
$list = flatten_menu_tree($tree);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
|
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '메뉴 관리',
|
'title' => '메뉴 관리',
|
||||||
'content' => view('admin/menu/index', [
|
'content' => view('admin/menu/index', [
|
||||||
'types' => $types,
|
'types' => $types,
|
||||||
'mtIdx' => $mtIdx,
|
'mtIdx' => $mtIdx,
|
||||||
'mtCode' => $currentType->mt_code ?? '',
|
'mtCode' => $currentTypeCode,
|
||||||
'list' => $list,
|
'list' => $list,
|
||||||
'levelNames' => config('Roles')->levelNames,
|
'levelNames' => config('Roles')->levelNames,
|
||||||
|
'debug_mode' => $debugMode,
|
||||||
|
'debug_info' => [
|
||||||
|
'lg_idx' => $lgIdx,
|
||||||
|
'requested_mt_idx' => $requestedMtIdx,
|
||||||
|
'resolved_mt_idx' => $mtIdx,
|
||||||
|
'effective_mt_idx' => $effectiveMtIdx,
|
||||||
|
'resolved_mt_code' => $currentTypeCode,
|
||||||
|
'list_count' => count($list),
|
||||||
|
'fallback_applied' => $fallbackApplied ? 'Y' : 'N',
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -69,14 +116,23 @@ class Menu extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function list()
|
public function list()
|
||||||
{
|
{
|
||||||
|
if ($deny = $this->denyUnlessLevel4Plus(true)) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if ($lgIdx === null) {
|
if ($lgIdx === null) {
|
||||||
return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']);
|
return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']);
|
||||||
}
|
}
|
||||||
$mtIdx = (int) $this->request->getGet('mt_idx');
|
$types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
|
||||||
|
$requestedMtIdx = (int) $this->request->getGet('mt_idx');
|
||||||
|
$mtIdx = $this->resolveMtIdx($requestedMtIdx, $types);
|
||||||
if ($mtIdx <= 0) {
|
if ($mtIdx <= 0) {
|
||||||
return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']);
|
return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']);
|
||||||
}
|
}
|
||||||
|
$type = $this->typeModel->find($mtIdx);
|
||||||
|
if ($type && (string) ($type->mt_code ?? '') === 'site') {
|
||||||
|
$this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
|
||||||
|
}
|
||||||
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
|
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
|
||||||
return $this->response->setJSON(['status' => 1, 'data' => $list]);
|
return $this->response->setJSON(['status' => 1, 'data' => $list]);
|
||||||
}
|
}
|
||||||
@@ -86,6 +142,9 @@ class Menu extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
|
if ($deny = $this->denyUnlessLevel4Plus()) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if ($lgIdx === null) {
|
if ($lgIdx === null) {
|
||||||
return redirect()->to(base_url('admin/select-local-government'))
|
return redirect()->to(base_url('admin/select-local-government'))
|
||||||
@@ -96,10 +155,10 @@ class Menu extends BaseController
|
|||||||
$mmDep = (int) $this->request->getPost('mm_dep');
|
$mmDep = (int) $this->request->getPost('mm_dep');
|
||||||
$mmName = trim((string) $this->request->getPost('mm_name'));
|
$mmName = trim((string) $this->request->getPost('mm_name'));
|
||||||
if ($mtIdx <= 0) {
|
if ($mtIdx <= 0) {
|
||||||
return redirect()->back()->with('error', '메뉴 종류를 선택하세요.');
|
return $this->menusRedirect($mtIdx)->with('error', '메뉴 종류를 선택하세요.');
|
||||||
}
|
}
|
||||||
if ($mmName === '') {
|
if ($mmName === '') {
|
||||||
return redirect()->back()->with('error', '메뉴명을 입력하세요.');
|
return $this->menusRedirect($mtIdx)->with('error', '메뉴명을 입력하세요.');
|
||||||
}
|
}
|
||||||
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
|
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
|
||||||
$data = [
|
$data = [
|
||||||
@@ -118,7 +177,9 @@ class Menu extends BaseController
|
|||||||
if ($mmPidx > 0) {
|
if ($mmPidx > 0) {
|
||||||
$this->menuModel->updateCnode($mmPidx, 1);
|
$this->menuModel->updateCnode($mmPidx, 1);
|
||||||
}
|
}
|
||||||
return redirect()->back()->with('success', '메뉴가 등록되었습니다.');
|
$this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
|
||||||
|
$this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx);
|
||||||
|
return $this->menusRedirect($mtIdx)->with('success', '메뉴가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +187,9 @@ class Menu extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
|
if ($deny = $this->denyUnlessLevel4Plus()) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if ($lgIdx === null) {
|
if ($lgIdx === null) {
|
||||||
return redirect()->to(base_url('admin/select-local-government'))
|
return redirect()->to(base_url('admin/select-local-government'))
|
||||||
@@ -133,10 +197,12 @@ class Menu extends BaseController
|
|||||||
}
|
}
|
||||||
$row = $this->menuModel->find($id);
|
$row = $this->menuModel->find($id);
|
||||||
if (! $row) {
|
if (! $row) {
|
||||||
return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.');
|
return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
|
||||||
|
->with('error', '메뉴를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
if ((int) $row->lg_idx !== $lgIdx) {
|
if ((int) $row->lg_idx !== $lgIdx) {
|
||||||
return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
|
return $this->menusRedirect((int) $row->mt_idx)
|
||||||
|
->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
|
||||||
}
|
}
|
||||||
$data = [
|
$data = [
|
||||||
'mm_name' => (string) $this->request->getPost('mm_name'),
|
'mm_name' => (string) $this->request->getPost('mm_name'),
|
||||||
@@ -145,7 +211,9 @@ class Menu extends BaseController
|
|||||||
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
|
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
|
||||||
];
|
];
|
||||||
$this->menuModel->update($id, $data);
|
$this->menuModel->update($id, $data);
|
||||||
return redirect()->back()->with('success', '메뉴가 수정되었습니다.');
|
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
|
||||||
|
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
|
||||||
|
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +221,9 @@ class Menu extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
|
if ($deny = $this->denyUnlessLevel4Plus()) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if ($lgIdx === null) {
|
if ($lgIdx === null) {
|
||||||
return redirect()->to(base_url('admin/select-local-government'))
|
return redirect()->to(base_url('admin/select-local-government'))
|
||||||
@@ -160,13 +231,16 @@ class Menu extends BaseController
|
|||||||
}
|
}
|
||||||
$row = $this->menuModel->find($id);
|
$row = $this->menuModel->find($id);
|
||||||
if (! $row || (int) $row->lg_idx !== $lgIdx) {
|
if (! $row || (int) $row->lg_idx !== $lgIdx) {
|
||||||
return redirect()->back()->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.');
|
return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
|
||||||
|
->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.');
|
||||||
}
|
}
|
||||||
$result = $this->menuModel->deleteSafe($id);
|
$result = $this->menuModel->deleteSafe($id);
|
||||||
if ($result['ok']) {
|
if ($result['ok']) {
|
||||||
return redirect()->back()->with('success', '메뉴가 삭제되었습니다.');
|
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
|
||||||
|
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
|
||||||
|
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
return redirect()->back()->with('error', $result['msg']);
|
return $this->menusRedirect((int) $row->mt_idx)->with('error', $result['msg']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,17 +248,28 @@ class Menu extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function move()
|
public function move()
|
||||||
{
|
{
|
||||||
|
if ($deny = $this->denyUnlessLevel4Plus()) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if ($lgIdx === null) {
|
if ($lgIdx === null) {
|
||||||
return redirect()->to(base_url('admin/select-local-government'))
|
return redirect()->to(base_url('admin/select-local-government'))
|
||||||
->with('error', '지자체를 선택하세요.');
|
->with('error', '지자체를 선택하세요.');
|
||||||
}
|
}
|
||||||
$ids = $this->request->getPost('mm_idx');
|
$ids = $this->request->getPost('mm_idx');
|
||||||
|
$postMtIdx = (int) $this->request->getPost('mt_idx');
|
||||||
if (! is_array($ids) || empty($ids)) {
|
if (! is_array($ids) || empty($ids)) {
|
||||||
return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.');
|
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
|
||||||
}
|
}
|
||||||
|
$firstId = (int) ($ids[0] ?? 0);
|
||||||
|
$firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null;
|
||||||
$this->menuModel->setOrder($ids, $lgIdx);
|
$this->menuModel->setOrder($ids, $lgIdx);
|
||||||
return redirect()->back()->with('success', '순서가 적용되었습니다.');
|
if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
|
||||||
|
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
|
||||||
|
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
|
||||||
|
}
|
||||||
|
$mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
|
||||||
|
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,9 +290,62 @@ class Menu extends BaseController
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$levels = array_map('intval', $levels);
|
$levels = array_map('intval', $levels);
|
||||||
// super admin(4)은 DB 저장 대상 아님. 1,2,3은 그대로 저장
|
// super/본부(4·5)는 mm_level 저장 대상 아님. 1,2,3은 그대로 저장
|
||||||
$levels = array_filter($levels, static fn ($v) => $v > 0 && $v !== \Config\Roles::LEVEL_SUPER_ADMIN);
|
$levels = array_filter($levels, static fn ($v) => $v > 0 && ! \Config\Roles::isSuperAdminEquivalent($v));
|
||||||
|
|
||||||
return implode(',', array_values($levels));
|
return implode(',', array_values($levels));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청된 mt_idx를 현재 DB 상태에 맞게 보정.
|
||||||
|
* - 유효한 mt_idx면 그대로 사용
|
||||||
|
* - 레거시 site 값(2) 요청 시 site 타입의 실제 mt_idx로 치환
|
||||||
|
* - 그 외 미지정/잘못된 값은 site 우선, 없으면 첫 타입으로 보정
|
||||||
|
*
|
||||||
|
* @param array<int,object> $types
|
||||||
|
*/
|
||||||
|
private function resolveMtIdx(int $requestedMtIdx, array $types): int
|
||||||
|
{
|
||||||
|
if (empty($types)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validTypeIds = array_map(static fn ($t): int => (int) ($t->mt_idx ?? 0), $types);
|
||||||
|
if ($requestedMtIdx > 0 && in_array($requestedMtIdx, $validTypeIds, true)) {
|
||||||
|
return $requestedMtIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteType = $this->typeModel->where('mt_code', 'site')->first();
|
||||||
|
if ($siteType !== null) {
|
||||||
|
// 과거 링크(/admin/menus?mt_idx=2) 호환
|
||||||
|
if ($requestedMtIdx === 2 || $requestedMtIdx <= 0 || ! in_array($requestedMtIdx, $validTypeIds, true)) {
|
||||||
|
return (int) $siteType->mt_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $types[0]->mt_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 관리는 레벨4 이상(슈퍼/본부 관리자)만 허용.
|
||||||
|
*
|
||||||
|
* @return \CodeIgniter\HTTP\RedirectResponse|\CodeIgniter\HTTP\ResponseInterface|null
|
||||||
|
*/
|
||||||
|
private function denyUnlessLevel4Plus(bool $json = false)
|
||||||
|
{
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
if (Roles::isSuperAdminEquivalent($level)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($json) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'status' => 0,
|
||||||
|
'msg' => '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(base_url('admin'))
|
||||||
|
->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class PackagingUnit extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (! $lgIdx) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$builder = $this->unitModel->where('pu_lg_idx', $lgIdx);
|
$builder = $this->unitModel->where('pu_lg_idx', $lgIdx);
|
||||||
@@ -38,30 +38,26 @@ class PackagingUnit extends BaseController
|
|||||||
$builder->groupStart()->where('pu_end_date IS NULL')->orWhere('pu_end_date <=', $endDate)->groupEnd();
|
$builder->groupStart()->where('pu_end_date IS NULL')->orWhere('pu_end_date <=', $endDate)->groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $builder->orderBy('pu_bag_code', 'ASC')->orderBy('pu_start_date', 'DESC')->findAll();
|
$list = $builder->orderBy('pu_bag_code', 'ASC')->orderBy('pu_start_date', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->unitModel->pager;
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('포장 단위 관리', 'admin/packaging_unit/index', [
|
||||||
'title' => '포장 단위 관리',
|
'list' => $list, 'startDate' => $startDate, 'endDate' => $endDate, 'pager' => $pager,
|
||||||
'content' => view('admin/packaging_unit/index', [
|
|
||||||
'list' => $list, 'startDate' => $startDate, 'endDate' => $endDate,
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
if (!admin_effective_lg_idx()) {
|
if (! admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(mgmt_url('packaging-units'))->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('포장 단위 등록', 'admin/packaging_unit/create', ['bagCodes' => $bagCodes]);
|
||||||
'title' => '포장 단위 등록',
|
|
||||||
'content' => view('admin/packaging_unit/create', ['bagCodes' => $bagCodes]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -85,7 +81,7 @@ class PackagingUnit extends BaseController
|
|||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagName = '';
|
$bagName = '';
|
||||||
if ($kind) {
|
if ($kind) {
|
||||||
$detail = model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_code', $bagCode)->first();
|
$detail = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kind->ck_idx, (string) $bagCode, $lgIdx);
|
||||||
$bagName = $detail ? $detail->cd_name : '';
|
$bagName = $detail ? $detail->cd_name : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,32 +102,30 @@ class PackagingUnit extends BaseController
|
|||||||
'pu_reg_mb_idx' => session()->get('mb_idx'),
|
'pu_reg_mb_idx' => session()->get('mb_idx'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('success', '포장 단위가 등록되었습니다.');
|
return redirect()->to(mgmt_url('packaging-units'))->with('success', '포장 단위가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->unitModel->find($id);
|
$item = $this->unitModel->find($id);
|
||||||
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('포장 단위 수정', 'admin/packaging_unit/edit', ['item' => $item, 'bagCodes' => $bagCodes]);
|
||||||
'title' => '포장 단위 수정',
|
|
||||||
'content' => view('admin/packaging_unit/edit', ['item' => $item, 'bagCodes' => $bagCodes]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->unitModel->find($id);
|
$item = $this->unitModel->find($id);
|
||||||
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -149,18 +143,35 @@ class PackagingUnit extends BaseController
|
|||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$db->transStart();
|
$db->transStart();
|
||||||
|
|
||||||
$trackFields = ['pu_box_per_pack', 'pu_pack_per_sheet'];
|
$trackFields = ['pu_box_per_pack', 'pu_pack_per_sheet', 'pu_start_date', 'pu_end_date', 'pu_state'];
|
||||||
|
$fieldLabels = [
|
||||||
|
'pu_box_per_pack' => '박스당 팩 수',
|
||||||
|
'pu_pack_per_sheet' => '팩당 낱장 수',
|
||||||
|
'pu_start_date' => '적용시작일',
|
||||||
|
'pu_end_date' => '적용종료일',
|
||||||
|
'pu_state' => '상태',
|
||||||
|
];
|
||||||
foreach ($trackFields as $field) {
|
foreach ($trackFields as $field) {
|
||||||
$oldVal = (string) $item->$field;
|
$oldRaw = $item->$field;
|
||||||
$newVal = (string) $this->request->getPost($field);
|
$newRaw = $this->request->getPost($field);
|
||||||
|
if ($field === 'pu_end_date') {
|
||||||
|
$oldRaw = $oldRaw ?: '';
|
||||||
|
$newRaw = $newRaw ?: '';
|
||||||
|
}
|
||||||
|
if ($field === 'pu_state') {
|
||||||
|
$oldRaw = (int) $oldRaw === 1 ? '사용' : '미사용';
|
||||||
|
$newRaw = (int) $newRaw === 1 ? '사용' : '미사용';
|
||||||
|
}
|
||||||
|
$oldVal = (string) $oldRaw;
|
||||||
|
$newVal = (string) $newRaw;
|
||||||
if ($oldVal !== $newVal) {
|
if ($oldVal !== $newVal) {
|
||||||
$this->historyModel->insert([
|
$this->historyModel->insert([
|
||||||
'puh_pu_idx' => $id,
|
'puh_pu_idx' => $id,
|
||||||
'puh_field' => $field,
|
'puh_field' => $fieldLabels[$field] ?? $field,
|
||||||
'puh_old_value' => $oldVal,
|
'puh_old_value' => $oldVal,
|
||||||
'puh_new_value' => $newVal,
|
'puh_new_value' => $newVal,
|
||||||
'puh_changed_at'=> date('Y-m-d H:i:s'),
|
'puh_changed_at' => date('Y-m-d H:i:s'),
|
||||||
'puh_changed_by'=> session()->get('mb_idx'),
|
'puh_changed_by' => session()->get('mb_idx'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,34 +190,33 @@ class PackagingUnit extends BaseController
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('success', '포장 단위가 수정되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('packaging-units'))->with('success', '포장 단위가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->unitModel->find($id);
|
$item = $this->unitModel->find($id);
|
||||||
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->unitModel->delete($id);
|
$this->unitModel->delete($id);
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('success', '포장 단위가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('packaging-units'))->with('success', '포장 단위가 삭제되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(int $puIdx)
|
public function history(int $puIdx)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->unitModel->find($puIdx);
|
$item = $this->unitModel->find($puIdx);
|
||||||
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->historyModel->where('puh_pu_idx', $puIdx)->orderBy('puh_changed_at', 'DESC')->findAll();
|
$list = $this->historyModel->where('puh_pu_idx', $puIdx)->orderBy('puh_changed_at', 'DESC')->findAll();
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('포장 단위 변경 이력 — ' . $item->pu_bag_name, 'admin/packaging_unit/history', ['item' => $item, 'list' => $list]);
|
||||||
'title' => '포장 단위 변경 이력 — ' . $item->pu_bag_name,
|
|
||||||
'content' => view('admin/packaging_unit/history', ['item' => $item, 'list' => $list]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ class PasswordChange extends BaseController
|
|||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
return view('admin/layout', [
|
helper('admin');
|
||||||
'title' => '비밀번호 변경',
|
|
||||||
'content' => view('admin/password_change/index'),
|
return $this->renderWorkPage('비밀번호 변경', 'admin/password_change/index');
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update()
|
public function update()
|
||||||
{
|
{
|
||||||
|
helper('admin');
|
||||||
$rules = [
|
$rules = [
|
||||||
'current_password' => 'required',
|
'current_password' => 'required',
|
||||||
'new_password' => 'required|min_length[4]|max_length[255]',
|
'new_password' => 'required|min_length[4]|max_length[255]',
|
||||||
@@ -50,6 +50,6 @@ class PasswordChange extends BaseController
|
|||||||
'mb_passwd' => password_hash($this->request->getPost('new_password'), PASSWORD_DEFAULT),
|
'mb_passwd' => password_hash($this->request->getPost('new_password'), PASSWORD_DEFAULT),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/password-change'))->with('success', '비밀번호가 변경되었습니다.');
|
return redirect()->to(mgmt_url('password-change'))->with('success', '비밀번호가 변경되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers\Admin;
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
use App\Controllers\BaseController;
|
use App\Controllers\BaseController;
|
||||||
@@ -7,6 +9,8 @@ use App\Models\SalesAgencyModel;
|
|||||||
|
|
||||||
class SalesAgency extends BaseController
|
class SalesAgency extends BaseController
|
||||||
{
|
{
|
||||||
|
private const SCHEMA_ERROR = '판매 대행소 테이블에 sa_kind·sa_code 컬럼이 없습니다. DB에 writable/database/sales_agency_migrate_to_kind_code_name.sql(또는 신규용 sales_agency_tables.sql)을 적용한 뒤 다시 시도해 주세요.';
|
||||||
|
|
||||||
private SalesAgencyModel $model;
|
private SalesAgencyModel $model;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -18,105 +22,157 @@ class SalesAgency extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) {
|
if (! $lgIdx) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $this->model->where('sa_lg_idx', $lgIdx)->orderBy('sa_idx', 'DESC')->findAll();
|
$saKind = trim((string) ($this->request->getGet('sa_kind') ?? ''));
|
||||||
|
$saCode = trim((string) ($this->request->getGet('sa_code') ?? ''));
|
||||||
|
$saName = trim((string) ($this->request->getGet('sa_name') ?? ''));
|
||||||
|
$saIdx = trim((string) ($this->request->getGet('sa_idx') ?? ''));
|
||||||
|
|
||||||
return view('admin/layout', [
|
$builder = $this->model->where('sa_lg_idx', $lgIdx);
|
||||||
'title' => '판매 대행소 관리',
|
if ($saKind !== '') {
|
||||||
'content' => view('admin/sales_agency/index', ['list' => $list]),
|
$builder->like('sa_kind', $saKind);
|
||||||
|
}
|
||||||
|
if ($saCode !== '') {
|
||||||
|
$builder->like('sa_code', $saCode);
|
||||||
|
}
|
||||||
|
if ($saName !== '') {
|
||||||
|
$builder->like('sa_name', $saName);
|
||||||
|
}
|
||||||
|
if ($saIdx !== '' && ctype_digit($saIdx)) {
|
||||||
|
$builder->where('sa_idx', (int) $saIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->orderForDisplay()->paginate(20);
|
||||||
|
$pager = $this->model->pager;
|
||||||
|
|
||||||
|
$queryForPager = [
|
||||||
|
'sa_kind' => $saKind,
|
||||||
|
'sa_code' => $saCode,
|
||||||
|
'sa_name' => $saName,
|
||||||
|
'sa_idx' => $saIdx,
|
||||||
|
];
|
||||||
|
$queryForPager = array_filter($queryForPager, static fn ($v) => $v !== null && $v !== '');
|
||||||
|
apply_pager_path($pager, mgmt_path('sales-agencies'), $queryForPager);
|
||||||
|
|
||||||
|
return $this->renderWorkPage('판매 대행소 관리', 'admin/sales_agency/index', [
|
||||||
|
'list' => $list,
|
||||||
|
'pager' => $pager,
|
||||||
|
'sa_kind' => $saKind,
|
||||||
|
'sa_code' => $saCode,
|
||||||
|
'sa_name' => $saName,
|
||||||
|
'sa_idx' => $saIdx,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
return view('admin/layout', [
|
helper('admin');
|
||||||
'title' => '판매 대행소 등록',
|
if (! admin_effective_lg_idx()) {
|
||||||
'content' => view('admin/sales_agency/create'),
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
return $this->renderWorkPage('판매 대행소 등록', 'admin/sales_agency/create');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->model->hasKindCodeColumns()) {
|
||||||
|
return redirect()->back()->withInput()->with('error', self::SCHEMA_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
|
'sa_kind' => 'required|max_length[50]',
|
||||||
|
'sa_code' => 'required|max_length[50]',
|
||||||
'sa_name' => 'required|max_length[100]',
|
'sa_name' => 'required|max_length[100]',
|
||||||
'sa_biz_no' => 'permit_empty|max_length[20]',
|
|
||||||
'sa_rep_name' => 'permit_empty|max_length[50]',
|
|
||||||
'sa_tel' => 'permit_empty|max_length[20]',
|
|
||||||
'sa_addr' => 'permit_empty|max_length[255]',
|
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules)) {
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$code = trim((string) $this->request->getPost('sa_code'));
|
||||||
|
if ($this->model->where('sa_lg_idx', $lgIdx)->where('sa_code', $code)->first() !== null) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '동일 지자체에 같은 대행소 코드가 이미 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->model->insert([
|
$this->model->insert([
|
||||||
'sa_lg_idx' => admin_effective_lg_idx(),
|
'sa_lg_idx' => $lgIdx,
|
||||||
'sa_name' => $this->request->getPost('sa_name'),
|
'sa_kind' => trim((string) $this->request->getPost('sa_kind')),
|
||||||
'sa_biz_no' => $this->request->getPost('sa_biz_no') ?? '',
|
'sa_code' => $code,
|
||||||
'sa_rep_name' => $this->request->getPost('sa_rep_name') ?? '',
|
'sa_name' => trim((string) $this->request->getPost('sa_name')),
|
||||||
'sa_tel' => $this->request->getPost('sa_tel') ?? '',
|
|
||||||
'sa_addr' => $this->request->getPost('sa_addr') ?? '',
|
|
||||||
'sa_state' => 1,
|
|
||||||
'sa_regdate' => date('Y-m-d H:i:s'),
|
'sa_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/sales-agencies'))->with('success', '판매 대행소가 등록되었습니다.');
|
return redirect()->to(mgmt_url('sales-agencies'))->with('success', '판매 대행소가 등록되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('판매 대행소 수정', 'admin/sales_agency/edit', ['item' => $item]);
|
||||||
'title' => '판매 대행소 수정',
|
|
||||||
'content' => view('admin/sales_agency/edit', ['item' => $item]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || ! $lgIdx || (int) $item->sa_lg_idx !== $lgIdx) {
|
||||||
return redirect()->to(site_url('admin/sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->model->hasKindCodeColumns()) {
|
||||||
|
return redirect()->back()->withInput()->with('error', self::SCHEMA_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
|
'sa_kind' => 'required|max_length[50]',
|
||||||
|
'sa_code' => 'required|max_length[50]',
|
||||||
'sa_name' => 'required|max_length[100]',
|
'sa_name' => 'required|max_length[100]',
|
||||||
'sa_state' => 'required|in_list[0,1]',
|
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules)) {
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$code = trim((string) $this->request->getPost('sa_code'));
|
||||||
|
$dup = $this->model->where('sa_lg_idx', $lgIdx)->where('sa_code', $code)->where('sa_idx !=', $id)->first();
|
||||||
|
if ($dup !== null) {
|
||||||
|
return redirect()->back()->withInput()->with('error', '동일 지자체에 같은 대행소 코드가 이미 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->model->update($id, [
|
$this->model->update($id, [
|
||||||
'sa_name' => $this->request->getPost('sa_name'),
|
'sa_kind' => trim((string) $this->request->getPost('sa_kind')),
|
||||||
'sa_biz_no' => $this->request->getPost('sa_biz_no') ?? '',
|
'sa_code' => $code,
|
||||||
'sa_rep_name' => $this->request->getPost('sa_rep_name') ?? '',
|
'sa_name' => trim((string) $this->request->getPost('sa_name')),
|
||||||
'sa_tel' => $this->request->getPost('sa_tel') ?? '',
|
|
||||||
'sa_addr' => $this->request->getPost('sa_addr') ?? '',
|
|
||||||
'sa_state' => (int) $this->request->getPost('sa_state'),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/sales-agencies'))->with('success', '판매 대행소가 수정되었습니다.');
|
return redirect()->to(mgmt_url('sales-agencies'))->with('success', '판매 대행소가 수정되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(int $id)
|
public function delete(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
$item = $this->model->find($id);
|
$item = $this->model->find($id);
|
||||||
if (!$item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
|
if (! $item || ! $lgIdx || (int) $item->sa_lg_idx !== $lgIdx) {
|
||||||
return redirect()->to(site_url('admin/sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->model->delete($id);
|
$this->model->delete($id);
|
||||||
return redirect()->to(site_url('admin/sales-agencies'))->with('success', '판매 대행소가 삭제되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('sales-agencies'))->with('success', '삭제되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,12 @@ use Config\Roles;
|
|||||||
class SelectLocalGovernment extends BaseController
|
class SelectLocalGovernment extends BaseController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 지자체 선택 화면 (super admin 전용)
|
* 지자체 선택 화면 (super·본부 관리자)
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
|
if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
|
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = model(LocalGovernmentModel::class)
|
$list = model(LocalGovernmentModel::class)
|
||||||
@@ -35,8 +35,8 @@ class SelectLocalGovernment extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
|
if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
|
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$lgIdx = (int) $this->request->getPost('lg_idx');
|
$lgIdx = (int) $this->request->getPost('lg_idx');
|
||||||
|
|||||||
@@ -26,36 +26,52 @@ class ShopOrder extends BaseController
|
|||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$builder = $this->orderModel->where('so_lg_idx', $lgIdx);
|
$builder = $this->orderModel->where('so_lg_idx', $lgIdx);
|
||||||
$startDate = $this->request->getGet('start_date');
|
$startDate = $this->request->getGet('start_date');
|
||||||
$endDate = $this->request->getGet('end_date');
|
$endDate = $this->request->getGet('end_date');
|
||||||
if ($startDate) $builder->where('so_delivery_date >=', $startDate);
|
if ($startDate) {
|
||||||
if ($endDate) $builder->where('so_delivery_date <=', $endDate);
|
$builder->where('so_delivery_date >=', $startDate);
|
||||||
|
}
|
||||||
|
if ($endDate) {
|
||||||
|
$builder->where('so_delivery_date <=', $endDate);
|
||||||
|
}
|
||||||
|
|
||||||
$list = $builder->orderBy('so_idx', 'DESC')->findAll();
|
$list = $builder->orderBy('so_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->orderModel->pager;
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('주문 접수 관리', 'admin/shop_order/index', compact('list', 'startDate', 'endDate', 'pager'));
|
||||||
'title' => '주문 접수 관리',
|
|
||||||
'content' => view('admin/shop_order/index', compact('list', 'startDate', 'endDate')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$lgIdx = admin_effective_lg_idx();
|
$lgIdx = admin_effective_lg_idx();
|
||||||
if (!$lgIdx) return redirect()->to(site_url('admin/shop-orders'))->with('error', '지자체를 선택해 주세요.');
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(mgmt_url('shop-orders'))->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll();
|
$shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll();
|
||||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
|
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||||
|
$priceMap = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx);
|
||||||
|
$unitRows = model(PackagingUnitModel::class)
|
||||||
|
->where('pu_lg_idx', $lgIdx)
|
||||||
|
->where('pu_state', 1)
|
||||||
|
->findAll();
|
||||||
|
$unitMap = [];
|
||||||
|
foreach ($unitRows as $unit) {
|
||||||
|
$code = (string) ($unit->pu_bag_code ?? '');
|
||||||
|
if ($code === '' || isset($unitMap[$code])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$unitMap[$code] = $unit;
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('주문 접수', 'admin/shop_order/create', compact('shops', 'bagCodes', 'priceMap', 'unitMap'));
|
||||||
'title' => '주문 접수',
|
|
||||||
'content' => view('admin/shop_order/create', compact('shops', 'bagCodes')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -65,7 +81,7 @@ class ShopOrder extends BaseController
|
|||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'so_ds_idx' => 'required|is_natural_no_zero',
|
'so_ds_idx' => 'required|is_natural_no_zero',
|
||||||
'so_delivery_date'=> 'required|valid_date[Y-m-d]',
|
'so_delivery_date' => 'required|valid_date[Y-m-d]',
|
||||||
'so_payment_type' => 'required|in_list[이체,가상계좌]',
|
'so_payment_type' => 'required|in_list[이체,가상계좌]',
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules)) {
|
||||||
@@ -78,33 +94,54 @@ class ShopOrder extends BaseController
|
|||||||
$dsIdx = (int) $this->request->getPost('so_ds_idx');
|
$dsIdx = (int) $this->request->getPost('so_ds_idx');
|
||||||
$shop = model(DesignatedShopModel::class)->find($dsIdx);
|
$shop = model(DesignatedShopModel::class)->find($dsIdx);
|
||||||
|
|
||||||
$this->orderModel->insert([
|
$orderData = [
|
||||||
'so_lg_idx' => $lgIdx,
|
'so_lg_idx' => $lgIdx,
|
||||||
'so_ds_idx' => $dsIdx,
|
'so_ds_idx' => $dsIdx,
|
||||||
'so_ds_name' => $shop ? $shop->ds_name : '',
|
'so_ds_name' => $shop ? $shop->ds_name : '',
|
||||||
'so_order_date' => date('Y-m-d'),
|
'so_order_date' => date('Y-m-d'),
|
||||||
'so_delivery_date'=> $this->request->getPost('so_delivery_date'),
|
'so_delivery_date' => $this->request->getPost('so_delivery_date'),
|
||||||
'so_payment_type' => $this->request->getPost('so_payment_type'),
|
'so_payment_type' => $this->request->getPost('so_payment_type'),
|
||||||
'so_status' => 'normal',
|
'so_status' => 'normal',
|
||||||
'so_orderer_idx' => session()->get('mb_idx'),
|
'so_orderer_idx' => session()->get('mb_idx'),
|
||||||
'so_regdate' => date('Y-m-d H:i:s'),
|
'so_regdate' => date('Y-m-d H:i:s'),
|
||||||
]);
|
];
|
||||||
|
// shop_order.so_channel 이 아직 반영되지 않은 DB와의 호환 처리
|
||||||
|
if ($db->fieldExists('so_channel', 'shop_order')) {
|
||||||
|
$orderData['so_channel'] = 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertOk = $this->orderModel->insert($orderData);
|
||||||
|
if ($insertOk === false) {
|
||||||
|
$db->transRollback();
|
||||||
|
$errors = $this->orderModel->errors();
|
||||||
|
$msg = ! empty($errors) ? implode(' / ', array_values($errors)) : '주문 저장에 실패했습니다.';
|
||||||
|
return redirect()->back()->withInput()->with('error', $msg);
|
||||||
|
}
|
||||||
$soIdx = (int) $this->orderModel->getInsertID();
|
$soIdx = (int) $this->orderModel->getInsertID();
|
||||||
|
if ($soIdx <= 0) {
|
||||||
|
$db->transRollback();
|
||||||
|
return redirect()->back()->withInput()->with('error', '주문번호 생성에 실패했습니다. DB 스키마를 확인해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
|
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
|
||||||
$qtys = $this->request->getPost('item_qty') ?? [];
|
$qtys = $this->request->getPost('item_qty') ?? [];
|
||||||
$totalQty = 0; $totalAmt = 0;
|
$totalQty = 0;
|
||||||
|
$totalAmt = 0;
|
||||||
|
|
||||||
foreach ($bagCodes as $i => $code) {
|
foreach ($bagCodes as $i => $code) {
|
||||||
if (empty($code) || empty($qtys[$i])) continue;
|
if (empty($code) || empty($qtys[$i])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$qty = (int) $qtys[$i];
|
$qty = (int) $qtys[$i];
|
||||||
|
|
||||||
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first();
|
$price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $code);
|
||||||
$unitPrice = $price ? (float) $price->bp_consumer : 0;
|
$unitPrice = $price ? (float) $price->bp_consumer : 0;
|
||||||
$amount = $unitPrice * $qty;
|
$amount = $unitPrice * $qty;
|
||||||
|
|
||||||
$unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
|
$unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
|
||||||
$boxCount = 0; $packCount = 0; $sheetCount = $qty;
|
$boxCount = 0;
|
||||||
|
$packCount = 0;
|
||||||
|
$sheetCount = $qty;
|
||||||
if ($unit && (int) $unit->pu_total_per_box > 0) {
|
if ($unit && (int) $unit->pu_total_per_box > 0) {
|
||||||
$boxCount = intdiv($qty, (int) $unit->pu_total_per_box);
|
$boxCount = intdiv($qty, (int) $unit->pu_total_per_box);
|
||||||
$remainder = $qty % (int) $unit->pu_total_per_box;
|
$remainder = $qty % (int) $unit->pu_total_per_box;
|
||||||
@@ -115,7 +152,7 @@ class ShopOrder extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
$detail = $kindO ? model(CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $code)->first() : null;
|
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null;
|
||||||
|
|
||||||
$this->itemModel->insert([
|
$this->itemModel->insert([
|
||||||
'soi_so_idx' => $soIdx,
|
'soi_so_idx' => $soIdx,
|
||||||
@@ -126,7 +163,7 @@ class ShopOrder extends BaseController
|
|||||||
'soi_amount' => $amount,
|
'soi_amount' => $amount,
|
||||||
'soi_box_count' => $boxCount,
|
'soi_box_count' => $boxCount,
|
||||||
'soi_pack_count' => $packCount,
|
'soi_pack_count' => $packCount,
|
||||||
'soi_sheet_count'=> $sheetCount,
|
'soi_sheet_count' => $sheetCount,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$totalQty += $qty;
|
$totalQty += $qty;
|
||||||
@@ -136,18 +173,19 @@ class ShopOrder extends BaseController
|
|||||||
$this->orderModel->update($soIdx, ['so_total_qty' => $totalQty, 'so_total_amount' => $totalAmt]);
|
$this->orderModel->update($soIdx, ['so_total_qty' => $totalQty, 'so_total_amount' => $totalAmt]);
|
||||||
$db->transComplete();
|
$db->transComplete();
|
||||||
|
|
||||||
return redirect()->to(site_url('admin/shop-orders'))->with('success', '주문이 접수되었습니다.');
|
return redirect()->to(mgmt_url('shop-orders'))->with('success', '주문이 접수되었습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(int $id)
|
public function cancel(int $id)
|
||||||
{
|
{
|
||||||
helper('admin');
|
helper('admin');
|
||||||
$order = $this->orderModel->find($id);
|
$order = $this->orderModel->find($id);
|
||||||
if (!$order || (int) $order->so_lg_idx !== admin_effective_lg_idx()) {
|
if (! $order || (int) $order->so_lg_idx !== admin_effective_lg_idx()) {
|
||||||
return redirect()->to(site_url('admin/shop-orders'))->with('error', '주문을 찾을 수 없습니다.');
|
return redirect()->to(mgmt_url('shop-orders'))->with('error', '주문을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->orderModel->update($id, ['so_status' => 'cancelled']);
|
$this->orderModel->update($id, ['so_status' => 'cancelled']);
|
||||||
return redirect()->to(site_url('admin/shop-orders'))->with('success', '주문이 취소되었습니다.');
|
|
||||||
|
return redirect()->to(mgmt_url('shop-orders'))->with('success', '주문이 취소되었습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class User extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function index(): string
|
public function index(): string
|
||||||
{
|
{
|
||||||
$list = $this->memberModel->orderBy('mb_idx', 'DESC')->findAll();
|
$list = $this->memberModel->orderBy('mb_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->memberModel->pager;
|
||||||
$approvalMap = [];
|
$approvalMap = [];
|
||||||
try {
|
try {
|
||||||
$memberIds = array_map(static fn ($row) => (int) $row->mb_idx, $list);
|
$memberIds = array_map(static fn ($row) => (int) $row->mb_idx, $list);
|
||||||
@@ -56,6 +57,7 @@ class User extends BaseController
|
|||||||
'list' => $list,
|
'list' => $list,
|
||||||
'roles' => $this->roles,
|
'roles' => $this->roles,
|
||||||
'approvalMap' => $approvalMap,
|
'approvalMap' => $approvalMap,
|
||||||
|
'pager' => $pager,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -119,8 +121,10 @@ class User extends BaseController
|
|||||||
if (! $member) {
|
if (! $member) {
|
||||||
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
|
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
$member->mb_email = pii_decrypt($member->mb_email ?? '');
|
$email = pii_decrypt($member->mb_email ?? '');
|
||||||
$member->mb_phone = pii_decrypt($member->mb_phone ?? '');
|
$phone = pii_decrypt($member->mb_phone ?? '');
|
||||||
|
$member->mb_email = $email;
|
||||||
|
$member->mb_phone = $phone;
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '회원 수정',
|
'title' => '회원 수정',
|
||||||
'content' => view('admin/user/edit', [
|
'content' => view('admin/user/edit', [
|
||||||
@@ -175,9 +179,26 @@ class User extends BaseController
|
|||||||
return redirect()->to(site_url('admin/users'))->with('success', '회원 정보가 수정되었습니다.');
|
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 admin만 super admin(4) 부여 가능, 그 외는 1~3만 허용.
|
* super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만.
|
||||||
*
|
*
|
||||||
* @return array<int,string>
|
* @return array<int,string>
|
||||||
*/
|
*/
|
||||||
@@ -185,10 +206,11 @@ class User extends BaseController
|
|||||||
{
|
{
|
||||||
$levelNames = $this->roles->levelNames;
|
$levelNames = $this->roles->levelNames;
|
||||||
$myLevel = (int) session()->get('mb_level');
|
$myLevel = (int) session()->get('mb_level');
|
||||||
if ($myLevel === Roles::LEVEL_SUPER_ADMIN) {
|
if (Roles::isSuperAdminEquivalent($myLevel)) {
|
||||||
return $levelNames;
|
return $levelNames;
|
||||||
}
|
}
|
||||||
unset($levelNames[Roles::LEVEL_SUPER_ADMIN]);
|
unset($levelNames[Roles::LEVEL_SUPER_ADMIN], $levelNames[Roles::LEVEL_HEADQUARTERS_ADMIN]);
|
||||||
|
|
||||||
return $levelNames;
|
return $levelNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
app/Controllers/Admin/WorkMovedToBag.php
Normal file
41
app/Controllers/Admin/WorkMovedToBag.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 구 관리자 업무 URL(admin/…) → 메인 사이트 업무 URL(bag/…) 영구 이전.
|
||||||
|
* POST 폼은 307로 메서드·본문 유지를 시도하고, GET 은 301.
|
||||||
|
*/
|
||||||
|
class WorkMovedToBag extends BaseController
|
||||||
|
{
|
||||||
|
public function toBag(string $prefix, string $rest = ''): \CodeIgniter\HTTP\RedirectResponse
|
||||||
|
{
|
||||||
|
$rest = trim($rest, '/');
|
||||||
|
|
||||||
|
if ($prefix === 'packaging-units') {
|
||||||
|
$path = 'packaging-units/manage';
|
||||||
|
if ($rest !== '') {
|
||||||
|
$path .= '/' . $rest;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$path = $prefix;
|
||||||
|
if ($rest !== '') {
|
||||||
|
$path .= '/' . $rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = site_url('bag/' . $path);
|
||||||
|
$query = $this->request->getUri()->getQuery();
|
||||||
|
if ($query !== '') {
|
||||||
|
$target .= '?' . $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $this->request->getMethod() === 'post' ? 307 : 301;
|
||||||
|
|
||||||
|
return redirect()->to($target)->setStatusCode($code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Libraries\TotpService;
|
||||||
use App\Models\LocalGovernmentModel;
|
use App\Models\LocalGovernmentModel;
|
||||||
use App\Models\MemberApprovalRequestModel;
|
use App\Models\MemberApprovalRequestModel;
|
||||||
use App\Models\MemberLogModel;
|
use App\Models\MemberLogModel;
|
||||||
use App\Models\MemberModel;
|
use App\Models\MemberModel;
|
||||||
|
use CodeIgniter\HTTP\RedirectResponse;
|
||||||
|
|
||||||
class Auth extends BaseController
|
class Auth extends BaseController
|
||||||
{
|
{
|
||||||
@@ -20,7 +22,10 @@ class Auth extends BaseController
|
|||||||
return redirect()->to('/');
|
return redirect()->to('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('auth/login');
|
return view('auth/login', [
|
||||||
|
'pageTitle' => '로그인 - GBLS',
|
||||||
|
'cardMax' => 'max-w-md',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function login()
|
public function login()
|
||||||
@@ -123,35 +128,181 @@ class Auth extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 성공
|
if ($this->needsTotpStep($member)) {
|
||||||
$sessionData = [
|
$this->beginPending2faSession((int) $member->mb_idx);
|
||||||
'mb_idx' => $member->mb_idx,
|
$enabled = (int) ($member->mb_totp_enabled ?? 0) === 1;
|
||||||
'mb_id' => $member->mb_id,
|
if ($enabled) {
|
||||||
'mb_name' => $member->mb_name,
|
return redirect()->to(site_url('login/two-factor'));
|
||||||
'mb_level' => $member->mb_level,
|
}
|
||||||
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
session()->set('pending_totp_setup', true);
|
||||||
'logged_in' => true,
|
|
||||||
];
|
|
||||||
session()->set($sessionData);
|
|
||||||
|
|
||||||
$memberModel->update($member->mb_idx, [
|
return redirect()->to(site_url('login/totp-setup'));
|
||||||
'mb_latestdate' => date('Y-m-d H:i:s'),
|
}
|
||||||
'mb_login_fail_count' => 0,
|
|
||||||
'mb_locked_until' => null,
|
return $this->completeLogin($member, $logData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showTwoFactor()
|
||||||
|
{
|
||||||
|
if (session()->get('logged_in')) {
|
||||||
|
return redirect()->to('/');
|
||||||
|
}
|
||||||
|
$member = $this->ensurePending2faContext();
|
||||||
|
if ($member === null) {
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
|
||||||
|
}
|
||||||
|
if (session()->get('pending_totp_setup')) {
|
||||||
|
return redirect()->to(site_url('login/totp-setup'));
|
||||||
|
}
|
||||||
|
if ((int) ($member->mb_totp_enabled ?? 0) !== 1) {
|
||||||
|
return redirect()->to(site_url('login/totp-setup'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth/login_two_factor', [
|
||||||
|
'memberId' => $member->mb_id,
|
||||||
|
'pageTitle' => '2차 인증 - GBLS',
|
||||||
|
'cardMax' => 'max-w-md',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->insertMemberLog($logData, true, '로그인 성공', $member->mb_idx);
|
|
||||||
|
|
||||||
// 지자체 관리자 → 관리자 대시보드로 이동
|
|
||||||
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
|
|
||||||
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.');
|
|
||||||
}
|
|
||||||
// super admin → 지자체 선택 페이지로 이동 (선택 후 관리자 페이지 사용)
|
|
||||||
if ((int) $member->mb_level === \Config\Roles::LEVEL_SUPER_ADMIN) {
|
|
||||||
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
|
public function verifyTwoFactor()
|
||||||
|
{
|
||||||
|
if (session()->get('logged_in')) {
|
||||||
|
return redirect()->to('/');
|
||||||
|
}
|
||||||
|
$member = $this->ensurePending2faContext();
|
||||||
|
if ($member === null) {
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
|
||||||
|
}
|
||||||
|
if (session()->get('pending_totp_setup') || (int) ($member->mb_totp_enabled ?? 0) !== 1) {
|
||||||
|
return redirect()->to(site_url('login/totp-setup'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'totp_code' => 'required|exact_length[6]|numeric',
|
||||||
|
];
|
||||||
|
$messages = [
|
||||||
|
'totp_code' => [
|
||||||
|
'required' => '인증 코드 6자리를 입력해 주세요.',
|
||||||
|
'exact_length' => '인증 코드는 6자리 숫자입니다.',
|
||||||
|
'numeric' => '인증 코드는 숫자만 입력해 주세요.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if (! $this->validate($rules, $messages)) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (string) $this->request->getPost('totp_code');
|
||||||
|
helper('pii_encryption');
|
||||||
|
$secret = pii_decrypt((string) ($member->mb_totp_secret ?? ''));
|
||||||
|
if ($secret === '') {
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '2차 인증 설정이 올바르지 않습니다. 관리자에게 문의해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totp = new TotpService();
|
||||||
|
if (! $totp->verify($secret, $code)) {
|
||||||
|
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showTotpSetup()
|
||||||
|
{
|
||||||
|
if (session()->get('logged_in')) {
|
||||||
|
return redirect()->to('/');
|
||||||
|
}
|
||||||
|
$member = $this->ensurePending2faContext();
|
||||||
|
if ($member === null) {
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
|
||||||
|
}
|
||||||
|
if (! session()->get('pending_totp_setup')) {
|
||||||
|
if ((int) ($member->mb_totp_enabled ?? 0) === 1) {
|
||||||
|
return redirect()->to(site_url('login/two-factor'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$totp = new TotpService();
|
||||||
|
$secret = session()->get('pending_totp_secret');
|
||||||
|
if (! is_string($secret) || $secret === '') {
|
||||||
|
$secret = $totp->createSecret();
|
||||||
|
session()->set('pending_totp_secret', $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrDataUri = null;
|
||||||
|
try {
|
||||||
|
$qrDataUri = $totp->getQrDataUri((string) $member->mb_id, $secret);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$qrDataUri = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth/totp_setup', [
|
||||||
|
'memberId' => $member->mb_id,
|
||||||
|
'qrDataUri' => $qrDataUri,
|
||||||
|
'secret' => $secret,
|
||||||
|
'pageTitle' => '2차 인증 등록 - GBLS',
|
||||||
|
'cardMax' => 'max-w-lg',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeTotpSetup()
|
||||||
|
{
|
||||||
|
if (session()->get('logged_in')) {
|
||||||
|
return redirect()->to('/');
|
||||||
|
}
|
||||||
|
$member = $this->ensurePending2faContext();
|
||||||
|
if ($member === null) {
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
|
||||||
|
}
|
||||||
|
if (! session()->get('pending_totp_setup')) {
|
||||||
|
return redirect()->to(site_url('login/two-factor'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'totp_code' => 'required|exact_length[6]|numeric',
|
||||||
|
];
|
||||||
|
$messages = [
|
||||||
|
'totp_code' => [
|
||||||
|
'required' => '인증 코드 6자리를 입력해 주세요.',
|
||||||
|
'exact_length' => '인증 코드는 6자리 숫자입니다.',
|
||||||
|
'numeric' => '인증 코드는 숫자만 입력해 주세요.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if (! $this->validate($rules, $messages)) {
|
||||||
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = session()->get('pending_totp_secret');
|
||||||
|
if (! is_string($secret) || $secret === '') {
|
||||||
|
return redirect()->to(site_url('login/totp-setup'))->with('error', '설정 정보가 없습니다. 페이지를 새로고침해 주세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = (string) $this->request->getPost('totp_code');
|
||||||
|
$totp = new TotpService();
|
||||||
|
if (! $totp->verify($secret, $code)) {
|
||||||
|
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('pii_encryption');
|
||||||
|
model(MemberModel::class)->update((int) $member->mb_idx, [
|
||||||
|
'mb_totp_secret' => pii_encrypt($secret),
|
||||||
|
'mb_totp_enabled' => 1,
|
||||||
|
]);
|
||||||
|
session()->remove('pending_totp_setup');
|
||||||
|
session()->remove('pending_totp_secret');
|
||||||
|
|
||||||
|
$fresh = model(MemberModel::class)->find((int) $member->mb_idx);
|
||||||
|
if ($fresh === null) {
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout()
|
public function logout()
|
||||||
@@ -182,6 +333,7 @@ class Auth extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->clearPending2faSession();
|
||||||
session()->destroy();
|
session()->destroy();
|
||||||
|
|
||||||
return redirect()->to('login')->with('success', '로그아웃되었습니다.');
|
return redirect()->to('login')->with('success', '로그아웃되었습니다.');
|
||||||
@@ -196,6 +348,8 @@ class Auth extends BaseController
|
|||||||
|
|
||||||
return view('auth/register', [
|
return view('auth/register', [
|
||||||
'localGovernments' => $localGovernments,
|
'localGovernments' => $localGovernments,
|
||||||
|
'pageTitle' => '회원가입 - GBLS',
|
||||||
|
'cardMax' => 'max-w-md',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +452,130 @@ class Auth extends BaseController
|
|||||||
return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.');
|
return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function needsTotpStep(object $member): bool
|
||||||
|
{
|
||||||
|
if (! config('Auth')->requireTotp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \Config\Roles::requiresTotp((int) $member->mb_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function beginPending2faSession(int $mbIdx): void
|
||||||
|
{
|
||||||
|
session()->set([
|
||||||
|
'pending_2fa' => true,
|
||||||
|
'pending_mb_idx' => $mbIdx,
|
||||||
|
'pending_2fa_started' => time(),
|
||||||
|
'totp_attempts' => 0,
|
||||||
|
]);
|
||||||
|
session()->remove('pending_totp_setup');
|
||||||
|
session()->remove('pending_totp_secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearPending2faSession(): void
|
||||||
|
{
|
||||||
|
session()->remove([
|
||||||
|
'pending_2fa',
|
||||||
|
'pending_mb_idx',
|
||||||
|
'pending_2fa_started',
|
||||||
|
'pending_totp_setup',
|
||||||
|
'pending_totp_secret',
|
||||||
|
'totp_attempts',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pending2faExpired(): bool
|
||||||
|
{
|
||||||
|
$started = (int) session()->get('pending_2fa_started');
|
||||||
|
if ($started <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$ttl = config('Auth')->pending2faTtlSeconds;
|
||||||
|
|
||||||
|
return (time() - $started) > $ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensurePending2faContext(): ?object
|
||||||
|
{
|
||||||
|
if (! session()->get('pending_2fa')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($this->pending2faExpired()) {
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$mbIdx = (int) session()->get('pending_mb_idx');
|
||||||
|
if ($mbIdx <= 0) {
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$member = model(MemberModel::class)->find($mbIdx);
|
||||||
|
if ($member === null) {
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $logData
|
||||||
|
*/
|
||||||
|
private function handleTotpFailure(object $member, array $logData): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->insertMemberLog($logData, false, '2차 인증 실패', (int) $member->mb_idx);
|
||||||
|
$attempts = (int) session()->get('totp_attempts') + 1;
|
||||||
|
session()->set('totp_attempts', $attempts);
|
||||||
|
$max = config('Auth')->totpMaxAttempts;
|
||||||
|
if ($attempts >= $max) {
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
|
||||||
|
return redirect()->to(site_url('login'))->with('error', "인증 코드가 {$max}회 틀려 세션이 종료되었습니다. 처음부터 로그인해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->withInput()
|
||||||
|
->with('error', '인증 코드가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $logData
|
||||||
|
*/
|
||||||
|
private function completeLogin(object $member, array $logData): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->clearPending2faSession();
|
||||||
|
$sessionData = [
|
||||||
|
'mb_idx' => $member->mb_idx,
|
||||||
|
'mb_id' => $member->mb_id,
|
||||||
|
'mb_name' => $member->mb_name,
|
||||||
|
'mb_level' => $member->mb_level,
|
||||||
|
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
||||||
|
'logged_in' => true,
|
||||||
|
];
|
||||||
|
session()->set($sessionData);
|
||||||
|
|
||||||
|
model(MemberModel::class)->update($member->mb_idx, [
|
||||||
|
'mb_latestdate' => date('Y-m-d H:i:s'),
|
||||||
|
'mb_login_fail_count' => 0,
|
||||||
|
'mb_locked_until' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
|
||||||
|
|
||||||
|
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
|
||||||
|
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.');
|
||||||
|
}
|
||||||
|
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
|
||||||
|
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
private function buildLogData(string $mbId, ?int $mbIdx): array
|
private function buildLogData(string $mbId, ?int $mbIdx): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,4 +42,49 @@ abstract class BaseController extends Controller
|
|||||||
// Preload any models, libraries, etc, here.
|
// Preload any models, libraries, etc, here.
|
||||||
// $this->session = service('session');
|
// $this->session = service('session');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/* 또는 /bag/* 업무 화면 공통: 요청이 bag 이면 메인 사이트 레이아웃, 아니면 관리자 레이아웃.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $contentData
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 워크스페이스 탭(iframe) 안에서 열린 요청인지. ?embed=1 또는 Sec-Fetch-Dest=iframe.
|
||||||
|
* iframe 내 링크 이동·폼 전송·리다이렉트까지 모두 임베드로 처리되도록 헤더로도 판정한다.
|
||||||
|
*/
|
||||||
|
protected function isEmbeddedRequest(): bool
|
||||||
|
{
|
||||||
|
if ($this->request->getGet('embed') !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$dest = strtolower(trim((string) $this->request->getHeaderLine('Sec-Fetch-Dest')));
|
||||||
|
|
||||||
|
return $dest === 'iframe' || $dest === 'frame';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
|
||||||
|
{
|
||||||
|
$content = view($contentView, $contentData);
|
||||||
|
helper('admin');
|
||||||
|
$path = function_exists('current_nav_request_path') ? current_nav_request_path() : '';
|
||||||
|
if ($path === '') {
|
||||||
|
$uri = service('request')->getUri();
|
||||||
|
$path = trim((string) $uri->getPath(), '/');
|
||||||
|
}
|
||||||
|
while (str_starts_with($path, 'index.php/')) {
|
||||||
|
$path = substr($path, strlen('index.php/'));
|
||||||
|
}
|
||||||
|
if ($path === 'bag' || str_starts_with($path, 'bag/')) {
|
||||||
|
// /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal 셸
|
||||||
|
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Libraries\GovPortalCodeKindsPage;
|
||||||
use App\Models\LocalGovernmentModel;
|
use App\Models\LocalGovernmentModel;
|
||||||
|
|
||||||
class Home extends BaseController
|
class Home extends BaseController
|
||||||
@@ -9,18 +10,261 @@ class Home extends BaseController
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
if (session()->get('logged_in')) {
|
if (session()->get('logged_in')) {
|
||||||
return $this->dashboard();
|
helper('admin');
|
||||||
|
|
||||||
|
// 워크스페이스 탭(iframe) 안: 대시보드 본문을 임베드로.
|
||||||
|
if ($this->isEmbeddedRequest()) {
|
||||||
|
return view('bag/layout/embed', [
|
||||||
|
'title' => '업무 현황',
|
||||||
|
'bare' => true,
|
||||||
|
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인 후 기본 화면 = 워크스페이스(탭). 메뉴를 탭으로 열어 작업 상태 유지.
|
||||||
|
return view('bag/layout/workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('welcome_message');
|
return view('welcome_message');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 후 원래 메인 화면 (admin 유사 레이아웃 + site 메뉴 호버 드롭다운)
|
* 워크스페이스 — 메뉴를 탭(iframe)으로 열어두고 작업 상태를 유지하는 화면.
|
||||||
|
*/
|
||||||
|
public function workspace()
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return redirect()->to(base_url('login'));
|
||||||
|
}
|
||||||
|
helper('admin');
|
||||||
|
|
||||||
|
return view('bag/layout/workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 대시보드용 — GBLS에 실제 존재하는 데이터만 집계.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function portalDashboardData(): array
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
|
||||||
|
if ($lgIdx === null) {
|
||||||
|
$raw = session()->get('mb_lg_idx');
|
||||||
|
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = [];
|
||||||
|
$totalQty = 0;
|
||||||
|
$orderCount = 0;
|
||||||
|
$palette = ['#3b82f6', '#10b981', '#f59e0b', '#6366f1', '#ef4444', '#0ea5e9', '#14b8a6', '#a855f7', '#f97316'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($lgIdx !== null && $db->tableExists('bag_inventory')) {
|
||||||
|
$rows = $db->table('bag_inventory')
|
||||||
|
->select('bi_bag_name, bi_bag_code, bi_qty')
|
||||||
|
->where('bi_lg_idx', $lgIdx)
|
||||||
|
->orderBy('bi_qty', 'DESC')
|
||||||
|
->get()->getResultArray();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$inventory[] = [
|
||||||
|
'name' => (string) ($r['bi_bag_name'] ?? $r['bi_bag_code'] ?? ''),
|
||||||
|
'qty' => (int) ($r['bi_qty'] ?? 0),
|
||||||
|
];
|
||||||
|
$totalQty += (int) ($r['bi_qty'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$inventory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재고 구성(상위 품목 비율)
|
||||||
|
$stockMix = [];
|
||||||
|
foreach (array_slice($inventory, 0, 6) as $i => $item) {
|
||||||
|
$stockMix[] = [
|
||||||
|
'name' => $item['name'],
|
||||||
|
'value' => $totalQty > 0 ? (int) round($item['qty'] / $totalQty * 100) : 0,
|
||||||
|
'color' => $palette[$i % count($palette)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 부족 재고(수량 적은 하위 품목) — 최대 재고 대비 비율
|
||||||
|
$maxQty = $inventory !== [] ? max(array_column($inventory, 'qty')) : 0;
|
||||||
|
$lowStock = [];
|
||||||
|
foreach (array_slice(array_reverse($inventory), 0, 5) as $item) {
|
||||||
|
$lowStock[] = [
|
||||||
|
'name' => $item['name'],
|
||||||
|
'qty' => $item['qty'],
|
||||||
|
'percent' => $maxQty > 0 ? (int) round($item['qty'] / $maxQty * 100) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($lgIdx !== null && $db->tableExists('shop_order')) {
|
||||||
|
$orderCount = (int) $db->table('shop_order')
|
||||||
|
->where('so_lg_idx', $lgIdx)
|
||||||
|
->where('so_status', 'normal')
|
||||||
|
->countAllResults();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$orderCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingApprovals = 0;
|
||||||
|
try {
|
||||||
|
if ($db->tableExists('member_approval_request')) {
|
||||||
|
$pendingApprovals = (int) $db->table('member_approval_request')
|
||||||
|
->where('mar_status', 'pending')
|
||||||
|
->countAllResults();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pendingApprovals = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지도용 — 현재 지자체 지정판매소(이름·주소). 좌표는 클라이언트(카카오 지오코딩)에서 변환.
|
||||||
|
$mapShops = [];
|
||||||
|
try {
|
||||||
|
if ($lgIdx !== null && $db->tableExists('designated_shop')) {
|
||||||
|
$rows = $db->table('designated_shop')
|
||||||
|
->select('ds_name, ds_addr, ds_addr_jibun')
|
||||||
|
->where('ds_lg_idx', $lgIdx)
|
||||||
|
->where('ds_addr IS NOT NULL')
|
||||||
|
->where('ds_addr <>', '')
|
||||||
|
->orderBy('ds_idx', 'ASC')
|
||||||
|
->limit(40)
|
||||||
|
->get()->getResultArray();
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$addr = trim((string) ($r['ds_addr'] ?? ''));
|
||||||
|
if ($addr === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$mapShops[] = [
|
||||||
|
'name' => (string) ($r['ds_name'] ?? ''),
|
||||||
|
'addr' => $addr,
|
||||||
|
'jibun' => trim((string) ($r['ds_addr_jibun'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$mapShops = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최근 활동(activity_log) — 실제 변경 이력
|
||||||
|
$actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소'];
|
||||||
|
$tableLabel = [
|
||||||
|
'bag_order' => '발주', 'bag_receiving' => '입고', 'bag_sale' => '판매',
|
||||||
|
'bag_issue' => '불출', 'bag_inventory' => '재고', 'shop_order' => '주문접수',
|
||||||
|
'designated_shop' => '지정판매소', 'bag_price' => '단가', 'member' => '회원',
|
||||||
|
];
|
||||||
|
$recent = [];
|
||||||
|
try {
|
||||||
|
if ($db->tableExists('activity_log')) {
|
||||||
|
$logs = $db->table('activity_log')
|
||||||
|
->select('al_action, al_table, al_regdate')
|
||||||
|
->orderBy('al_idx', 'DESC')->limit(6)->get()->getResultArray();
|
||||||
|
foreach ($logs as $l) {
|
||||||
|
$t = (string) ($l['al_regdate'] ?? '');
|
||||||
|
$recent[] = [
|
||||||
|
'time' => $t !== '' ? date('m.d H:i', strtotime($t)) : '',
|
||||||
|
'text' => ($tableLabel[$l['al_table']] ?? (string) $l['al_table'])
|
||||||
|
. ' ' . ($actionLabel[$l['al_action']] ?? (string) $l['al_action']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$recent = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lgLabel' => $this->resolveLgLabel(),
|
||||||
|
'mbName' => (string) (session()->get('mb_name') ?? '담당자'),
|
||||||
|
'mbId' => (string) (session()->get('mb_id') ?? ''),
|
||||||
|
'levelName' => config(\Config\Roles::class)->getLevelName((int) session()->get('mb_level')),
|
||||||
|
'totalQty' => $totalQty,
|
||||||
|
'itemCount' => count($inventory),
|
||||||
|
'orderCount' => $orderCount,
|
||||||
|
'pendingApprovals' => $pendingApprovals,
|
||||||
|
'stockMix' => $stockMix,
|
||||||
|
'lowStock' => $lowStock,
|
||||||
|
'recentActivity' => $recent,
|
||||||
|
'mapShops' => $mapShops,
|
||||||
|
'kakaoJsKey' => config(\Config\Kakao::class)->javascriptKey,
|
||||||
|
'menuSearchOptions' => (function_exists('gov_portal_nav_context') && function_exists('gov_portal_menu_search_options'))
|
||||||
|
? gov_portal_menu_search_options(gov_portal_nav_context(false)['navItems'])
|
||||||
|
: [],
|
||||||
|
'menuFlat' => $this->buildMenuFlat(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴검색 자동완성용 — 사이트 메뉴를 (상위·메뉴명·URL) 평탄 목록으로.
|
||||||
|
*
|
||||||
|
* @return list<array{parent:string,name:string,url:string}>
|
||||||
|
*/
|
||||||
|
private function buildMenuFlat(): array
|
||||||
|
{
|
||||||
|
if (! function_exists('gov_portal_nav_context')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$flat = [];
|
||||||
|
foreach (gov_portal_nav_context(false)['navItems'] as $parent) {
|
||||||
|
$pName = (string) ($parent['name'] ?? '');
|
||||||
|
if (! empty($parent['children'])) {
|
||||||
|
foreach ($parent['children'] as $child) {
|
||||||
|
$url = (string) ($child['url'] ?? '');
|
||||||
|
if ($url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$flat[] = ['parent' => $pName, 'name' => (string) ($child['name'] ?? ''), 'url' => $url];
|
||||||
|
}
|
||||||
|
} elseif (! empty($parent['url'])) {
|
||||||
|
$flat[] = ['parent' => '', 'name' => $pName, 'url' => (string) $parent['url']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
|
||||||
*/
|
*/
|
||||||
public function dashboard()
|
public function dashboard()
|
||||||
{
|
{
|
||||||
return view('bag/daily_inventory');
|
return view('bag/layout/main', [
|
||||||
|
'title' => '업무 현황 · 종합·그래프',
|
||||||
|
'content' => view('bag/dashboard_blend_inner', [
|
||||||
|
'lgLabel' => $this->resolveLgLabel(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 후 메인 — 단순형 요약 대시보드. URL: /dashboard/simple
|
||||||
|
* 기존 /dashboard 화면이 복잡하다는 피드백용으로, 핵심 지표·링크만 노출.
|
||||||
|
*/
|
||||||
|
public function dashboardSimple()
|
||||||
|
{
|
||||||
|
return view('bag/layout/main', [
|
||||||
|
'title' => '업무 현황 · 요약',
|
||||||
|
'content' => view('bag/lg_dashboard_simple', [
|
||||||
|
'lgLabel' => $this->resolveLgLabel(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 후 메인 — 중간 밀도 대시보드. URL: /dashboard/compact
|
||||||
|
* /dashboard 보다 단순하지만 simple 보다 정보량을 늘린 화면.
|
||||||
|
*/
|
||||||
|
public function dashboardCompact()
|
||||||
|
{
|
||||||
|
return view('bag/layout/main', [
|
||||||
|
'title' => '업무 현황 · 컴팩트',
|
||||||
|
'content' => view('bag/lg_dashboard_compact', [
|
||||||
|
'lgLabel' => $this->resolveLgLabel(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +305,129 @@ class Home extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /dashboard 와 동일 본문(호환 URL)
|
||||||
|
*/
|
||||||
|
public function dashboardBlend()
|
||||||
|
{
|
||||||
|
return $this->dashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 후 메인 — 라이트(축약) 대시보드. URL: /dashboard/lite
|
||||||
|
* dashboard_blend 의 일부 KPI/표/차트만 남긴 단순화 화면.
|
||||||
|
*/
|
||||||
|
public function dashboardLite()
|
||||||
|
{
|
||||||
|
return view('bag/layout/main', [
|
||||||
|
'title' => '업무 현황 · 라이트',
|
||||||
|
'content' => view('bag/dashboard_blend_lite_inner', [
|
||||||
|
'lgLabel' => $this->resolveLgLabel(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공공 포털형(국가재난관리정보시스템 스타일) 메인 시안.
|
||||||
|
* URL: /dashboard/gov-portal — 기능은 요약 대시보드와 동일, UI만 별도 레이아웃.
|
||||||
|
*/
|
||||||
|
public function dashboardGovPortal()
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return redirect()->to(base_url('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
|
||||||
|
return view('home/dashboard_gov_portal', gov_portal_dashboard_view_data($this->resolveLgLabel(), 'base'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공공 포털형 변형 — 가로 MY MENU·와이드 맵·KPI 띠. URL: /dashboard/gov-portal-strip
|
||||||
|
*/
|
||||||
|
public function dashboardGovPortalStrip()
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return redirect()->to(base_url('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
|
||||||
|
return view('home/_dashboard_gov_portal_strip_layout', array_merge(
|
||||||
|
gov_portal_dashboard_view_data($this->resolveLgLabel(), 'strip'),
|
||||||
|
['stripInnerView' => 'home/_dashboard_gov_portal_strip_home_inner']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공공 포털형(기본) — 기본 코드관리 UI 시안. URL: /dashboard/gov-portal/code-kinds
|
||||||
|
*/
|
||||||
|
public function dashboardGovPortalCodeKinds()
|
||||||
|
{
|
||||||
|
return $this->renderGovPortalCodeKinds('base');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공공 포털형(변형 strip) — 기본 코드관리 UI 시안. URL: /dashboard/gov-portal-strip/code-kinds
|
||||||
|
*/
|
||||||
|
public function dashboardGovPortalStripCodeKinds()
|
||||||
|
{
|
||||||
|
return $this->renderGovPortalCodeKinds('strip');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \CodeIgniter\HTTP\RedirectResponse|string
|
||||||
|
*/
|
||||||
|
private function renderGovPortalCodeKinds(string $variant)
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return redirect()->to(base_url('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
|
||||||
|
$portalPath = gov_portal_code_kinds_portal_path($variant);
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
$filters = [
|
||||||
|
'q_code' => $this->request->getGet('q_code'),
|
||||||
|
'q_name' => $this->request->getGet('q_name'),
|
||||||
|
'q_state' => $this->request->getGet('q_state'),
|
||||||
|
];
|
||||||
|
$builder = new GovPortalCodeKindsPage();
|
||||||
|
$ckIdx = (int) ($this->request->getGet('ck_idx') ?? 0);
|
||||||
|
$pageData = $builder->buildPageData($lgIdx, $level, $lgIdx, $ckIdx, $filters);
|
||||||
|
|
||||||
|
if ($ckIdx === 0 && $pageData['codeKinds'] !== []) {
|
||||||
|
$pageData = $builder->buildPageData(
|
||||||
|
$lgIdx,
|
||||||
|
$level,
|
||||||
|
$lgIdx,
|
||||||
|
(int) $pageData['codeKinds'][0]->ck_idx,
|
||||||
|
$filters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewData = array_merge(
|
||||||
|
gov_portal_dashboard_view_data($this->resolveLgLabel(), $variant),
|
||||||
|
$pageData,
|
||||||
|
[
|
||||||
|
'govActiveChildHref' => $portalPath,
|
||||||
|
'pageBaseUrl' => site_url($portalPath),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($variant === 'strip') {
|
||||||
|
return view('home/_dashboard_gov_portal_strip_layout', array_merge($viewData, [
|
||||||
|
'stripInnerView' => 'home/_gov_portal_code_kinds_body',
|
||||||
|
'stripIncludeWorkCss' => true,
|
||||||
|
'stripShowProfileLinks' => true,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('home/dashboard_gov_portal_code_kinds', $viewData);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 재고 조회(수불) 화면 (목업)
|
* 재고 조회(수불) 화면 (목업)
|
||||||
*/
|
*/
|
||||||
@@ -90,9 +457,14 @@ class Home extends BaseController
|
|||||||
protected function resolveLgLabel(): string
|
protected function resolveLgLabel(): string
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$idx = session()->get('mb_lg_idx');
|
helper('admin');
|
||||||
if ($idx === null || $idx === '') {
|
$idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
|
||||||
return '로그인 지자체 (미지정)';
|
if ($idx === null) {
|
||||||
|
$raw = session()->get('mb_lg_idx');
|
||||||
|
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
if ($idx === null) {
|
||||||
|
return '지자체 미지정';
|
||||||
}
|
}
|
||||||
$row = model(LocalGovernmentModel::class)->find((int) $idx);
|
$row = model(LocalGovernmentModel::class)->find((int) $idx);
|
||||||
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
|
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
|
||||||
@@ -102,6 +474,7 @@ class Home extends BaseController
|
|||||||
// 테이블 미생성 등
|
// 테이블 미생성 등
|
||||||
}
|
}
|
||||||
|
|
||||||
return '북구 (데모)';
|
return '지자체';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
50
app/Docs/manual/00_overview.md
Normal file
50
app/Docs/manual/00_overview.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 시작하기 · 시스템 개요
|
||||||
|
|
||||||
|
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. **이 시스템을 처음 쓰는 분**도 화면을 이해하고 업무를 처리할 수 있도록, 화면마다 쓰이는 용어와 버튼의 의미를 설명합니다.
|
||||||
|
|
||||||
|
## 1. 이 시스템은 무엇을 하나요?
|
||||||
|
|
||||||
|
지자체가 주민에게 파는 **종량제 쓰레기봉투**가 ① 제작업체에 **주문(발주)** 되고 → ② 창고로 **들어오고(입고)** → ③ **재고**로 보관되다가 → ④ 동네 가게(지정판매소)에 **팔리거나(판매)** 무료 대상자에게 **나눠지는(불출)** 전 과정을 컴퓨터로 관리합니다. 마지막엔 ⑤ 얼마나 팔렸는지 **집계·정산(현황/리포트)** 합니다.
|
||||||
|
|
||||||
|
## 2. 꼭 알아야 할 기본 용어 (용어 사전)
|
||||||
|
|
||||||
|
| 용어 | 쉬운 설명 |
|
||||||
|
|---|---|
|
||||||
|
| **발주** | 봉투를 제작업체에 "이만큼 만들어 주세요"라고 **주문**하는 것 |
|
||||||
|
| **입고** | 주문한 봉투가 창고에 **도착해 들여놓는** 것 |
|
||||||
|
| **재고** | 지금 창고에 **남아 있는 봉투 수량** |
|
||||||
|
| **불출** | 봉투를 창고에서 **꺼내 내보내는** 것 (주로 무료 배부) |
|
||||||
|
| **수불(受拂)** | **들어오고(수입)·나가는(불출)** 움직임을 적은 장부 |
|
||||||
|
| **지정판매소** | 봉투를 파는 **동네 가게**(편의점·마트 등) |
|
||||||
|
| **대행소(판매대행소)** | 봉투 **배송·유통을 대행**하는 업체 |
|
||||||
|
| **실사** | 컴퓨터 기록과 **실제 창고 수량이 맞는지 직접 세어 확인**하는 것 |
|
||||||
|
| **박스 / 팩 / 낱장** | 포장 단위. **박스 > 팩 > 낱장(봉투 1장)**. 1박스 = 여러 팩, 1팩 = 여러 낱장 |
|
||||||
|
| **LOT(로트)** | 한 번의 발주 묶음에 부여되는 **추적용 일련번호** |
|
||||||
|
| **바코드(봉투번호)** | 박스·팩·낱장마다 붙는 **고유 번호**(스캔용) |
|
||||||
|
| **무료용 / 공공용** | 주민 무료 배부용 / 공공기관 사용용 봉투 구분 |
|
||||||
|
|
||||||
|
> 박스·팩·낱장·LOT·바코드의 자세한 규칙은 좌측 목차 **[봉투·LOT·바코드 코드체계]** 참고.
|
||||||
|
|
||||||
|
## 3. 화면 구성과 사용법
|
||||||
|
|
||||||
|
- 로그인하면 **워크스페이스**(탭 작업공간)가 열립니다. 상단에 대분류 메뉴, **대분류를 클릭하면 왼쪽에 소메뉴**가 펼쳐집니다.
|
||||||
|
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
|
||||||
|
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
|
||||||
|
|
||||||
|
> 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
|
||||||
|
|
||||||
|
## 4. 사용자 역할(권한)
|
||||||
|
|
||||||
|
| 역할 | 할 수 있는 일 |
|
||||||
|
|---|---|
|
||||||
|
| 일반 사용자 | 기본 조회 |
|
||||||
|
| 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
|
||||||
|
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
||||||
|
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
|
||||||
|
|
||||||
|
## 5. 화면별 설명은 어디에?
|
||||||
|
|
||||||
|
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
|
||||||
|
- **발주·입고** / **재고·실사** / **판매·반품·불출·주문** / **현황·리포트·수불** / **기본정보(판매소·단가·코드)**
|
||||||
|
|
||||||
|
각 화면 설명은 **그 화면 고유의 용어·입력 항목·버튼·작업 순서**만 담았습니다.
|
||||||
61
app/Docs/manual/01_account.md
Normal file
61
app/Docs/manual/01_account.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 로그인 · 회원가입 · 계정
|
||||||
|
|
||||||
|
시스템을 쓰려면 **계정(아이디)** 이 필요합니다. 이 페이지는 회원가입부터 로그인, 2차 인증, 로그아웃까지의 과정을 설명합니다.
|
||||||
|
|
||||||
|
## 1. 회원가입
|
||||||
|
|
||||||
|
로그인 화면 아래쪽 **[회원가입]** 을 눌러 신청합니다. 입력 항목은 다음과 같습니다.
|
||||||
|
|
||||||
|
| 항목 | 필수 | 설명 |
|
||||||
|
|---|:---:|---|
|
||||||
|
| **아이디** | 필수 | 로그인에 쓰는 ID. **4자 이상**, 이미 쓰는 아이디는 사용할 수 없습니다. |
|
||||||
|
| **비밀번호** | 필수 | **4자 이상**. 안전을 위해 영문·숫자·기호를 섞는 것을 권장합니다. |
|
||||||
|
| **비밀번호 확인** | 필수 | 위 비밀번호와 똑같이 한 번 더 입력(오타 방지). |
|
||||||
|
| **이름** | 필수 | 담당자 이름. |
|
||||||
|
| **이메일** | 선택 | 안내용. 입력 시 형식 검사를 합니다. |
|
||||||
|
| **연락처** | 선택 | 전화번호. |
|
||||||
|
| **지자체** | 선택 | 소속 지자체. 해당되면 선택합니다. |
|
||||||
|
| **사용자 역할** | 필수 | 신청할 권한(아래 표 참고). 실제 권한은 **관리자 승인 시 확정**됩니다. |
|
||||||
|
|
||||||
|
> 이메일·연락처 같은 개인정보는 **암호화되어 저장**됩니다.
|
||||||
|
|
||||||
|
### 신청할 수 있는 역할
|
||||||
|
|
||||||
|
| 역할 | 주로 하는 일 |
|
||||||
|
|---|---|
|
||||||
|
| **일반 사용자** | 기본 조회 |
|
||||||
|
| **지정판매소** | 봉투 판매·반품, 자기 판매 현황 조회 |
|
||||||
|
| **지자체 관리자** | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
||||||
|
|
||||||
|
### 가입 후 — 관리자 승인 대기
|
||||||
|
|
||||||
|
회원가입을 제출하면 **바로 로그인되지 않고 "승인 대기" 상태**가 됩니다.
|
||||||
|
|
||||||
|
- **관리자가 승인하면** 그때부터 로그인할 수 있습니다.
|
||||||
|
- 승인 전에 로그인하면 *"관리자 승인 후 로그인 가능합니다."* 안내가 나옵니다.
|
||||||
|
- 승인이 **반려**되면 *"승인이 반려되었습니다. 관리자에게 문의해 주세요."* 가 나옵니다 — 담당 관리자에게 문의하세요.
|
||||||
|
|
||||||
|
## 2. 로그인
|
||||||
|
|
||||||
|
로그인 화면에서 **아이디**와 **비밀번호**를 입력합니다.
|
||||||
|
|
||||||
|
- 성공하면 **워크스페이스**(탭 작업공간)로 들어갑니다.
|
||||||
|
- 아이디·비밀번호가 틀리면 안내 메시지가 나옵니다. (승인 대기/반려 상태도 위와 같이 안내됩니다.)
|
||||||
|
|
||||||
|
### 2차 인증(OTP)
|
||||||
|
|
||||||
|
보안 설정에 따라 비밀번호 확인 뒤 **2차 인증** 단계가 나올 수 있습니다.
|
||||||
|
|
||||||
|
- **이미 OTP를 등록한 경우** — 스마트폰 인증 앱(예: Google Authenticator)에 표시되는 **6자리 숫자**를 입력합니다.
|
||||||
|
- **처음 사용하는 경우** — 화면의 **QR코드 또는 설정 키**를 인증 앱에 등록한 뒤, 앱에 나온 6자리 숫자로 설정을 완료합니다. 이후 로그인부터 OTP를 입력하게 됩니다.
|
||||||
|
|
||||||
|
> OTP 숫자는 일정 시간마다 바뀌므로, **현재 표시된 숫자**를 입력해야 합니다. 휴대폰 시간이 자동(네트워크 시간)으로 맞춰져 있어야 정확합니다.
|
||||||
|
|
||||||
|
## 3. 로그아웃
|
||||||
|
|
||||||
|
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
|
||||||
|
|
||||||
|
## 4. 비밀번호·계정 문제
|
||||||
|
|
||||||
|
- **비밀번호를 바꾸거나 분실**한 경우, 계정·권한 변경은 **담당 관리자**가 처리합니다. 관리자에게 문의하세요.
|
||||||
|
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.
|
||||||
92
app/Docs/manual/05_workspace.md
Normal file
92
app/Docs/manual/05_workspace.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# 화면 구성 · 워크스페이스 · 단축키
|
||||||
|
|
||||||
|
이 시스템은 **여러 작업 화면을 탭으로 열어 두고 오가며** 일할 수 있도록 만들어졌습니다(웹 브라우저의 탭과 비슷합니다). 이 페이지는 화면이 어떻게 구성되는지, 탭을 어떻게 쓰는지, 그리고 빠르게 쓰는 단축키를 설명합니다.
|
||||||
|
|
||||||
|
## 1. 전체 화면 구성
|
||||||
|
|
||||||
|
로그인하면 **워크스페이스**(탭 작업공간)가 기본으로 열립니다. 화면은 크게 네 부분입니다.
|
||||||
|
|
||||||
|
| 영역 | 위치 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| **상단 헤더** | 맨 위 | 로고, **대분류 메뉴**, 오른쪽에 소속 지자체·접속자·관리자·로그아웃 |
|
||||||
|
| **왼쪽 사이드바** | 좌측 | 현재 선택한 대분류의 **소메뉴 목록**. 아래에는 매뉴얼·FAQ 링크 |
|
||||||
|
| **탭바** | 본문 위 | 지금 열어 둔 작업 화면들의 **탭 목록** |
|
||||||
|
| **본문** | 가운데 | 현재 탭의 실제 작업 화면 |
|
||||||
|
|
||||||
|
> **대분류를 클릭하면** 왼쪽 사이드바에 그 안의 소메뉴가 펼쳐집니다. 현재 위치한 메뉴는 왼쪽에서 **▸** 로 표시됩니다.
|
||||||
|
|
||||||
|
## 2. 탭(워크스페이스) 사용법
|
||||||
|
|
||||||
|
- **메뉴를 클릭하면 탭으로 열립니다.** 같은 메뉴를 다시 누르면 새 탭을 또 만들지 않고 **이미 열린 탭으로 이동**합니다.
|
||||||
|
- **탭을 전환해도 작업 내용이 유지됩니다.** A 화면에 무언가 입력하다가 B 화면을 잠깐 보고 와도, A 탭의 입력은 그대로 남아 있습니다.
|
||||||
|
- **탭 ↔ 왼쪽 메뉴 연동:** 탭을 전환하면 왼쪽 사이드바의 강조 위치도 그 탭의 메뉴로 **자동으로 따라갑니다.**
|
||||||
|
- 탭이 많아지면 가로로 스크롤되며, **최대 12개**까지 열 수 있습니다. 12개를 넘기면 가장 오래된 탭이 자동으로 닫힙니다.
|
||||||
|
|
||||||
|
### 탭의 버튼
|
||||||
|
|
||||||
|
| 버튼 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| **↻** (탭 위) | 그 **탭만** 새로고침합니다. 다른 탭은 그대로 둡니다. |
|
||||||
|
| **×** (탭 위) | 그 탭을 닫습니다. |
|
||||||
|
| 탭을 **가운데(휠) 클릭** | 마우스 휠을 누르면 그 탭이 닫힙니다(브라우저와 동일). |
|
||||||
|
| 탭에 **마우스를 올리면** | 이름이 길어 잘렸을 때 **전체 제목**이 말풍선으로 보입니다. |
|
||||||
|
|
||||||
|
### 탭이 유지되는 범위
|
||||||
|
|
||||||
|
- **브라우저를 새로고침**하거나 **관리자 페이지에 갔다가 돌아와도** 열어 두었던 탭이 **다시 복원**됩니다.
|
||||||
|
- 단, **브라우저 탭(창)을 완전히 닫으면** 작업공간은 초기화됩니다. (이 유지는 "이번 접속 동안"만 적용됩니다.)
|
||||||
|
- **다른 아이디로 로그인하면** 이전 사용자의 탭은 복원되지 않고 **기본 화면으로 초기화**됩니다. (계정별로 분리됩니다.)
|
||||||
|
- 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.**
|
||||||
|
|
||||||
|
## 3. 분할 보기 (여러 화면 한눈에)
|
||||||
|
|
||||||
|
여러 화면을 **동시에 펼쳐 놓고** 비교하거나 함께 작업할 수 있습니다. 탭바 오른쪽의 **분할 버튼**으로 화면을 나눕니다.
|
||||||
|
|
||||||
|
| 버튼 | 모양 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| **1분할** | □ | 한 화면만 크게 (기본) |
|
||||||
|
| **2분할 (좌우)** | ◫ | 화면을 왼쪽/오른쪽 두 칸으로 |
|
||||||
|
| **2분할 (상하)** | ⬓ | 화면을 위/아래 두 칸으로 |
|
||||||
|
| **4분할** | ⊞ | 2×2 네 칸으로 |
|
||||||
|
|
||||||
|
### 칸에 화면 배치하기
|
||||||
|
|
||||||
|
- 분할하면 열어 둔 화면들이 칸에 자동으로 채워집니다. 빈 칸에는 안내 문구가 표시됩니다.
|
||||||
|
- **칸을 클릭하면 그 칸이 "선택"**(파란 테두리)됩니다. 이 상태에서 **위 탭바의 탭을 클릭**하면 그 화면이 **선택된 칸**에 들어갑니다.
|
||||||
|
- 각 칸 위의 **헤더**에는 화면 이름과 함께 **↻(이 칸 새로고침)·×(이 칸 비우기)** 버튼이 있습니다.
|
||||||
|
|
||||||
|
### 칸 크기 조절
|
||||||
|
|
||||||
|
- 칸 사이의 **구분선에 마우스를 올리면 ↔/↕ 커서**가 나타납니다. **드래그**하면 칸 크기(비율)를 자유롭게 조절할 수 있습니다.
|
||||||
|
- 구분선을 **더블클릭**하면 **50:50으로 초기화**됩니다.
|
||||||
|
- 조절한 분할 모양·크기도 새로고침·관리자 왕복 후 **그대로 복원**됩니다.
|
||||||
|
|
||||||
|
> 분할 상태에서도 각 화면의 작업 내용은 그대로 유지됩니다. 마음껏 나눴다 합쳤다 해도 입력하던 내용이 사라지지 않습니다.
|
||||||
|
|
||||||
|
## 4. 키보드 단축키
|
||||||
|
|
||||||
|
자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다.
|
||||||
|
|
||||||
|
동작은 **현재 선택된 칸**을 기준으로 적용됩니다(1분할이면 그 한 화면).
|
||||||
|
|
||||||
|
| 단축키 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| **Alt + 1 ~ 9** | **n번째 탭**을 선택된 칸에 표시 |
|
||||||
|
| **Alt + W** | 선택된 칸의 **탭 닫기** |
|
||||||
|
| **Alt + ]** | 선택된 칸을 **다음 탭**으로 |
|
||||||
|
| **Alt + [** | 선택된 칸을 **이전 탭**으로 |
|
||||||
|
|
||||||
|
> macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1).
|
||||||
|
>
|
||||||
|
> 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다.
|
||||||
|
|
||||||
|
## 5. 그 밖의 이동
|
||||||
|
|
||||||
|
- **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다.
|
||||||
|
- **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다.
|
||||||
|
- **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다.
|
||||||
|
|
||||||
|
## 6. 도움말 보는 법
|
||||||
|
|
||||||
|
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
|
||||||
|
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.
|
||||||
36
app/Docs/manual/10_workflow.md
Normal file
36
app/Docs/manual/10_workflow.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 핵심 업무 흐름
|
||||||
|
|
||||||
|
봉투 한 묶음이 시스템에서 거치는 전체 흐름입니다. 처음 사용하신다면 이 순서대로 익히는 것을 권장합니다.
|
||||||
|
|
||||||
|
## 전체 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
발주 ─→ 입고 ─→ 재고(실사) ─→ 판매 / 불출 ─→ 판매현황 · 수불 · 통계
|
||||||
|
```
|
||||||
|
|
||||||
|
| 단계 | 무엇을 하나 | 주요 메뉴 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 발주 | 봉투 종류·수량을 제작업체에 주문 | 발주 입고 관리 › 발주 등록 |
|
||||||
|
| ② 입고 | 도착한 물량을 시스템에 등록(스캐너/일괄) | 발주 입고 관리 › 입고 |
|
||||||
|
| ③ 재고 | 현재 보유 수량 확인, 실사로 실수량 보정 | 재고 관리 |
|
||||||
|
| ④ 판매 | 지정판매소에 판매·반품 처리 | 판매 관리 |
|
||||||
|
| ④ 불출 | 무료 대상자에게 무상 지급 | 불출 관리 |
|
||||||
|
| ⑤ 현황 | 일·기간·연간 판매 및 수불·통계 조회 | 판매 현황 / 봉투 수불 / 통계 분석 |
|
||||||
|
|
||||||
|
## 각 단계 한 줄 요약
|
||||||
|
|
||||||
|
1. **발주** — 봉투 품목·수량·납기를 입력해 발주서를 만들면 추적용 **LOT 번호**가 부여됩니다.
|
||||||
|
2. **입고** — 발주분이 도착하면 입고로 등록합니다. 이때 박스·팩·낱장 단위의 **바코드**가 생성됩니다.
|
||||||
|
3. **재고** — 품목별 현재 재고를 조회하고, 정기적으로 **실사**(선별 → 등록 → 적용)로 실제 수량과 맞춥니다.
|
||||||
|
4. **판매/불출** — 지정판매소 판매·반품, 또는 무료 대상자 불출로 재고가 감소합니다.
|
||||||
|
5. **현황·통계** — 일계표·기간별·연간 판매와 봉투 수불, 전년대비/월별/계절 추이를 확인합니다.
|
||||||
|
|
||||||
|
## 봉투 추적 단위
|
||||||
|
|
||||||
|
봉투는 다음 계층으로 추적됩니다. 자세한 코드 규칙은 **[봉투·LOT·바코드 코드체계]** 문서를 참고하세요.
|
||||||
|
|
||||||
|
```
|
||||||
|
LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 › 번호알기** 화면을 사용하세요.
|
||||||
66
app/Docs/manual/20_order_receiving.md
Normal file
66
app/Docs/manual/20_order_receiving.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 발주 · 입고
|
||||||
|
|
||||||
|
봉투를 제작업체에 **주문(발주)** 하고, 도착한 봉투를 창고에 **들여놓는(입고)** 단계입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발주 등록 · *발주 입고 관리 › 발주 등록*
|
||||||
|
|
||||||
|
봉투를 **얼마나 주문할지** 입력해 발주서를 만드는 화면입니다. 저장하면 추적용 **LOT 번호**가 자동으로 붙습니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **발주가능봉투**: 조달청(나라장터)에 등록되어 주문할 수 있는 봉투 종류.
|
||||||
|
- **입고처**: 들어온 봉투를 받을 창고/장소.
|
||||||
|
- **조달수수료**: 발주 금액에 붙는 수수료율(%).
|
||||||
|
- **Box당 팩 / 팩당 낱장 / 1박스 총 낱장**: 포장 환산 정보(참고용 표).
|
||||||
|
|
||||||
|
**입력 항목**: 발주월, 발주일, 협회, 제작업체, 입고처, **봉투 품목별 수량(박스 단위)**.
|
||||||
|
|
||||||
|
**버튼**: `발주`(저장) · `변경 저장`(수정 시) · `취소`.
|
||||||
|
|
||||||
|
**작업 순서**
|
||||||
|
1. 발주월·발주일, 제작업체·입고처를 고릅니다.
|
||||||
|
2. 아래 봉투 종류별로 **주문할 박스 수량**을 입력합니다(금액·총 낱장은 자동 계산).
|
||||||
|
3. `발주` 를 누르면 발주가 생성되고 LOT 번호가 부여됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발주 현황 · *발주 입고 관리 › 발주 현황*
|
||||||
|
|
||||||
|
낸 발주를 **조회·관리**하는 목록 화면입니다.
|
||||||
|
|
||||||
|
**필터**: 발주기간(월~월) · 제작업체 · 품명 · **입고구분(전체/입고완료/미입고)**.
|
||||||
|
|
||||||
|
**표 컬럼**: 발주일자 · 제작업체 · 품명 · **발주수량 · 입고수량 · 미입고수량** · 발주금액 · 입고처 · 비고.
|
||||||
|
- **미입고수량** = 발주했지만 아직 안 들어온 수량.
|
||||||
|
|
||||||
|
**버튼**: `엑셀저장` · `인쇄` · `발주등록`. (목록에서 개별 발주의 상세·취소 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 입고 처리 · *발주 입고 관리 › 입고[스캐너] / 일괄입고*
|
||||||
|
|
||||||
|
도착한 봉투를 시스템에 **들여놓는** 화면입니다. 입고하면 박스·팩·낱장 **바코드가 생성**되고 재고가 늘어납니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **인계자(제작업체)** / **인수자(대행소)**: 봉투를 넘기는 쪽 / 받는 쪽.
|
||||||
|
- **입고량(매)**: 실제로 들어온 **낱장 수**("매" = 장).
|
||||||
|
- **LOT NO / 발주 NO**: 어떤 발주분인지 식별하는 번호.
|
||||||
|
|
||||||
|
**입고[스캐너]**: 발주 건을 보고 행마다 **입고량(매)** 을 직접 입력 → `입고처리`.
|
||||||
|
**일괄입고**: 여러 발주 건을 **체크박스로 골라** 한 번에 입고. 미입고량은 파란색으로 강조됩니다.
|
||||||
|
|
||||||
|
**작업 순서**
|
||||||
|
1. 제작업체·인수자·인계자·입고일을 고릅니다.
|
||||||
|
2. 들어온 만큼 **입고량(매)** 을 입력(또는 일괄 선택)합니다.
|
||||||
|
3. `입고처리` → 재고 반영. **재고 관리**에서 수량이 늘었는지 확인하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 입고 현황 · *발주 입고 관리 › 입고 현황*
|
||||||
|
|
||||||
|
입고 기록을 기간별로 조회합니다.
|
||||||
|
|
||||||
|
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
|
||||||
|
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
|
||||||
|
**버튼**: `엑셀저장` · `인쇄`.
|
||||||
39
app/Docs/manual/30_inventory.md
Normal file
39
app/Docs/manual/30_inventory.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 재고 · 실사
|
||||||
|
|
||||||
|
지금 창고에 **남은 봉투**를 확인하고, 컴퓨터 기록과 **실제 수량을 맞추는(실사)** 단계입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 재고 현황 · *재고 관리 › 재고 현황*
|
||||||
|
|
||||||
|
품목별로 **현재 남은 수량**을 봅니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **시군구재고**: 지자체(시·군·구) **창고**에 있는 재고.
|
||||||
|
- **대행소재고**: 배송 **대행소**가 보유 중인 재고.
|
||||||
|
- **계**: 둘을 합친 총 재고.
|
||||||
|
|
||||||
|
**필터**: 기준일자 · 대행소(전체/선택).
|
||||||
|
**표 컬럼**: 품목구분 · 봉투/스티커종류 · **계 · 시군구재고 · 대행소재고**.
|
||||||
|
**버튼**: `조회` · `엑셀저장` · `인쇄` · `실사선별조회`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실사 (재고 확인) · *재고 관리 › 실사 선별 조회 / 실사 선별 관리*
|
||||||
|
|
||||||
|
**실사**는 시스템에 적힌 수량(전산재고)과 **창고에서 직접 센 수량(실사재고)** 을 비교해 차이를 바로잡는 작업입니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **전산재고**: 시스템 기록상 수량.
|
||||||
|
- **실사재고**: 현장에서 직접 센 수량(직접 입력).
|
||||||
|
- **차이**: 실사 − 전산. (양수 = 더 많음, 음수 = 부족)
|
||||||
|
- **박스 / 팩 / 낱장**: 셀 단위. 팩코드·낱장(시작~끝) 구간으로 표시됩니다.
|
||||||
|
|
||||||
|
**작업 순서**
|
||||||
|
1. **실사 선별**: 실사할 기간·품목을 골라 대상 목록을 만듭니다(팝업에서 작업일자·품목 선택).
|
||||||
|
2. **실사재고 입력**: 팩/박스별로 실제 센 수량을 입력하면 **차이**가 자동 표시됩니다.
|
||||||
|
3. **저장(적용)**: 검토 후 적용하면 차이가 재고에 반영됩니다.
|
||||||
|
|
||||||
|
**주요 표 컬럼**: 팩코드 · 포장량 · 재고(전산) · **실사재고(입력)** · 차이 · 낱장(시작) · 낱장(끝).
|
||||||
|
|
||||||
|
> 적용 전까지는 재고에 영향을 주지 않으므로, 세는 도중 중단해도 안전합니다.
|
||||||
83
app/Docs/manual/40_sales_issue.md
Normal file
83
app/Docs/manual/40_sales_issue.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 판매 · 반품 · 불출 · 주문
|
||||||
|
|
||||||
|
재고를 외부로 내보내는 단계입니다. **판매**(가게에 유상 공급)·**불출**(무료 배부)·**주문 접수**(전화 등).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지정판매소 판매 · *판매 관리 › 지정 판매소 판매*
|
||||||
|
|
||||||
|
동네 가게(지정판매소)에 봉투를 **판매**하고, 어떤 봉투를 줬는지 **바코드로 기록**합니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **판매소코드/상호/대표자**: 판매하는 가게 정보(검색해서 선택).
|
||||||
|
- **봉투코드(스캔)**: 내보내는 봉투의 바코드. 스캔/입력하면 어떤 LOT·포장단위인지 식별됩니다.
|
||||||
|
- **포장단위(Box/Pack/Sheet)**: 박스/팩/낱장.
|
||||||
|
|
||||||
|
**입력/순서**
|
||||||
|
1. 위에서 **판매소를 검색·선택**합니다(코드·상호·전화·주소로 검색).
|
||||||
|
2. 판매할 봉투 종류·수량을 고르거나 **봉투코드를 스캔**합니다.
|
||||||
|
3. `판매저장` → 재고가 줄고 판매 내역이 기록됩니다.
|
||||||
|
|
||||||
|
**표 컬럼**: (판매내역) 봉투종류·접수량·판매량·단가·판매금액 / (상세) 봉투종류·봉투코드·수량·포장단위.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지정 판매소 반품 / 판매·반품 취소 · *판매 관리*
|
||||||
|
|
||||||
|
- **반품**: 가게가 안 팔린 봉투를 **되돌려 받는** 것. 스캔/선택 후 저장하면 재고가 다시 늘어납니다.
|
||||||
|
- **판매 취소 / 반품 취소**: 잘못 처리한 건을 되돌립니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 판매/반품 현황 · *판매 관리* 또는 *판매 현황*
|
||||||
|
|
||||||
|
기간별 판매·반품 내역을 봅니다.
|
||||||
|
|
||||||
|
**필터**: 조회기간.
|
||||||
|
**표 컬럼**: 판매소 · 판매일 · 봉투코드 · 봉투명 · 수량 · 단가 · 금액 · **구분(판매/반품/취소)**.
|
||||||
|
**버튼**: `조회` · `초기화` · `주문등록` · `판매등록`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전화 주문 접수 · *판매 관리 › 전화 접수*
|
||||||
|
|
||||||
|
가게가 전화로 주문한 내용을 **접수**합니다(실제 출고/판매는 이후 단계).
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **접수일 / 배달일**: 주문 받은 날 / 가져다줄 날(보통 다음날 자동).
|
||||||
|
- **결제구분**: 이체 / 가상계좌.
|
||||||
|
- **1박스·1팩(낱장/판매가)**: 포장별 수량·가격 참고값.
|
||||||
|
|
||||||
|
**입력/순서**
|
||||||
|
1. **판매소 검색·선택**(코드·사업자번호·상호·전화·주소).
|
||||||
|
2. 결제구분을 고르고, 봉투 **품목·주문수량·포장단위(박스/팩/낱장)** 를 입력(`행추가`로 여러 품목).
|
||||||
|
3. `등록` → 주문 접수 완료.
|
||||||
|
|
||||||
|
> **주문 접수(간편)**: 판매소·배달일·결제방법과 봉투별 수량만 입력하는 간단 버전.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 무료용 불출 처리 · *불출 관리 › 무료용 불출 처리*
|
||||||
|
|
||||||
|
무료 대상자(동사무소 등)에게 봉투를 **무상으로 내보내는(불출)** 화면입니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **불출구분(무료용/공공용)**: 주민 무료 배부용 / 공공기관용.
|
||||||
|
- **불출처**: 봉투를 최종 전달할 곳(동사무소·구청·기타).
|
||||||
|
- **재고(낱장) / 환산(낱장)**: 현재 재고 / 입력 수량을 낱장으로 환산한 값.
|
||||||
|
|
||||||
|
**입력/순서**
|
||||||
|
1. 불출년도·분기, 불출구분, 불출일, **불출처(동)** 를 고릅니다.
|
||||||
|
2. **바코드 스캔** 또는 `행추가`로 봉투 종류·수량·포장단위를 입력합니다.
|
||||||
|
3. `저장` → 재고가 줄고 불출 내역이 기록됩니다.
|
||||||
|
|
||||||
|
**표 컬럼**: 봉투코드 · 봉투종류 · 수량 · 포장 · 재고(낱장) · 환산(낱장).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 무료용 불출 취소 · *불출 관리 › 무료용 불출 취소*
|
||||||
|
|
||||||
|
잘못 불출한 건을 **되돌려 재고를 복원**합니다.
|
||||||
|
|
||||||
|
**필터**: 불출월 · 불출처 · 불출구분 · 봉투종류.
|
||||||
|
**순서**: 불출 목록에서 건을 고르고, 품목 내역에서 **취소할 항목을 체크 → 취소수량 입력** 후 처리.
|
||||||
63
app/Docs/manual/50_reports.md
Normal file
63
app/Docs/manual/50_reports.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 현황 · 리포트 · 수불
|
||||||
|
|
||||||
|
입고·판매·불출 기록을 **모아 보여주는** 조회 화면들입니다. 대부분 **기간을 지정해 조회**하고 `엑셀저장`·`인쇄`로 내보낼 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기간별 봉투 수불 현황 · *봉투 수불 관리 › 기간별 봉투 수불 현황*
|
||||||
|
|
||||||
|
**수불(受拂)** = 들어오고 나간 움직임. 기간 동안 봉투가 얼마나 들어오고(입고) 나갔는지(판매·불출 등)를 한 표로 봅니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **전일재고**: 조회 시작일 **전날**의 재고.
|
||||||
|
- **입고**: 입고량 + 반품 + 기타.
|
||||||
|
- **출고**: 판매 + 무료불출 + 반품 + 기타.
|
||||||
|
- **잔량**: 전일재고 + 입고 − 출고.
|
||||||
|
|
||||||
|
**필터**: 조회기간 · 봉투형식 · 봉투구분 · 대행소 · **집계방식(일자별/기간별)**.
|
||||||
|
**표 컬럼**: 일자 · 품목 · 전일재고 · 입고(소계) · 출고(소계) · 잔량.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 일계표 · *판매 현황 › 일계표*
|
||||||
|
|
||||||
|
하루치 판매를 **일계(당일)** 와 **누계(월 누적)** 로 집계합니다.
|
||||||
|
|
||||||
|
**용어**: **일계** = 그날 합계, **누계(월)** = 월초~당일 누적, **징수액** = 판매금액 − 수수료.
|
||||||
|
**필터**: 조회일자 · 대행소 · 구분.
|
||||||
|
**표**: 봉투종류별 — 일계(수량·판매금액·수수료·징수액) / 누계(월) 동일 항목.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지정 판매소별 판매현황 · *판매 현황 › 판매소별 판매현황*
|
||||||
|
|
||||||
|
판매소마다 **얼마나 팔았는지**(수량 또는 금액)를 월별로 비교합니다.
|
||||||
|
|
||||||
|
**필터**: 기간 · 읍면동 · 봉투종류 · **지표(수량/금액)**.
|
||||||
|
**표**: 판매소명 · 판매소코드 · 월별 값 · 합계.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOT 수불 조회 · *봉투 수불 관리 › LOT 수불 조회*
|
||||||
|
|
||||||
|
특정 **봉투번호(바코드)** 또는 **LOT**의 입고·판매·반품 **이력**을 추적합니다.
|
||||||
|
|
||||||
|
**입력**: 봉투번호(바코드/팩코드/박스코드/낱장코드).
|
||||||
|
**표**: 일자 · 품목 · 포장단위 · **구분(입고/판매/반품)** · 수량 · LOT번호.
|
||||||
|
> 입력할 코드 형식이 헷갈리면 좌측 **[봉투·LOT·바코드 코드체계]** 또는 도움말의 **번호알기**를 참고하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 반품/파기 현황 · *봉투 수불 관리 › 반품/파기 현황*
|
||||||
|
|
||||||
|
**용어**: **반품** = 판매소가 되돌린 봉투(출고 탭) / **파기** = 반품분의 폐기(입고 탭).
|
||||||
|
**필터**: 조회기간 · 입출고구분.
|
||||||
|
**표**: 일자 · 판매소명 · 봉투종류 · 수량 · 구분(반품/파기).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 그 밖의 현황
|
||||||
|
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
|
||||||
|
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
|
||||||
|
- **홈택스 처리**: 세금계산서용 데이터(엑셀) 생성.
|
||||||
|
- **통계 분석(전년대비·월별·계절 추이)**: 판매 추세를 그래프로.
|
||||||
69
app/Docs/manual/60_basic_info.md
Normal file
69
app/Docs/manual/60_basic_info.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 기본정보 (판매소 · 단가 · 코드)
|
||||||
|
|
||||||
|
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지정판매소 관리 · *기본정보관리 › 지정 판매소 관리/조회*
|
||||||
|
|
||||||
|
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
|
||||||
|
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
|
||||||
|
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
|
||||||
|
|
||||||
|
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
|
||||||
|
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
|
||||||
|
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 단가 관리 · *기본정보관리 › 단가 관리*
|
||||||
|
|
||||||
|
봉투 **가격**을 기간별로 관리합니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **발주단가**: 제작업체에 주는 가격(살 때).
|
||||||
|
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
|
||||||
|
- **판매단가**: 최종 소비자 판매가.
|
||||||
|
- **수수료율**: 판매수수료율(%).
|
||||||
|
- **적용시작/종료**: 그 단가가 유효한 기간.
|
||||||
|
|
||||||
|
**필터**: 봉투구분 · 봉투코드 · 조회기간.
|
||||||
|
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
|
||||||
|
> 조회 전용이며, 등록·수정은 `단가관리(CRUD)` 화면에서 합니다(이력 보존).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 포장 단위 관리 · *기본정보관리 › 포장 단위 관리*
|
||||||
|
|
||||||
|
봉투 1박스·1팩에 **몇 장이 들어가는지** 정의합니다. 이 값으로 박스↔낱장이 환산됩니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **박스당 팩수**: 1박스 안의 팩 개수.
|
||||||
|
- **팩당 낱장수**: 1팩 안의 낱장(봉투) 수.
|
||||||
|
- **1박스 총 낱장** = 박스당 팩수 × 팩당 낱장수.
|
||||||
|
|
||||||
|
**표 컬럼**: 봉투코드 · 봉투명 · 박스당팩수 · 팩당낱장수 · 1박스총낱장 · 적용시작/종료 · 상태(사용/만료).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기본코드 관리 · *기본정보관리 › 기본 코드 관리*
|
||||||
|
|
||||||
|
시스템 곳곳의 **선택 항목(드롭다운)** 값을 관리합니다. 왼쪽에 **코드 종류**, 오른쪽에 그 종류의 **세부코드**가 나옵니다.
|
||||||
|
|
||||||
|
**이 화면의 용어**
|
||||||
|
- **코드 종류**: 분류(예: 봉투구분, 동코드, 결제구분, 불출구분).
|
||||||
|
- **세부코드**: 그 분류의 실제 값(예: 봉투구분 → 봉투/스티커).
|
||||||
|
|
||||||
|
**자주 쓰는 코드 종류**
|
||||||
|
- **봉투구분**(봉투/스티커) · **동코드**(지역 동) · **결제구분**(이체/가상계좌) · **불출구분**(무료용/공공용).
|
||||||
|
|
||||||
|
**표 컬럼**: 코드 · 코드명 · (세부코드 개수) · 상태(사용/미사용) · 작업(수정/삭제 — 관리자).
|
||||||
|
> 등록·수정·삭제는 슈퍼 관리자만 가능합니다. 조회는 누구나 가능합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 그 밖의 기본정보
|
||||||
|
- **판매 대행소 / 담당자 / 업체(제작·협회·회수) / 무료용 대상자 관리**: 각각 거래처·담당자·대상처를 등록·조회하는 목록 화면입니다(등록·수정은 관리자).
|
||||||
57
app/Docs/manual/90_code_system.md
Normal file
57
app/Docs/manual/90_code_system.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# 봉투 · LOT · 바코드 코드체계
|
||||||
|
|
||||||
|
봉투번호(바코드)·LOT 번호·품목코드가 무엇을 뜻하는지 정리한 안내입니다. LOT 수불 조회나 번호알기 화면에서 코드를 입력할 때 참고하세요.
|
||||||
|
|
||||||
|
## 추적 단위 계층
|
||||||
|
|
||||||
|
```
|
||||||
|
LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 단위 | 예시 | 용도 |
|
||||||
|
|---|---|---|
|
||||||
|
| LOT | `OQXCKH` | 발주 단위, LOT 수불·시드 |
|
||||||
|
| 박스 | `OQXCKH-000008-B001` | 박스 단위 입출고 |
|
||||||
|
| 팩 | `OQXCKH-000008-P299` | 팩 단위(스캔 가능) |
|
||||||
|
| 낱장 | `OQXCKH-000008-P299-S00125` | 봉투 한 장 단위 판매·반품 |
|
||||||
|
|
||||||
|
> `000008`은 "8번째 봉투"가 아니라 **입고 건 번호를 6자리로 채운 값**입니다.
|
||||||
|
|
||||||
|
## 바코드 형식 읽는 법
|
||||||
|
|
||||||
|
`OQXCKH-000008-P299-S00125` 를 예로 들면:
|
||||||
|
|
||||||
|
| 구간 | 값 | 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| 접두 | `OQXCKH` | 발주 LOT 번호 |
|
||||||
|
| 입고번호 | `000008` | 입고 건 번호(6자리) |
|
||||||
|
| 팩 | `P299` | 그 입고 건의 299번째 팩 |
|
||||||
|
| 낱장 | `S00125` | 그 팩의 125번째 낱장(봉투 한 장) |
|
||||||
|
|
||||||
|
- 박스는 `B001`, 팩은 `P299` 처럼 접두 문자로 구분합니다.
|
||||||
|
- LOT 번호는 발주 시 자동 부여되는 영문·숫자 6자리입니다.
|
||||||
|
|
||||||
|
## 품목코드 (봉투 종류)
|
||||||
|
|
||||||
|
품목코드는 봉투의 **종류·용량**을 식별하는 마스터 코드로, 바코드와는 별개입니다.
|
||||||
|
|
||||||
|
| 자리 | 의미 | 예 |
|
||||||
|
|---|---|---|
|
||||||
|
| 앞 2자리 | 봉투 구분(10 일반, 20 공공, 30 무료 …) | `10` |
|
||||||
|
| 다음 2자리 | 용량 | `15` |
|
||||||
|
| 마지막 1자리 | 재질 등 | `2` |
|
||||||
|
|
||||||
|
**예:** `10152` = 일반용 20L. 같은 품목이라도 발주·입고 건마다 바코드(LOT) 접두는 달라집니다.
|
||||||
|
|
||||||
|
## 번호알기로 확인하기
|
||||||
|
|
||||||
|
코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 › 번호알기(봉투번호확인)** 화면에 코드를 입력하세요.
|
||||||
|
|
||||||
|
입력 가능한 형식:
|
||||||
|
|
||||||
|
| 단위 | 입력 형식 | 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| 낱장 | `{LOT}-{입고번호}-P{팩}-S{낱장}` | `OQXCKH-000008-P299-S00125` |
|
||||||
|
| 팩 | `{LOT}-{입고번호}-P{팩}` | `OQXCKH-000008-P299` |
|
||||||
|
| 박스 | `{LOT}-{입고번호}-B{박스}` | `OQXCKH-000008-B001` |
|
||||||
|
| LOT | 영문·숫자 4~8자리 | `OQXCKH` |
|
||||||
29
app/Docs/manual/99_faq.md
Normal file
29
app/Docs/manual/99_faq.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 자주 묻는 질문 · 문의
|
||||||
|
|
||||||
|
## 자주 묻는 질문
|
||||||
|
|
||||||
|
### Q. 로그인 후 업무 화면이 안 열려요.
|
||||||
|
슈퍼 관리자는 **작업할 지자체를 먼저 선택**해야 합니다. 상단 안내에 따라 지자체를 선택하세요. 일반/판매소 계정은 권한 범위 내 메뉴만 보입니다.
|
||||||
|
|
||||||
|
### Q. 메뉴가 안 보여요.
|
||||||
|
역할(권한)에 따라 노출 메뉴가 다릅니다. **시작하기 › 역할별 접근 한눈에 보기** 표를 확인하세요. 그래도 필요한 메뉴가 없으면 관리자에게 문의하세요.
|
||||||
|
|
||||||
|
### Q. 입고했는데 재고에 안 보여요.
|
||||||
|
입고가 정상 저장됐는지 **발주 입고 관리 › 입고 현황**에서 확인하고, **재고 관리 › 재고 현황**에서 품목·지자체 필터를 점검하세요.
|
||||||
|
|
||||||
|
### Q. 판매/불출을 잘못 처리했어요.
|
||||||
|
- 판매: **지정 판매소 판매 취소** 또는 **반품**으로 되돌립니다.
|
||||||
|
- 불출: **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
|
||||||
|
|
||||||
|
### Q. 봉투 코드(바코드)가 무슨 뜻인지 모르겠어요.
|
||||||
|
**도움말 › 번호알기(봉투번호확인)** 에 코드를 입력하면 바코드·인쇄숫자·인식번호로 분해해 보여줍니다. 형식은 **봉투·LOT·바코드 코드체계** 문서를 참고하세요.
|
||||||
|
|
||||||
|
### Q. 비밀번호를 바꾸고 싶어요.
|
||||||
|
**기본정보관리 › PASSWORD 변경** 에서 변경할 수 있습니다.
|
||||||
|
|
||||||
|
### Q. 리포트를 엑셀/인쇄로 저장할 수 있나요?
|
||||||
|
대부분의 현황·리포트 화면에 **엑셀 내보내기**와 **인쇄** 기능이 있습니다. 이 매뉴얼 화면도 우측 상단 **인쇄** 버튼으로 출력할 수 있습니다.
|
||||||
|
|
||||||
|
## 문의
|
||||||
|
|
||||||
|
시스템 사용 중 문제가 있으면 시스템 운영 담당자 또는 소속 지자체 관리자에게 문의하세요.
|
||||||
@@ -11,7 +11,7 @@ use Config\Roles;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 전용 접근 필터.
|
* 관리자 전용 접근 필터.
|
||||||
* logged_in 이고 mb_level 이 SUPER_ADMIN(4) 또는 LOCAL_ADMIN(3) 일 때만 통과.
|
* logged_in 이고 mb_level 이 SUPER_ADMIN(4)·HEADQUARTERS_ADMIN(5)·LOCAL_ADMIN(3) 일 때만 통과.
|
||||||
*/
|
*/
|
||||||
class AdminAuthFilter implements FilterInterface
|
class AdminAuthFilter implements FilterInterface
|
||||||
{
|
{
|
||||||
@@ -22,15 +22,16 @@ class AdminAuthFilter implements FilterInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$level = (int) session()->get('mb_level');
|
$level = (int) session()->get('mb_level');
|
||||||
if ($level !== Roles::LEVEL_SUPER_ADMIN && $level !== Roles::LEVEL_LOCAL_ADMIN) {
|
$isAdminLevel = Roles::isSuperAdminEquivalent($level) || $level === Roles::LEVEL_LOCAL_ADMIN;
|
||||||
|
if (! $isAdminLevel) {
|
||||||
return redirect()->to(site_url('/'))->with('error', '관리자만 접근할 수 있습니다.');
|
return redirect()->to(site_url('/'))->with('error', '관리자만 접근할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Super admin: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용)
|
// Super/본부: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용)
|
||||||
$uri = $request->getUri();
|
$uri = $request->getUri();
|
||||||
$seg2 = $uri->getSegment(2);
|
$seg2 = $uri->getSegment(2);
|
||||||
$allowedWithoutSelection = ['select-local-government', 'local-governments'];
|
$allowedWithoutSelection = ['select-local-government', 'local-governments'];
|
||||||
if ($level === Roles::LEVEL_SUPER_ADMIN && ! in_array($seg2, $allowedWithoutSelection, true)) {
|
if (Roles::isSuperAdminEquivalent($level) && ! in_array($seg2, $allowedWithoutSelection, true)) {
|
||||||
$selected = session()->get('admin_selected_lg_idx');
|
$selected = session()->get('admin_selected_lg_idx');
|
||||||
if ($selected === null || $selected === '') {
|
if ($selected === null || $selected === '') {
|
||||||
return redirect()->to(site_url('admin/select-local-government'))->with('error', '작업할 지자체를 먼저 선택해 주세요.');
|
return redirect()->to(site_url('admin/select-local-government'))->with('error', '작업할 지자체를 먼저 선택해 주세요.');
|
||||||
|
|||||||
29
app/Filters/LoginAuthFilter.php
Normal file
29
app/Filters/LoginAuthFilter.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use CodeIgniter\Filters\FilterInterface;
|
||||||
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인만 필요 (mb_level 무관). 기본코드 조회 등 시민·판매소도 접근 가능한 /admin/* 하위용.
|
||||||
|
*/
|
||||||
|
class LoginAuthFilter implements FilterInterface
|
||||||
|
{
|
||||||
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '로그인이 필요합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||||
|
{
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,17 +6,20 @@ use Config\Roles;
|
|||||||
|
|
||||||
if (! function_exists('admin_effective_lg_idx')) {
|
if (! function_exists('admin_effective_lg_idx')) {
|
||||||
/**
|
/**
|
||||||
* 현재 로그인한 관리자가 작업 대상으로 사용하는 지자체 PK.
|
* 관리자 화면·사이트 메뉴·Bag 등에서 쓰는 작업 지자체 PK.
|
||||||
* Super admin → admin_selected_lg_idx, 지자체 관리자 → mb_lg_idx, 그 외 null.
|
* Super/본부 → admin_selected_lg_idx(미선택 시 null).
|
||||||
|
* 지자체관리자·지정판매소·일반 사용자 → mb_lg_idx(없으면 null).
|
||||||
*/
|
*/
|
||||||
function admin_effective_lg_idx(): ?int
|
function admin_effective_lg_idx(): ?int
|
||||||
{
|
{
|
||||||
$level = (int) session()->get('mb_level');
|
$level = (int) session()->get('mb_level');
|
||||||
if ($level === Roles::LEVEL_SUPER_ADMIN) {
|
if (Roles::isSuperAdminEquivalent($level)) {
|
||||||
$idx = session()->get('admin_selected_lg_idx');
|
$idx = session()->get('admin_selected_lg_idx');
|
||||||
return $idx !== null && $idx !== '' ? (int) $idx : null;
|
return $idx !== null && $idx !== '' ? (int) $idx : null;
|
||||||
}
|
}
|
||||||
if ($level === Roles::LEVEL_LOCAL_ADMIN) {
|
if ($level === Roles::LEVEL_LOCAL_ADMIN
|
||||||
|
|| $level === Roles::LEVEL_SHOP
|
||||||
|
|| $level === Roles::LEVEL_CITIZEN) {
|
||||||
$idx = session()->get('mb_lg_idx');
|
$idx = session()->get('mb_lg_idx');
|
||||||
return $idx !== null && $idx !== '' ? (int) $idx : null;
|
return $idx !== null && $idx !== '' ? (int) $idx : null;
|
||||||
}
|
}
|
||||||
@@ -24,10 +27,27 @@ if (! function_exists('admin_effective_lg_idx')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('resolve_site_menu_lg_idx')) {
|
||||||
|
/**
|
||||||
|
* site 상단 메뉴(menu 테이블) 조회용 지자체 PK.
|
||||||
|
* admin_effective_lg_idx() 우선(메뉴 관리·Bag과 동일), 없으면 mb_lg_idx, 그다음 기본 1.
|
||||||
|
*/
|
||||||
|
function resolve_site_menu_lg_idx(): int
|
||||||
|
{
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if ($lgIdx !== null) {
|
||||||
|
return $lgIdx;
|
||||||
|
}
|
||||||
|
$raw = session()->get('mb_lg_idx');
|
||||||
|
|
||||||
|
return ($raw !== null && $raw !== '') ? (int) $raw : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! function_exists('get_admin_nav_items')) {
|
if (! function_exists('get_admin_nav_items')) {
|
||||||
/**
|
/**
|
||||||
* 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열).
|
* 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열).
|
||||||
* 지자체 미선택(super admin)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열.
|
* 지자체 미선택(super/본부)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열.
|
||||||
*
|
*
|
||||||
* 하위 메뉴 포함 트리 구조가 필요하면 get_admin_nav_tree() 사용.
|
* 하위 메뉴 포함 트리 구조가 필요하면 get_admin_nav_tree() 사용.
|
||||||
*/
|
*/
|
||||||
@@ -130,22 +150,31 @@ if (! function_exists('get_site_nav_tree')) {
|
|||||||
function get_site_nav_tree(): array
|
function get_site_nav_tree(): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$lgIdx = session()->get('mb_lg_idx');
|
$lgIdx = resolve_site_menu_lg_idx();
|
||||||
// 시민 등 지자체 정보가 세션에 없으면 기본 지자체(1) 기준으로 메뉴를 보여 준다.
|
|
||||||
if ($lgIdx === null || $lgIdx === '') {
|
|
||||||
$lgIdx = 1;
|
|
||||||
}
|
|
||||||
$typeRow = model(\App\Models\MenuTypeModel::class)->getByCode('site');
|
|
||||||
if (! $typeRow) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$mbLevel = (int) session()->get('mb_level');
|
$mbLevel = (int) session()->get('mb_level');
|
||||||
$menuModel = model(\App\Models\MenuModel::class);
|
$menuModel = model(\App\Models\MenuModel::class);
|
||||||
$flat = $menuModel->getVisibleByLevel((int) $typeRow->mt_idx, $mbLevel, (int) $lgIdx);
|
$typeRow = model(\App\Models\MenuTypeModel::class)->getByCode('site');
|
||||||
|
$siteMtIdx = $typeRow ? (int) $typeRow->mt_idx : 0;
|
||||||
|
if ($siteMtIdx <= 0) {
|
||||||
|
// 운영 DB 불일치 대비: menu_type 누락 시 legacy site mt_idx(4)로 시도
|
||||||
|
$siteMtIdx = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
$flat = $menuModel->getVisibleByLevel($siteMtIdx, $mbLevel, (int) $lgIdx);
|
||||||
// 현재 지자체에 site 메뉴가 없으면, 기본 지자체(1)의 메뉴를 한 번 복사한 뒤 다시 시도
|
// 현재 지자체에 site 메뉴가 없으면, 기본 지자체(1)의 메뉴를 한 번 복사한 뒤 다시 시도
|
||||||
if (empty($flat)) {
|
if (empty($flat)) {
|
||||||
$menuModel->copyDefaultsFromLg((int) $typeRow->mt_idx, 1, (int) $lgIdx);
|
$menuModel->copyDefaultsFromLg($siteMtIdx, 1, (int) $lgIdx);
|
||||||
$flat = $menuModel->getVisibleByLevel((int) $typeRow->mt_idx, $mbLevel, (int) $lgIdx);
|
$flat = $menuModel->getVisibleByLevel($siteMtIdx, $mbLevel, (int) $lgIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// site 타입 매핑 불일치(예: menu_type=2, menu 데이터=4) 보정
|
||||||
|
if (empty($flat) && $siteMtIdx !== 4) {
|
||||||
|
$legacyMtIdx = 4;
|
||||||
|
$flat = $menuModel->getVisibleByLevel($legacyMtIdx, $mbLevel, (int) $lgIdx);
|
||||||
|
if (empty($flat)) {
|
||||||
|
$menuModel->copyDefaultsFromLg($legacyMtIdx, 1, (int) $lgIdx);
|
||||||
|
$flat = $menuModel->getVisibleByLevel($legacyMtIdx, $mbLevel, (int) $lgIdx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (empty($flat)) {
|
if (empty($flat)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -156,3 +185,783 @@ if (! function_exists('get_site_nav_tree')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('current_nav_request_path')) {
|
||||||
|
/**
|
||||||
|
* 메뉴 활성·mm_link 비교용 현재 경로 (라우트 기준, base_url 뒤 세그먼트).
|
||||||
|
* request->getPath() · uri_string() · SiteURI::getRoutePath() 중 비어 있지 않은 값을 사용.
|
||||||
|
*/
|
||||||
|
function current_nav_request_path(): string
|
||||||
|
{
|
||||||
|
helper('url');
|
||||||
|
|
||||||
|
$request = service('request');
|
||||||
|
// 프레임워크 권장: uri_string() = baseURL 기준 경로 (우선)
|
||||||
|
$candidates = [trim(uri_string(), '/')];
|
||||||
|
if ($request instanceof \CodeIgniter\HTTP\IncomingRequest) {
|
||||||
|
$candidates[] = trim((string) $request->getPath(), '/');
|
||||||
|
}
|
||||||
|
$uri = $request->getUri();
|
||||||
|
if ($uri instanceof \CodeIgniter\HTTP\SiteURI) {
|
||||||
|
$candidates[] = trim($uri->getRoutePath(), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = '';
|
||||||
|
foreach ($candidates as $c) {
|
||||||
|
if ($c !== '') {
|
||||||
|
$path = $c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (str_starts_with($path, 'index.php/')) {
|
||||||
|
$path = substr($path, strlen('index.php/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURL 에 경로가 있으면(서브폴더 설치) URI 앞에 붙은 동일 접두 제거
|
||||||
|
$basePath = parse_url(config(\Config\App::class)->baseURL, PHP_URL_PATH);
|
||||||
|
$basePath = is_string($basePath) ? trim($basePath, '/') : '';
|
||||||
|
if ($basePath !== '' && $path !== '' && ($path === $basePath || str_starts_with($path, $basePath . '/'))) {
|
||||||
|
$path = $path === $basePath ? '' : substr($path, strlen($basePath) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('normalize_menu_link_for_url')) {
|
||||||
|
/**
|
||||||
|
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.
|
||||||
|
* http(s)://... 전체 URL이면 path 만 사용하고, 앞뒤 공백·슬래시를 정리합니다.
|
||||||
|
*/
|
||||||
|
function normalize_menu_link_for_url(?string $mmLink): string
|
||||||
|
{
|
||||||
|
$s = trim((string) $mmLink);
|
||||||
|
if ($s === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (str_contains($s, '://')) {
|
||||||
|
$path = parse_url($s, PHP_URL_PATH);
|
||||||
|
$s = is_string($path) ? trim($path, '/') : '';
|
||||||
|
} else {
|
||||||
|
$s = trim($s, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (str_starts_with($s, 'index.php/')) {
|
||||||
|
$s = substr($s, strlen('index.php/'));
|
||||||
|
}
|
||||||
|
if (str_starts_with($s, 'public/')) {
|
||||||
|
$s = substr($s, strlen('public/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$basePath = parse_url(config(\Config\App::class)->baseURL, PHP_URL_PATH);
|
||||||
|
$basePath = is_string($basePath) ? trim($basePath, '/') : '';
|
||||||
|
if ($basePath !== '' && $s !== '' && ($s === $basePath || str_starts_with($s, $basePath . '/'))) {
|
||||||
|
$s = $s === $basePath ? '' : substr($s, strlen($basePath) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('mgmt_path')) {
|
||||||
|
/**
|
||||||
|
* 업무 화면 상대 경로 (페이저 setPath 등). 선행 슬래시 없음.
|
||||||
|
*/
|
||||||
|
function mgmt_path(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path, '/');
|
||||||
|
// bag/packaging-units 는 조회 전용(Bag) — CRUD 는 /bag/packaging-units/manage/* 로 분리
|
||||||
|
if ($path === 'packaging-units') {
|
||||||
|
$path = 'packaging-units/manage';
|
||||||
|
} elseif (str_starts_with($path, 'packaging-units/')) {
|
||||||
|
$path = 'packaging-units/manage/' . substr($path, strlen('packaging-units/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bag/' . $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('mgmt_url')) {
|
||||||
|
/**
|
||||||
|
* 업무 화면 링크: 정식 URL 은 /bag/* (adminAuth). 포장 단위 CRUD 는 packaging-units/manage 로 치환.
|
||||||
|
*/
|
||||||
|
function mgmt_url(string $path): string
|
||||||
|
{
|
||||||
|
helper('url');
|
||||||
|
|
||||||
|
return site_url(mgmt_path($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('apply_pager_path')) {
|
||||||
|
/**
|
||||||
|
* CI4 페이저: setPath 는 상대 경로만 허용(전체 URL 시 baseURL 이중 결합).
|
||||||
|
* 검색 조건은 only() 로 유지합니다.
|
||||||
|
*
|
||||||
|
* @param \CodeIgniter\Pager\Pager $pager
|
||||||
|
*/
|
||||||
|
function apply_pager_path($pager, string $path, array $queryForPager = []): void
|
||||||
|
{
|
||||||
|
$pager->setPath($path);
|
||||||
|
if ($queryForPager !== []) {
|
||||||
|
$pager->only(array_keys($queryForPager));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('work_area_home_url')) {
|
||||||
|
/**
|
||||||
|
* 지자체 미선택 등으로 돌아갈 때: bag 업무 중이면 대시보드, 관리자면 admin 홈.
|
||||||
|
*/
|
||||||
|
function work_area_home_url(): string
|
||||||
|
{
|
||||||
|
helper('url');
|
||||||
|
$seg1 = service('request')->getUri()->getSegment(1);
|
||||||
|
|
||||||
|
return ($seg1 === 'bag') ? site_url('dashboard') : site_url('admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('format_ymd_korean')) {
|
||||||
|
/**
|
||||||
|
* Y-m-d 날짜를 '2026년 1월 5일' 형식으로 (월·일은 숫자, 월명은 한글 '월').
|
||||||
|
*/
|
||||||
|
function format_ymd_korean(?string $ymd): string
|
||||||
|
{
|
||||||
|
if ($ymd === null || trim($ymd) === '') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$t = \DateTimeImmutable::createFromFormat('Y-m-d', trim($ymd));
|
||||||
|
if ($t === false) {
|
||||||
|
return $ymd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $t->format('Y') . '년 ' . (int) $t->format('n') . '월 ' . (int) $t->format('j') . '일';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('parse_ymd_from_triple')) {
|
||||||
|
/**
|
||||||
|
* 연·월·일 GET 값으로 Y-m-d 생성 (유효하지 않은 날짜는 null).
|
||||||
|
*/
|
||||||
|
function parse_ymd_from_triple(?string $y, ?string $m, ?string $d): ?string
|
||||||
|
{
|
||||||
|
if ($y === null || $y === '' || $m === null || $m === '' || $d === null || $d === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$yi = (int) $y;
|
||||||
|
$mi = (int) $m;
|
||||||
|
$di = (int) $d;
|
||||||
|
if ($yi < 1000 || $yi > 9999 || ! checkdate($mi, $di, $yi)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%04d-%02d-%02d', $yi, $mi, $di);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('site_nav_resolved_link_path')) {
|
||||||
|
/**
|
||||||
|
* 사이트 상단 메뉴 URL 세그먼트. mm_link(DB)만 사용 (비어 있으면 빈 문자열).
|
||||||
|
*
|
||||||
|
* @param string|null $mmName 호환용(미사용).
|
||||||
|
*
|
||||||
|
* @return string base_url() 인자 세그먼트(앞뒤 슬래시 없음)
|
||||||
|
*/
|
||||||
|
function site_nav_resolved_link_path(?string $mmLink, ?string $mmName = null): string
|
||||||
|
{
|
||||||
|
return normalize_menu_link_for_url($mmLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('menu_link_candidate_paths')) {
|
||||||
|
/**
|
||||||
|
* 활성 비교용 경로 후보. DB에 "menus" 처럼 짧게 넣은 경우 실제 URI가 admin/menus·bag/… 일 수 있어,
|
||||||
|
* 현재 요청 경로에 맞게 admin/·bag/ 접두를 붙인 후보도 만든다. (슬래시 포함·admin 단독은 그대로 1개만)
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function menu_link_candidate_paths(?string $mmLink, string $currentPath): array
|
||||||
|
{
|
||||||
|
$p = normalize_menu_link_for_url($mmLink);
|
||||||
|
if ($p === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (str_contains($p, '/') || $p === 'admin') {
|
||||||
|
$cands = [$p];
|
||||||
|
if (preg_match('#^bag/packaging-units/manage(/.*)?$#', $p, $m)) {
|
||||||
|
$cands[] = 'admin/packaging-units' . ($m[1] ?? '');
|
||||||
|
} elseif (preg_match('#^admin/packaging-units(/.*)?$#', $p, $m)) {
|
||||||
|
$cands[] = 'bag/packaging-units/manage' . ($m[1] ?? '');
|
||||||
|
} elseif ($p === 'bag/inventory/inspection-select') {
|
||||||
|
// 실사 선별 조회 메뉴는 작업 화면(inspection-work)도 동일 메뉴로 활성 처리
|
||||||
|
$cands[] = 'bag/inventory/inspection-work';
|
||||||
|
$cands[] = 'bag/inventory/inspection';
|
||||||
|
} elseif (str_starts_with($p, 'admin/')) {
|
||||||
|
$cands[] = 'bag/' . substr($p, strlen('admin/'));
|
||||||
|
} elseif (str_starts_with($p, 'bag/')) {
|
||||||
|
$cands[] = 'admin/' . substr($p, strlen('bag/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($cands));
|
||||||
|
}
|
||||||
|
$out = [$p];
|
||||||
|
if (str_starts_with($currentPath, 'admin/') || $currentPath === 'admin') {
|
||||||
|
$out[] = 'admin/' . $p;
|
||||||
|
}
|
||||||
|
if (str_starts_with($currentPath, 'bag/') || $currentPath === 'bag') {
|
||||||
|
$out[] = 'bag/' . $p;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('menu_link_preferred_href_path')) {
|
||||||
|
/**
|
||||||
|
* base_url() 용 경로: 짧게 저장된 mm_link 는 현재 요청 기준 admin/·bag/ 후보 중 가장 알맞은 것 사용.
|
||||||
|
*/
|
||||||
|
function menu_link_preferred_href_path(?string $mmLink, string $currentPath): string
|
||||||
|
{
|
||||||
|
$cands = menu_link_candidate_paths($mmLink, $currentPath);
|
||||||
|
if ($cands === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
foreach ($cands as $c) {
|
||||||
|
$cl = strtolower($currentPath);
|
||||||
|
$cc = strtolower($c);
|
||||||
|
if ($cl === $cc || str_starts_with($cl, $cc . '/')) {
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($cands as $c) {
|
||||||
|
if (str_contains($c, '/')) {
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cands[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('menu_single_path_matches_request')) {
|
||||||
|
/**
|
||||||
|
* 단일 정규 경로가 현재 요청 path 와 일치하는지.
|
||||||
|
*
|
||||||
|
* @param list<string> $dashboardPathAliases
|
||||||
|
*/
|
||||||
|
function menu_single_path_matches_request(string $path, string $currentPath, array $dashboardPathAliases = []): bool
|
||||||
|
{
|
||||||
|
if ($path === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$pathLower = strtolower($path);
|
||||||
|
$currentLower = strtolower($currentPath);
|
||||||
|
$aliasesLower = array_map(strtolower(...), $dashboardPathAliases);
|
||||||
|
|
||||||
|
if ($dashboardPathAliases !== []
|
||||||
|
&& in_array($pathLower, $aliasesLower, true)
|
||||||
|
&& in_array($currentLower, $aliasesLower, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($currentLower === $pathLower) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($pathLower === 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_starts_with($currentLower, $pathLower . '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('menu_link_matches_request')) {
|
||||||
|
/**
|
||||||
|
* 메뉴 mm_link(DB)가 현재 요청과 같은 메뉴인지. 비어 있으면 false.
|
||||||
|
*
|
||||||
|
* @param list<string> $dashboardPathAliases
|
||||||
|
*/
|
||||||
|
function menu_link_matches_request(?string $mmLink, string $currentPath, array $dashboardPathAliases = []): bool
|
||||||
|
{
|
||||||
|
foreach (menu_link_candidate_paths($mmLink, $currentPath) as $cand) {
|
||||||
|
if (menu_single_path_matches_request($cand, $currentPath, $dashboardPathAliases)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('site_nav_link_matches_current')) {
|
||||||
|
/**
|
||||||
|
* 사이트 상단 메뉴 활성 여부 (경로 후보·대시보드 별칭은 menu_link_matches_request 와 동일).
|
||||||
|
*
|
||||||
|
* @param list<string> $dashboardPathAliases
|
||||||
|
*/
|
||||||
|
function site_nav_link_matches_current(?string $mmLink, string $currentPath, array $dashboardPathAliases = []): bool
|
||||||
|
{
|
||||||
|
return menu_link_matches_request($mmLink, $currentPath, $dashboardPathAliases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('menu_active_child_for_parent')) {
|
||||||
|
/**
|
||||||
|
* 같은 부모 아래 형제 소메뉴 중, 현재 요청에 해당하는 항목을 하나만 고른다.
|
||||||
|
*
|
||||||
|
* 짧은 mm_link(예: bag/designated-shops)가 긴 경로(bag/designated-shops/browse)와
|
||||||
|
* 동시에 prefix 규칙으로 매칭될 때, 가장 구체적인 경로(일치한 후보 문자열 길이 최대)만 활성으로 본다.
|
||||||
|
* 길이가 같으면 mm_num이 작은 항목을 선택(동일 URL이 여러 메뉴에 매핑된 경우 등).
|
||||||
|
*
|
||||||
|
* @param object{children?: array<int, object>} $parentNavItem
|
||||||
|
* @param list<string> $dashboardPathAliases
|
||||||
|
*
|
||||||
|
* @return object|null 활성으로 표시할 자식 노드(mm_idx 등 포함), 없으면 null
|
||||||
|
*/
|
||||||
|
function menu_active_child_for_parent(object $parentNavItem, string $currentPath, array $dashboardPathAliases = []): ?object
|
||||||
|
{
|
||||||
|
$children = $parentNavItem->children ?? [];
|
||||||
|
if ($children === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$best = null;
|
||||||
|
$bestLen = -1;
|
||||||
|
$bestMmNum = PHP_INT_MAX;
|
||||||
|
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$mmLink = $child->mm_link ?? null;
|
||||||
|
$maxLen = -1;
|
||||||
|
foreach (menu_link_candidate_paths($mmLink, $currentPath) as $cand) {
|
||||||
|
if (menu_single_path_matches_request($cand, $currentPath, $dashboardPathAliases)) {
|
||||||
|
$maxLen = max($maxLen, strlen($cand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($maxLen < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$mmNum = (int) ($child->mm_num ?? 0);
|
||||||
|
if ($maxLen > $bestLen || ($maxLen === $bestLen && $mmNum < $bestMmNum)) {
|
||||||
|
$bestLen = $maxLen;
|
||||||
|
$bestMmNum = $mmNum;
|
||||||
|
$best = $child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('site_nav_active_child_for_parent')) {
|
||||||
|
/**
|
||||||
|
* 사이트 상단 메뉴 전용 호환 래퍼.
|
||||||
|
*
|
||||||
|
* @param object{children?: array<int, object>} $parentNavItem
|
||||||
|
* @param list<string> $dashboardPathAliases
|
||||||
|
*
|
||||||
|
* @return object|null
|
||||||
|
*/
|
||||||
|
function site_nav_active_child_for_parent(object $parentNavItem, string $currentPath, array $dashboardPathAliases = []): ?object
|
||||||
|
{
|
||||||
|
return menu_active_child_for_parent($parentNavItem, $currentPath, $dashboardPathAliases);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('session_user_nav_display')) {
|
||||||
|
/**
|
||||||
|
* 상단 메뉴바용: 로그인 사용자 이름·역할 표시
|
||||||
|
*
|
||||||
|
* @return array{name: string, role_label: string}|null
|
||||||
|
*/
|
||||||
|
function session_user_nav_display(): ?array
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) session()->get('mb_name'));
|
||||||
|
if ($name === '') {
|
||||||
|
$name = (string) session()->get('mb_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
$roleLabel = config('Roles')->getLevelName($level);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'role_label' => $roleLabel,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_active_variant')) {
|
||||||
|
/**
|
||||||
|
* 공공 포털 시안 변형: base(좌측 MY MENU) | strip(호버 상단 메뉴).
|
||||||
|
*/
|
||||||
|
function gov_portal_active_variant(?string $fromPath = null): string
|
||||||
|
{
|
||||||
|
$path = strtolower(ltrim($fromPath ?? current_nav_request_path(), '/'));
|
||||||
|
|
||||||
|
return str_starts_with($path, 'dashboard/gov-portal-strip') ? 'strip' : 'base';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_code_kinds_portal_path')) {
|
||||||
|
/**
|
||||||
|
* 포털 UI 기본 코드관리 시안 경로 (변형별).
|
||||||
|
*/
|
||||||
|
function gov_portal_code_kinds_portal_path(?string $variant = null): string
|
||||||
|
{
|
||||||
|
return gov_portal_active_variant($variant) === 'strip'
|
||||||
|
? 'dashboard/gov-portal-strip/code-kinds'
|
||||||
|
: 'dashboard/gov-portal/code-kinds';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_menu_href_remap')) {
|
||||||
|
/**
|
||||||
|
* gov-portal 상·좌측·드롭다운 메뉴 전용: 기본 코드관리 → 변형별 포털 시안 URL.
|
||||||
|
*/
|
||||||
|
function gov_portal_menu_href_remap(string $href, ?string $variant = null): string
|
||||||
|
{
|
||||||
|
if (strtolower(ltrim($href, '/')) !== 'bag/code-kinds') {
|
||||||
|
return $href;
|
||||||
|
}
|
||||||
|
|
||||||
|
return gov_portal_code_kinds_portal_path($variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_nav_match_path')) {
|
||||||
|
/**
|
||||||
|
* 포털 시안 URL 접속 시 사이트 메뉴(mm_link) 활성 판별용.
|
||||||
|
*/
|
||||||
|
function gov_portal_nav_match_path(string $currentPath): string
|
||||||
|
{
|
||||||
|
$key = strtolower(ltrim($currentPath, '/'));
|
||||||
|
|
||||||
|
return match ($key) {
|
||||||
|
'dashboard/gov-portal/code-kinds',
|
||||||
|
'dashboard/gov-portal-strip/code-kinds' => 'bag/code-kinds',
|
||||||
|
default => $currentPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_dashboard_aliases')) {
|
||||||
|
/**
|
||||||
|
* 포털 대시보드 현재 경로·메뉴 활성 판별용 별칭.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function gov_portal_dashboard_aliases(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'dashboard',
|
||||||
|
'dashboard/blend',
|
||||||
|
'dashboard/simple',
|
||||||
|
'dashboard/lite',
|
||||||
|
'dashboard/compact',
|
||||||
|
'dashboard/dense',
|
||||||
|
'dashboard/charts',
|
||||||
|
'dashboard/modern',
|
||||||
|
'dashboard/gov-portal',
|
||||||
|
'dashboard/gov-portal-strip',
|
||||||
|
'dashboard/gov-portal/code-kinds',
|
||||||
|
'dashboard/gov-portal-strip/code-kinds',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_nav_context')) {
|
||||||
|
/**
|
||||||
|
* 공공 포털형 대시보드(gov-portal)용 사이트 메뉴 트리·JSON·활성 대메뉴 인덱스.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* siteNavTree: array<int, object>,
|
||||||
|
* navItems: list<array{idx:int,name:string,href:string,url:string,children:list<array>,hasChildren:bool}>,
|
||||||
|
* navJson: string,
|
||||||
|
* activeParentIdx: int,
|
||||||
|
* currentPath: string,
|
||||||
|
* dashboardAliases: list<string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
function gov_portal_nav_context(bool $remapLinks = true, ?array $tree = null): array
|
||||||
|
{
|
||||||
|
helper('url');
|
||||||
|
|
||||||
|
$tree = $tree ?? get_site_nav_tree();
|
||||||
|
$rawPath = current_nav_request_path();
|
||||||
|
$variant = gov_portal_active_variant($rawPath);
|
||||||
|
$currentPath = gov_portal_nav_match_path($rawPath);
|
||||||
|
$aliases = gov_portal_dashboard_aliases();
|
||||||
|
$items = [];
|
||||||
|
$activeParentIdx = 0;
|
||||||
|
|
||||||
|
foreach ($tree as $pIdx => $parent) {
|
||||||
|
$children = [];
|
||||||
|
foreach ($parent->children ?? [] as $child) {
|
||||||
|
$href = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
|
||||||
|
if ($remapLinks) {
|
||||||
|
$href = gov_portal_menu_href_remap($href, $variant);
|
||||||
|
}
|
||||||
|
$children[] = [
|
||||||
|
'idx' => (int) ($child->mm_idx ?? 0),
|
||||||
|
'name' => (string) ($child->mm_name ?? ''),
|
||||||
|
'href' => $href,
|
||||||
|
'url' => $href !== '' ? base_url($href) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentHref = menu_link_preferred_href_path($parent->mm_link ?? null, $currentPath);
|
||||||
|
$isParentActive = site_nav_link_matches_current($parent->mm_link ?? null, $currentPath, $aliases);
|
||||||
|
$activeChild = menu_active_child_for_parent($parent, $currentPath, $aliases);
|
||||||
|
if ($isParentActive || $activeChild !== null) {
|
||||||
|
$activeParentIdx = (int) $pIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'idx' => (int) ($parent->mm_idx ?? 0),
|
||||||
|
'name' => (string) ($parent->mm_name ?? ''),
|
||||||
|
'href' => $parentHref,
|
||||||
|
'url' => $parentHref !== '' ? base_url($parentHref) : '',
|
||||||
|
'children' => $children,
|
||||||
|
'hasChildren' => $children !== [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'siteNavTree' => $tree,
|
||||||
|
'navItems' => $items,
|
||||||
|
'navJson' => json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS),
|
||||||
|
'activeParentIdx' => $activeParentIdx,
|
||||||
|
'currentPath' => $rawPath,
|
||||||
|
'dashboardAliases'=> $aliases,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_menu_search_options')) {
|
||||||
|
/**
|
||||||
|
* 메뉴검색 입력 아래 표시할 바로가기(사이트 메뉴에서 추출, 최대 N개).
|
||||||
|
*
|
||||||
|
* @param list<array> $navItems gov_portal_nav_context()['navItems']
|
||||||
|
* @return list<array{label:string,url:string,keyword:string}>
|
||||||
|
*/
|
||||||
|
function gov_portal_menu_search_options(array $navItems, int $max = 8): array
|
||||||
|
{
|
||||||
|
$picked = [];
|
||||||
|
$seen = [];
|
||||||
|
$prefer = ['재고', '발주', '수불', '판매', '통계', '실사', '발급', '주문', '코드', '지정판매', '구매'];
|
||||||
|
|
||||||
|
$push = static function (array $child) use (&$picked, &$seen, $max): bool {
|
||||||
|
$href = (string) ($child['href'] ?? '');
|
||||||
|
if ($href === '' || isset($seen[$href])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$seen[$href] = true;
|
||||||
|
$picked[] = [
|
||||||
|
'label' => (string) ($child['name'] ?? ''),
|
||||||
|
'url' => (string) ($child['url'] ?? ''),
|
||||||
|
'keyword' => (string) ($child['name'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
return count($picked) >= $max;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($prefer as $needle) {
|
||||||
|
if (count($picked) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($navItems as $parent) {
|
||||||
|
foreach ($parent['children'] ?? [] as $child) {
|
||||||
|
$name = (string) ($child['name'] ?? '');
|
||||||
|
if ($name === '' || ! str_contains($name, $needle)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($push($child)) {
|
||||||
|
break 3;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (count($picked) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($navItems as $parent) {
|
||||||
|
if (count($picked) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($parent['children'] ?? [] as $child) {
|
||||||
|
if ($push($child)) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $picked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_dashboard_view_data')) {
|
||||||
|
/**
|
||||||
|
* 공공 포털형 대시보드(gov-portal / gov-portal-strip) 뷰 데이터.
|
||||||
|
* 컨트롤러에서 view() 두 번째 인자로 전달한다.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function gov_portal_dashboard_view_data(string $lgLabel, string $activeVariant): array
|
||||||
|
{
|
||||||
|
helper('url');
|
||||||
|
$govNav = gov_portal_nav_context();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lgLabel' => $lgLabel,
|
||||||
|
'activeVariant' => $activeVariant,
|
||||||
|
'mbName' => (string) (session()->get('mb_name') ?? '담당자'),
|
||||||
|
'mbId' => (string) (session()->get('mb_id') ?? ''),
|
||||||
|
'levelName' => config('Roles')->getLevelName((int) session()->get('mb_level')),
|
||||||
|
'weeklyRequests' => [7, 12, 9, 14, 8, 11, 10],
|
||||||
|
'stockMix' => [
|
||||||
|
['name' => '일반용', 'value' => 52, 'color' => '#3b82f6'],
|
||||||
|
['name' => '음식물', 'value' => 28, 'color' => '#10b981'],
|
||||||
|
['name' => '특수', 'value' => 20, 'color' => '#f59e0b'],
|
||||||
|
],
|
||||||
|
'lowStock' => [
|
||||||
|
['name' => '일반 20L', 'percent' => 34],
|
||||||
|
['name' => '특수규격 A', 'percent' => 22],
|
||||||
|
['name' => '재사용봉투', 'percent' => 58],
|
||||||
|
],
|
||||||
|
'stockAlerts' => [
|
||||||
|
[
|
||||||
|
'count' => 18,
|
||||||
|
'label' => '정상',
|
||||||
|
'class' => 'al-blue',
|
||||||
|
'bags' => ['일반 10L', '일반 20L', '음식물 2L', '음식물 5L'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'count' => 3,
|
||||||
|
'label' => '주의',
|
||||||
|
'class' => 'al-yellow',
|
||||||
|
'bags' => ['일반 50L', '특수 소형', '음식물 10L'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'count' => 2,
|
||||||
|
'label' => '경계',
|
||||||
|
'class' => 'al-orange',
|
||||||
|
'bags' => ['특수규격 A', '재사용봉투'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'count' => 1,
|
||||||
|
'label' => '부족',
|
||||||
|
'class' => 'al-red',
|
||||||
|
'bags' => ['일반 20L'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'quickLinks' => [
|
||||||
|
['label' => '기본 코드관리', 'desc' => '포털 UI · 코드 종류·세부코드', 'url' => base_url(gov_portal_code_kinds_portal_path($activeVariant)), 'icon' => 'fa-code'],
|
||||||
|
['label' => '창고 재고 조회', 'desc' => '품목별 현재 재고', 'url' => base_url('bag/inventory'), 'icon' => 'fa-boxes-stacked'],
|
||||||
|
['label' => '발주(구매신청) 등록', 'desc' => '지정판매소 발주 입력', 'url' => base_url('bag/order/create'), 'icon' => 'fa-cart-shopping'],
|
||||||
|
['label' => '수불 흐름 보기', 'desc' => '입고·출고 내역', 'url' => base_url('bag/flow'), 'icon' => 'fa-arrow-right-arrow-left'],
|
||||||
|
['label' => '판매 내역 조회', 'desc' => '기간별 판매 현황', 'url' => base_url('bag/sales'), 'icon' => 'fa-receipt'],
|
||||||
|
['label' => '전년 대비 통계', 'desc' => '통계분석 · YoY', 'url' => base_url('bag/analytics/year-over-year'), 'icon' => 'fa-chart-line'],
|
||||||
|
['label' => '도움말 / 매뉴얼', 'desc' => '업무별 사용 안내', 'url' => base_url('bag/help'), 'icon' => 'fa-circle-question'],
|
||||||
|
],
|
||||||
|
'notices' => [
|
||||||
|
['title' => '봉투 단가 조정 예고 — 3/1 적용 예정', 'date' => '2026.05.12'],
|
||||||
|
['title' => '실사·재고 점검 일정 안내', 'date' => '2026.05.08'],
|
||||||
|
['title' => '지정판매소 바코드 연동 점검 완료', 'date' => '2026.04.29'],
|
||||||
|
],
|
||||||
|
'timeline' => [
|
||||||
|
['time' => '14:32', 'text' => 'GS25 검단점 — 일반 20L 판매 3건'],
|
||||||
|
['time' => '13:10', 'text' => '북구 창고 — 입고 확인 완료'],
|
||||||
|
['time' => '11:45', 'text' => '구매신청 #1042 승인 대기'],
|
||||||
|
['time' => '09:20', 'text' => '회원 가입 승인 1건 처리'],
|
||||||
|
],
|
||||||
|
'govMapPanel' => [
|
||||||
|
'centerLat' => 35.8714,
|
||||||
|
'centerLng' => 128.6014,
|
||||||
|
'zoom' => 11,
|
||||||
|
'markers' => [
|
||||||
|
['lat' => 35.8852, 'lng' => 128.5821, 'kind' => 'warehouse', 'title' => '북구 종량제 창고'],
|
||||||
|
['lat' => 35.8684, 'lng' => 128.6243, 'kind' => 'shop', 'title' => 'GS25 검단점'],
|
||||||
|
['lat' => 35.8621, 'lng' => 128.5948, 'kind' => 'shop', 'title' => 'CU 칠곡중앙점'],
|
||||||
|
['lat' => 35.8776, 'lng' => 128.6479, 'kind' => 'flow', 'title' => '동구 입고·출고 거점'],
|
||||||
|
['lat' => 35.8512, 'lng' => 128.6112, 'kind' => 'shop', 'title' => '이마트 월배점'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'portalVariants' => [
|
||||||
|
['label' => '기본', 'url' => base_url('dashboard/gov-portal')],
|
||||||
|
['label' => '변형', 'url' => base_url('dashboard/gov-portal-strip')],
|
||||||
|
],
|
||||||
|
'siteNavTree' => $govNav['siteNavTree'],
|
||||||
|
'govNavItems' => $govNav['navItems'],
|
||||||
|
'menuSearchOptions' => gov_portal_menu_search_options($govNav['navItems']),
|
||||||
|
'govNavJson' => $govNav['navJson'],
|
||||||
|
'govActiveParentIdx' => $govNav['activeParentIdx'],
|
||||||
|
'govCurrentPath' => gov_portal_nav_match_path($govNav['currentPath']),
|
||||||
|
'govDashboardAliases' => $govNav['dashboardAliases'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('gov_portal_nav_partial_vars')) {
|
||||||
|
/**
|
||||||
|
* CI4 view() partial에 넘길 사이트 메뉴·네비 변수만 추출.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $viewData
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function gov_portal_nav_partial_vars(array $viewData): array
|
||||||
|
{
|
||||||
|
$keys = [
|
||||||
|
'siteNavTree',
|
||||||
|
'govNavItems',
|
||||||
|
'govNavJson',
|
||||||
|
'govActiveParentIdx',
|
||||||
|
'govCurrentPath',
|
||||||
|
'govDashboardAliases',
|
||||||
|
'govActiveChildHref',
|
||||||
|
];
|
||||||
|
$out = [];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (array_key_exists($key, $viewData)) {
|
||||||
|
$out[$key] = $viewData[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('manual_help_url_for_path')) {
|
||||||
|
/**
|
||||||
|
* 현재(또는 주어진) 화면 경로에 대응하는 매뉴얼 URL. 매칭 없으면 ''.
|
||||||
|
* Config\Manual::$screenHelp 의 가장 긴(구체적) 접두를 우선 매칭한다.
|
||||||
|
*/
|
||||||
|
function manual_help_url_for_path(?string $path = null): string
|
||||||
|
{
|
||||||
|
helper('url');
|
||||||
|
$path = strtolower(trim($path ?? current_nav_request_path(), '/'));
|
||||||
|
if ($path === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$map = config(\Config\Manual::class)->screenHelp ?? [];
|
||||||
|
$bestSlug = '';
|
||||||
|
$bestLen = -1;
|
||||||
|
foreach ($map as $prefix => $slug) {
|
||||||
|
$p = strtolower((string) $prefix);
|
||||||
|
if ($path === $p || str_starts_with($path . '/', $p . '/')) {
|
||||||
|
if (strlen($p) > $bestLen) {
|
||||||
|
$bestLen = strlen($p);
|
||||||
|
$bestSlug = (string) $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bestSlug !== '' ? base_url('bag/manual/' . $bestSlug) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
43
app/Helpers/audit_helper.php
Normal file
43
app/Helpers/audit_helper.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! function_exists('audit_log')) {
|
||||||
|
/**
|
||||||
|
* CRUD 활동 로그 기록
|
||||||
|
*
|
||||||
|
* @param string $action 'create', 'update', 'delete'
|
||||||
|
* @param string $table 대상 테이블명
|
||||||
|
* @param int $recordId 대상 레코드 PK
|
||||||
|
* @param array|null $before 변경 전 데이터 (update/delete 시)
|
||||||
|
* @param array|null $after 변경 후 데이터 (create/update 시)
|
||||||
|
*/
|
||||||
|
function audit_log(string $action, string $table, int $recordId, ?array $before = null, ?array $after = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
// 테이블 존재 여부 확인 (없으면 skip)
|
||||||
|
if ($db->query("SHOW TABLES LIKE 'activity_log'")->getNumRows() === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mbIdx = session()->get('mb_idx');
|
||||||
|
$ip = service('request')->getIPAddress();
|
||||||
|
|
||||||
|
model(\App\Models\ActivityLogModel::class)->insert([
|
||||||
|
'al_mb_idx' => $mbIdx ? (int) $mbIdx : null,
|
||||||
|
'al_action' => $action,
|
||||||
|
'al_table' => $table,
|
||||||
|
'al_record_id' => $recordId,
|
||||||
|
'al_data_before' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
'al_data_after' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
'al_ip' => $ip,
|
||||||
|
'al_regdate' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 로깅 실패 시 본 로직 방해하지 않음
|
||||||
|
log_message('error', 'audit_log failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
507
app/Helpers/export_helper.php
Normal file
507
app/Helpers/export_helper.php
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV 엑셀 내보내기 헬퍼
|
||||||
|
*
|
||||||
|
* UTF-8 BOM 포함으로 한글 엑셀 호환성 보장
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (! function_exists('export_csv')) {
|
||||||
|
/**
|
||||||
|
* CSV 파일을 브라우저로 다운로드 전송
|
||||||
|
*
|
||||||
|
* @param string $filename 파일명 (확장자 포함, 예: 'export.csv')
|
||||||
|
* @param string[] $headers 컬럼 헤더 배열
|
||||||
|
* @param array $rows 데이터 행 배열 (각 행은 배열)
|
||||||
|
*/
|
||||||
|
function export_csv(string $filename, array $headers, array $rows): void
|
||||||
|
{
|
||||||
|
// 파일명에 .csv 확장자 보장
|
||||||
|
if (! str_ends_with($filename, '.csv')) {
|
||||||
|
$filename .= '.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = service('response');
|
||||||
|
$response->setHeader('Content-Type', 'text/csv; charset=UTF-8');
|
||||||
|
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||||
|
$response->setHeader('Pragma', 'no-cache');
|
||||||
|
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
|
||||||
|
// UTF-8 BOM (한글 엑셀 호환)
|
||||||
|
$output = "\xEF\xBB\xBF";
|
||||||
|
|
||||||
|
// 헤더 행
|
||||||
|
$output .= csv_encode_row($headers);
|
||||||
|
|
||||||
|
// 데이터 행
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$output .= csv_encode_row(array_values((array) $row));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->setBody($output);
|
||||||
|
$response->send();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('csv_encode_row')) {
|
||||||
|
/**
|
||||||
|
* 배열 한 행을 CSV 문자열로 변환
|
||||||
|
*
|
||||||
|
* @param array $fields
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function csv_encode_row(array $fields): string
|
||||||
|
{
|
||||||
|
$escaped = [];
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
$val = (string) ($field ?? '');
|
||||||
|
// 쌍따옴표 이스케이프 및 감싸기
|
||||||
|
if (str_contains($val, '"') || str_contains($val, ',') || str_contains($val, "\n") || str_contains($val, "\r")) {
|
||||||
|
$val = '"' . str_replace('"', '""', $val) . '"';
|
||||||
|
}
|
||||||
|
$escaped[] = $val;
|
||||||
|
}
|
||||||
|
return implode(',', $escaped) . "\r\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('export_excel_2003_xml')) {
|
||||||
|
/**
|
||||||
|
* Excel 2003 XML(SpreadsheetML)로 브라우저 다운로드 (.xls 확장자, 별도 라이브러리 불필요)
|
||||||
|
*
|
||||||
|
* @param string $filename 저장 파일명(확장자는 .xls로 정규화)
|
||||||
|
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
|
||||||
|
* @param string[] $headers 컬럼 헤더
|
||||||
|
* @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력)
|
||||||
|
*/
|
||||||
|
function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows): void
|
||||||
|
{
|
||||||
|
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
||||||
|
|
||||||
|
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
||||||
|
$safeSheet = function_exists('mb_substr')
|
||||||
|
? mb_substr($safeSheet, 0, 31, 'UTF-8')
|
||||||
|
: substr($safeSheet, 0, 31);
|
||||||
|
|
||||||
|
$esc = static function (mixed $v): string {
|
||||||
|
return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
$parts[] = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||||
|
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
|
||||||
|
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
|
||||||
|
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
|
||||||
|
$parts[] = '<Table>';
|
||||||
|
|
||||||
|
$parts[] = '<Row>';
|
||||||
|
foreach ($headers as $h) {
|
||||||
|
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
|
||||||
|
}
|
||||||
|
$parts[] = '</Row>';
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$parts[] = '<Row>';
|
||||||
|
foreach (array_values((array) $row) as $cell) {
|
||||||
|
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
|
||||||
|
}
|
||||||
|
$parts[] = '</Row>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = '</Table>';
|
||||||
|
$parts[] = '</Worksheet>';
|
||||||
|
$parts[] = '</Workbook>';
|
||||||
|
|
||||||
|
$output = implode('', $parts);
|
||||||
|
|
||||||
|
$response = service('response');
|
||||||
|
$response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8');
|
||||||
|
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||||
|
$response->setHeader('Pragma', 'no-cache');
|
||||||
|
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
$response->setBody($output);
|
||||||
|
$response->send();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('export_excel_2003_xml_workbook')) {
|
||||||
|
/**
|
||||||
|
* Excel 2003 XML — 다중 시트, 인쇄 미리보기와 유사한 헤더·줄바꿈·열 너비
|
||||||
|
*
|
||||||
|
* @param string $filename 저장 파일명
|
||||||
|
* @param list<array{name: string, headers: list<string>, rows: list<list<string>>, col_widths?: list<int>}> $sheets
|
||||||
|
*/
|
||||||
|
function export_excel_2003_xml_workbook(string $filename, array $sheets): void
|
||||||
|
{
|
||||||
|
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
||||||
|
|
||||||
|
$esc = static function (mixed $v): string {
|
||||||
|
return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
$safeSheetName = static function (string $name) use ($esc): string {
|
||||||
|
$safe = str_replace(['/', '\\', '?', '*', '[', ']', ':'], '', $name);
|
||||||
|
$safe = function_exists('mb_substr') ? mb_substr($safe, 0, 31, 'UTF-8') : substr($safe, 0, 31);
|
||||||
|
|
||||||
|
return $esc($safe !== '' ? $safe : 'Sheet');
|
||||||
|
};
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
$parts[] = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||||
|
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
|
||||||
|
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
|
||||||
|
$parts[] = '<Styles>';
|
||||||
|
$parts[] = '<Style ss:ID="Default"><Alignment ss:Vertical="Top" ss:WrapText="1" ss:Horizontal="Left"/><Font ss:FontName="맑은 고딕" x:CharSet="129" ss:Size="9"/></Style>';
|
||||||
|
$parts[] = '<Style ss:ID="Header"><Font ss:Bold="1" ss:Size="9" ss:FontName="맑은 고딕" x:CharSet="129"/><Interior ss:Color="#F3F4F6" ss:Pattern="Solid"/><Alignment ss:Horizontal="Left" ss:Vertical="Center" ss:WrapText="1"/><Borders><Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1"/></Borders></Style>';
|
||||||
|
$parts[] = '</Styles>';
|
||||||
|
|
||||||
|
foreach ($sheets as $sheet) {
|
||||||
|
$sheetName = $safeSheetName((string) ($sheet['name'] ?? 'Sheet'));
|
||||||
|
$headers = array_values((array) ($sheet['headers'] ?? []));
|
||||||
|
$rows = (array) ($sheet['rows'] ?? []);
|
||||||
|
$colWidths = array_values((array) ($sheet['col_widths'] ?? []));
|
||||||
|
|
||||||
|
$parts[] = '<Worksheet ss:Name="' . $sheetName . '">';
|
||||||
|
$parts[] = '<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><PageSetup><Layout x:Orientation="Landscape"/></PageSetup></WorksheetOptions>';
|
||||||
|
$parts[] = '<Table>';
|
||||||
|
|
||||||
|
$colCount = max(count($headers), 1);
|
||||||
|
for ($i = 0; $i < $colCount; $i++) {
|
||||||
|
$px = (int) ($colWidths[$i] ?? 72);
|
||||||
|
$width = max(48, min(280, $px));
|
||||||
|
$excelW = round($width / 6.5, 1);
|
||||||
|
$parts[] = '<Column ss:Index="' . ($i + 1) . '" ss:AutoFitWidth="0" ss:Width="' . $excelW . '"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = '<Row ss:StyleID="Header">';
|
||||||
|
foreach ($headers as $h) {
|
||||||
|
$parts[] = '<Cell ss:StyleID="Header"><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
|
||||||
|
}
|
||||||
|
$parts[] = '</Row>';
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$parts[] = '<Row>';
|
||||||
|
foreach (array_values((array) $row) as $cell) {
|
||||||
|
$parts[] = '<Cell ss:StyleID="Default"><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
|
||||||
|
}
|
||||||
|
$parts[] = '</Row>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = '</Table>';
|
||||||
|
$parts[] = '</Worksheet>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = '</Workbook>';
|
||||||
|
|
||||||
|
$output = implode('', $parts);
|
||||||
|
|
||||||
|
$response = service('response');
|
||||||
|
$response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8');
|
||||||
|
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||||
|
$response->setHeader('Pragma', 'no-cache');
|
||||||
|
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
$response->setBody($output);
|
||||||
|
$response->send();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('bag_flow_report_build_spreadsheet')) {
|
||||||
|
/**
|
||||||
|
* 기간별 봉투 수불 엑셀 통합문서 생성 (PhpSpreadsheet — 열 너비·병합 안정)
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $reportRows
|
||||||
|
* @param list<string> $metaLines
|
||||||
|
*/
|
||||||
|
function bag_flow_report_build_spreadsheet(
|
||||||
|
string $lgName,
|
||||||
|
string $title,
|
||||||
|
array $metaLines,
|
||||||
|
array $reportRows
|
||||||
|
): \PhpOffice\PhpSpreadsheet\Spreadsheet {
|
||||||
|
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
|
||||||
|
$spreadsheet->getDefaultStyle()->getFont()->setName('맑은 고딕')->setSize(10);
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle('수불현황');
|
||||||
|
$bodyFontSize = 10;
|
||||||
|
|
||||||
|
$lastCol = 'N';
|
||||||
|
$colWidths = [
|
||||||
|
'A' => 22.0,
|
||||||
|
'B' => 26.0,
|
||||||
|
'C' => 12.0,
|
||||||
|
'D' => 11.0,
|
||||||
|
'E' => 11.0,
|
||||||
|
'F' => 11.0,
|
||||||
|
'G' => 12.0,
|
||||||
|
'H' => 11.0,
|
||||||
|
'I' => 12.0,
|
||||||
|
'J' => 12.0,
|
||||||
|
'K' => 12.0,
|
||||||
|
'L' => 11.0,
|
||||||
|
'M' => 12.0,
|
||||||
|
'N' => 12.0,
|
||||||
|
];
|
||||||
|
foreach ($colWidths as $col => $width) {
|
||||||
|
$sheet->getColumnDimension($col)->setWidth($width);
|
||||||
|
$sheet->getColumnDimension($col)->setAutoSize(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = 1;
|
||||||
|
if ($lgName !== '') {
|
||||||
|
$sheet->setCellValue("A{$r}", $lgName);
|
||||||
|
$sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('666666');
|
||||||
|
$r++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet->setCellValue("A{$r}", $title);
|
||||||
|
$sheet->mergeCells("A{$r}:{$lastCol}{$r}");
|
||||||
|
$sheet->getStyle("A{$r}")->getFont()->setBold(true)->setSize($bodyFontSize);
|
||||||
|
$r++;
|
||||||
|
|
||||||
|
foreach ($metaLines as $line) {
|
||||||
|
$sheet->setCellValue("A{$r}", $line);
|
||||||
|
$sheet->mergeCells("A{$r}:{$lastCol}{$r}");
|
||||||
|
$sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('555555');
|
||||||
|
$r++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$r++;
|
||||||
|
|
||||||
|
$h1 = $r;
|
||||||
|
$h2 = $r + 1;
|
||||||
|
|
||||||
|
$sheet->setCellValue("A{$h1}", '일자');
|
||||||
|
$sheet->mergeCells("A{$h1}:A{$h2}");
|
||||||
|
$sheet->setCellValue("B{$h1}", '품목');
|
||||||
|
$sheet->mergeCells("B{$h1}:B{$h2}");
|
||||||
|
$sheet->setCellValue("C{$h1}", '전일');
|
||||||
|
$sheet->mergeCells("C{$h1}:C{$h2}");
|
||||||
|
$sheet->setCellValue("D{$h1}", '입고');
|
||||||
|
$sheet->mergeCells("D{$h1}:G{$h1}");
|
||||||
|
$sheet->setCellValue("H{$h1}", '출고');
|
||||||
|
$sheet->mergeCells("H{$h1}:M{$h1}");
|
||||||
|
$sheet->setCellValue("N{$h1}", '잔량');
|
||||||
|
$sheet->mergeCells("N{$h1}:N{$h2}");
|
||||||
|
|
||||||
|
$subHeaders = ['입고', '반품', '기타', '입계', '판매', '일반', '무료', '반품', '기타', '출계'];
|
||||||
|
foreach ($subHeaders as $i => $label) {
|
||||||
|
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(4 + $i);
|
||||||
|
$sheet->setCellValue("{$col}{$h2}", $label);
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerStyle = [
|
||||||
|
'font' => ['bold' => true, 'size' => $bodyFontSize],
|
||||||
|
'alignment' => [
|
||||||
|
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
|
||||||
|
'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
|
||||||
|
'wrapText' => false,
|
||||||
|
],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['rgb' => 'E9ECEF'],
|
||||||
|
],
|
||||||
|
'borders' => [
|
||||||
|
'bottom' => ['borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$sheet->getStyle("A{$h1}:{$lastCol}{$h2}")->applyFromArray($headerStyle);
|
||||||
|
|
||||||
|
$dataRow = $h2 + 1;
|
||||||
|
foreach ($reportRows as $row) {
|
||||||
|
$rowType = (string) ($row['row_type'] ?? 'data');
|
||||||
|
if (! in_array($rowType, ['data', 'subtotal', 'grand'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet->fromArray([
|
||||||
|
(string) ($row['date'] ?? ''),
|
||||||
|
(string) ($row['item_name'] ?? ''),
|
||||||
|
(int) ($row['prev_stock'] ?? 0),
|
||||||
|
(int) ($row['recv_in'] ?? 0),
|
||||||
|
(int) ($row['recv_return'] ?? 0),
|
||||||
|
(int) ($row['recv_misc'] ?? 0),
|
||||||
|
(int) ($row['recv_total'] ?? 0),
|
||||||
|
(int) ($row['out_sale'] ?? 0),
|
||||||
|
(int) ($row['out_issue_gen'] ?? 0),
|
||||||
|
(int) ($row['out_issue_free'] ?? 0),
|
||||||
|
(int) ($row['out_return'] ?? 0),
|
||||||
|
(int) ($row['out_misc'] ?? 0),
|
||||||
|
(int) ($row['out_total'] ?? 0),
|
||||||
|
(int) ($row['balance'] ?? 0),
|
||||||
|
], null, "A{$dataRow}", true);
|
||||||
|
|
||||||
|
$sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}")
|
||||||
|
->getNumberFormat()
|
||||||
|
->setFormatCode('#,##0');
|
||||||
|
$sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}")
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_RIGHT);
|
||||||
|
$sheet->getStyle("A{$dataRow}:B{$dataRow}")
|
||||||
|
->getAlignment()
|
||||||
|
->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT)
|
||||||
|
->setWrapText(false);
|
||||||
|
$sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}")
|
||||||
|
->getFont()
|
||||||
|
->setSize($bodyFontSize);
|
||||||
|
|
||||||
|
if (in_array($rowType, ['subtotal', 'grand'], true)) {
|
||||||
|
$sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'size' => $bodyFontSize],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['rgb' => 'FFF8E1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dataRow > $h2 + 1) {
|
||||||
|
$sheet->getStyle('A' . ($h2 + 1) . ':' . $lastCol . ($dataRow - 1))
|
||||||
|
->getBorders()
|
||||||
|
->getAllBorders()
|
||||||
|
->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_HAIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet->getPageSetup()->setOrientation(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::ORIENTATION_PORTRAIT);
|
||||||
|
$sheet->getPageSetup()->setPaperSize(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::PAPERSIZE_A4);
|
||||||
|
$sheet->getPageSetup()->setFitToWidth(1);
|
||||||
|
$sheet->getPageSetup()->setFitToHeight(0);
|
||||||
|
|
||||||
|
return $spreadsheet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('export_bag_flow_report_excel')) {
|
||||||
|
/**
|
||||||
|
* 기간별 봉투 수불 (/bag/flow) — 인쇄와 동일한 헤더·2단 표 (xlsx, PhpSpreadsheet)
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $reportRows
|
||||||
|
* @param list<string> $metaLines
|
||||||
|
*/
|
||||||
|
function export_bag_flow_report_excel(
|
||||||
|
string $filename,
|
||||||
|
string $lgName,
|
||||||
|
string $title,
|
||||||
|
array $metaLines,
|
||||||
|
array $reportRows
|
||||||
|
): void {
|
||||||
|
$baseName = preg_replace('/\.[^.]+$/u', '', $filename);
|
||||||
|
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow';
|
||||||
|
$baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow';
|
||||||
|
$filename = $baseName . '.xlsx';
|
||||||
|
|
||||||
|
$spreadsheet = bag_flow_report_build_spreadsheet($lgName, $title, $metaLines, $reportRows);
|
||||||
|
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
try {
|
||||||
|
$writer->save('php://output');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
unset($spreadsheet);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
unset($spreadsheet);
|
||||||
|
$output = ob_get_clean();
|
||||||
|
if ($output === false) {
|
||||||
|
$output = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = service('response');
|
||||||
|
$response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
$asciiName = preg_replace('/[^\x20-\x7E]+/', '_', $filename) ?? 'bag_flow.xlsx';
|
||||||
|
$response->setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
'attachment; filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename)
|
||||||
|
);
|
||||||
|
$response->setHeader('Pragma', 'no-cache');
|
||||||
|
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
$response->setBody($output);
|
||||||
|
$response->send();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('export_xlsx')) {
|
||||||
|
/**
|
||||||
|
* Office Open XML(.xlsx) 브라우저 다운로드 (PhpSpreadsheet)
|
||||||
|
*
|
||||||
|
* @param string $filename 저장 파일명(확장자는 .xlsx로 정규화)
|
||||||
|
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
|
||||||
|
* @param string[] $headers 컬럼 헤더
|
||||||
|
* @param array $rows 데이터 행(각 행은 배열)
|
||||||
|
*/
|
||||||
|
function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void
|
||||||
|
{
|
||||||
|
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
|
||||||
|
|
||||||
|
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
||||||
|
$safeSheet = function_exists('mb_substr')
|
||||||
|
? mb_substr($safeSheet, 0, 31, 'UTF-8')
|
||||||
|
: substr($safeSheet, 0, 31);
|
||||||
|
if ($safeSheet === '') {
|
||||||
|
$safeSheet = 'Sheet1';
|
||||||
|
}
|
||||||
|
|
||||||
|
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle($safeSheet);
|
||||||
|
|
||||||
|
$data = [array_map(static fn ($v): string => (string) ($v ?? ''), array_values($headers))];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$data[] = array_map(static fn ($v): string => (string) ($v ?? ''), array_values((array) $row));
|
||||||
|
}
|
||||||
|
$sheet->fromArray($data, null, 'A1', true);
|
||||||
|
|
||||||
|
$headerCount = max(1, count($headers));
|
||||||
|
$rowCount = max(1, count($data));
|
||||||
|
$lastCol = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($headerCount);
|
||||||
|
$fullRange = 'A1:' . $lastCol . $rowCount;
|
||||||
|
|
||||||
|
// 헤더/데이터 모두 좌측 정렬(요구사항)
|
||||||
|
$sheet->getStyle($fullRange)->getAlignment()->setHorizontal(
|
||||||
|
\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT
|
||||||
|
);
|
||||||
|
|
||||||
|
// 가독성을 위해 기본 열 너비를 넓게 지정
|
||||||
|
for ($i = 1; $i <= $headerCount; $i++) {
|
||||||
|
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i);
|
||||||
|
$sheet->getColumnDimension($col)->setWidth(22);
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
||||||
|
ob_start();
|
||||||
|
try {
|
||||||
|
$writer->save('php://output');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
unset($spreadsheet);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
unset($spreadsheet);
|
||||||
|
$output = ob_get_clean();
|
||||||
|
if ($output === false) {
|
||||||
|
$output = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = service('response');
|
||||||
|
$response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||||
|
$response->setHeader('Pragma', 'no-cache');
|
||||||
|
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
$response->setBody($output);
|
||||||
|
$response->send();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* 저장 형식: 암호화된 값은 "ENC:" + base64(암호문) 으로 저장. "ENC:" 없으면 평문(기존)으로 간주.
|
* 저장 형식: 암호화된 값은 "ENC:" + base64(암호문) 으로 저장. "ENC:" 없으면 평문(기존)으로 간주.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (! function_exists('pii_encrypt')) {
|
if (! function_exists('pii_encrypt')) {
|
||||||
function pii_encrypt(?string $value): string
|
function pii_encrypt(?string $value): string
|
||||||
{
|
{
|
||||||
@@ -21,9 +22,8 @@ if (! function_exists('pii_encrypt')) {
|
|||||||
}
|
}
|
||||||
$encrypter = service('encrypter');
|
$encrypter = service('encrypter');
|
||||||
$encrypted = $encrypter->encrypt($value);
|
$encrypted = $encrypter->encrypt($value);
|
||||||
|
|
||||||
return 'ENC:' . base64_encode($encrypted);
|
return 'ENC:' . base64_encode($encrypted);
|
||||||
} catch (Throwable) {
|
} catch (Throwable $e) {
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,13 +44,23 @@ if (! function_exists('pii_decrypt')) {
|
|||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
$encrypter = service('encrypter');
|
$encrypter = service('encrypter');
|
||||||
$raw = base64_decode(substr($value, 4), true);
|
$payload = substr($value, 4);
|
||||||
if ($raw === false) {
|
|
||||||
return $value;
|
// 현재 포맷: ENC: + base64(raw ciphertext)
|
||||||
|
$raw = base64_decode($payload, true);
|
||||||
|
if ($raw !== false) {
|
||||||
|
try {
|
||||||
|
return $encrypter->decrypt($raw);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// legacy 포맷 재시도
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $encrypter->decrypt($raw);
|
// 레거시 포맷 호환:
|
||||||
} catch (Throwable) {
|
// - ENC: + encrypter 반환값(rawData=false 환경 등) 또는
|
||||||
|
// - ENC: + 기타 문자열 포맷
|
||||||
|
return $encrypter->decrypt($payload);
|
||||||
|
} catch (Throwable $e) {
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
660
app/Libraries/BagAnalyticsReportBuilder.php
Normal file
660
app/Libraries/BagAnalyticsReportBuilder.php
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 분석 관리 (전년대비 / 월별·계절별 추이)
|
||||||
|
*
|
||||||
|
* 월별·계절별 추이·전년대비: bs_type = sale 판매량·판매금액만 집계 (반품·취소 제외)
|
||||||
|
*/
|
||||||
|
class BagAnalyticsReportBuilder
|
||||||
|
{
|
||||||
|
private \CodeIgniter\Database\BaseConnection $db;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $bagNames = [];
|
||||||
|
|
||||||
|
/** 판매(bs_type=sale) 낱장 수량만 합산 */
|
||||||
|
private function saleQtySql(): string
|
||||||
|
{
|
||||||
|
return "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{label: string, months_label: string, months: list<int>, cross_year: bool}>
|
||||||
|
*/
|
||||||
|
public static function seasonCatalog(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'spring' => ['label' => '봄', 'months_label' => '3~5월', 'months' => [3, 4, 5], 'cross_year' => false],
|
||||||
|
'summer' => ['label' => '여름', 'months_label' => '6~8월', 'months' => [6, 7, 8], 'cross_year' => false],
|
||||||
|
'autumn' => ['label' => '가을', 'months_label' => '9~11월', 'months' => [9, 10, 11], 'cross_year' => false],
|
||||||
|
'winter' => ['label' => '겨울', 'months_label' => '전년12·1~2월', 'months' => [12, 1, 2], 'cross_year' => true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeSeason(string $season): string
|
||||||
|
{
|
||||||
|
$raw = trim($season);
|
||||||
|
$aliases = [
|
||||||
|
'봄' => 'spring',
|
||||||
|
'여름' => 'summer',
|
||||||
|
'가을' => 'autumn',
|
||||||
|
'겨울' => 'winter',
|
||||||
|
];
|
||||||
|
$key = $aliases[$raw] ?? strtolower($raw);
|
||||||
|
$catalog = self::seasonCatalog();
|
||||||
|
|
||||||
|
return isset($catalog[$key]) ? $key : 'spring';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
|
||||||
|
{
|
||||||
|
$this->db = $db ?? \Config\Database::connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* gugunOptions: list<array{code: string, name: string}>,
|
||||||
|
* agencies: list<object>,
|
||||||
|
* lgName: string,
|
||||||
|
* gugunLabel: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function loadFilterOptions(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
|
||||||
|
$lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : '';
|
||||||
|
|
||||||
|
$gugunRows = $this->db->query("
|
||||||
|
SELECT DISTINCT ds_gugun_code AS code
|
||||||
|
FROM designated_shop
|
||||||
|
WHERE ds_lg_idx = ? AND ds_gugun_code != ''
|
||||||
|
ORDER BY ds_gugun_code
|
||||||
|
", [$lgIdx])->getResultArray();
|
||||||
|
|
||||||
|
$gugunOptions = [['code' => '', 'name' => '전체']];
|
||||||
|
foreach ($gugunRows as $row) {
|
||||||
|
$code = trim((string) ($row['code'] ?? ''));
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$gugunOptions[] = ['code' => $code, 'name' => $this->gugunLabel($lgIdx, $code)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$agencies = model(\App\Models\SalesAgencyModel::class)
|
||||||
|
->where('sa_lg_idx', $lgIdx)
|
||||||
|
->orderForDisplay()
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'gugunOptions' => $gugunOptions,
|
||||||
|
'agencies' => $agencies,
|
||||||
|
'lgName' => $lgName,
|
||||||
|
'gugunLabel' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* rows: list<array<string, mixed>>,
|
||||||
|
* months: list<int>,
|
||||||
|
* prevYear: int,
|
||||||
|
* year: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function buildYearOverYear(
|
||||||
|
int $lgIdx,
|
||||||
|
int $year,
|
||||||
|
string $gugunCode,
|
||||||
|
int $dsIdx,
|
||||||
|
bool $queried
|
||||||
|
): array {
|
||||||
|
$prevYear = $year - 1;
|
||||||
|
$months = range(1, 12);
|
||||||
|
if (! $queried) {
|
||||||
|
return ['rows' => [], 'months' => $months, 'prevYear' => $prevYear, 'year' => $year];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadBagNames($lgIdx);
|
||||||
|
$agg = $this->aggregateMonthlyByBag($lgIdx, $prevYear, $year, $gugunCode, $dsIdx);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$codesFromAgg = array_map(static fn ($c): string => (string) $c, array_keys($agg));
|
||||||
|
foreach ($this->bagCodesForReport($lgIdx, $codesFromAgg) as $code) {
|
||||||
|
$code = (string) $code;
|
||||||
|
$name = $this->resolveBagName($code);
|
||||||
|
$rows[] = $this->yoyBlock($code, (string) $name, '수량', $agg, $prevYear, $year, $months, false);
|
||||||
|
$rows[] = $this->yoyBlock($code, (string) $name, '금액', $agg, $prevYear, $year, $months, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'months' => $months, 'prevYear' => $prevYear, 'year' => $year];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{rows: list<array<string, mixed>>, meta: array<string, int>}
|
||||||
|
*/
|
||||||
|
public function buildMonthlyTrend(
|
||||||
|
int $lgIdx,
|
||||||
|
string $baseYm,
|
||||||
|
string $trendBasis,
|
||||||
|
float $deviationMin,
|
||||||
|
string $gugunCode,
|
||||||
|
int $saIdx,
|
||||||
|
bool $queried
|
||||||
|
): array {
|
||||||
|
$empty = ['rows' => [], 'meta' => ['shopCount' => 0, 'monthSalesShops' => 0]];
|
||||||
|
if (! $queried || ! preg_match('/^(\d{4})-(\d{2})$/', $baseYm, $m)) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int) $m[1];
|
||||||
|
$month = (int) $m[2];
|
||||||
|
$shops = $this->loadShops($lgIdx, $gugunCode, $saIdx);
|
||||||
|
if ($shops === []) {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthlyByShop = $this->monthlyNetByShop($lgIdx, $year, $month, $gugunCode, $saIdx);
|
||||||
|
$avgByShop = $this->averageNetByShop($lgIdx, $year - 1, $gugunCode, $trendBasis, $month, $saIdx);
|
||||||
|
$prevYearSameMonth = $trendBasis === 'year_avg'
|
||||||
|
? $this->monthlyNetByShop($lgIdx, $year - 1, $month, $gugunCode, $saIdx)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$monthSalesShops = 0;
|
||||||
|
foreach ($shops as $shop) {
|
||||||
|
$sid = (int) ($shop['ds_idx'] ?? 0);
|
||||||
|
$monthly = (float) ($monthlyByShop[$sid] ?? 0.0);
|
||||||
|
$avg = (float) ($avgByShop[$sid] ?? 0.0);
|
||||||
|
if ($trendBasis === 'year_avg' && $avg <= 0) {
|
||||||
|
$avg = (float) ($prevYearSameMonth[$sid] ?? 0.0);
|
||||||
|
}
|
||||||
|
if ($monthly > 0) {
|
||||||
|
$monthSalesShops++;
|
||||||
|
}
|
||||||
|
$diff = $monthly - $avg;
|
||||||
|
$pct = $avg > 0 ? round(($diff / $avg) * 100, 2) : ($monthly > 0 ? 100.0 : 0.0);
|
||||||
|
// 편차 N% 이상 = |편차(%)| ≥ N (증가·감소 모두)
|
||||||
|
if ($deviationMin > 0 && abs($pct) < $deviationMin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'agency_name' => (string) ($shop['agency_name'] ?? ''),
|
||||||
|
'shop_no' => (string) ($shop['ds_shop_no'] ?? ''),
|
||||||
|
'shop_name' => (string) ($shop['ds_name'] ?? ''),
|
||||||
|
'rep_name' => (string) ($shop['ds_rep_name'] ?? ''),
|
||||||
|
'prev_avg' => (int) round($avg),
|
||||||
|
'monthly_qty' => (int) round($monthly),
|
||||||
|
'avg_diff' => (int) round($diff),
|
||||||
|
'deviation_pct'=> $pct,
|
||||||
|
'designated_at'=> (string) ($shop['ds_designated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no']));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $rows,
|
||||||
|
'meta' => [
|
||||||
|
'shopCount' => count($shops),
|
||||||
|
'monthSalesShops' => $monthSalesShops,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function buildSeasonalTrend(
|
||||||
|
int $lgIdx,
|
||||||
|
int $baseYear,
|
||||||
|
string $season,
|
||||||
|
float $deviationMin,
|
||||||
|
string $gugunCode,
|
||||||
|
bool $queried
|
||||||
|
): array {
|
||||||
|
if (! $queried) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$seasonKey = self::normalizeSeason($season);
|
||||||
|
$seasonDef = self::seasonCatalog()[$seasonKey];
|
||||||
|
$months = $seasonDef['months'];
|
||||||
|
|
||||||
|
$saIdx = 0;
|
||||||
|
$shops = $this->loadShops($lgIdx, $gugunCode, $saIdx);
|
||||||
|
if ($shops === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$crossYear = (bool) ($seasonDef['cross_year'] ?? false);
|
||||||
|
$currentByShop = $this->seasonalNetByShop($lgIdx, $baseYear, $months, $gugunCode, $saIdx, $crossYear);
|
||||||
|
$prevByShop = $this->seasonalNetByShop($lgIdx, $baseYear - 1, $months, $gugunCode, $saIdx, $crossYear);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($shops as $shop) {
|
||||||
|
$sid = (int) ($shop['ds_idx'] ?? 0);
|
||||||
|
$curr = (float) ($currentByShop[$sid] ?? 0.0);
|
||||||
|
$prev = (float) ($prevByShop[$sid] ?? 0.0);
|
||||||
|
$diff = $curr - $prev;
|
||||||
|
$pct = $prev > 0 ? round(($diff / $prev) * 100, 2) : ($curr > 0 ? 100.0 : 0.0);
|
||||||
|
if ($deviationMin > 0 && abs($pct) < $deviationMin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'agency_name' => (string) ($shop['agency_name'] ?? ''),
|
||||||
|
'shop_name' => (string) ($shop['ds_name'] ?? ''),
|
||||||
|
'shop_no' => (string) ($shop['ds_shop_no'] ?? ''),
|
||||||
|
'rep_name' => (string) ($shop['ds_rep_name'] ?? ''),
|
||||||
|
'prev_season_avg'=> (int) round($prev),
|
||||||
|
'base_season_avg'=> (int) round($curr),
|
||||||
|
'avg_diff' => (int) round($diff),
|
||||||
|
'deviation_pct' => $pct,
|
||||||
|
'designated_at' => (string) ($shop['ds_designated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no']));
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function gugunLabel(int $lgIdx, string $code): string
|
||||||
|
{
|
||||||
|
static $cache = [];
|
||||||
|
$key = $lgIdx . ':' . $code;
|
||||||
|
if (isset($cache[$key])) {
|
||||||
|
return $cache[$key];
|
||||||
|
}
|
||||||
|
$row = $this->db->table('code_detail cd')
|
||||||
|
->select('cd.cd_name')
|
||||||
|
->join('code_kind ck', 'ck.ck_idx = cd.cd_ck_idx', 'inner')
|
||||||
|
->where('ck.ck_code', 'G')
|
||||||
|
->where('cd.cd_lg_idx', $lgIdx)
|
||||||
|
->where('cd.cd_code', $code)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
$cache[$key] = trim((string) ($row['cd_name'] ?? $code));
|
||||||
|
|
||||||
|
return $cache[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBagName(string $code): string
|
||||||
|
{
|
||||||
|
if (isset($this->bagNames[$code])) {
|
||||||
|
return (string) $this->bagNames[$code];
|
||||||
|
}
|
||||||
|
if (ctype_digit($code) && isset($this->bagNames[(int) $code])) {
|
||||||
|
return (string) $this->bagNames[(int) $code];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadBagNames(int $lgIdx): void
|
||||||
|
{
|
||||||
|
if ($this->bagNames !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
|
if (! $kindO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
|
||||||
|
$code = trim((string) ($d->cd_code ?? ''));
|
||||||
|
if ($code !== '') {
|
||||||
|
$this->bagNames[$code] = trim((string) ($d->cd_name ?? $code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $codesFromAgg
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function bagCodesForReport(int $lgIdx, array $codesFromAgg): array
|
||||||
|
{
|
||||||
|
$this->loadBagNames($lgIdx);
|
||||||
|
$codes = array_keys($this->bagNames);
|
||||||
|
if ($codesFromAgg !== []) {
|
||||||
|
$merged = array_merge($codes, $codesFromAgg);
|
||||||
|
$codes = [];
|
||||||
|
foreach ($merged as $c) {
|
||||||
|
$codes[] = (string) $c;
|
||||||
|
}
|
||||||
|
$codes = array_values(array_unique($codes));
|
||||||
|
sort($codes, SORT_STRING);
|
||||||
|
} else {
|
||||||
|
$codes = array_map(static fn ($c): string => (string) $c, $codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, array<int, array{qty: float, amt: float}>>>
|
||||||
|
*/
|
||||||
|
private function aggregateMonthlyByBag(
|
||||||
|
int $lgIdx,
|
||||||
|
int $fromYear,
|
||||||
|
int $toYear,
|
||||||
|
string $gugunCode,
|
||||||
|
int $dsIdx
|
||||||
|
): array {
|
||||||
|
$saleQty = $this->saleQtySql();
|
||||||
|
$sql = "
|
||||||
|
SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m,
|
||||||
|
SUM({$saleQty}) AS sale_qty,
|
||||||
|
SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS sale_amt
|
||||||
|
FROM bag_sale bs
|
||||||
|
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||||
|
WHERE bs.bs_lg_idx = ?
|
||||||
|
AND YEAR(bs.bs_sale_date) BETWEEN ? AND ?
|
||||||
|
";
|
||||||
|
$params = [$lgIdx, $fromYear, $toYear];
|
||||||
|
if ($gugunCode !== '') {
|
||||||
|
$sql .= ' AND ds.ds_gugun_code = ?';
|
||||||
|
$params[] = $gugunCode;
|
||||||
|
}
|
||||||
|
if ($dsIdx > 0) {
|
||||||
|
$sql .= ' AND bs.bs_ds_idx = ?';
|
||||||
|
$params[] = $dsIdx;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bs.bs_bag_code, YEAR(bs.bs_sale_date), MONTH(bs.bs_sale_date)';
|
||||||
|
|
||||||
|
$agg = [];
|
||||||
|
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
|
||||||
|
$code = (string) ($row['bag_code'] ?? '');
|
||||||
|
$y = (int) ($row['y'] ?? 0);
|
||||||
|
$m = (int) ($row['m'] ?? 0);
|
||||||
|
if ($code === '' || $y <= 0 || $m <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$agg[$code][$y][$m] = [
|
||||||
|
'qty' => (float) ($row['sale_qty'] ?? 0),
|
||||||
|
'amt' => (float) ($row['sale_amt'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $agg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<int, array<int, array{qty: float, amt: float}>>> $agg
|
||||||
|
* @param list<int> $months
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function yoyBlock(
|
||||||
|
string $code,
|
||||||
|
string $name,
|
||||||
|
string $section,
|
||||||
|
array $agg,
|
||||||
|
int $prevYear,
|
||||||
|
int $year,
|
||||||
|
array $months,
|
||||||
|
bool $useAmount
|
||||||
|
): array {
|
||||||
|
$key = $useAmount ? 'amt' : 'qty';
|
||||||
|
$lines = [];
|
||||||
|
foreach ([$prevYear => (string) $prevYear . '년', $year => (string) $year . '년', 0 => '증감'] as $y => $label) {
|
||||||
|
$monthVals = [];
|
||||||
|
$total = 0.0;
|
||||||
|
foreach ($months as $mo) {
|
||||||
|
$v = 0.0;
|
||||||
|
if ($y === 0) {
|
||||||
|
$p = (float) ($agg[$code][$prevYear][$mo][$key] ?? 0);
|
||||||
|
$c = (float) ($agg[$code][$year][$mo][$key] ?? 0);
|
||||||
|
$v = $c - $p;
|
||||||
|
} else {
|
||||||
|
$v = (float) ($agg[$code][$y][$mo][$key] ?? 0);
|
||||||
|
}
|
||||||
|
$monthVals[$mo] = (int) round($v);
|
||||||
|
$total += $v;
|
||||||
|
}
|
||||||
|
$lines[] = ['label' => $label, 'months' => $monthVals, 'total' => (int) round($total)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bag_code' => $code,
|
||||||
|
'bag_name' => $name,
|
||||||
|
'section' => $section,
|
||||||
|
'lines' => $lines,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @return array<string, string> 구·군코드 → 대행소명
|
||||||
|
*/
|
||||||
|
private function agencyNameByGugun(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$best = [];
|
||||||
|
foreach ($this->db->query("
|
||||||
|
SELECT TRIM(bo.bo_gugun_code) AS code, bo.bo_agency_idx AS sa_idx, COUNT(*) AS cnt
|
||||||
|
FROM bag_order bo
|
||||||
|
WHERE bo.bo_lg_idx = ?
|
||||||
|
AND bo.bo_status = 'normal'
|
||||||
|
AND bo.bo_agency_idx IS NOT NULL
|
||||||
|
AND TRIM(bo.bo_gugun_code) != ''
|
||||||
|
GROUP BY TRIM(bo.bo_gugun_code), bo.bo_agency_idx
|
||||||
|
", [$lgIdx])->getResultArray() as $row) {
|
||||||
|
$code = (string) ($row['code'] ?? '');
|
||||||
|
$cnt = (int) ($row['cnt'] ?? 0);
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! isset($best[$code]) || $cnt > $best[$code]['cnt']) {
|
||||||
|
$best[$code] = ['cnt' => $cnt, 'sa_idx' => (int) ($row['sa_idx'] ?? 0)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
foreach ($best as $code => $info) {
|
||||||
|
$sa = model(\App\Models\SalesAgencyModel::class)->find($info['sa_idx']);
|
||||||
|
$names[$code] = $sa ? trim((string) ($sa->sa_name ?? '')) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadShops(int $lgIdx, string $gugunCode, int $saIdx = 0): array
|
||||||
|
{
|
||||||
|
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||||
|
$agencyByGugun = $this->agencyNameByGugun($lgIdx);
|
||||||
|
|
||||||
|
$sql = '
|
||||||
|
SELECT ds.ds_idx, ds.ds_shop_no, ds.ds_name, ds.ds_rep_name, ds.ds_designated_at,
|
||||||
|
ds.ds_gugun_code';
|
||||||
|
if ($hasDsSa) {
|
||||||
|
$sql .= ', ds.ds_sa_idx';
|
||||||
|
}
|
||||||
|
$sql .= '
|
||||||
|
FROM designated_shop ds
|
||||||
|
WHERE ds.ds_lg_idx = ? AND ds.ds_state = 1
|
||||||
|
';
|
||||||
|
$params = [$lgIdx];
|
||||||
|
|
||||||
|
if ($gugunCode !== '') {
|
||||||
|
$sql .= ' AND ds.ds_gugun_code = ?';
|
||||||
|
$params[] = $gugunCode;
|
||||||
|
}
|
||||||
|
if ($saIdx > 0 && $hasDsSa) {
|
||||||
|
$sql .= ' AND ds.ds_sa_idx = ?';
|
||||||
|
$params[] = $saIdx;
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY ds.ds_shop_no ASC, ds.ds_idx ASC';
|
||||||
|
|
||||||
|
$rows = $this->db->query($sql, $params)->getResultArray();
|
||||||
|
$saNames = [];
|
||||||
|
if ($hasDsSa) {
|
||||||
|
foreach (model(\App\Models\SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->findAll() as $sa) {
|
||||||
|
$saNames[(int) ($sa->sa_idx ?? 0)] = trim((string) ($sa->sa_name ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$name = '';
|
||||||
|
if ($hasDsSa) {
|
||||||
|
$saidx = (int) ($row['ds_sa_idx'] ?? 0);
|
||||||
|
$name = $saNames[$saidx] ?? '';
|
||||||
|
}
|
||||||
|
if ($name === '') {
|
||||||
|
$code = trim((string) ($row['ds_gugun_code'] ?? ''));
|
||||||
|
$name = $agencyByGugun[$code] ?? '';
|
||||||
|
}
|
||||||
|
$row['agency_name'] = $name;
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array
|
||||||
|
{
|
||||||
|
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||||
|
$saleQty = $this->saleQtySql();
|
||||||
|
$sql = "
|
||||||
|
SELECT bs.bs_ds_idx AS ds_idx,
|
||||||
|
SUM({$saleQty}) AS sale_qty
|
||||||
|
FROM bag_sale bs
|
||||||
|
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||||
|
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ?
|
||||||
|
AND bs.bs_ds_idx IS NOT NULL
|
||||||
|
";
|
||||||
|
$params = [$lgIdx, $year, $month];
|
||||||
|
if ($gugunCode !== '') {
|
||||||
|
$sql .= ' AND ds.ds_gugun_code = ?';
|
||||||
|
$params[] = $gugunCode;
|
||||||
|
}
|
||||||
|
if ($saIdx > 0 && $hasDsSa) {
|
||||||
|
$sql .= ' AND ds.ds_sa_idx = ?';
|
||||||
|
$params[] = $saIdx;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bs.bs_ds_idx';
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
|
||||||
|
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['sale_qty'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
private function averageNetByShop(
|
||||||
|
int $lgIdx,
|
||||||
|
int $year,
|
||||||
|
string $gugunCode,
|
||||||
|
string $trendBasis,
|
||||||
|
int $refMonth,
|
||||||
|
int $saIdx = 0
|
||||||
|
): array {
|
||||||
|
if ($trendBasis === 'month') {
|
||||||
|
return $this->monthlyNetByShop($lgIdx, $year, $refMonth, $gugunCode, $saIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||||
|
$saleQty = $this->saleQtySql();
|
||||||
|
$sql = "
|
||||||
|
SELECT bs.bs_ds_idx AS ds_idx,
|
||||||
|
SUM({$saleQty}) / 12 AS avg_qty
|
||||||
|
FROM bag_sale bs
|
||||||
|
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||||
|
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
|
||||||
|
AND bs.bs_ds_idx IS NOT NULL
|
||||||
|
";
|
||||||
|
$params = [$lgIdx, $year];
|
||||||
|
if ($gugunCode !== '') {
|
||||||
|
$sql .= ' AND ds.ds_gugun_code = ?';
|
||||||
|
$params[] = $gugunCode;
|
||||||
|
}
|
||||||
|
if ($saIdx > 0 && $hasDsSa) {
|
||||||
|
$sql .= ' AND ds.ds_sa_idx = ?';
|
||||||
|
$params[] = $saIdx;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bs.bs_ds_idx';
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
|
||||||
|
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $months
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
private function seasonalNetByShop(
|
||||||
|
int $lgIdx,
|
||||||
|
int $year,
|
||||||
|
array $months,
|
||||||
|
string $gugunCode,
|
||||||
|
int $saIdx = 0,
|
||||||
|
bool $crossYearWinter = false
|
||||||
|
): array {
|
||||||
|
if ($months === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$divisor = count($months);
|
||||||
|
$saleQty = $this->saleQtySql();
|
||||||
|
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||||
|
|
||||||
|
if ($crossYearWinter) {
|
||||||
|
$sql = "
|
||||||
|
SELECT bs.bs_ds_idx AS ds_idx,
|
||||||
|
SUM({$saleQty}) / ? AS avg_qty
|
||||||
|
FROM bag_sale bs
|
||||||
|
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||||
|
WHERE bs.bs_lg_idx = ?
|
||||||
|
AND bs.bs_ds_idx IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = 12)
|
||||||
|
OR (YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) IN (1, 2))
|
||||||
|
)
|
||||||
|
";
|
||||||
|
$params = [$divisor, $lgIdx, $year - 1, $year];
|
||||||
|
} else {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($months), '?'));
|
||||||
|
$sql = "
|
||||||
|
SELECT bs.bs_ds_idx AS ds_idx,
|
||||||
|
SUM({$saleQty}) / ? AS avg_qty
|
||||||
|
FROM bag_sale bs
|
||||||
|
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
|
||||||
|
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
|
||||||
|
AND MONTH(bs.bs_sale_date) IN ({$placeholders})
|
||||||
|
AND bs.bs_ds_idx IS NOT NULL
|
||||||
|
";
|
||||||
|
$params = array_merge([$divisor], [$lgIdx, $year], $months);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gugunCode !== '') {
|
||||||
|
$sql .= ' AND ds.ds_gugun_code = ?';
|
||||||
|
$params[] = $gugunCode;
|
||||||
|
}
|
||||||
|
if ($saIdx > 0 && $hasDsSa) {
|
||||||
|
$sql .= ' AND ds.ds_sa_idx = ?';
|
||||||
|
$params[] = $saIdx;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bs.bs_ds_idx';
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
|
||||||
|
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
}
|
||||||
463
app/Libraries/BagFlowReportBuilder.php
Normal file
463
app/Libraries/BagFlowReportBuilder.php
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기간별 봉투 수불 현황 집계 (bag/flow)
|
||||||
|
*/
|
||||||
|
class BagFlowReportBuilder
|
||||||
|
{
|
||||||
|
private \CodeIgniter\Database\BaseConnection $db;
|
||||||
|
|
||||||
|
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
|
||||||
|
{
|
||||||
|
$this->db = $db ?? \Config\Database::connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function bagCodeKey(mixed $code): string
|
||||||
|
{
|
||||||
|
return (string) $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* rows: list<array<string, mixed>>,
|
||||||
|
* bagKindLabels: array<string, string>,
|
||||||
|
* queried: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(
|
||||||
|
int $lgIdx,
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
string $aggMode,
|
||||||
|
string $bagCodeFilter,
|
||||||
|
string $bagKindFilter,
|
||||||
|
int $saIdx,
|
||||||
|
bool $queried
|
||||||
|
): array {
|
||||||
|
$bagKindLabels = $this->loadBagKindLabels();
|
||||||
|
$products = $this->loadProducts($lgIdx, $bagCodeFilter, $bagKindFilter);
|
||||||
|
if ($products === [] || ! $queried) {
|
||||||
|
return ['rows' => [], 'bagKindLabels' => $bagKindLabels, 'queried' => $queried];
|
||||||
|
}
|
||||||
|
|
||||||
|
$codes = array_keys($products);
|
||||||
|
$dayBefore = date('Y-m-d', strtotime($startDate . ' -1 day'));
|
||||||
|
$openingRaw = $this->aggregateMovements($lgIdx, $codes, $saIdx, null, $dayBefore);
|
||||||
|
$opening = $this->collapseOpeningBalances($openingRaw);
|
||||||
|
$periodMoves = $this->aggregateMovements($lgIdx, $codes, $saIdx, $startDate, $endDate);
|
||||||
|
|
||||||
|
if ($aggMode === 'daily') {
|
||||||
|
$rows = $this->buildDailyRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels);
|
||||||
|
} else {
|
||||||
|
$rows = $this->buildPeriodRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'bagKindLabels' => $bagKindLabels, 'queried' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> code => name
|
||||||
|
*/
|
||||||
|
private function loadProducts(int $lgIdx, string $bagCodeFilter, string $bagKindFilter): array
|
||||||
|
{
|
||||||
|
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
|
if (! $kindO) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$details = model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx);
|
||||||
|
$products = [];
|
||||||
|
foreach ($details as $d) {
|
||||||
|
$code = (string) ($d->cd_code ?? '');
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bagCodeFilter !== '' && $code !== $bagCodeFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($bagKindFilter !== '' && ! str_starts_with($code, $bagKindFilter)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$products[self::bagCodeKey($code)] = (string) ($d->cd_name ?? $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $products;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function loadBagKindLabels(): array
|
||||||
|
{
|
||||||
|
$kindE = model(\App\Models\CodeKindModel::class)->where('ck_code', 'E')->first();
|
||||||
|
if (! $kindE) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$labels = [];
|
||||||
|
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null) as $d) {
|
||||||
|
$labels[(string) $d->cd_code] = (string) $d->cd_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $codes
|
||||||
|
* @return array<string, array<string, array<string, int>>> bag_code => date => metrics
|
||||||
|
*/
|
||||||
|
private function aggregateMovements(
|
||||||
|
int $lgIdx,
|
||||||
|
array $codes,
|
||||||
|
int $saIdx,
|
||||||
|
?string $fromDate,
|
||||||
|
?string $toDate
|
||||||
|
): array {
|
||||||
|
if ($codes === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$buckets = [];
|
||||||
|
$ensure = static function (string $code, string $date) use (&$buckets): array {
|
||||||
|
if (! isset($buckets[$code][$date])) {
|
||||||
|
$buckets[$code][$date] = self::emptyMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $buckets[$code][$date];
|
||||||
|
};
|
||||||
|
|
||||||
|
$hasMisc = $this->db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0;
|
||||||
|
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
|
||||||
|
$codePlaceholders = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
|
||||||
|
// 입고(발주 입고)
|
||||||
|
$sql = "
|
||||||
|
SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS qty
|
||||||
|
FROM bag_receiving
|
||||||
|
WHERE br_lg_idx = ? AND br_bag_code IN ({$codePlaceholders})
|
||||||
|
";
|
||||||
|
$params = array_merge([$lgIdx], $codes);
|
||||||
|
if ($fromDate !== null) {
|
||||||
|
$sql .= ' AND br_receive_date >= ?';
|
||||||
|
$params[] = $fromDate;
|
||||||
|
}
|
||||||
|
if ($toDate !== null) {
|
||||||
|
$sql .= ' AND br_receive_date <= ?';
|
||||||
|
$params[] = $toDate;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY br_bag_code, br_receive_date';
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$date = (string) $row->mv_date;
|
||||||
|
$m = $ensure($code, $date);
|
||||||
|
$m['recv_in'] += (int) $row->qty;
|
||||||
|
$buckets[$code][$date] = $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 판매·반품(반품=입고)
|
||||||
|
$sql = "
|
||||||
|
SELECT bs.bs_bag_code AS bag_code, bs.bs_sale_date AS mv_date, bs.bs_type AS mv_type,
|
||||||
|
SUM(ABS(bs.bs_qty)) AS qty
|
||||||
|
FROM bag_sale bs
|
||||||
|
";
|
||||||
|
if ($saIdx > 0 && $hasDsSa) {
|
||||||
|
$sql .= ' INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_sa_idx = ?';
|
||||||
|
}
|
||||||
|
$sql .= " WHERE bs.bs_lg_idx = ? AND bs.bs_bag_code IN ({$codePlaceholders})";
|
||||||
|
$params = $saIdx > 0 && $hasDsSa ? [$saIdx, $lgIdx] : [$lgIdx];
|
||||||
|
$params = array_merge($params, $codes);
|
||||||
|
if ($fromDate !== null) {
|
||||||
|
$sql .= ' AND bs.bs_sale_date >= ?';
|
||||||
|
$params[] = $fromDate;
|
||||||
|
}
|
||||||
|
if ($toDate !== null) {
|
||||||
|
$sql .= ' AND bs.bs_sale_date <= ?';
|
||||||
|
$params[] = $toDate;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bs.bs_bag_code, bs.bs_sale_date, bs.bs_type';
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$date = (string) $row->mv_date;
|
||||||
|
$qty = (int) $row->qty;
|
||||||
|
$m = $ensure($code, $date);
|
||||||
|
$type = (string) $row->mv_type;
|
||||||
|
if ($type === 'return') {
|
||||||
|
$m['recv_return'] += $qty;
|
||||||
|
} else {
|
||||||
|
$m['out_sale'] += $qty;
|
||||||
|
}
|
||||||
|
$buckets[$code][$date] = $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 불출
|
||||||
|
$sql = "
|
||||||
|
SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, bi2_issue_type AS issue_type,
|
||||||
|
SUM(bi2_qty) AS qty
|
||||||
|
FROM bag_issue
|
||||||
|
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$codePlaceholders})
|
||||||
|
";
|
||||||
|
$params = array_merge([$lgIdx], $codes);
|
||||||
|
if ($fromDate !== null) {
|
||||||
|
$sql .= ' AND bi2_issue_date >= ?';
|
||||||
|
$params[] = $fromDate;
|
||||||
|
}
|
||||||
|
if ($toDate !== null) {
|
||||||
|
$sql .= ' AND bi2_issue_date <= ?';
|
||||||
|
$params[] = $toDate;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bi2_bag_code, bi2_issue_date, bi2_issue_type';
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$date = (string) $row->mv_date;
|
||||||
|
$qty = (int) $row->qty;
|
||||||
|
$m = $ensure($code, $date);
|
||||||
|
$issueType = (string) $row->issue_type;
|
||||||
|
if (str_contains($issueType, '무료')) {
|
||||||
|
$m['out_issue_free'] += $qty;
|
||||||
|
} else {
|
||||||
|
$m['out_issue_gen'] += $qty;
|
||||||
|
}
|
||||||
|
$buckets[$code][$date] = $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasMisc) {
|
||||||
|
$sql = "
|
||||||
|
SELECT bmf_bag_code AS bag_code, bmf_date AS mv_date, bmf_type AS mv_type,
|
||||||
|
SUM(bmf_qty) AS qty
|
||||||
|
FROM bag_misc_flow
|
||||||
|
WHERE bmf_lg_idx = ? AND bmf_bag_code IN ({$codePlaceholders})
|
||||||
|
";
|
||||||
|
$params = array_merge([$lgIdx], $codes);
|
||||||
|
if ($fromDate !== null) {
|
||||||
|
$sql .= ' AND bmf_date >= ?';
|
||||||
|
$params[] = $fromDate;
|
||||||
|
}
|
||||||
|
if ($toDate !== null) {
|
||||||
|
$sql .= ' AND bmf_date <= ?';
|
||||||
|
$params[] = $toDate;
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY bmf_bag_code, bmf_date, bmf_type';
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$date = (string) $row->mv_date;
|
||||||
|
$qty = (int) $row->qty;
|
||||||
|
$m = $ensure($code, $date);
|
||||||
|
if ((string) $row->mv_type === 'in') {
|
||||||
|
$m['recv_misc'] += $qty;
|
||||||
|
} else {
|
||||||
|
$m['out_misc'] += $qty;
|
||||||
|
}
|
||||||
|
$buckets[$code][$date] = $m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($buckets as $code => $byDate) {
|
||||||
|
$key = self::bagCodeKey($code);
|
||||||
|
foreach ($byDate as $date => $m) {
|
||||||
|
$normalized[$key][$date] = self::finalizeMetrics($m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $products
|
||||||
|
* @param array<string, array<string, int>> $opening date key '_open'
|
||||||
|
* @param array<string, array<string, array<string, int>>> $periodMoves
|
||||||
|
* @param array<string, string> $bagKindLabels
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildPeriodRows(
|
||||||
|
array $products,
|
||||||
|
array $opening,
|
||||||
|
array $periodMoves,
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
array $bagKindLabels
|
||||||
|
): array {
|
||||||
|
$periodKey = $startDate . '~' . $endDate;
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($products as $codeKey => $name) {
|
||||||
|
$code = self::bagCodeKey($codeKey);
|
||||||
|
$kind = strlen($code) >= 2 ? substr($code, 0, 2) : '';
|
||||||
|
$grouped[$kind][] = ['code' => $code, 'name' => $name];
|
||||||
|
}
|
||||||
|
ksort($grouped);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$grand = self::emptyMetrics();
|
||||||
|
$grand['row_type'] = 'grand';
|
||||||
|
$grand['date'] = '';
|
||||||
|
$grand['item_name'] = '총계';
|
||||||
|
|
||||||
|
foreach ($grouped as $kind => $items) {
|
||||||
|
$sub = self::emptyMetrics();
|
||||||
|
$sub['row_type'] = 'subtotal';
|
||||||
|
$sub['date'] = '';
|
||||||
|
$sub['item_name'] = ($bagKindLabels[$kind] ?? '기타') . ' 소계';
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$code = self::bagCodeKey($item['code']);
|
||||||
|
$m = self::emptyMetrics();
|
||||||
|
foreach ($periodMoves[$code] ?? [] as $dayMetrics) {
|
||||||
|
$m = self::mergeMetrics($m, $dayMetrics);
|
||||||
|
}
|
||||||
|
$m = self::finalizeMetrics($m);
|
||||||
|
$m['prev_stock'] = (int) ($opening[$code] ?? 0);
|
||||||
|
$m['balance'] = $m['prev_stock'] + $m['recv_total'] - $m['out_total'];
|
||||||
|
$m['row_type'] = 'data';
|
||||||
|
$m['date'] = $periodKey;
|
||||||
|
$m['item_name'] = $item['name'];
|
||||||
|
$m['bag_code'] = $code;
|
||||||
|
$m['bag_kind'] = $kind;
|
||||||
|
$rows[] = $m;
|
||||||
|
$sub = self::mergeMetrics($sub, $m);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = self::finalizeMetrics($sub);
|
||||||
|
$sub['balance'] = $sub['prev_stock'] + $sub['recv_total'] - $sub['out_total'];
|
||||||
|
$rows[] = $sub;
|
||||||
|
$grand = self::mergeMetrics($grand, $sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
$grand = self::finalizeMetrics($grand);
|
||||||
|
$grand['balance'] = $grand['prev_stock'] + $grand['recv_total'] - $grand['out_total'];
|
||||||
|
$rows[] = $grand;
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, array<string, int>>> $openingRaw
|
||||||
|
* @return array<string, int> bag_code => 전일(기간 전) 재고
|
||||||
|
*/
|
||||||
|
private function collapseOpeningBalances(array $openingRaw): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($openingRaw as $code => $byDate) {
|
||||||
|
$net = self::emptyMetrics();
|
||||||
|
foreach ($byDate as $m) {
|
||||||
|
$net = self::mergeMetrics($net, $m);
|
||||||
|
}
|
||||||
|
$net = self::finalizeMetrics($net);
|
||||||
|
$out[self::bagCodeKey($code)] = $net['recv_total'] - $net['out_total'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $products
|
||||||
|
* @param array<string, array<string, array<string, int>>> $opening
|
||||||
|
* @param array<string, array<string, array<string, int>>> $periodMoves
|
||||||
|
* @param array<string, string> $bagKindLabels
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildDailyRows(
|
||||||
|
array $products,
|
||||||
|
array $opening,
|
||||||
|
array $periodMoves,
|
||||||
|
string $startDate,
|
||||||
|
string $endDate,
|
||||||
|
array $bagKindLabels
|
||||||
|
): array {
|
||||||
|
$dates = [];
|
||||||
|
$cursor = strtotime($startDate);
|
||||||
|
$endTs = strtotime($endDate);
|
||||||
|
while ($cursor <= $endTs) {
|
||||||
|
$dates[] = date('Y-m-d', $cursor);
|
||||||
|
$cursor = strtotime('+1 day', $cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($products as $codeKey => $name) {
|
||||||
|
$code = self::bagCodeKey($codeKey);
|
||||||
|
$kind = strlen($code) >= 2 ? substr($code, 0, 2) : '';
|
||||||
|
$running = (int) ($opening[$code] ?? 0);
|
||||||
|
foreach ($dates as $date) {
|
||||||
|
$dayM = $periodMoves[$code][$date] ?? self::emptyMetrics();
|
||||||
|
$dayM = self::finalizeMetrics($dayM);
|
||||||
|
$prev = $running;
|
||||||
|
$running = $prev + $dayM['recv_total'] - $dayM['out_total'];
|
||||||
|
$dayM['prev_stock'] = $prev;
|
||||||
|
$dayM['balance'] = $running;
|
||||||
|
$dayM['row_type'] = 'data';
|
||||||
|
$dayM['date'] = $date;
|
||||||
|
$dayM['item_name'] = $name;
|
||||||
|
$dayM['bag_code'] = $code;
|
||||||
|
$dayM['bag_kind'] = $kind;
|
||||||
|
if ($this->rowHasActivity($dayM)) {
|
||||||
|
$rows[] = $dayM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int|float> $m
|
||||||
|
*/
|
||||||
|
private function rowHasActivity(array $m): bool
|
||||||
|
{
|
||||||
|
foreach (['recv_in', 'recv_return', 'recv_misc', 'out_sale', 'out_issue_gen', 'out_issue_free', 'out_return', 'out_misc'] as $k) {
|
||||||
|
if ((int) ($m[$k] ?? 0) !== 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($m['prev_stock'] ?? 0) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private static function emptyMetrics(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'prev_stock' => 0,
|
||||||
|
'recv_in' => 0,
|
||||||
|
'recv_return' => 0,
|
||||||
|
'recv_misc' => 0,
|
||||||
|
'recv_total' => 0,
|
||||||
|
'out_sale' => 0,
|
||||||
|
'out_issue_gen' => 0,
|
||||||
|
'out_issue_free' => 0,
|
||||||
|
'out_return' => 0,
|
||||||
|
'out_misc' => 0,
|
||||||
|
'out_total' => 0,
|
||||||
|
'balance' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $m
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private static function finalizeMetrics(array $m): array
|
||||||
|
{
|
||||||
|
$m['recv_total'] = (int) $m['recv_in'] + (int) $m['recv_return'] + (int) $m['recv_misc'];
|
||||||
|
$m['out_total'] = (int) $m['out_sale'] + (int) $m['out_issue_gen'] + (int) $m['out_issue_free']
|
||||||
|
+ (int) $m['out_return'] + (int) $m['out_misc'];
|
||||||
|
|
||||||
|
return $m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $a
|
||||||
|
* @param array<string, int> $b
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private static function mergeMetrics(array $a, array $b): array
|
||||||
|
{
|
||||||
|
foreach (self::emptyMetrics() as $k => $_) {
|
||||||
|
$a[$k] = (int) ($a[$k] ?? 0) + (int) ($b[$k] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $a;
|
||||||
|
}
|
||||||
|
}
|
||||||
900
app/Libraries/BagLotFlowBuilder.php
Normal file
900
app/Libraries/BagLotFlowBuilder.php
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOT 수불 조회 (레거시 w_gd033a)
|
||||||
|
* — 바코드(팩/박스/낱장) 또는 LOT 번호로 일자·입출고처·구분 이력
|
||||||
|
*/
|
||||||
|
class BagLotFlowBuilder
|
||||||
|
{
|
||||||
|
private \CodeIgniter\Database\BaseConnection $db;
|
||||||
|
|
||||||
|
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
|
||||||
|
{
|
||||||
|
$this->db = $db ?? \Config\Database::connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* ok: bool,
|
||||||
|
* message: string,
|
||||||
|
* barcode: string,
|
||||||
|
* unit: string,
|
||||||
|
* bag_code: string,
|
||||||
|
* bag_name: string,
|
||||||
|
* lot_no: string,
|
||||||
|
* box_code: string,
|
||||||
|
* pack_code: string,
|
||||||
|
* qty_box: int,
|
||||||
|
* qty_pack: int,
|
||||||
|
* qty_sheet: int,
|
||||||
|
* rows: list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function buildByBarcode(int $lgIdx, string $barcode, bool $queried): array
|
||||||
|
{
|
||||||
|
$empty = $this->emptyResult($barcode);
|
||||||
|
if (! $queried || trim($barcode) === '') {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $this->resolveBarcode($lgIdx, trim($barcode));
|
||||||
|
if (! $resolved['ok']) {
|
||||||
|
return array_merge($empty, [
|
||||||
|
'message' => (string) ($resolved['message'] ?? '등록되지 않은 바코드입니다.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->collectFlowRows($lgIdx, $resolved);
|
||||||
|
usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0));
|
||||||
|
|
||||||
|
return array_merge($empty, [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => '',
|
||||||
|
'barcode' => (string) ($resolved['barcode'] ?? $barcode),
|
||||||
|
'unit' => (string) ($resolved['unit'] ?? ''),
|
||||||
|
'bag_code' => (string) ($resolved['bag_code'] ?? ''),
|
||||||
|
'bag_name' => (string) ($resolved['bag_name'] ?? ''),
|
||||||
|
'lot_no' => (string) ($resolved['lot_no'] ?? ''),
|
||||||
|
'box_code' => (string) ($resolved['box_code'] ?? ''),
|
||||||
|
'pack_code' => (string) ($resolved['pack_code'] ?? ''),
|
||||||
|
'qty_box' => (int) ($resolved['qty_box'] ?? 0),
|
||||||
|
'qty_pack' => (int) ($resolved['qty_pack'] ?? 0),
|
||||||
|
'qty_sheet' => (int) ($resolved['qty_sheet'] ?? 0),
|
||||||
|
'rows' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildByLotNo(int $lgIdx, string $lotNo, bool $queried): array
|
||||||
|
{
|
||||||
|
$empty = $this->emptyResult('');
|
||||||
|
if (! $queried || trim($lotNo) === '') {
|
||||||
|
return $empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lotNo = trim($lotNo);
|
||||||
|
if (! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
return array_merge($empty, ['message' => '바코드(팩) 데이터가 없습니다.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$packRows = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_pack_code, brpc_box_code, brpc_bag_code, brpc_bag_name, brpc_lot_no')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_lot_no', $lotNo)
|
||||||
|
->limit(500)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
if ($packRows === []) {
|
||||||
|
$order = $this->db->table('bag_order')
|
||||||
|
->where('bo_lg_idx', $lgIdx)
|
||||||
|
->where('bo_lot_no', $lotNo)
|
||||||
|
->orderBy('bo_version', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (! $order) {
|
||||||
|
return array_merge($empty, ['message' => '해당 LOT·바코드를 찾을 수 없습니다.', 'lot_no' => $lotNo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildLotFromOrderOnly($lgIdx, $lotNo, $order);
|
||||||
|
}
|
||||||
|
|
||||||
|
$codes = [];
|
||||||
|
$bagCode = '';
|
||||||
|
$bagName = '';
|
||||||
|
foreach ($packRows as $p) {
|
||||||
|
$codes[] = (string) ($p['brpc_pack_code'] ?? '');
|
||||||
|
$box = (string) ($p['brpc_box_code'] ?? '');
|
||||||
|
if ($box !== '') {
|
||||||
|
$codes[] = $box;
|
||||||
|
}
|
||||||
|
if ($bagCode === '') {
|
||||||
|
$bagCode = (string) ($p['brpc_bag_code'] ?? '');
|
||||||
|
$bagName = (string) ($p['brpc_bag_name'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$codes = array_values(array_unique(array_filter($codes, static fn (string $c): bool => $c !== '')));
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->loadReceivingEventsForLot($lgIdx, $lotNo) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0));
|
||||||
|
if (count($rows) > 500) {
|
||||||
|
$rows = array_slice($rows, -500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($empty, [
|
||||||
|
'ok' => true,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'bag_code' => $bagCode,
|
||||||
|
'bag_name' => $bagName,
|
||||||
|
'barcode' => $lotNo,
|
||||||
|
'rows' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function emptyResult(string $barcode): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => '',
|
||||||
|
'barcode' => $barcode,
|
||||||
|
'unit' => '',
|
||||||
|
'bag_code' => '',
|
||||||
|
'bag_name' => '',
|
||||||
|
'lot_no' => '',
|
||||||
|
'box_code' => '',
|
||||||
|
'pack_code' => '',
|
||||||
|
'qty_box' => 0,
|
||||||
|
'qty_pack' => 0,
|
||||||
|
'qty_sheet' => 0,
|
||||||
|
'rows' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, message?: string, barcode?: string, unit?: string, bag_code?: string, bag_name?: string, lot_no?: string, box_code?: string, pack_code?: string, pack_ids?: list<int>, qty_box?: int, qty_pack?: int, qty_sheet?: int}
|
||||||
|
*/
|
||||||
|
private function resolveBarcode(int $lgIdx, string $barcode): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
return ['ok' => false, 'message' => '바코드(팩) 데이터가 없습니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pack = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_pack_code', $barcode)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if ($pack) {
|
||||||
|
return $this->resolvedFromPackRow($barcode, '팩', $pack, 0, 1, (int) ($pack['brpc_sheet_qty'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
$boxRows = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_box_code', $barcode)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
if ($boxRows !== []) {
|
||||||
|
$first = $boxRows[0];
|
||||||
|
$sheetQty = 0;
|
||||||
|
foreach ($boxRows as $row) {
|
||||||
|
$sheetQty += (int) ($row['brpc_sheet_qty'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty);
|
||||||
|
}
|
||||||
|
|
||||||
|
$exactSheet = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_sheet_start_code', $barcode)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($exactSheet) && $exactSheet !== []) {
|
||||||
|
return $this->resolvedFromPackRow($barcode, '낱장', $exactSheet, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheetRows = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_sheet_start_code !=', '')
|
||||||
|
->where('brpc_sheet_end_code !=', '')
|
||||||
|
->limit(200)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($sheetRows as $row) {
|
||||||
|
$start = (string) ($row['brpc_sheet_start_code'] ?? '');
|
||||||
|
$end = (string) ($row['brpc_sheet_end_code'] ?? '');
|
||||||
|
if ($this->barcodeInRange($barcode, $start, $end)) {
|
||||||
|
return $this->resolvedFromPackRow($barcode, '낱장', $row, 0, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromScan = $this->resolveBarcodeFromScanTables($lgIdx, $barcode);
|
||||||
|
if ($fromScan !== null) {
|
||||||
|
return $fromScan;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 판매·반품 스캔에만 있는 낱장 코드(입고 팩 테이블 미등록) 조회
|
||||||
|
*
|
||||||
|
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}|null
|
||||||
|
*/
|
||||||
|
private function resolveBarcodeFromScanTables(int $lgIdx, string $barcode): ?array
|
||||||
|
{
|
||||||
|
$bagCode = '';
|
||||||
|
$bagName = '';
|
||||||
|
if ($this->db->tableExists('bag_sale_scan_code')) {
|
||||||
|
$sale = $this->db->table('bag_sale_scan_code')
|
||||||
|
->select('bssc_bag_code, bssc_bag_name')
|
||||||
|
->where('bssc_lg_idx', $lgIdx)
|
||||||
|
->where('bssc_code', $barcode)
|
||||||
|
->orderBy('bssc_regdate', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($sale) && $sale !== []) {
|
||||||
|
$bagCode = (string) ($sale['bssc_bag_code'] ?? '');
|
||||||
|
$bagName = (string) ($sale['bssc_bag_name'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($bagCode === '' && $this->db->tableExists('bag_return_scan_code')) {
|
||||||
|
$ret = $this->db->table('bag_return_scan_code')
|
||||||
|
->select('brsc_bag_code, brsc_bag_name')
|
||||||
|
->where('brsc_lg_idx', $lgIdx)
|
||||||
|
->where('brsc_code', $barcode)
|
||||||
|
->orderBy('brsc_regdate', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($ret) && $ret !== []) {
|
||||||
|
$bagCode = (string) ($ret['brsc_bag_code'] ?? '');
|
||||||
|
$bagName = (string) ($ret['brsc_bag_name'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($bagCode === '' && $bagName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packRow = $this->findPackRowContainingSheet($lgIdx, $barcode);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'barcode' => $barcode,
|
||||||
|
'unit' => '낱장',
|
||||||
|
'bag_code' => $bagCode,
|
||||||
|
'bag_name' => $bagName,
|
||||||
|
'lot_no' => (string) ($packRow['brpc_lot_no'] ?? ''),
|
||||||
|
'box_code' => (string) ($packRow['brpc_box_code'] ?? ''),
|
||||||
|
'pack_code' => (string) ($packRow['brpc_pack_code'] ?? ''),
|
||||||
|
'pack_ids' => isset($packRow['brpc_idx']) ? [(int) $packRow['brpc_idx']] : [],
|
||||||
|
'qty_box' => 0,
|
||||||
|
'qty_pack' => 0,
|
||||||
|
'qty_sheet' => 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function findPackRowContainingSheet(int $lgIdx, string $barcode): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$exact = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_sheet_start_code', $barcode)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($exact) && $exact !== []) {
|
||||||
|
return $exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_sheet_start_code !=', '')
|
||||||
|
->where('brpc_sheet_end_code !=', '')
|
||||||
|
->limit(200)
|
||||||
|
->get()
|
||||||
|
->getResultArray() as $row) {
|
||||||
|
$start = (string) ($row['brpc_sheet_start_code'] ?? '');
|
||||||
|
$end = (string) ($row['brpc_sheet_end_code'] ?? '');
|
||||||
|
if ($this->barcodeInRange($barcode, $start, $end)) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $pack
|
||||||
|
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}
|
||||||
|
*/
|
||||||
|
private function resolvedFromPackRow(string $barcode, string $unit, array $pack, int $qtyBox, int $qtyPack, int $qtySheet): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'barcode' => $barcode,
|
||||||
|
'unit' => $unit,
|
||||||
|
'bag_code' => (string) ($pack['brpc_bag_code'] ?? ''),
|
||||||
|
'bag_name' => (string) ($pack['brpc_bag_name'] ?? ''),
|
||||||
|
'lot_no' => (string) ($pack['brpc_lot_no'] ?? ''),
|
||||||
|
'box_code' => (string) ($pack['brpc_box_code'] ?? ''),
|
||||||
|
'pack_code' => (string) ($pack['brpc_pack_code'] ?? ''),
|
||||||
|
'pack_ids' => [(int) ($pack['brpc_idx'] ?? 0)],
|
||||||
|
'qty_box' => $qtyBox,
|
||||||
|
'qty_pack' => $qtyPack,
|
||||||
|
'qty_sheet' => $qtySheet,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $resolved
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function collectFlowRows(int $lgIdx, array $resolved): array
|
||||||
|
{
|
||||||
|
$unit = (string) ($resolved['unit'] ?? '');
|
||||||
|
|
||||||
|
return match ($unit) {
|
||||||
|
'낱장' => $this->collectFlowRowsForSheet($lgIdx, $resolved),
|
||||||
|
'박스' => $this->collectFlowRowsForBox($lgIdx, $resolved),
|
||||||
|
default => $this->collectFlowRowsForPack($lgIdx, $resolved),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 낱장: 해당 바코드 판매·반품만 + 소속 팩 입고 1건(발주·동일 LOT 전체 이력 제외)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $resolved
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function collectFlowRowsForSheet(int $lgIdx, array $resolved): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$barcode = trim((string) ($resolved['barcode'] ?? ''));
|
||||||
|
if ($barcode === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
|
||||||
|
if ($brIdx > 0) {
|
||||||
|
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->loadScanEventsForCodes($lgIdx, [$barcode]) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
foreach ($this->loadReturnEventsForCodes($lgIdx, [$barcode]) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팩: 팩·낱장 바코드 스캔 + 입고 + LOT 발주
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $resolved
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function collectFlowRowsForPack(int $lgIdx, array $resolved): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$codes = array_values(array_unique(array_filter([
|
||||||
|
(string) ($resolved['barcode'] ?? ''),
|
||||||
|
(string) ($resolved['pack_code'] ?? ''),
|
||||||
|
], static fn (string $c): bool => $c !== '')));
|
||||||
|
|
||||||
|
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
|
||||||
|
if ($brIdx > 0) {
|
||||||
|
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lotNo = (string) ($resolved['lot_no'] ?? '');
|
||||||
|
if ($lotNo !== '') {
|
||||||
|
foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 박스: 박스·소속 팩 코드 스캔 + 입고(박스 내 팩) + LOT 발주
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $resolved
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function collectFlowRowsForBox(int $lgIdx, array $resolved): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$boxCode = (string) ($resolved['box_code'] ?? '');
|
||||||
|
$codes = array_values(array_unique(array_filter([
|
||||||
|
(string) ($resolved['barcode'] ?? ''),
|
||||||
|
$boxCode,
|
||||||
|
(string) ($resolved['pack_code'] ?? ''),
|
||||||
|
], static fn (string $c): bool => $c !== '')));
|
||||||
|
|
||||||
|
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
$packCodes = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_box_code', $boxCode)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($packCodes as $p) {
|
||||||
|
$pc = (string) ($p['brpc_pack_code'] ?? '');
|
||||||
|
if ($pc !== '' && ! in_array($pc, $codes, true)) {
|
||||||
|
$codes[] = $pc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenBr = [];
|
||||||
|
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
$brRows = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_br_idx')
|
||||||
|
->distinct()
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_box_code', $boxCode)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($brRows as $brRow) {
|
||||||
|
$brIdx = (int) ($brRow['brpc_br_idx'] ?? 0);
|
||||||
|
if ($brIdx <= 0 || isset($seenBr[$brIdx])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenBr[$brIdx] = true;
|
||||||
|
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lotNo = (string) ($resolved['lot_no'] ?? '');
|
||||||
|
if ($lotNo !== '') {
|
||||||
|
foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) {
|
||||||
|
$rows[] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function receivingBrIdxForPackCode(int $lgIdx, string $packCode): int
|
||||||
|
{
|
||||||
|
if ($packCode === '' || ! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_br_idx')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_pack_code', $packCode)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return (int) ($p['brpc_br_idx'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function loadReceivingEventsByBrIdx(int $lgIdx, int $brIdx): array
|
||||||
|
{
|
||||||
|
$sql = "
|
||||||
|
SELECT r.br_receive_date, r.br_sender_name, r.br_regdate,
|
||||||
|
o.bo_order_date, c.cp_name, sa.sa_name
|
||||||
|
FROM bag_receiving r
|
||||||
|
LEFT JOIN bag_order o ON o.bo_idx = r.br_bo_idx
|
||||||
|
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
|
||||||
|
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
|
||||||
|
WHERE r.br_lg_idx = ? AND r.br_idx = ?
|
||||||
|
LIMIT 20
|
||||||
|
";
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->db->query($sql, [$lgIdx, $brIdx])->getResultArray() as $r) {
|
||||||
|
$rows[] = $this->makeEvent(
|
||||||
|
(string) ($r['br_receive_date'] ?? ''),
|
||||||
|
(string) ($r['br_regdate'] ?? ''),
|
||||||
|
$this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name'),
|
||||||
|
'입고'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function loadReceivingEventsForLot(int $lgIdx, string $lotNo): array
|
||||||
|
{
|
||||||
|
$sql = "
|
||||||
|
SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, r.br_bag_name,
|
||||||
|
c.cp_name, sa.sa_name
|
||||||
|
FROM bag_receiving r
|
||||||
|
INNER JOIN bag_order o ON o.bo_idx = r.br_bo_idx AND o.bo_lot_no = ?
|
||||||
|
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
|
||||||
|
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
|
||||||
|
WHERE r.br_lg_idx = ?
|
||||||
|
ORDER BY r.br_receive_date ASC, r.br_idx ASC
|
||||||
|
LIMIT 200
|
||||||
|
";
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->db->query($sql, [$lotNo, $lgIdx])->getResultArray() as $r) {
|
||||||
|
$label = trim((string) ($r['br_bag_name'] ?? ''));
|
||||||
|
$rows[] = $this->makeEvent(
|
||||||
|
(string) ($r['br_receive_date'] ?? ''),
|
||||||
|
(string) ($r['br_regdate'] ?? ''),
|
||||||
|
$this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name') . ($label !== '' ? ' · ' . $label : ''),
|
||||||
|
'입고'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $codes
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function loadScanEventsForCodes(int $lgIdx, array $codes): array
|
||||||
|
{
|
||||||
|
if ($codes === [] || ! $this->db->tableExists('bag_sale_scan_code')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
$params = array_merge([$lgIdx], $codes);
|
||||||
|
$sql = "
|
||||||
|
SELECT b.bssc_regdate, b.bssc_state, b.bssc_code, d.ds_name, d.ds_shop_no
|
||||||
|
FROM bag_sale_scan_code b
|
||||||
|
LEFT JOIN designated_shop d ON d.ds_idx = b.bssc_ds_idx
|
||||||
|
WHERE b.bssc_lg_idx = ? AND b.bssc_code IN ({$placeholders})
|
||||||
|
ORDER BY b.bssc_regdate ASC
|
||||||
|
LIMIT 200
|
||||||
|
";
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->db->query($sql, $params)->getResultArray() as $r) {
|
||||||
|
$state = strtolower((string) ($r['bssc_state'] ?? ''));
|
||||||
|
$type = $state === 'in_stock' ? '반품입고' : '출고';
|
||||||
|
$shop = trim((string) ($r['ds_name'] ?? ''));
|
||||||
|
if ($shop === '') {
|
||||||
|
$shop = trim((string) ($r['ds_shop_no'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($shop === '') {
|
||||||
|
$shop = '지정판매소';
|
||||||
|
}
|
||||||
|
$rows[] = $this->makeEvent(
|
||||||
|
$this->dateOnly((string) ($r['bssc_regdate'] ?? '')),
|
||||||
|
(string) ($r['bssc_regdate'] ?? ''),
|
||||||
|
$shop,
|
||||||
|
$type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $codes
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function loadReturnEventsForCodes(int $lgIdx, array $codes): array
|
||||||
|
{
|
||||||
|
if ($codes === [] || ! $this->db->tableExists('bag_return_scan_code')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
$params = array_merge([$lgIdx], $codes);
|
||||||
|
$sql = "
|
||||||
|
SELECT r.brsc_return_date, r.brsc_regdate, r.brsc_code, d.ds_name, d.ds_shop_no
|
||||||
|
FROM bag_return_scan_code r
|
||||||
|
LEFT JOIN designated_shop d ON d.ds_idx = r.brsc_ds_idx
|
||||||
|
WHERE r.brsc_lg_idx = ? AND r.brsc_code IN ({$placeholders})
|
||||||
|
ORDER BY r.brsc_return_date ASC
|
||||||
|
LIMIT 200
|
||||||
|
";
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->db->query($sql, $params)->getResultArray() as $r) {
|
||||||
|
$shop = trim((string) ($r['ds_name'] ?? ''));
|
||||||
|
if ($shop === '') {
|
||||||
|
$shop = trim((string) ($r['ds_shop_no'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($shop === '') {
|
||||||
|
$shop = '지정판매소';
|
||||||
|
}
|
||||||
|
$rows[] = $this->makeEvent(
|
||||||
|
(string) ($r['brsc_return_date'] ?? ''),
|
||||||
|
(string) ($r['brsc_regdate'] ?? ''),
|
||||||
|
$shop,
|
||||||
|
'반품'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
|
||||||
|
*/
|
||||||
|
private function loadOrderEventsForLot(int $lgIdx, string $lotNo): array
|
||||||
|
{
|
||||||
|
$sql = "
|
||||||
|
SELECT o.bo_order_date, o.bo_regdate, c.cp_name, sa.sa_name
|
||||||
|
FROM bag_order o
|
||||||
|
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
|
||||||
|
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
|
||||||
|
WHERE o.bo_lg_idx = ? AND o.bo_lot_no = ? AND o.bo_status = 'normal'
|
||||||
|
ORDER BY o.bo_version DESC
|
||||||
|
LIMIT 1
|
||||||
|
";
|
||||||
|
$r = $this->db->query($sql, [$lgIdx, $lotNo])->getRowArray();
|
||||||
|
if (! $r) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$this->makeEvent(
|
||||||
|
(string) ($r['bo_order_date'] ?? ''),
|
||||||
|
(string) ($r['bo_regdate'] ?? ''),
|
||||||
|
$this->pickSource($r, '제작·발주', 'cp_name', 'sa_name'),
|
||||||
|
'발주'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $order
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildLotFromOrderOnly(int $lgIdx, string $lotNo, array $order): array
|
||||||
|
{
|
||||||
|
$rows = $this->loadOrderEventsForLot($lgIdx, $lotNo);
|
||||||
|
$boIdx = (int) ($order['bo_idx'] ?? 0);
|
||||||
|
if ($boIdx > 0) {
|
||||||
|
$sql = "
|
||||||
|
SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, r.br_bag_name,
|
||||||
|
c.cp_name, sa.sa_name
|
||||||
|
FROM bag_receiving r
|
||||||
|
LEFT JOIN bag_order o ON o.bo_idx = r.br_bo_idx
|
||||||
|
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
|
||||||
|
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
|
||||||
|
WHERE r.br_bo_idx = ?
|
||||||
|
ORDER BY r.br_receive_date ASC
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, [$boIdx])->getResultArray() as $r) {
|
||||||
|
$rows[] = $this->makeEvent(
|
||||||
|
(string) ($r['br_receive_date'] ?? ''),
|
||||||
|
(string) ($r['br_regdate'] ?? ''),
|
||||||
|
$this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name'),
|
||||||
|
'입고'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0));
|
||||||
|
|
||||||
|
return array_merge($this->emptyResult($lotNo), [
|
||||||
|
'ok' => true,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'barcode' => $lotNo,
|
||||||
|
'rows' => $rows,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function pickSource(array $row, string $default, string ...$keys): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$v = trim((string) ($row[$key] ?? ''));
|
||||||
|
if ($v !== '') {
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}
|
||||||
|
*/
|
||||||
|
private function makeEvent(string $dateYmd, string $sortDatetime, string $counterparty, string $flowType): array
|
||||||
|
{
|
||||||
|
$ts = strtotime($sortDatetime !== '' ? $sortDatetime : $dateYmd);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'flow_date' => $dateYmd !== '' ? $dateYmd : ($ts ? date('Y-m-d', $ts) : ''),
|
||||||
|
'counterparty' => $counterparty,
|
||||||
|
'flow_type' => $flowType,
|
||||||
|
'sort_ts' => $ts !== false ? $ts : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dateOnly(string $datetime): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $datetime, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ts = strtotime($datetime);
|
||||||
|
|
||||||
|
return $ts ? date('Y-m-d', $ts) : $datetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOT 수불 조회 화면 테스트용 — 등록된 바코드·LOT 샘플
|
||||||
|
*
|
||||||
|
* @return list<array{kind: string, code: string, bag_name: string, lot_no: string, state: string, hint: string}>
|
||||||
|
*/
|
||||||
|
public function loadTestSamples(int $lgIdx, int $limit = 80): array
|
||||||
|
{
|
||||||
|
$samples = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
$push = static function (array &$samples, array &$seen, string $kind, string $code, string $bagName, string $lotNo, string $state, string $hint) use ($limit): void {
|
||||||
|
$code = trim($code);
|
||||||
|
if ($code === '' || isset($seen[$code]) || count($samples) >= $limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$seen[$code] = true;
|
||||||
|
$samples[] = [
|
||||||
|
'kind' => $kind,
|
||||||
|
'code' => $code,
|
||||||
|
'bag_name' => $bagName,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'state' => $state,
|
||||||
|
'hint' => $hint,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
foreach ($this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_pack_code, brpc_box_code, brpc_sheet_start_code, brpc_bag_name, brpc_lot_no, brpc_state')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->orderBy('brpc_idx', 'DESC')
|
||||||
|
->limit(40)
|
||||||
|
->get()
|
||||||
|
->getResultArray() as $row) {
|
||||||
|
$state = $this->packStateLabel((string) ($row['brpc_state'] ?? ''));
|
||||||
|
$bagName = (string) ($row['brpc_bag_name'] ?? '');
|
||||||
|
$lotNo = (string) ($row['brpc_lot_no'] ?? '');
|
||||||
|
$push($samples, $seen, '팩', (string) ($row['brpc_pack_code'] ?? ''), $bagName, $lotNo, $state, '입고 팩 코드');
|
||||||
|
}
|
||||||
|
|
||||||
|
$boxRows = $this->db->query("
|
||||||
|
SELECT brpc_box_code,
|
||||||
|
MAX(brpc_bag_name) AS brpc_bag_name,
|
||||||
|
MAX(brpc_lot_no) AS brpc_lot_no,
|
||||||
|
MAX(brpc_state) AS brpc_state
|
||||||
|
FROM bag_receiving_pack_code
|
||||||
|
WHERE brpc_lg_idx = ? AND brpc_box_code != ''
|
||||||
|
GROUP BY brpc_box_code
|
||||||
|
ORDER BY MAX(brpc_idx) DESC
|
||||||
|
LIMIT 15
|
||||||
|
", [$lgIdx])->getResultArray();
|
||||||
|
foreach ($boxRows as $row) {
|
||||||
|
$push($samples, $seen, '박스', (string) ($row['brpc_box_code'] ?? ''), (string) ($row['brpc_bag_name'] ?? ''), (string) ($row['brpc_lot_no'] ?? ''), $this->packStateLabel((string) ($row['brpc_state'] ?? '')), '박스 단위 조회');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheetRows = $this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_sheet_start_code, brpc_bag_name, brpc_lot_no, brpc_state')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_sheet_start_code !=', '')
|
||||||
|
->orderBy('brpc_idx', 'DESC')
|
||||||
|
->limit(15)
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($sheetRows as $row) {
|
||||||
|
$push($samples, $seen, '낱장', (string) ($row['brpc_sheet_start_code'] ?? ''), (string) ($row['brpc_bag_name'] ?? ''), (string) ($row['brpc_lot_no'] ?? ''), $this->packStateLabel((string) ($row['brpc_state'] ?? '')), '낱장 시작 코드');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lotRows = $this->db->query("
|
||||||
|
SELECT DISTINCT brpc_lot_no
|
||||||
|
FROM bag_receiving_pack_code
|
||||||
|
WHERE brpc_lg_idx = ? AND brpc_lot_no != ''
|
||||||
|
ORDER BY brpc_lot_no DESC
|
||||||
|
LIMIT 10
|
||||||
|
", [$lgIdx])->getResultArray();
|
||||||
|
foreach ($lotRows as $row) {
|
||||||
|
$lot = (string) ($row['brpc_lot_no'] ?? '');
|
||||||
|
$push($samples, $seen, 'LOT', $lot, '(LOT 전체)', $lot, '—', 'lot_no 파라미터·입력 동일');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('bag_sale_scan_code')) {
|
||||||
|
foreach ($this->db->table('bag_sale_scan_code b')
|
||||||
|
->select('b.bssc_code, b.bssc_bag_name, b.bssc_unit, b.bssc_state, b.bssc_regdate')
|
||||||
|
->where('b.bssc_lg_idx', $lgIdx)
|
||||||
|
->orderBy('b.bssc_regdate', 'DESC')
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->getResultArray() as $row) {
|
||||||
|
$state = strtolower((string) ($row['bssc_state'] ?? '')) === 'sold' ? '판매' : '반품재고';
|
||||||
|
$push($samples, $seen, '스캔', (string) ($row['bssc_code'] ?? ''), (string) ($row['bssc_bag_name'] ?? ''), '', $state, '판매·반품 스캔 이력');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function packStateLabel(string $state): string
|
||||||
|
{
|
||||||
|
return match (strtolower($state)) {
|
||||||
|
'in_stock' => '재고',
|
||||||
|
'sold' => '판매',
|
||||||
|
default => $state !== '' ? $state : '—',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function barcodeInRange(string $code, string $start, string $end): bool
|
||||||
|
{
|
||||||
|
if ($start === '' || $end === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extract = static function (string $v): array {
|
||||||
|
if (preg_match('/^(.*?)(\d+)$/', $v, $m) === 1) {
|
||||||
|
return [(string) $m[1], (int) $m[2], strlen((string) $m[2])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['', -1, 0];
|
||||||
|
};
|
||||||
|
[$cp, $cn, $cl] = $extract($code);
|
||||||
|
[$sp, $sn, $sl] = $extract($start);
|
||||||
|
[$ep, $en, $el] = $extract($end);
|
||||||
|
if ($cn >= 0 && $sn >= 0 && $en >= 0 && $cp === $sp && $sp === $ep && $cl === $sl && $sl === $el) {
|
||||||
|
return $cn >= $sn && $cn <= $en;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp($code, $start) >= 0 && strcmp($code, $end) <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
app/Libraries/BagNumberLookup.php
Normal file
225
app/Libraries/BagNumberLookup.php
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 봉투번호확인(번호알기) — 코드 입력 → 바코드·인쇄숫자·인식번호 분해.
|
||||||
|
* LOT-입고PK-팩/박스-낱장 형식 및 DB 등록 바코드 지원.
|
||||||
|
*/
|
||||||
|
class BagNumberLookup
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* ok: bool,
|
||||||
|
* message: string,
|
||||||
|
* input: string,
|
||||||
|
* barcode_text: string,
|
||||||
|
* print_text: string,
|
||||||
|
* recognition_text: string,
|
||||||
|
* unit: string,
|
||||||
|
* bag_code: string,
|
||||||
|
* bag_name: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function resolve(string $raw, ?int $lgIdx = null): array
|
||||||
|
{
|
||||||
|
$input = trim($raw);
|
||||||
|
if ($input === '') {
|
||||||
|
return $this->fail('코드를 입력해 주세요.', $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtoupper(preg_replace('/\s+/', '', $input) ?? $input);
|
||||||
|
$parsed = $this->parseStructuredCode($normalized);
|
||||||
|
|
||||||
|
if ($parsed === null && $lgIdx !== null) {
|
||||||
|
$parsed = $this->resolveFromDatabase($lgIdx, $normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parsed === null) {
|
||||||
|
return $this->fail('인식할 수 없는 코드입니다. 봉투 바코드(LOT-입고번호-팩/박스-낱장) 형식을 확인해 주세요.', $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => '',
|
||||||
|
'input' => $input,
|
||||||
|
'barcode_text' => $this->formatRow($parsed['barcode'], 4),
|
||||||
|
'print_text' => $this->formatRow($parsed['print'], 3),
|
||||||
|
'recognition_text' => $this->formatRow($parsed['recognition'], 2),
|
||||||
|
'unit' => (string) ($parsed['unit'] ?? ''),
|
||||||
|
'bag_code' => (string) ($parsed['bag_code'] ?? ''),
|
||||||
|
'bag_name' => (string) ($parsed['bag_name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $parts
|
||||||
|
*/
|
||||||
|
private function formatRow(array $parts, int $slots): string
|
||||||
|
{
|
||||||
|
$cells = array_pad($parts, $slots, '-');
|
||||||
|
foreach ($cells as $i => $cell) {
|
||||||
|
if ($cell === '' || $cell === null) {
|
||||||
|
$cells[$i] = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
|
||||||
|
*/
|
||||||
|
private function parseStructuredCode(string $code): ?array
|
||||||
|
{
|
||||||
|
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})-S(\d+)$/i', $code, $m) === 1) {
|
||||||
|
$sheet = str_pad((string) $m[4], 5, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], 'S' . $sheet],
|
||||||
|
'print' => [(string) (int) $m[2], (string) (int) $m[3], (string) (int) $sheet],
|
||||||
|
'recognition' => [(string) $m[2], 'P' . $m[3]],
|
||||||
|
'unit' => '낱장',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})$/i', $code, $m) === 1) {
|
||||||
|
return [
|
||||||
|
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], '-'],
|
||||||
|
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
|
||||||
|
'recognition' => [(string) $m[2], 'P' . $m[3]],
|
||||||
|
'unit' => '팩',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^([A-Z0-9]+)-(\d{6})-B(\d{3})$/i', $code, $m) === 1) {
|
||||||
|
return [
|
||||||
|
'barcode' => [(string) $m[1], (string) $m[2], 'B' . $m[3], '-'],
|
||||||
|
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
|
||||||
|
'recognition' => [(string) $m[2], 'B' . $m[3]],
|
||||||
|
'unit' => '박스',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^([A-Z0-9]{4,8})$/i', $code) === 1) {
|
||||||
|
return [
|
||||||
|
'barcode' => [$code, '-', '-', '-'],
|
||||||
|
'print' => ['-', '-', '-'],
|
||||||
|
'recognition' => [$code, '-'],
|
||||||
|
'unit' => 'LOT',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
|
||||||
|
*/
|
||||||
|
private function resolveFromDatabase(int $lgIdx, string $code): ?array
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_pack_code', $code)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($row) && $row !== []) {
|
||||||
|
return $this->parsedFromPackRow($code, $row, '팩');
|
||||||
|
}
|
||||||
|
|
||||||
|
$boxRows = $db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_box_code', $code)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($boxRows) && $boxRows !== []) {
|
||||||
|
return $this->parsedFromPackRow($code, $boxRows, '박스');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheetRow = $db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_sheet_start_code <=', $code)
|
||||||
|
->where('brpc_sheet_end_code >=', $code)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($sheetRow) && $sheetRow !== []) {
|
||||||
|
$parsed = $this->parseStructuredCode($code);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
$parsed['bag_code'] = (string) ($sheetRow['brpc_bag_code'] ?? '');
|
||||||
|
$parsed['bag_name'] = (string) ($sheetRow['brpc_bag_name'] ?? '');
|
||||||
|
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$exactSheet = $db->table('bag_receiving_pack_code')
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->groupStart()
|
||||||
|
->where('brpc_sheet_start_code', $code)
|
||||||
|
->orWhere('brpc_sheet_end_code', $code)
|
||||||
|
->groupEnd()
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
if (is_array($exactSheet) && $exactSheet !== []) {
|
||||||
|
$parsed = $this->parseStructuredCode($code);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
$parsed['bag_code'] = (string) ($exactSheet['brpc_bag_code'] ?? '');
|
||||||
|
$parsed['bag_name'] = (string) ($exactSheet['brpc_bag_name'] ?? '');
|
||||||
|
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code:string,bag_name:string}
|
||||||
|
*/
|
||||||
|
private function parsedFromPackRow(string $code, array $row, string $unit): array
|
||||||
|
{
|
||||||
|
$parsed = $this->parseStructuredCode($code);
|
||||||
|
if ($parsed === null) {
|
||||||
|
$parsed = [
|
||||||
|
'barcode' => [$code, '-', '-', '-'],
|
||||||
|
'print' => ['-', '-', '-'],
|
||||||
|
'recognition' => ['-', '-'],
|
||||||
|
'unit' => $unit,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$parsed['unit'] = $unit;
|
||||||
|
$parsed['bag_code'] = (string) ($row['brpc_bag_code'] ?? '');
|
||||||
|
$parsed['bag_name'] = (string) ($row['brpc_bag_name'] ?? '');
|
||||||
|
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,message:string,input:string,barcode_text:string,print_text:string,recognition_text:string,unit:string,bag_code:string,bag_name:string}
|
||||||
|
*/
|
||||||
|
private function fail(string $message, string $input): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $message,
|
||||||
|
'input' => $input,
|
||||||
|
'barcode_text' => $this->formatRow([], 4),
|
||||||
|
'print_text' => $this->formatRow([], 3),
|
||||||
|
'recognition_text' => $this->formatRow([], 2),
|
||||||
|
'unit' => '',
|
||||||
|
'bag_code' => '',
|
||||||
|
'bag_name' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
494
app/Libraries/BagSupplyPlanBuilder.php
Normal file
494
app/Libraries/BagSupplyPlanBuilder.php
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쓰레기봉투 수급 계획 (레거시 w_gm820r 유추)
|
||||||
|
*
|
||||||
|
* - 바코드 봉투: bag_receiving_pack_code 에 등록된 품목
|
||||||
|
* - 기존 봉투: 그 외 (수기·비바코드 재고)
|
||||||
|
* - 소진일수 ≈ (총재고 / 월판매량) × 30
|
||||||
|
* - 발주예정일 = 기준일 + 소진일수 − 적정재고보유일수(제작기일)
|
||||||
|
* - 긴급(발주예정일 ≤ 기준일): 발주수량 ≈ max(0, 월판매량×18 − 총재고)
|
||||||
|
*/
|
||||||
|
class BagSupplyPlanBuilder
|
||||||
|
{
|
||||||
|
private const AVG_SALES_MONTHS = 12;
|
||||||
|
|
||||||
|
/** 긴급 발주 시 목표 보유 개월 수 (레거시 화면 값 유추) */
|
||||||
|
private const URGENT_REPLENISH_MONTHS = 18;
|
||||||
|
|
||||||
|
private \CodeIgniter\Database\BaseConnection $db;
|
||||||
|
|
||||||
|
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
|
||||||
|
{
|
||||||
|
$this->db = $db ?? \Config\Database::connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* rows: list<array<string, mixed>>,
|
||||||
|
* barcodeCodes: array<string, bool>,
|
||||||
|
* queried: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(
|
||||||
|
int $lgIdx,
|
||||||
|
string $refDate,
|
||||||
|
int $leadDays,
|
||||||
|
string $stockScope,
|
||||||
|
string $salesScope,
|
||||||
|
bool $queried
|
||||||
|
): array {
|
||||||
|
$barcodeCodes = $this->loadBarcodeCodes($lgIdx);
|
||||||
|
$products = $this->loadProducts($lgIdx);
|
||||||
|
if ($products === [] || ! $queried) {
|
||||||
|
return ['rows' => [], 'barcodeCodes' => $barcodeCodes, 'queried' => $queried];
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = $this->loadInventoryMap($lgIdx);
|
||||||
|
$pendingIn = $this->loadPendingInbound($lgIdx);
|
||||||
|
$lastOrders = $this->loadLastOrders($lgIdx);
|
||||||
|
$monthlySales = $this->loadMonthlyAverageSales($lgIdx, $refDate, $salesScope, $barcodeCodes);
|
||||||
|
$movementsSince = $this->loadMovementsSinceOrders($lgIdx, $lastOrders, $refDate);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($products as $code => $name) {
|
||||||
|
$isBarcode = isset($barcodeCodes[$code]);
|
||||||
|
$rawStock = (int) ($inventory[$code] ?? 0);
|
||||||
|
$currentStock = $this->scopedStock($rawStock, $stockScope, $isBarcode);
|
||||||
|
$pendingQty = $this->scopedPending((int) ($pendingIn[$code] ?? 0), $stockScope, $isBarcode);
|
||||||
|
$totalStock = $currentStock + $pendingQty;
|
||||||
|
$monthlyFloat = (float) ($monthlySales[$code] ?? 0.0);
|
||||||
|
$monthlyAvg = (int) round($monthlyFloat);
|
||||||
|
|
||||||
|
$depletionDays = $this->calcDepletionDays($totalStock, $monthlyFloat);
|
||||||
|
$scheduleDate = $this->calcScheduleDate($refDate, $depletionDays, $leadDays);
|
||||||
|
$orderQty = $this->calcOrderQty($refDate, $scheduleDate, $depletionDays, $leadDays, $monthlyAvg, $totalStock);
|
||||||
|
|
||||||
|
$last = $lastOrders[$code] ?? null;
|
||||||
|
$orderDate = $last ? (string) ($last['order_date'] ?? '') : '';
|
||||||
|
$lastQty = $last ? (int) ($last['qty_sheet'] ?? 0) : 0;
|
||||||
|
$stockAtOrder = 0;
|
||||||
|
if ($orderDate !== '' && $lastQty > 0) {
|
||||||
|
$mv = $movementsSince[$code] ?? ['sale' => 0, 'recv' => 0, 'issue' => 0];
|
||||||
|
$stockAtOrder = max(0, $rawStock + $mv['sale'] - $mv['recv'] - $mv['issue']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'bag_code' => $code,
|
||||||
|
'bag_name' => $name,
|
||||||
|
'is_barcode' => $isBarcode,
|
||||||
|
'last_order_date' => $orderDate,
|
||||||
|
'last_order_qty' => $lastQty,
|
||||||
|
'stock_at_order' => $stockAtOrder,
|
||||||
|
'current_stock' => $currentStock,
|
||||||
|
'pending_inbound' => $pendingQty,
|
||||||
|
'total_stock' => $totalStock,
|
||||||
|
'monthly_avg_sales' => $monthlyAvg,
|
||||||
|
'depletion_days' => $depletionDays,
|
||||||
|
'schedule_date' => $scheduleDate,
|
||||||
|
'schedule_overdue' => $scheduleDate !== '' && $scheduleDate <= $refDate && $depletionDays < 99999,
|
||||||
|
'order_qty' => $orderQty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['bag_code'], (string) $b['bag_code']));
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'barcodeCodes' => $barcodeCodes, 'queried' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, bool>
|
||||||
|
*/
|
||||||
|
private function loadBarcodeCodes(int $lgIdx): array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('bag_receiving_pack_code')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$set = [];
|
||||||
|
foreach ($this->db->table('bag_receiving_pack_code')
|
||||||
|
->select('brpc_bag_code')
|
||||||
|
->distinct()
|
||||||
|
->where('brpc_lg_idx', $lgIdx)
|
||||||
|
->where('brpc_bag_code !=', '')
|
||||||
|
->get()
|
||||||
|
->getResultArray() as $row) {
|
||||||
|
$code = trim((string) ($row['brpc_bag_code'] ?? ''));
|
||||||
|
if ($code !== '') {
|
||||||
|
$set[$code] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> code => name
|
||||||
|
*/
|
||||||
|
private function loadProducts(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||||
|
$products = [];
|
||||||
|
if ($kindO) {
|
||||||
|
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
|
||||||
|
$code = trim((string) ($d->cd_code ?? ''));
|
||||||
|
if ($code !== '') {
|
||||||
|
$products[$code] = trim((string) ($d->cd_name ?? $code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->db->table('bag_inventory')
|
||||||
|
->select('bi_bag_code, bi_bag_name')
|
||||||
|
->where('bi_lg_idx', $lgIdx)
|
||||||
|
->get()
|
||||||
|
->getResultArray() as $row) {
|
||||||
|
$code = trim((string) ($row['bi_bag_code'] ?? ''));
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! isset($products[$code])) {
|
||||||
|
$products[$code] = trim((string) ($row['bi_bag_name'] ?? $code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $products;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function loadInventoryMap(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach (model(\App\Models\BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll() as $inv) {
|
||||||
|
$code = trim((string) ($inv->bi_bag_code ?? ''));
|
||||||
|
if ($code !== '') {
|
||||||
|
$map[$code] = (int) ($inv->bi_qty ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function loadPendingInbound(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
$sql = "
|
||||||
|
SELECT boi.boi_bag_code AS bag_code,
|
||||||
|
SUM(GREATEST(0, CAST(boi.boi_qty_sheet AS SIGNED) - IFNULL(r.recv_qty, 0))) AS pending_qty
|
||||||
|
FROM bag_order_item boi
|
||||||
|
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT br_bo_idx, br_bag_code, SUM(br_qty_sheet) AS recv_qty
|
||||||
|
FROM bag_receiving
|
||||||
|
GROUP BY br_bo_idx, br_bag_code
|
||||||
|
) r ON r.br_bo_idx = bo.bo_idx AND r.br_bag_code = boi.boi_bag_code
|
||||||
|
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
|
||||||
|
GROUP BY boi.boi_bag_code
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, [$lgIdx])->getResult() as $row) {
|
||||||
|
$qty = (int) ($row->pending_qty ?? 0);
|
||||||
|
if ($qty > 0) {
|
||||||
|
$map[(string) $row->bag_code] = $qty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{order_date: string, qty_sheet: int, bag_name: string}>
|
||||||
|
*/
|
||||||
|
private function loadLastOrders(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
$supportsWindow = $this->db->DBDriver === 'MySQLi';
|
||||||
|
if ($supportsWindow) {
|
||||||
|
$sql = "
|
||||||
|
SELECT bag_code, order_date, qty_sheet, bag_name
|
||||||
|
FROM (
|
||||||
|
SELECT boi.boi_bag_code AS bag_code, bo.bo_order_date AS order_date,
|
||||||
|
boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY boi.boi_bag_code
|
||||||
|
ORDER BY bo.bo_order_date DESC, bo.bo_idx DESC
|
||||||
|
) AS rn
|
||||||
|
FROM bag_order_item boi
|
||||||
|
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
|
||||||
|
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
|
||||||
|
) t
|
||||||
|
WHERE t.rn = 1
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) {
|
||||||
|
$code = trim((string) ($row['bag_code'] ?? ''));
|
||||||
|
if ($code === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$code] = [
|
||||||
|
'order_date' => (string) ($row['order_date'] ?? ''),
|
||||||
|
'qty_sheet' => (int) ($row['qty_sheet'] ?? 0),
|
||||||
|
'bag_name' => (string) ($row['bag_name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT boi.boi_bag_code AS bag_code, MAX(bo.bo_order_date) AS order_date
|
||||||
|
FROM bag_order_item boi
|
||||||
|
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
|
||||||
|
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
|
||||||
|
GROUP BY boi.boi_bag_code
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) {
|
||||||
|
$code = trim((string) ($row['bag_code'] ?? ''));
|
||||||
|
$orderDate = (string) ($row['order_date'] ?? '');
|
||||||
|
if ($code === '' || $orderDate === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$item = $this->db->query("
|
||||||
|
SELECT boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name
|
||||||
|
FROM bag_order_item boi
|
||||||
|
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
|
||||||
|
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
|
||||||
|
AND boi.boi_bag_code = ? AND bo.bo_order_date = ?
|
||||||
|
ORDER BY bo.bo_idx DESC
|
||||||
|
LIMIT 1
|
||||||
|
", [$lgIdx, $code, $orderDate])->getRowArray();
|
||||||
|
if ($item) {
|
||||||
|
$map[$code] = [
|
||||||
|
'order_date' => $orderDate,
|
||||||
|
'qty_sheet' => (int) ($item['qty_sheet'] ?? 0),
|
||||||
|
'bag_name' => (string) ($item['bag_name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{order_date: string, qty_sheet: int, bag_name: string}> $lastOrders
|
||||||
|
* @return array<string, array{sale: int, recv: int, issue: int}>
|
||||||
|
*/
|
||||||
|
private function loadMovementsSinceOrders(int $lgIdx, array $lastOrders, string $refDate): array
|
||||||
|
{
|
||||||
|
$byCode = [];
|
||||||
|
$minDate = $refDate;
|
||||||
|
foreach ($lastOrders as $code => $info) {
|
||||||
|
$d = (string) ($info['order_date'] ?? '');
|
||||||
|
if ($d === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byCode[$code] = $d;
|
||||||
|
if ($d < $minDate) {
|
||||||
|
$minDate = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($byCode === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$codes = array_keys($byCode);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($codes), '?'));
|
||||||
|
$params = array_merge([$lgIdx], $codes, [$minDate, $refDate]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($codes as $code) {
|
||||||
|
$out[$code] = ['sale' => 0, 'recv' => 0, 'issue' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT bs_bag_code AS bag_code, bs_sale_date AS mv_date,
|
||||||
|
SUM(CASE WHEN bs_type IN ('sale','cancel') THEN ABS(bs_qty) ELSE 0 END) AS sale_qty
|
||||||
|
FROM bag_sale
|
||||||
|
WHERE bs_lg_idx = ? AND bs_bag_code IN ({$placeholders})
|
||||||
|
AND bs_sale_date >= ? AND bs_sale_date <= ?
|
||||||
|
GROUP BY bs_bag_code, bs_sale_date
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$orderDate = $byCode[$code] ?? '';
|
||||||
|
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$code]['sale'] += (int) $row->sale_qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS recv_qty
|
||||||
|
FROM bag_receiving
|
||||||
|
WHERE br_lg_idx = ? AND br_bag_code IN ({$placeholders})
|
||||||
|
AND br_receive_date >= ? AND br_receive_date <= ?
|
||||||
|
GROUP BY br_bag_code, br_receive_date
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$orderDate = $byCode[$code] ?? '';
|
||||||
|
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$code]['recv'] += (int) $row->recv_qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, SUM(bi2_qty) AS issue_qty
|
||||||
|
FROM bag_issue
|
||||||
|
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$placeholders})
|
||||||
|
AND bi2_issue_date >= ? AND bi2_issue_date <= ?
|
||||||
|
GROUP BY bi2_bag_code, bi2_issue_date
|
||||||
|
";
|
||||||
|
foreach ($this->db->query($sql, $params)->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$orderDate = $byCode[$code] ?? '';
|
||||||
|
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$code]['issue'] += (int) $row->issue_qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, bool> $barcodeCodes
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private function loadMonthlyAverageSales(
|
||||||
|
int $lgIdx,
|
||||||
|
string $refDate,
|
||||||
|
string $salesScope,
|
||||||
|
array $barcodeCodes
|
||||||
|
): array {
|
||||||
|
$fromDate = date('Y-m-d', strtotime($refDate . ' -' . self::AVG_SALES_MONTHS . ' months'));
|
||||||
|
$legacyNet = [];
|
||||||
|
$barcodeNet = [];
|
||||||
|
|
||||||
|
foreach ($this->db->query("
|
||||||
|
SELECT bs_bag_code AS bag_code,
|
||||||
|
SUM(CASE WHEN bs_type = 'sale' THEN ABS(bs_qty)
|
||||||
|
WHEN bs_type IN ('return','cancel') THEN -ABS(bs_qty)
|
||||||
|
ELSE 0 END) AS net_qty
|
||||||
|
FROM bag_sale
|
||||||
|
WHERE bs_lg_idx = ? AND bs_sale_date > ? AND bs_sale_date <= ?
|
||||||
|
GROUP BY bs_bag_code
|
||||||
|
", [$lgIdx, $fromDate, $refDate])->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$legacyNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->tableExists('bag_sale_scan_code')) {
|
||||||
|
foreach ($this->db->query("
|
||||||
|
SELECT bssc_bag_code AS bag_code, SUM(bssc_qty) AS net_qty
|
||||||
|
FROM bag_sale_scan_code
|
||||||
|
WHERE bssc_lg_idx = ? AND bssc_state = 'sold'
|
||||||
|
AND DATE(bssc_regdate) > ? AND DATE(bssc_regdate) <= ?
|
||||||
|
GROUP BY bssc_bag_code
|
||||||
|
", [$lgIdx, $fromDate, $refDate])->getResult() as $row) {
|
||||||
|
$code = (string) $row->bag_code;
|
||||||
|
$barcodeNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged = [];
|
||||||
|
$allCodes = array_unique(array_merge(array_keys($legacyNet), array_keys($barcodeNet)));
|
||||||
|
foreach ($allCodes as $code) {
|
||||||
|
$isBarcode = isset($barcodeCodes[$code]);
|
||||||
|
$legacy = $legacyNet[$code] ?? 0.0;
|
||||||
|
$scan = $barcodeNet[$code] ?? 0.0;
|
||||||
|
$merged[$code] = match ($salesScope) {
|
||||||
|
'legacy' => $isBarcode ? 0.0 : $legacy,
|
||||||
|
'barcode' => $isBarcode ? ($scan > 0 ? $scan : $legacy) : 0.0,
|
||||||
|
default => $isBarcode && $scan > 0 ? $scan : $legacy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return $merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopedStock(int $qty, string $scope, bool $isBarcode): int
|
||||||
|
{
|
||||||
|
return match ($scope) {
|
||||||
|
'legacy' => $isBarcode ? 0 : $qty,
|
||||||
|
'barcode' => $isBarcode ? $qty : 0,
|
||||||
|
default => $qty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopedPending(int $qty, string $scope, bool $isBarcode): int
|
||||||
|
{
|
||||||
|
return $this->scopedStock($qty, $scope, $isBarcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcDepletionDays(int $totalStock, float $monthlyAvg): int
|
||||||
|
{
|
||||||
|
if ($monthlyAvg <= 0.0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(($totalStock / $monthlyAvg) * 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcScheduleDate(string $refDate, int $depletionDays, int $leadDays): string
|
||||||
|
{
|
||||||
|
if ($depletionDays <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($depletionDays >= 99999) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = $depletionDays - $leadDays;
|
||||||
|
if ($offset < 0) {
|
||||||
|
return $refDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = \DateTimeImmutable::createFromFormat('Y-m-d', $refDate);
|
||||||
|
if ($base === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$scheduled = $base->modify('+' . $offset . ' days');
|
||||||
|
} catch (\Exception) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int) $scheduled->format('Y');
|
||||||
|
$refYear = (int) $base->format('Y');
|
||||||
|
if ($year < $refYear - 1 || $year > $refYear + 120) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scheduled->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calcOrderQty(
|
||||||
|
string $refDate,
|
||||||
|
string $scheduleDate,
|
||||||
|
int $depletionDays,
|
||||||
|
int $leadDays,
|
||||||
|
int $monthlyAvg,
|
||||||
|
int $totalStock
|
||||||
|
): int {
|
||||||
|
if ($monthlyAvg <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$urgent = $scheduleDate !== '' && $scheduleDate <= $refDate;
|
||||||
|
$lowStock = $depletionDays > 0 && $depletionDays <= $leadDays && $scheduleDate !== '';
|
||||||
|
if (! $urgent && ! $lowStock) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = (int) round($monthlyAvg * ($urgent ? self::URGENT_REPLENISH_MONTHS : max(2, (int) ceil($leadDays / 30.0))));
|
||||||
|
|
||||||
|
return max(0, $target - $totalStock);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/Libraries/Blockchain/SqlLedger.php
Normal file
106
app/Libraries/Blockchain/SqlLedger.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Libraries\Blockchain;
|
||||||
|
|
||||||
|
use App\Models\BlockchainLedgerModel;
|
||||||
|
|
||||||
|
class SqlLedger
|
||||||
|
{
|
||||||
|
private BlockchainLedgerModel $ledgerModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->ledgerModel = model(BlockchainLedgerModel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array{index:int,hash:string,previous_hash:string}
|
||||||
|
*/
|
||||||
|
public function appendBlock(string $txType, array $payload, ?string $entityUuid, int $entityVersion, ?int $actorIdx, ?int $lgIdx): array
|
||||||
|
{
|
||||||
|
$latest = $this->ledgerModel->orderBy('bl_idx', 'DESC')->first();
|
||||||
|
// 원장이 비어 있으면 $latest 가 null — $latest->bl_hash 는 PHP 8에서 치명 오류(? 는 ?? 와 달리 속성 접근 자체가 먼저 평가됨)
|
||||||
|
$previousHash = ($latest === null || ! isset($latest->bl_hash) || (string) $latest->bl_hash === '')
|
||||||
|
? str_repeat('0', 64)
|
||||||
|
: (string) $latest->bl_hash;
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$normalizedPayload = $this->normalizeArray($payload);
|
||||||
|
$payloadJson = json_encode($normalizedPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
|
||||||
|
|
||||||
|
$hashInput = implode('|', [
|
||||||
|
$now,
|
||||||
|
$txType,
|
||||||
|
$entityUuid ?? '',
|
||||||
|
(string) $entityVersion,
|
||||||
|
$payloadJson,
|
||||||
|
$previousHash,
|
||||||
|
'0',
|
||||||
|
]);
|
||||||
|
$currentHash = hash('sha256', $hashInput);
|
||||||
|
|
||||||
|
$this->ledgerModel->insert([
|
||||||
|
'bl_created_at' => $now,
|
||||||
|
'bl_tx_type' => $txType,
|
||||||
|
'bl_entity_uuid' => $entityUuid,
|
||||||
|
'bl_entity_version' => $entityVersion,
|
||||||
|
'bl_payload' => $payloadJson,
|
||||||
|
'bl_previous_hash' => $previousHash,
|
||||||
|
'bl_hash' => $currentHash,
|
||||||
|
'bl_nonce' => 0,
|
||||||
|
'bl_actor_idx' => $actorIdx,
|
||||||
|
'bl_lg_idx' => $lgIdx,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'index' => (int) $this->ledgerModel->getInsertID(),
|
||||||
|
'hash' => $currentHash,
|
||||||
|
'previous_hash' => $previousHash,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $data
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeArray(array $data): array
|
||||||
|
{
|
||||||
|
ksort($data);
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
if ($this->isAssoc($value)) {
|
||||||
|
/** @var array<string,mixed> $assoc */
|
||||||
|
$assoc = $value;
|
||||||
|
$data[$key] = $this->normalizeArray($assoc);
|
||||||
|
} else {
|
||||||
|
$normalizedList = [];
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if (is_array($item) && $this->isAssoc($item)) {
|
||||||
|
/** @var array<string,mixed> $assoc */
|
||||||
|
$assoc = $item;
|
||||||
|
$normalizedList[] = $this->normalizeArray($assoc);
|
||||||
|
} else {
|
||||||
|
$normalizedList[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$data[$key] = $normalizedList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $array
|
||||||
|
*/
|
||||||
|
private function isAssoc(array $array): bool
|
||||||
|
{
|
||||||
|
if ($array === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($array) !== range(0, count($array) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Libraries/GovPortalCodeKindsPage.php
Normal file
101
app/Libraries/GovPortalCodeKindsPage.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
use App\Models\CodeDetailModel;
|
||||||
|
use App\Models\CodeKindModel;
|
||||||
|
use Config\Roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공공 포털형 기본 코드관리 UI 시안 전용 데이터 (/bag/code-kinds 와 별도 URL, 동일 집계)
|
||||||
|
*/
|
||||||
|
class GovPortalCodeKindsPage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildPageData(?int $lgIdx, int $level, ?int $adminLgIdx, int $selectedCkIdx, array $filters): array
|
||||||
|
{
|
||||||
|
$kindModel = model(CodeKindModel::class);
|
||||||
|
$detailModel = model(CodeDetailModel::class);
|
||||||
|
$kinds = [];
|
||||||
|
$countMap = [];
|
||||||
|
$selectedKind = null;
|
||||||
|
$detailList = [];
|
||||||
|
$rowCanEdit = [];
|
||||||
|
|
||||||
|
$qCode = trim((string) ($filters['q_code'] ?? ''));
|
||||||
|
$qName = trim((string) ($filters['q_name'] ?? ''));
|
||||||
|
$qState = (string) ($filters['q_state'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$builder = $kindModel->orderBy('ck_code', 'ASC');
|
||||||
|
if ($qCode !== '') {
|
||||||
|
$builder->like('ck_code', $qCode);
|
||||||
|
}
|
||||||
|
if ($qName !== '') {
|
||||||
|
$builder->like('ck_name', $qName);
|
||||||
|
}
|
||||||
|
if ($qState === '1' || $qState === '0') {
|
||||||
|
$builder->where('ck_state', (int) $qState);
|
||||||
|
}
|
||||||
|
$kinds = $builder->findAll();
|
||||||
|
foreach ($kinds as $row) {
|
||||||
|
$countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx)
|
||||||
|
->filterByTenantScope($lgIdx)
|
||||||
|
->countAllResults();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', '[GovPortalCodeKinds] {type} {message}', [
|
||||||
|
'type' => $e::class,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$canManageKinds = Roles::canManageCodeKindMaster($level);
|
||||||
|
$canManageDetails = Roles::canManageCodeMaster($level);
|
||||||
|
|
||||||
|
if ($kinds !== []) {
|
||||||
|
foreach ($kinds as $row) {
|
||||||
|
if ((int) $row->ck_idx === $selectedCkIdx) {
|
||||||
|
$selectedKind = $row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($selectedKind === null) {
|
||||||
|
$selectedKind = $kinds[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selectedKind !== null) {
|
||||||
|
$detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx)
|
||||||
|
->filterByTenantScope($lgIdx)
|
||||||
|
->orderBy('cd_sort', 'ASC')
|
||||||
|
->orderBy('cd_code', 'ASC')
|
||||||
|
->orderBy('cd_idx', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
foreach ($detailList as $row) {
|
||||||
|
$rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLgIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'codeKinds' => $kinds,
|
||||||
|
'countMap' => $countMap,
|
||||||
|
'canManageKinds' => $canManageKinds,
|
||||||
|
'canManageDetails' => $canManageDetails,
|
||||||
|
'selectedKind' => $selectedKind,
|
||||||
|
'detailList' => $detailList,
|
||||||
|
'rowCanEdit' => $rowCanEdit,
|
||||||
|
'totalCount' => count($kinds),
|
||||||
|
'filters' => [
|
||||||
|
'q_code' => $qCode,
|
||||||
|
'q_name' => $qName,
|
||||||
|
'q_state' => $qState,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/Libraries/ManualRenderer.php
Normal file
166
app/Libraries/ManualRenderer.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
use Config\Manual as ManualConfig;
|
||||||
|
use League\CommonMark\Exception\CommonMarkException;
|
||||||
|
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 매뉴얼(설명서) 렌더러.
|
||||||
|
*
|
||||||
|
* - 목차(manifest)는 Config\Manual 에서 가져온다.
|
||||||
|
* - slug → 파일 매핑은 화이트리스트(manifest)로만 결정한다(사용자 입력으로 파일명 조합 금지).
|
||||||
|
* - 마크다운은 GFM(표·코드블록)로 변환하며, md 내 raw HTML 은 이스케이프한다.
|
||||||
|
*/
|
||||||
|
class ManualRenderer
|
||||||
|
{
|
||||||
|
private ManualConfig $config;
|
||||||
|
|
||||||
|
private GithubFlavoredMarkdownConverter $converter;
|
||||||
|
|
||||||
|
public function __construct(?ManualConfig $config = null)
|
||||||
|
{
|
||||||
|
$this->config = $config ?? config(ManualConfig::class);
|
||||||
|
$this->converter = new GithubFlavoredMarkdownConverter([
|
||||||
|
// 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다.
|
||||||
|
'html_input' => 'escape',
|
||||||
|
'allow_unsafe_links' => false,
|
||||||
|
'max_nesting_level' => 50,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목차(slug → title/file). 배열 순서가 노출 순서.
|
||||||
|
*
|
||||||
|
* @return array<string, array{title: string, file: string}>
|
||||||
|
*/
|
||||||
|
public function pages(): array
|
||||||
|
{
|
||||||
|
return $this->config->pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목차의 첫 번째 slug (기본 진입 페이지). */
|
||||||
|
public function firstSlug(): string
|
||||||
|
{
|
||||||
|
return (string) (array_key_first($this->config->pages) ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slug 메타 조회. 화이트리스트에 없으면 null.
|
||||||
|
*
|
||||||
|
* @return array{title: string, file: string}|null
|
||||||
|
*/
|
||||||
|
public function find(string $slug): ?array
|
||||||
|
{
|
||||||
|
return $this->config->pages[$slug] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''.
|
||||||
|
*/
|
||||||
|
public function plainText(string $slug): string
|
||||||
|
{
|
||||||
|
$html = $this->render($slug);
|
||||||
|
if ($html === null || $html === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/\s+/u', ' ', $text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순).
|
||||||
|
*
|
||||||
|
* @return list<array{slug:string,title:string,snippet:string,hits:int}>
|
||||||
|
*/
|
||||||
|
public function search(string $q): array
|
||||||
|
{
|
||||||
|
$q = trim($q);
|
||||||
|
if ($q === '' || mb_strlen($q) < 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$needle = mb_strtolower($q);
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->pages() as $slug => $page) {
|
||||||
|
$text = $this->plainText((string) $slug);
|
||||||
|
if ($text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hay = mb_strtolower($text);
|
||||||
|
$pos = mb_strpos($hay, $needle);
|
||||||
|
if ($pos === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hits = mb_substr_count($hay, $needle);
|
||||||
|
$start = max(0, $pos - 30);
|
||||||
|
$snippet = mb_substr($text, $start, 100);
|
||||||
|
if ($start > 0) {
|
||||||
|
$snippet = '…' . $snippet;
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'slug' => (string) $slug,
|
||||||
|
'title' => (string) $page['title'],
|
||||||
|
'snippet' => trim($snippet),
|
||||||
|
'hits' => $hits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
|
||||||
|
*/
|
||||||
|
public function render(string $slug): ?string
|
||||||
|
{
|
||||||
|
$page = $this->find($slug);
|
||||||
|
if ($page === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->resolvePath($page['file']);
|
||||||
|
if ($path === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = @file_get_contents($path);
|
||||||
|
if ($markdown === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (string) $this->converter->convert($markdown);
|
||||||
|
} catch (CommonMarkException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일명을 디렉터리 경계 안으로 안전하게 해석한다.
|
||||||
|
* manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다.
|
||||||
|
*/
|
||||||
|
private function resolvePath(string $file): ?string
|
||||||
|
{
|
||||||
|
$baseReal = realpath(rtrim($this->config->dir, '/\\'));
|
||||||
|
if ($baseReal === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file);
|
||||||
|
if ($candidate === false || ! is_file($candidate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = $baseReal . DIRECTORY_SEPARATOR;
|
||||||
|
if (! str_starts_with($candidate, $prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Libraries/TotpService.php
Normal file
49
app/Libraries/TotpService.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Libraries;
|
||||||
|
|
||||||
|
use Config\Auth as AuthConfig;
|
||||||
|
use RobThree\Auth\Providers\Qr\QRServerProvider;
|
||||||
|
use RobThree\Auth\TwoFactorAuth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP 생성·검증·QR (robthree/twofactorauth)
|
||||||
|
*/
|
||||||
|
class TotpService
|
||||||
|
{
|
||||||
|
private TwoFactorAuth $tfa;
|
||||||
|
|
||||||
|
public function __construct(?AuthConfig $authConfig = null)
|
||||||
|
{
|
||||||
|
$authConfig ??= config('Auth');
|
||||||
|
$this->tfa = new TwoFactorAuth(
|
||||||
|
new QRServerProvider(),
|
||||||
|
$authConfig->totpIssuer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createSecret(): string
|
||||||
|
{
|
||||||
|
return $this->tfa->createSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $plainSecret, string $code): bool
|
||||||
|
{
|
||||||
|
$code = preg_replace('/\s+/', '', $code) ?? '';
|
||||||
|
if (strlen($code) !== 6 || ! ctype_digit($code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->tfa->verifyCode($plainSecret, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PNG data URI (외부 QR API 호출 — 네트워크 필요)
|
||||||
|
*/
|
||||||
|
public function getQrDataUri(string $accountLabel, string $secret): string
|
||||||
|
{
|
||||||
|
return $this->tfa->getQRCodeImageAsDataUri($accountLabel, $secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Models/ActivityLogModel.php
Normal file
25
app/Models/ActivityLogModel.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class ActivityLogModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'activity_log';
|
||||||
|
protected $primaryKey = 'al_idx';
|
||||||
|
protected $returnType = 'object';
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'al_mb_idx',
|
||||||
|
'al_action',
|
||||||
|
'al_table',
|
||||||
|
'al_record_id',
|
||||||
|
'al_data_before',
|
||||||
|
'al_data_after',
|
||||||
|
'al_ip',
|
||||||
|
'al_regdate',
|
||||||
|
];
|
||||||
|
}
|
||||||
23
app/Models/BagIssueItemCodeModel.php
Normal file
23
app/Models/BagIssueItemCodeModel.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class BagIssueItemCodeModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'bag_issue_item_code';
|
||||||
|
protected $primaryKey = 'bic_idx';
|
||||||
|
protected $returnType = 'object';
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'bic_lg_idx',
|
||||||
|
'bic_bi2_idx',
|
||||||
|
'bic_bag_code',
|
||||||
|
'bic_issue_code',
|
||||||
|
'bic_qty',
|
||||||
|
'bic_cancel_qty',
|
||||||
|
'bic_state',
|
||||||
|
'bic_regdate',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -10,9 +10,26 @@ class BagOrderModel extends Model
|
|||||||
protected $primaryKey = 'bo_idx';
|
protected $primaryKey = 'bo_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동일 발주 UUID에 대해 bo_version이 최대인 행만 (수정으로 생긴 이전 버전 행은 목록·이력에서 제외).
|
||||||
|
* DB에는 버전별 행이 그대로 남고, 조회 시에만 필터한다.
|
||||||
|
*/
|
||||||
|
public function whereLatestHead(int $lgIdx): self
|
||||||
|
{
|
||||||
|
$lg = (int) $lgIdx;
|
||||||
|
|
||||||
|
return $this->where(
|
||||||
|
"(bo_uuid, bo_version) IN (SELECT bo_uuid, MAX(bo_version) FROM {$this->table} WHERE bo_lg_idx = {$lg} GROUP BY bo_uuid)",
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
|
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
|
||||||
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
|
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
|
||||||
|
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
|
||||||
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
|
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
|
||||||
'bo_regdate', 'bo_moddate',
|
'bo_regdate', 'bo_moddate',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,4 +16,45 @@ class BagPriceModel extends Model
|
|||||||
'bp_start_date', 'bp_end_date', 'bp_state',
|
'bp_start_date', 'bp_end_date', 'bp_state',
|
||||||
'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx',
|
'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 봉투코드에 단가 기간이 겹쳐도 "나중 등록 단가"가 우선되도록
|
||||||
|
* 활성 단가를 등록일/PK 역순으로 정렬해 봉투코드별 1건만 남긴다.
|
||||||
|
*
|
||||||
|
* @return array<string, object>
|
||||||
|
*/
|
||||||
|
public function latestActiveMapByBagCode(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$rows = $this->where('bp_lg_idx', $lgIdx)
|
||||||
|
->where('bp_state', 1)
|
||||||
|
->orderBy('bp_regdate', 'DESC')
|
||||||
|
->orderBy('bp_idx', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$code = (string) ($row->bp_bag_code ?? '');
|
||||||
|
if ($code === '' || isset($map[$code])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$code] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestActiveByBagCode(int $lgIdx, string $bagCode): ?object
|
||||||
|
{
|
||||||
|
$bagCode = trim($bagCode);
|
||||||
|
if ($bagCode === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->where('bp_lg_idx', $lgIdx)
|
||||||
|
->where('bp_bag_code', $bagCode)
|
||||||
|
->where('bp_state', 1)
|
||||||
|
->orderBy('bp_regdate', 'DESC')
|
||||||
|
->orderBy('bp_idx', 'DESC')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/Models/BlockchainLedgerModel.php
Normal file
25
app/Models/BlockchainLedgerModel.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class BlockchainLedgerModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'blockchain_ledger';
|
||||||
|
protected $primaryKey = 'bl_idx';
|
||||||
|
protected $returnType = 'object';
|
||||||
|
protected $useTimestamps = false;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'bl_created_at',
|
||||||
|
'bl_tx_type',
|
||||||
|
'bl_entity_uuid',
|
||||||
|
'bl_entity_version',
|
||||||
|
'bl_payload',
|
||||||
|
'bl_previous_hash',
|
||||||
|
'bl_hash',
|
||||||
|
'bl_nonce',
|
||||||
|
'bl_actor_idx',
|
||||||
|
'bl_lg_idx',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
@@ -12,6 +14,8 @@ class CodeDetailModel extends Model
|
|||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'cd_ck_idx',
|
'cd_ck_idx',
|
||||||
|
'cd_source',
|
||||||
|
'cd_lg_idx',
|
||||||
'cd_code',
|
'cd_code',
|
||||||
'cd_name',
|
'cd_name',
|
||||||
'cd_sort',
|
'cd_sort',
|
||||||
@@ -20,14 +24,54 @@ class CodeDetailModel extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 코드 종류의 세부코드 목록
|
* 목록 조회: 플랫폼(0) + (선택) 해당 지자체 행
|
||||||
|
*
|
||||||
|
* @param int|null $effectiveLgIdx null 또는 1 미만이면 플랫폼 공통만
|
||||||
*/
|
*/
|
||||||
public function getByKind(int $ckIdx, bool $activeOnly = false): array
|
public function filterByTenantScope(?int $effectiveLgIdx): self
|
||||||
{
|
{
|
||||||
$builder = $this->where('cd_ck_idx', $ckIdx);
|
if ($effectiveLgIdx === null || $effectiveLgIdx < 1) {
|
||||||
if ($activeOnly) {
|
return $this->where('cd_lg_idx', 0);
|
||||||
$builder->where('cd_state', 1);
|
|
||||||
}
|
}
|
||||||
return $builder->orderBy('cd_sort', 'ASC')->findAll();
|
|
||||||
|
return $this->groupStart()
|
||||||
|
->where('cd_lg_idx', 0)
|
||||||
|
->orWhere('cd_lg_idx', $effectiveLgIdx)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 코드 종류의 세부코드 목록
|
||||||
|
*
|
||||||
|
* @param int|null $effectiveLgIdx 테넌트 범위 (null=플랫폼만)
|
||||||
|
*/
|
||||||
|
public function getByKind(int $ckIdx, bool $activeOnly = false, ?int $effectiveLgIdx = null): array
|
||||||
|
{
|
||||||
|
$this->where('cd_ck_idx', $ckIdx);
|
||||||
|
$this->filterByTenantScope($effectiveLgIdx);
|
||||||
|
if ($activeOnly) {
|
||||||
|
$this->where('cd_state', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동일 정렬값일 때는 코드값 기준으로 안정적으로 정렬한다.
|
||||||
|
return $this->orderBy('cd_sort', 'ASC')
|
||||||
|
->orderBy('cd_code', 'ASC')
|
||||||
|
->orderBy('cd_idx', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동일 세부코드값: 지자체 전용이 있으면 우선, 없으면 플랫폼
|
||||||
|
*/
|
||||||
|
public function findResolvedByKindAndCode(int $ckIdx, string $code, ?int $effectiveLgIdx): ?object
|
||||||
|
{
|
||||||
|
if ($effectiveLgIdx !== null && $effectiveLgIdx > 0) {
|
||||||
|
$local = $this->where('cd_ck_idx', $ckIdx)->where('cd_code', $code)->where('cd_lg_idx', $effectiveLgIdx)->first();
|
||||||
|
if ($local !== null) {
|
||||||
|
return $local;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->where('cd_ck_idx', $ckIdx)->where('cd_code', $code)->where('cd_lg_idx', 0)->first();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,31 @@ class DesignatedShopModel extends Model
|
|||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'ds_lg_idx',
|
'ds_lg_idx',
|
||||||
|
'ds_sa_idx',
|
||||||
'ds_mb_idx',
|
'ds_mb_idx',
|
||||||
'ds_shop_no',
|
'ds_shop_no',
|
||||||
'ds_name',
|
'ds_name',
|
||||||
'ds_biz_no',
|
'ds_biz_no',
|
||||||
'ds_rep_name',
|
'ds_rep_name',
|
||||||
|
'ds_biz_type',
|
||||||
|
'ds_biz_kind',
|
||||||
'ds_va_number',
|
'ds_va_number',
|
||||||
|
'ds_va_bank',
|
||||||
|
'ds_va_account',
|
||||||
'ds_zip',
|
'ds_zip',
|
||||||
'ds_addr',
|
'ds_addr',
|
||||||
'ds_addr_jibun',
|
'ds_addr_jibun',
|
||||||
|
'ds_addr_detail',
|
||||||
'ds_tel',
|
'ds_tel',
|
||||||
'ds_rep_phone',
|
'ds_rep_phone',
|
||||||
'ds_email',
|
'ds_email',
|
||||||
'ds_gugun_code',
|
'ds_gugun_code',
|
||||||
|
'ds_zone_code',
|
||||||
|
'ds_branch_no',
|
||||||
'ds_designated_at',
|
'ds_designated_at',
|
||||||
'ds_state',
|
'ds_state',
|
||||||
|
'ds_state_changed_at',
|
||||||
|
'ds_change_reason',
|
||||||
'ds_regdate',
|
'ds_regdate',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class MemberModel extends Model
|
|||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'mb_id',
|
'mb_id',
|
||||||
'mb_passwd',
|
'mb_passwd',
|
||||||
|
'mb_totp_secret',
|
||||||
|
'mb_totp_enabled',
|
||||||
'mb_name',
|
'mb_name',
|
||||||
'mb_email',
|
'mb_email',
|
||||||
'mb_phone',
|
'mb_phone',
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ class MenuModel extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 mb_level에 노출할 메뉴만 필터링 (mm_is_view=Y, mm_level에 해당 레벨 포함 또는 빈값).
|
* 특정 mb_level에 노출할 메뉴만 필터링 (mm_is_view=Y, mm_level에 해당 레벨 포함 또는 빈값).
|
||||||
* lg_idx 기준 해당 지자체 메뉴만 대상. super admin(4)은 mm_level 무관하게 해당 지자체 메뉴 전체 노출.
|
* lg_idx 기준 해당 지자체 메뉴만 대상. super/본부(4·5)는 mm_level 무관하게 해당 지자체 메뉴 전체 노출.
|
||||||
*/
|
*/
|
||||||
public function getVisibleByLevel(int $mtIdx, int $mbLevel, int $lgIdx): array
|
public function getVisibleByLevel(int $mtIdx, int $mbLevel, int $lgIdx): array
|
||||||
{
|
{
|
||||||
$all = $this->getAllByType($mtIdx, $lgIdx);
|
$all = $this->getAllByType($mtIdx, $lgIdx);
|
||||||
if ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN) {
|
if (\Config\Roles::isSuperAdminEquivalent($mbLevel)) {
|
||||||
return array_values(array_filter($all, static fn ($row) => (string) $row->mm_is_view === 'Y'));
|
return array_values(array_filter($all, static fn ($row) => (string) $row->mm_is_view === 'Y'));
|
||||||
}
|
}
|
||||||
$levelStr = (string) $mbLevel;
|
$levelStr = (string) $mbLevel;
|
||||||
@@ -189,4 +189,102 @@ class MenuModel extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 메뉴 타입(mt_idx)을 source 지자체 기준으로 모든 지자체에 재배포.
|
||||||
|
* 기존 대상 지자체의 해당 타입 메뉴는 삭제 후 source 구조로 재생성한다.
|
||||||
|
*/
|
||||||
|
public function syncTypeToAllLgs(int $mtIdx, int $sourceLg): void
|
||||||
|
{
|
||||||
|
if ($mtIdx <= 0 || $sourceLg <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $this->where('mt_idx', $mtIdx)
|
||||||
|
->where('lg_idx', $sourceLg)
|
||||||
|
->orderBy('mm_dep', 'ASC')
|
||||||
|
->orderBy('mm_num', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
if (empty($source)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lgRows = $this->db->table('local_government')
|
||||||
|
->select('lg_idx')
|
||||||
|
->orderBy('lg_idx', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($lgRows as $lgRow) {
|
||||||
|
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
|
||||||
|
if ($destLg <= 0 || $destLg === $sourceLg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->transStart();
|
||||||
|
$this->where('mt_idx', $mtIdx)
|
||||||
|
->where('lg_idx', $destLg)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$idMap = [];
|
||||||
|
foreach ($source as $row) {
|
||||||
|
$oldId = (int) ($row->mm_idx ?? 0);
|
||||||
|
$oldP = (int) ($row->mm_pidx ?? 0);
|
||||||
|
$newPidx = 0;
|
||||||
|
if ($oldP > 0 && isset($idMap[$oldP])) {
|
||||||
|
$newPidx = (int) $idMap[$oldP];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->insert([
|
||||||
|
'mt_idx' => $mtIdx,
|
||||||
|
'lg_idx' => $destLg,
|
||||||
|
'mm_name' => (string) ($row->mm_name ?? ''),
|
||||||
|
'mm_link' => (string) ($row->mm_link ?? ''),
|
||||||
|
'mm_pidx' => $newPidx,
|
||||||
|
'mm_dep' => (int) ($row->mm_dep ?? 0),
|
||||||
|
'mm_num' => (int) ($row->mm_num ?? 0),
|
||||||
|
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
|
||||||
|
'mm_level' => (string) ($row->mm_level ?? ''),
|
||||||
|
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
|
||||||
|
]);
|
||||||
|
$idMap[$oldId] = (int) $this->getInsertID();
|
||||||
|
}
|
||||||
|
$this->db->transComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재고 관리 하위 메뉴는 "재고 현황", "실사 선별 조회"만 유지.
|
||||||
|
*/
|
||||||
|
public function pruneInventoryManagementMenus(int $mtIdx, int $lgIdx): void
|
||||||
|
{
|
||||||
|
if ($mtIdx <= 0 || $lgIdx <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentRows = $this->where('mt_idx', $mtIdx)
|
||||||
|
->where('lg_idx', $lgIdx)
|
||||||
|
->where('mm_pidx', 0)
|
||||||
|
->groupStart()
|
||||||
|
->where('mm_name', '재고 관리')
|
||||||
|
->orWhere('mm_name', '재고관리')
|
||||||
|
->groupEnd()
|
||||||
|
->findAll();
|
||||||
|
if ($parentRows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$parentIds = array_values(array_filter(array_map(
|
||||||
|
static fn ($row): int => (int) ($row->mm_idx ?? 0),
|
||||||
|
$parentRows
|
||||||
|
)));
|
||||||
|
if ($parentIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->where('mt_idx', $mtIdx)
|
||||||
|
->where('lg_idx', $lgIdx)
|
||||||
|
->whereIn('mm_pidx', $parentIds)
|
||||||
|
->whereNotIn('mm_link', ['bag/inventory', 'bag/inventory/inspection-select'])
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,29 @@ class PackagingUnitModel extends Model
|
|||||||
'pu_start_date', 'pu_end_date', 'pu_state',
|
'pu_start_date', 'pu_end_date', 'pu_state',
|
||||||
'pu_regdate', 'pu_moddate', 'pu_reg_mb_idx',
|
'pu_regdate', 'pu_moddate', 'pu_reg_mb_idx',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동일 봉투코드에 행이 여러 개여도 최신 등록 1건만 사용.
|
||||||
|
*
|
||||||
|
* @return array<string, object>
|
||||||
|
*/
|
||||||
|
public function latestActiveMapByBagCode(int $lgIdx): array
|
||||||
|
{
|
||||||
|
$rows = $this->where('pu_lg_idx', $lgIdx)
|
||||||
|
->where('pu_state', 1)
|
||||||
|
->orderBy('pu_regdate', 'DESC')
|
||||||
|
->orderBy('pu_idx', 'DESC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$code = (string) ($row->pu_bag_code ?? '');
|
||||||
|
if ($code === '' || isset($map[$code])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$code] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
@@ -11,7 +13,34 @@ class SalesAgencyModel extends Model
|
|||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'sa_lg_idx', 'sa_name', 'sa_biz_no', 'sa_rep_name',
|
'sa_lg_idx',
|
||||||
'sa_tel', 'sa_addr', 'sa_state', 'sa_regdate',
|
'sa_kind',
|
||||||
|
'sa_code',
|
||||||
|
'sa_name',
|
||||||
|
'sa_regdate',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** sales_agency 테이블에 sa_kind, sa_code 컬럼이 있는지(마이그레이션 적용 여부). */
|
||||||
|
public function hasKindCodeColumns(): bool
|
||||||
|
{
|
||||||
|
static $cache = null;
|
||||||
|
if ($cache === null) {
|
||||||
|
$cols = db_connect()->getFieldNames($this->table);
|
||||||
|
$cache = in_array('sa_kind', $cols, true) && in_array('sa_code', $cols, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 스키마면 구분·코드 순, 아니면 명·PK 순(옛 DB 호환).
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function orderForDisplay()
|
||||||
|
{
|
||||||
|
return $this->hasKindCodeColumns()
|
||||||
|
? $this->orderBy('sa_kind', 'ASC')->orderBy('sa_code', 'ASC')
|
||||||
|
: $this->orderBy('sa_name', 'ASC')->orderBy('sa_idx', 'ASC');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ShopOrderModel extends Model
|
|||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date',
|
'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date',
|
||||||
'so_payment_type', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount',
|
'so_payment_type', 'so_channel', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount',
|
||||||
'so_status', 'so_orderer_idx', 'so_regdate',
|
'so_status', 'so_orderer_idx', 'so_regdate',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '권한 승인 대기']) ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">권한 승인 대기</span>
|
<span class="text-sm font-bold text-gray-700">권한 승인 대기</span>
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2">
|
<div class="border border-gray-300 p-4 mt-2">
|
||||||
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
@@ -64,3 +68,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '로그인 이력']) ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<div class="flex items-center gap-4 text-sm">
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">조회</button>
|
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">조회</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
<div class="border border-gray-300 overflow-auto mt-2">
|
||||||
@@ -35,3 +37,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '재고 현황']) ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">재고 현황</span>
|
<span class="text-sm font-bold text-gray-700">재고 현황</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="<?= mgmt_url('bag-inventory/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
<div class="border border-gray-300 overflow-auto mt-2">
|
||||||
<table class="w-full data-table">
|
<table class="w-full data-table">
|
||||||
@@ -28,3 +35,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
|
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||||
<form action="<?= base_url('admin/bag-issues/store') ?>" method="POST" class="space-y-4">
|
<form action="<?= mgmt_url('bag-issues/store') ?>" method="POST" class="space-y-4">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
||||||
<a href="<?= base_url('admin/bag-issues') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= mgmt_url('bag-issues') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '무료용 불출 관리']) ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">무료용 불출 관리</span>
|
<span class="text-sm font-bold text-gray-700">무료용 불출 관리</span>
|
||||||
<a href="<?= base_url('admin/bag-issues/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">불출 처리</a>
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
<a href="<?= mgmt_url('bag-issues/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">불출 처리</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="p-2 bg-white border-b border-gray-200">
|
<section class="p-2 bg-white border-b border-gray-200">
|
||||||
<form method="GET" action="<?= base_url('admin/bag-issues') ?>" class="flex flex-wrap items-center gap-2">
|
<form method="GET" action="<?= mgmt_url('bag-issues') ?>" class="flex flex-wrap items-center gap-2">
|
||||||
<label class="text-sm text-gray-600">불출일</label>
|
<label class="text-sm text-gray-600">불출일</label>
|
||||||
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
<label class="text-sm text-gray-600">~</label>
|
<label class="text-sm text-gray-600">~</label>
|
||||||
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
||||||
<a href="<?= base_url('admin/bag-issues') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
<a href="<?= mgmt_url('bag-issues') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
<div class="border border-gray-300 overflow-auto mt-2">
|
||||||
@@ -27,7 +31,6 @@
|
|||||||
<th>봉투코드</th>
|
<th>봉투코드</th>
|
||||||
<th>봉투명</th>
|
<th>봉투명</th>
|
||||||
<th>수량</th>
|
<th>수량</th>
|
||||||
<th class="w-20">상태</th>
|
|
||||||
<th class="w-24">작업</th>
|
<th class="w-24">작업</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -43,9 +46,8 @@
|
|||||||
<td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td>
|
<td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td>
|
||||||
<td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td>
|
<td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td>
|
||||||
<td><?= number_format((int) $row->bi2_qty) ?></td>
|
<td><?= number_format((int) $row->bi2_qty) ?></td>
|
||||||
<td class="text-center"><?= esc($row->bi2_status) ?></td>
|
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<form action="<?= base_url('admin/bag-issues/cancel/' . (int) $row->bi2_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
|
<form action="<?= mgmt_url('bag-issues/cancel/' . (int) $row->bi2_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="text-orange-600 hover:underline text-sm">취소</button>
|
<button type="submit" class="text-orange-600 hover:underline text-sm">취소</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -53,8 +55,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($list)): ?>
|
<?php if (empty($list)): ?>
|
||||||
<tr><td colspan="11" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr>
|
<tr><td colspan="10" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -1,83 +1,443 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<span class="text-sm font-bold text-gray-700">발주 등록</span>
|
<span class="text-sm font-bold text-gray-700">발주 등록</span>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl">
|
|
||||||
<form action="<?= base_url('admin/bag-orders/store') ?>" method="POST" class="space-y-4">
|
<?php
|
||||||
|
$oldBagCodes = old('item_bag_code');
|
||||||
|
$oldQtyBoxes = old('item_qty_box');
|
||||||
|
$oldQtySheets = old('item_qty_sheet');
|
||||||
|
$oldBagCodes = is_array($oldBagCodes) ? $oldBagCodes : [];
|
||||||
|
$oldQtyBoxes = is_array($oldQtyBoxes) ? $oldQtyBoxes : [];
|
||||||
|
$oldQtySheets = is_array($oldQtySheets) ? $oldQtySheets : [];
|
||||||
|
$defaultOrderDate = old('bo_order_date', date('Y-m-d'));
|
||||||
|
$defaultOrderMonth = old('bo_order_month_ui', substr($defaultOrderDate, 0, 7));
|
||||||
|
|
||||||
|
$bagMeta = [];
|
||||||
|
foreach (($bagReferenceRows ?? []) as $row) {
|
||||||
|
$bagMeta[$row['code']] = [
|
||||||
|
'name' => $row['name'],
|
||||||
|
'orderPrice' => (float) $row['orderPrice'],
|
||||||
|
'boxPerPack' => (int) $row['boxPerPack'],
|
||||||
|
'packPerSheet' => (int) $row['packPerSheet'],
|
||||||
|
'totalPerBox' => max(1, (int) $row['totalPerBox']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$initialSelectedItems = [];
|
||||||
|
$maxOldCount = max(count($oldBagCodes), count($oldQtySheets), count($oldQtyBoxes));
|
||||||
|
for ($i = 0; $i < $maxOldCount; $i++) {
|
||||||
|
$code = trim((string) ($oldBagCodes[$i] ?? ''));
|
||||||
|
if ($code === '' || ! isset($bagMeta[$code])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fallbackQtyBox = (int) ($oldQtyBoxes[$i] ?? 0);
|
||||||
|
$rawQtySheet = (int) ($oldQtySheets[$i] ?? 0);
|
||||||
|
$fallbackTotalPerBox = (int) ($bagMeta[$code]['totalPerBox'] ?? 1);
|
||||||
|
if ($fallbackQtyBox <= 0 && $rawQtySheet > 0) {
|
||||||
|
$fallbackQtyBox = intdiv($rawQtySheet, max(1, $fallbackTotalPerBox));
|
||||||
|
}
|
||||||
|
$initialSelectedItems[] = [
|
||||||
|
'code' => $code,
|
||||||
|
'qtyBox' => max(0, $fallbackQtyBox),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="mt-2 space-y-2">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="border border-gray-300 bg-white p-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label>
|
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bo_order_date" type="date" value="<?= esc(old('bo_order_date', date('Y-m-d'))) ?>" required/>
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
|
||||||
|
<input id="bo_order_month_ui" name="bo_order_month_ui" type="month" value="<?= esc($defaultOrderMonth) ?>" class="border border-gray-300 rounded px-2 py-1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="bo_order_date" class="font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
|
||||||
|
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1" />
|
||||||
|
</div>
|
||||||
|
<p class="text-blue-600 font-bold">※ 발주수량은 박스단위로 입력해 주세요. (발주일은 미래일도 선택 가능)</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label>
|
<section class="xl:col-span-5 border border-gray-300 bg-white">
|
||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>"/>
|
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
|
||||||
<span class="text-sm text-gray-500">%</span>
|
<div class="overflow-auto max-h-[410px]">
|
||||||
</div>
|
<table class="w-full data-table text-sm">
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">제작업체</label>
|
|
||||||
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_company_idx">
|
|
||||||
<option value="">선택</option>
|
|
||||||
<?php foreach ($companies as $cp): ?>
|
|
||||||
<option value="<?= esc($cp->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $cp->cp_idx ? 'selected' : '' ?>>
|
|
||||||
<?= esc($cp->cp_name) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">입고처</label>
|
|
||||||
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_agency_idx">
|
|
||||||
<option value="">선택</option>
|
|
||||||
<?php foreach ($agencies as $ag): ?>
|
|
||||||
<option value="<?= esc($ag->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $ag->sa_idx ? 'selected' : '' ?>>
|
|
||||||
<?= esc($ag->sa_name) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<label class="block text-sm font-bold text-gray-700 mb-2">발주 품목</label>
|
|
||||||
<div class="border border-gray-300 overflow-auto">
|
|
||||||
<table class="w-full data-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-16">순번</th>
|
<th class="w-28">발주일</th>
|
||||||
<th>봉투</th>
|
<th>제작업체</th>
|
||||||
<th class="w-32">박스수</th>
|
<th>입고처</th>
|
||||||
|
<th class="w-16">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php for ($i = 0; $i < 3; $i++): ?>
|
<?php foreach (($recentOrders ?? []) as $history): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><?= $i + 1 ?></td>
|
<td class="text-center"><?= esc((string) $history->bo_order_date) ?></td>
|
||||||
<td>
|
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
|
||||||
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]">
|
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
|
||||||
<option value="">선택</option>
|
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
|
||||||
<?php foreach ($bagCodes as $cd): ?>
|
|
||||||
<option value="<?= esc($cd->cd_code) ?>">
|
|
||||||
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty_box[]" type="number" min="0" value="0"/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<?php endfor; ?>
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($recentOrders)): ?>
|
||||||
|
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="xl:col-span-7 border border-gray-300 bg-white">
|
||||||
|
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 Form</div>
|
||||||
|
<div class="p-2 space-y-2">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<label for="bo_fee_rate" class="w-20 font-bold text-gray-700">수수료</label>
|
||||||
|
<input id="bo_fee_rate" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>" class="border border-gray-300 rounded px-2 py-1 w-24 text-right" />
|
||||||
|
<span>%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
|
||||||
|
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<?php foreach (($associations ?? []) as $association): ?>
|
||||||
|
<option value="<?= esc((string) $association->cp_idx) ?>" <?= (int) old('bo_association_idx') === (int) $association->cp_idx ? 'selected' : '' ?>>
|
||||||
|
<?= esc((string) $association->cp_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<label for="bo_company_idx" class="w-20 font-bold text-gray-700">제작업체</label>
|
||||||
|
<select id="bo_company_idx" name="bo_company_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<?php foreach (($companies ?? []) as $company): ?>
|
||||||
|
<option value="<?= esc((string) $company->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $company->cp_idx ? 'selected' : '' ?>>
|
||||||
|
<?= esc((string) $company->cp_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<label for="bo_agency_idx" class="w-20 font-bold text-gray-700">입고처</label>
|
||||||
|
<select id="bo_agency_idx" name="bo_agency_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<?php foreach (($agencies ?? []) as $agency): ?>
|
||||||
|
<option value="<?= esc((string) $agency->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $agency->sa_idx ? 'selected' : '' ?>>
|
||||||
|
[<?= esc((string) ($agency->sa_kind ?? '')) ?>] <?= esc((string) ($agency->sa_code ?? '')) ?> — <?= esc((string) $agency->sa_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="border border-gray-300 overflow-auto">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<table class="w-full data-table text-sm order-input-table" id="order-item-table">
|
||||||
<a href="<?= base_url('admin/bag-orders') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-12">번호</th>
|
||||||
|
<th class="w-16">선택</th>
|
||||||
|
<th>품명</th>
|
||||||
|
<th class="w-28">수량(BOX)</th>
|
||||||
|
<th class="w-24">단가</th>
|
||||||
|
<th class="w-24">환산수량</th>
|
||||||
|
<th class="w-28">금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="selected-order-items-body"></tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th colspan="3" class="text-center">계</th>
|
||||||
|
<th class="text-right pr-2" id="sum-box-qty">0</th>
|
||||||
|
<th></th>
|
||||||
|
<th class="text-right pr-2" id="sum-sheet-qty">0</th>
|
||||||
|
<th class="text-right pr-2" id="sum-amount">0</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">발주</button>
|
||||||
|
<a href="<?= mgmt_url('bag-orders') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="border border-gray-300 bg-white">
|
||||||
|
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div>
|
||||||
|
<p class="text-xs text-gray-600 px-2 py-1">아래 목록에서 봉투를 선택하면 발주 품목에 추가됩니다. (개수 제한 없음)</p>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table class="w-full data-table text-sm order-reference-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-12">번호</th>
|
||||||
|
<th class="w-20">선택</th>
|
||||||
|
<th>봉투 종류</th>
|
||||||
|
<th class="w-24">발주단가</th>
|
||||||
|
<th class="w-24">Box당 팩</th>
|
||||||
|
<th class="w-24">팩당 낱장</th>
|
||||||
|
<th class="w-28">1박스 총 낱장</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach (($bagReferenceRows ?? []) as $idx => $row): ?>
|
||||||
|
<tr data-reference-row data-code="<?= esc((string) $row['code']) ?>" class="cursor-pointer">
|
||||||
|
<td class="text-center"><?= $idx + 1 ?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button type="button" class="js-toggle-bag border border-gray-300 rounded px-2 py-0.5 text-xs hover:bg-gray-100" data-code="<?= esc((string) $row['code']) ?>">선택</button>
|
||||||
|
</td>
|
||||||
|
<td class="text-left pl-2"><?= esc((string) $row['name']) ?></td>
|
||||||
|
<td class="text-right pr-2"><?= number_format((float) $row['orderPrice'], 2) ?></td>
|
||||||
|
<td class="text-right pr-2"><?= number_format((int) $row['boxPerPack']) ?></td>
|
||||||
|
<td class="text-right pr-2"><?= number_format((int) $row['packPerSheet']) ?></td>
|
||||||
|
<td class="text-right pr-2"><?= number_format((int) $row['totalPerBox']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($bagReferenceRows)): ?>
|
||||||
|
<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 봉투 기준 데이터가 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.order-input-table tbody tr,
|
||||||
|
.order-reference-table tbody tr {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.order-input-table tbody td,
|
||||||
|
.order-reference-table tbody td {
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
const initialSelectedItems = <?= json_encode($initialSelectedItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
const selectedBody = document.getElementById('selected-order-items-body');
|
||||||
|
const referenceRows = Array.from(document.querySelectorAll('[data-reference-row]'));
|
||||||
|
const sumBoxQtyEl = document.getElementById('sum-box-qty');
|
||||||
|
const sumSheetQtyEl = document.getElementById('sum-sheet-qty');
|
||||||
|
const sumAmountEl = document.getElementById('sum-amount');
|
||||||
|
const monthInput = document.getElementById('bo_order_month_ui');
|
||||||
|
const orderDateInput = document.getElementById('bo_order_date');
|
||||||
|
const orderForm = document.querySelector('form[action*="bag-orders/store"]');
|
||||||
|
const selectedItems = new Map();
|
||||||
|
let activeCode = null;
|
||||||
|
|
||||||
|
const formatNumber = (value) => new Intl.NumberFormat('ko-KR').format(Number.isFinite(value) ? value : 0);
|
||||||
|
const escapeHtml = (value) => String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
const syncMonthFromDate = () => {
|
||||||
|
if (!orderDateInput || !monthInput || !orderDateInput.value) return;
|
||||||
|
monthInput.value = orderDateInput.value.substring(0, 7);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncDateFromMonth = () => {
|
||||||
|
if (!orderDateInput || !monthInput || !monthInput.value) return;
|
||||||
|
const parts = monthInput.value.split('-');
|
||||||
|
if (parts.length !== 2) return;
|
||||||
|
const year = Number(parts[0]);
|
||||||
|
const month = Number(parts[1]);
|
||||||
|
if (!Number.isFinite(year) || !Number.isFinite(month)) return;
|
||||||
|
|
||||||
|
const currentDay = orderDateInput.value ? Number(orderDateInput.value.split('-')[2]) : 1;
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
const day = String(Math.min(Math.max(currentDay, 1), lastDay)).padStart(2, '0');
|
||||||
|
orderDateInput.value = `${String(year)}-${String(month).padStart(2, '0')}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateReferenceSelectionUi = () => {
|
||||||
|
referenceRows.forEach((row) => {
|
||||||
|
const code = row.dataset.code || '';
|
||||||
|
const button = row.querySelector('.js-toggle-bag');
|
||||||
|
const isSelected = selectedItems.has(code);
|
||||||
|
row.classList.toggle('bg-blue-50', isSelected);
|
||||||
|
if (button) {
|
||||||
|
button.textContent = isSelected ? '선택됨' : '선택';
|
||||||
|
button.classList.toggle('bg-blue-600', isSelected);
|
||||||
|
button.classList.toggle('text-white', isSelected);
|
||||||
|
button.classList.toggle('border-blue-600', isSelected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTotals = () => {
|
||||||
|
let sumBoxQty = 0;
|
||||||
|
let sumSheetQty = 0;
|
||||||
|
let sumAmount = 0;
|
||||||
|
|
||||||
|
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
|
||||||
|
const code = row.dataset.code || '';
|
||||||
|
const qtyInput = row.querySelector('.item-qty-box');
|
||||||
|
const qtyBox = Math.max(0, parseInt(qtyInput?.value || '0', 10));
|
||||||
|
const meta = bagMeta[code] || { orderPrice: 0, totalPerBox: 1 };
|
||||||
|
const unitPrice = Number(meta.orderPrice || 0);
|
||||||
|
const totalPerBox = Math.max(1, Number(meta.totalPerBox || 1));
|
||||||
|
const qtySheet = qtyBox * totalPerBox;
|
||||||
|
const amount = qtySheet * unitPrice;
|
||||||
|
|
||||||
|
const unitPriceEl = row.querySelector('.item-unit-price');
|
||||||
|
const qtySheetEl = row.querySelector('.item-qty-sheet');
|
||||||
|
const sheetHelpEl = row.querySelector('.item-sheet-help');
|
||||||
|
const amountEl = row.querySelector('.item-amount');
|
||||||
|
if (unitPriceEl) unitPriceEl.textContent = formatNumber(unitPrice);
|
||||||
|
if (qtySheetEl) qtySheetEl.textContent = formatNumber(qtySheet);
|
||||||
|
if (sheetHelpEl) sheetHelpEl.textContent = `낱장 ${formatNumber(qtySheet)}장`;
|
||||||
|
if (amountEl) amountEl.textContent = formatNumber(amount);
|
||||||
|
|
||||||
|
selectedItems.set(code, { qtyBox });
|
||||||
|
sumBoxQty += qtyBox;
|
||||||
|
sumSheetQty += qtySheet;
|
||||||
|
sumAmount += amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sumBoxQtyEl) sumBoxQtyEl.textContent = formatNumber(sumBoxQty);
|
||||||
|
if (sumSheetQtyEl) sumSheetQtyEl.textContent = formatNumber(sumSheetQty);
|
||||||
|
if (sumAmountEl) sumAmountEl.textContent = formatNumber(sumAmount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveRow = (code) => {
|
||||||
|
activeCode = code || null;
|
||||||
|
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
|
||||||
|
row.classList.toggle('bg-amber-50', row.dataset.code === activeCode);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedRows = () => {
|
||||||
|
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
|
||||||
|
if (codes.length === 0) {
|
||||||
|
selectedBody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">아래 "발주 등록 종류"에서 봉투를 선택해 주세요.</td></tr>';
|
||||||
|
setActiveRow(null);
|
||||||
|
updateTotals();
|
||||||
|
updateReferenceSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedBody.innerHTML = codes.map((code, idx) => {
|
||||||
|
const meta = bagMeta[code];
|
||||||
|
const qtyBox = Math.max(0, parseInt(String(selectedItems.get(code)?.qtyBox ?? 0), 10));
|
||||||
|
const name = meta?.name || code;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-item-row data-code="${escapeHtml(code)}" class="cursor-pointer">
|
||||||
|
<td class="text-center">${idx + 1}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button type="button" class="js-remove-selected text-xs text-red-600 hover:underline" data-code="${escapeHtml(code)}">해제</button>
|
||||||
|
</td>
|
||||||
|
<td class="text-left pl-2">
|
||||||
|
${escapeHtml(name)}
|
||||||
|
<input type="hidden" name="item_bag_code[]" value="${escapeHtml(code)}" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input name="item_qty_box[]" type="number" min="0" step="1" value="${qtyBox}" class="item-qty-box border border-gray-300 rounded px-2 py-1 text-sm w-full text-right leading-tight" />
|
||||||
|
<p class="text-[11px] text-gray-500 mt-1 item-sheet-help">낱장 0장</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-right pr-2 item-unit-price">0</td>
|
||||||
|
<td class="text-right pr-2 item-qty-sheet">0</td>
|
||||||
|
<td class="text-right pr-2 item-amount">0</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (!activeCode || !selectedItems.has(activeCode)) {
|
||||||
|
activeCode = codes[0];
|
||||||
|
}
|
||||||
|
setActiveRow(activeCode);
|
||||||
|
updateTotals();
|
||||||
|
updateReferenceSelectionUi();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelection = (code) => {
|
||||||
|
if (!code || !bagMeta[code]) return;
|
||||||
|
if (selectedItems.has(code)) {
|
||||||
|
selectedItems.delete(code);
|
||||||
|
if (activeCode === code) activeCode = null;
|
||||||
|
} else {
|
||||||
|
selectedItems.set(code, { qtyBox: 0 });
|
||||||
|
activeCode = code;
|
||||||
|
}
|
||||||
|
renderSelectedRows();
|
||||||
|
};
|
||||||
|
|
||||||
|
initialSelectedItems.forEach((item) => {
|
||||||
|
if (!item || !item.code || !bagMeta[item.code]) return;
|
||||||
|
selectedItems.set(item.code, { qtyBox: Math.max(0, parseInt(String(item.qtyBox ?? 0), 10)) });
|
||||||
|
activeCode = item.code;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedBody.addEventListener('click', (event) => {
|
||||||
|
const removeButton = event.target.closest('.js-remove-selected');
|
||||||
|
if (removeButton) {
|
||||||
|
toggleSelection(removeButton.dataset.code || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = event.target.closest('tr[data-item-row]');
|
||||||
|
if (!row) return;
|
||||||
|
const code = row.dataset.code || '';
|
||||||
|
setActiveRow(code);
|
||||||
|
const qtyInput = row.querySelector('.item-qty-box');
|
||||||
|
if (qtyInput) qtyInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedBody.addEventListener('input', (event) => {
|
||||||
|
const qtyInput = event.target.closest('.item-qty-box');
|
||||||
|
if (!qtyInput) return;
|
||||||
|
const row = qtyInput.closest('tr[data-item-row]');
|
||||||
|
if (!row) return;
|
||||||
|
const code = row.dataset.code || '';
|
||||||
|
selectedItems.set(code, { qtyBox: Math.max(0, parseInt(qtyInput.value || '0', 10)) });
|
||||||
|
updateTotals();
|
||||||
|
});
|
||||||
|
|
||||||
|
referenceRows.forEach((row) => {
|
||||||
|
row.addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('.js-toggle-bag');
|
||||||
|
if (button) {
|
||||||
|
toggleSelection(button.dataset.code || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest('td')) {
|
||||||
|
toggleSelection(row.dataset.code || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (monthInput) monthInput.addEventListener('change', () => { syncDateFromMonth(); updateTotals(); });
|
||||||
|
if (orderDateInput) orderDateInput.addEventListener('change', syncMonthFromDate);
|
||||||
|
|
||||||
|
if (orderForm) {
|
||||||
|
orderForm.addEventListener('submit', (event) => {
|
||||||
|
const hasValidItem = Array.from(selectedBody.querySelectorAll('tr[data-item-row]')).some((row) => {
|
||||||
|
const qtyInput = row.querySelector('.item-qty-box');
|
||||||
|
return Math.max(0, parseInt(qtyInput?.value || '0', 10)) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasValidItem) {
|
||||||
|
event.preventDefault();
|
||||||
|
alert('봉투를 선택하고 수량을 1 이상 입력해 주세요.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMonthFromDate();
|
||||||
|
renderSelectedRows();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="<?= base_url('admin/bag-orders') ?>" class="text-blue-600 hover:underline text-sm">← 발주 목록</a>
|
<a href="<?= mgmt_url('bag-orders') ?>" class="text-blue-600 hover:underline text-sm">← 발주 목록</a>
|
||||||
<span class="text-gray-400">|</span>
|
<span class="text-gray-400">|</span>
|
||||||
<span class="text-sm font-bold text-gray-700">발주 상세 — <?= esc($order->bo_lot_no) ?></span>
|
<span class="text-sm font-bold text-gray-700">발주 상세 — <?= esc($order->bo_lot_no) ?></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,75 +1,200 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<?php
|
||||||
|
// 발주기간: native month 입력은 로케일에 따라 Jan 등 영문 표기될 수 있어 YYYY-MM select + 한글 라벨 사용
|
||||||
|
$bagOrderYmChoices = [];
|
||||||
|
$bagOrderYmCenterY = (int) date('Y');
|
||||||
|
for ($by = $bagOrderYmCenterY - 4; $by <= $bagOrderYmCenterY + 2; $by++) {
|
||||||
|
for ($bm = 1; $bm <= 12; $bm++) {
|
||||||
|
$bagOrderYmChoices[] = sprintf('%04d-%02d', $by, $bm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ([(string) ($startMonth ?? ''), (string) ($endMonth ?? '')] as $ymExtra) {
|
||||||
|
if (preg_match('/^\d{4}-\d{2}$/', $ymExtra) && ! in_array($ymExtra, $bagOrderYmChoices, true)) {
|
||||||
|
$bagOrderYmChoices[] = $ymExtra;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($bagOrderYmChoices);
|
||||||
|
$bagOrderYmLabel = static function (string $ym): string {
|
||||||
|
if (preg_match('/^(\d{4})-(\d{2})$/', $ym, $m)) {
|
||||||
|
return $m[1] . '년 ' . (int) $m[2] . '월';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ym;
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<?= view('components/print_header', ['printTitle' => '봉투 발주 현황']) ?>
|
||||||
|
<section class="no-print border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">발주 현황</span>
|
<div class="flex items-center gap-2">
|
||||||
<a href="<?= base_url('admin/bag-orders/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">발주 등록</a>
|
<a href="<?= mgmt_url('bag-orders/export') . '?' . http_build_query(array_filter(['start_month' => $startMonth ?? '', 'end_month' => $endMonth ?? '', 'company_idx' => $companyIdx ?? 0, 'bag_code' => $bagCode ?? '', 'receive_type' => $receiveType ?? ''])) ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
<a href="<?= mgmt_url('bag-orders/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">발주 등록</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="p-2 bg-white border-b border-gray-200">
|
|
||||||
<form method="GET" action="<?= base_url('admin/bag-orders') ?>" class="flex flex-wrap items-center gap-2">
|
<section class="no-print p-2 bg-white border-b border-gray-200">
|
||||||
<label class="text-sm text-gray-600">발주일</label>
|
<!-- GBMS 발주현황: 발주기간은 [시작] ~ [끝] 한 줄 고정, 필터 블록은 가로 나열 후 좁으면 블록 단위로만 줄바꿈 -->
|
||||||
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<form method="GET" action="<?= mgmt_url('bag-orders') ?>" class="flex flex-wrap items-end gap-x-5 gap-y-3 w-full">
|
||||||
<label class="text-sm text-gray-600">~</label>
|
<div class="flex flex-nowrap items-center gap-2 shrink-0">
|
||||||
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<label class="text-sm text-gray-600 whitespace-nowrap">발주 기간</label>
|
||||||
<label class="text-sm text-gray-600">상태</label>
|
<select name="start_month" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11.5rem] max-w-[13rem] shrink-0">
|
||||||
<select name="status" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
<?php foreach ($bagOrderYmChoices as $ym): ?>
|
||||||
<option value="">전체</option>
|
<option value="<?= esc($ym) ?>" <?= ($startMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
|
||||||
<option value="normal" <?= ($status ?? '') === 'normal' ? 'selected' : '' ?>>정상</option>
|
<?php endforeach; ?>
|
||||||
<option value="cancelled" <?= ($status ?? '') === 'cancelled' ? 'selected' : '' ?>>취소</option>
|
|
||||||
<option value="deleted" <?= ($status ?? '') === 'deleted' ? 'selected' : '' ?>>삭제</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
<span class="text-sm text-gray-500 select-none">~</span>
|
||||||
|
<select name="end_month" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11.5rem] max-w-[13rem] shrink-0">
|
||||||
|
<?php foreach ($bagOrderYmChoices as $ym): ?>
|
||||||
|
<option value="<?= esc($ym) ?>" <?= ($endMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-nowrap items-center gap-2 shrink-0">
|
||||||
|
<label class="text-sm text-gray-600 whitespace-nowrap">제작 업체</label>
|
||||||
|
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11rem] max-w-[14rem]">
|
||||||
|
<option value="0">전 체</option>
|
||||||
|
<?php foreach (($companyOptions ?? []) as $company): ?>
|
||||||
|
<option value="<?= (int) $company->cp_idx ?>" <?= (int) ($companyIdx ?? 0) === (int) $company->cp_idx ? 'selected' : '' ?>>
|
||||||
|
<?= esc((string) $company->cp_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-nowrap items-center gap-2 shrink-0">
|
||||||
|
<label class="text-sm text-gray-600 whitespace-nowrap">품 명</label>
|
||||||
|
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11rem] max-w-[16rem]">
|
||||||
|
<option value="">전 체</option>
|
||||||
|
<?php foreach (($bagCodeOptions ?? []) as $bag): ?>
|
||||||
|
<option value="<?= esc((string) $bag->cd_code) ?>" <?= (string) ($bagCode ?? '') === (string) $bag->cd_code ? 'selected' : '' ?>>
|
||||||
|
<?= esc((string) $bag->cd_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-nowrap items-center gap-2 shrink-0">
|
||||||
|
<label class="text-sm text-gray-600 whitespace-nowrap">입고 구분</label>
|
||||||
|
<select name="receive_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-[8.5rem]">
|
||||||
|
<option value="all" <?= ($receiveType ?? 'all') === 'all' ? 'selected' : '' ?>>전 체</option>
|
||||||
|
<option value="received" <?= ($receiveType ?? 'all') === 'received' ? 'selected' : '' ?>>입고완료</option>
|
||||||
|
<option value="pending" <?= ($receiveType ?? 'all') === 'pending' ? 'selected' : '' ?>>미입고</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-nowrap items-center gap-2 shrink-0 sm:ml-auto">
|
||||||
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
||||||
<a href="<?= base_url('admin/bag-orders') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
<a href="<?= mgmt_url('bag-orders') ?>" class="text-sm text-gray-500 hover:underline whitespace-nowrap">초기화</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
|
||||||
<table class="w-full data-table">
|
<div class="bag-order-print-wrap border border-gray-300 overflow-auto mt-2">
|
||||||
|
<table class="bag-order-print-table w-full data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-16">번호</th>
|
<th class="w-32">발주일자</th>
|
||||||
<th>LOT번호</th>
|
<th class="min-w-[10rem]">제작 업체</th>
|
||||||
<th>발주일</th>
|
<th class="min-w-[12rem]">품 명</th>
|
||||||
<th>제작업체</th>
|
<th class="w-28">발주 수량</th>
|
||||||
<th>입고처</th>
|
<th class="w-28">입고 수량</th>
|
||||||
<th>품목수</th>
|
<th class="w-28">미입고수량</th>
|
||||||
<th>총수량</th>
|
<th class="w-32">발주 금액</th>
|
||||||
<th>총금액</th>
|
<th class="min-w-[9rem]">입고처</th>
|
||||||
<th class="w-20">상태</th>
|
<th class="min-w-[8rem]">비 고</th>
|
||||||
<th class="w-44">작업</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="text-right">
|
<tbody class="text-right">
|
||||||
<?php foreach ($list as $row): ?>
|
<?php $printedGroup = []; ?>
|
||||||
<tr>
|
<?php foreach (($rows ?? []) as $row): ?>
|
||||||
<td class="text-center"><?= esc($row->bo_idx) ?></td>
|
<?php if (! empty($row['is_subtotal'])): ?>
|
||||||
<td class="text-center font-mono"><?= esc($row->bo_lot_no) ?></td>
|
<tr class="bg-gray-50 font-semibold">
|
||||||
<td class="text-center"><?= esc($row->bo_order_date) ?></td>
|
<td colspan="3" class="text-center"><?= esc((string) ($row['label'] ?? '소계')) ?></td>
|
||||||
<td class="text-left pl-2"><?= esc($companyMap[$row->bo_company_idx] ?? '') ?></td>
|
<td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
|
||||||
<td class="text-left pl-2"><?= esc($agencyMap[$row->bo_agency_idx] ?? '') ?></td>
|
<td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
|
||||||
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['count'] ?? 0)) ?></td>
|
<td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
|
||||||
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['qty'] ?? 0)) ?></td>
|
<td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
|
||||||
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['amount'] ?? 0)) ?></td>
|
<td></td>
|
||||||
<td class="text-center">
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<?php continue; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
|
$boIdx = (int) ($row['bo_idx'] ?? 0);
|
||||||
echo esc($statusMap[$row->bo_status] ?? $row->bo_status);
|
$showGroup = ! isset($printedGroup[$boIdx]);
|
||||||
|
$rowspan = (int) (($groupRows[$boIdx] ?? 1));
|
||||||
|
if ($showGroup) {
|
||||||
|
$printedGroup[$boIdx] = true;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
</td>
|
<tr>
|
||||||
<td class="text-center">
|
<?php if ($showGroup): ?>
|
||||||
<a href="<?= base_url('admin/bag-orders/detail/' . (int) $row->bo_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">상세</a>
|
<td class="text-center align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
|
||||||
<form action="<?= base_url('admin/bag-orders/cancel/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
|
<td class="text-left pl-2 align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
|
||||||
<?= csrf_field() ?>
|
<?php endif; ?>
|
||||||
<button type="submit" class="text-orange-600 hover:underline text-sm mr-1">취소</button>
|
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
|
||||||
</form>
|
<td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
|
||||||
<form action="<?= base_url('admin/bag-orders/delete/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
|
<td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
|
||||||
<?= csrf_field() ?>
|
<td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
|
||||||
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
<td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
|
||||||
</form>
|
<td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
|
||||||
</td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($list)): ?>
|
|
||||||
<tr><td colspan="10" class="text-center text-gray-400 py-4">등록된 발주가 없습니다.</td></tr>
|
<?php if (empty($rows ?? [])): ?>
|
||||||
|
<tr><td colspan="9" class="text-center text-gray-400 py-6">조회 조건에 해당하는 발주 내역이 없습니다.</td></tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="bg-gray-100 font-bold">
|
||||||
|
<td colspan="3" class="text-center">총계</td>
|
||||||
|
<td class="text-right"><?= number_format((int) ($grandTotals['order_qty'] ?? 0)) ?></td>
|
||||||
|
<td class="text-right"><?= number_format((int) ($grandTotals['received_qty'] ?? 0)) ?></td>
|
||||||
|
<td class="text-right"><?= number_format((int) ($grandTotals['pending_qty'] ?? 0)) ?></td>
|
||||||
|
<td class="text-right"><?= number_format((float) ($grandTotals['amount'] ?? 0)) ?></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
#debug-icon,
|
||||||
|
#debug-bar,
|
||||||
|
#debug-bar-contents,
|
||||||
|
#debug-toolbar,
|
||||||
|
.debug-toolbar,
|
||||||
|
.ci-debug-toolbar,
|
||||||
|
[id^='debug-bar-'],
|
||||||
|
[id^='debug-icon'],
|
||||||
|
[class*='debug-toolbar'] {
|
||||||
|
display: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag-order-print-wrap {
|
||||||
|
overflow: visible !important;
|
||||||
|
border: none !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag-order-print-table {
|
||||||
|
width: 100% !important;
|
||||||
|
table-layout: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bag-order-print-table th,
|
||||||
|
.bag-order-print-table td {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
word-break: keep-all !important;
|
||||||
|
overflow-wrap: normal !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
padding: 2px 3px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<span class="text-sm font-bold text-gray-700">봉투 단가 등록</span>
|
<span class="text-sm font-bold text-gray-700">봉투 단가 등록</span>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||||
<form action="<?= base_url('admin/bag-prices/store') ?>" method="POST" class="space-y-4">
|
<form action="<?= mgmt_url('bag-prices/store') ?>" method="POST" class="space-y-4">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
||||||
<a href="<?= base_url('admin/bag-prices') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= mgmt_url('bag-prices') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
|
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||||
<form action="<?= base_url('admin/bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4">
|
<form action="<?= mgmt_url('bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
|
||||||
<a href="<?= base_url('admin/bag-prices') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= mgmt_url('bag-prices') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="<?= base_url('admin/bag-prices') ?>" class="text-blue-600 hover:underline text-sm">← 단가 목록</a>
|
<a href="<?= mgmt_url('bag-prices') ?>" class="text-blue-600 hover:underline text-sm">← 단가 목록</a>
|
||||||
<span class="text-gray-400">|</span>
|
<span class="text-gray-400">|</span>
|
||||||
<span class="text-sm font-bold text-gray-700">단가 변경 이력 — <?= esc($item->bp_bag_name) ?> (<?= esc($item->bp_bag_code) ?>)</span>
|
<span class="text-sm font-bold text-gray-700">단가 변경 이력 — <?= esc($item->bp_bag_name) ?> (<?= esc($item->bp_bag_code) ?>)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,97 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '봉투 단가 관리']) ?>
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">봉투 단가 관리</span>
|
<span class="text-sm font-bold text-gray-700">봉투 단가 관리</span>
|
||||||
<a href="<?= base_url('admin/bag-prices/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">단가 등록</a>
|
<div class="flex items-center gap-2 no-print">
|
||||||
|
<button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
<a href="<?= mgmt_url('bag-prices/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">단가 등록</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="p-2 bg-white border-b border-gray-200">
|
<section class="no-print border border-gray-200 rounded-lg bg-white p-3 mt-2">
|
||||||
<form method="GET" action="<?= base_url('admin/bag-prices') ?>" class="flex flex-wrap items-center gap-2">
|
<form method="get" action="<?= mgmt_url('bag-prices') ?>" class="flex flex-wrap items-end gap-3" autocomplete="off">
|
||||||
<label class="text-sm text-gray-600">적용시작일</label>
|
<div class="flex flex-col gap-0.5">
|
||||||
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<label class="text-xs text-gray-500">봉투구분</label>
|
||||||
<label class="text-sm text-gray-600">~</label>
|
<select name="bag_kind_e" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[9rem]">
|
||||||
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<option value="">전체</option>
|
||||||
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
<?php foreach ($bag_kind_options ?? [] as $cd): ?>
|
||||||
<a href="<?= base_url('admin/bag-prices') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
<option value="<?= esc($cd->cd_code) ?>" <?= (string) ($cd->cd_code ?? '') === (string) ($bag_kind_e ?? '') ? 'selected' : '' ?>>
|
||||||
|
<?= esc($cd->cd_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<label class="text-xs text-gray-500">봉투코드</label>
|
||||||
|
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[11rem]">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<?php foreach ($bag_codes ?? [] as $cd): ?>
|
||||||
|
<option value="<?= esc($cd->cd_code) ?>" <?= (string) ($cd->cd_code ?? '') === (string) ($bag_code ?? '') ? 'selected' : '' ?>>
|
||||||
|
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<label class="text-xs text-gray-500">조회 기간 (적용기간 겹침)</label>
|
||||||
|
<?php
|
||||||
|
$sp = $startParts ?? ['y' => '', 'm' => '', 'd' => ''];
|
||||||
|
$ep = $endParts ?? ['y' => '', 'm' => '', 'd' => ''];
|
||||||
|
$ymin = (int) ($dateYearMin ?? ((int) date('Y') - 12));
|
||||||
|
$ymax = (int) ($dateYearMax ?? ((int) date('Y') + 2));
|
||||||
|
?>
|
||||||
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
|
<span class="text-xs text-gray-500 mr-0.5">시작</span>
|
||||||
|
<select name="start_y" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[4.5rem]">
|
||||||
|
<option value="">연도</option>
|
||||||
|
<?php for ($yy = $ymin; $yy <= $ymax; $yy++): ?>
|
||||||
|
<option value="<?= $yy ?>" <?= (string) ($sp['y'] ?? '') === (string) $yy ? 'selected' : '' ?>><?= $yy ?></option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<select name="start_m" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
|
||||||
|
<option value="">월</option>
|
||||||
|
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
|
||||||
|
<option value="<?= $mi ?>" <?= isset($sp['m']) && (int) $sp['m'] === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<select name="start_d" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
|
||||||
|
<option value="">일</option>
|
||||||
|
<?php for ($di = 1; $di <= 31; $di++): ?>
|
||||||
|
<option value="<?= $di ?>" <?= isset($sp['d']) && (int) $sp['d'] === $di ? 'selected' : '' ?>><?= $di ?>일</option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<span class="text-sm text-gray-500 mx-0.5">~</span>
|
||||||
|
<span class="text-xs text-gray-500 mr-0.5">종료</span>
|
||||||
|
<select name="end_y" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[4.5rem]">
|
||||||
|
<option value="">연도</option>
|
||||||
|
<?php for ($yy = $ymin; $yy <= $ymax; $yy++): ?>
|
||||||
|
<option value="<?= $yy ?>" <?= (string) ($ep['y'] ?? '') === (string) $yy ? 'selected' : '' ?>><?= $yy ?></option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<select name="end_m" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
|
||||||
|
<option value="">월</option>
|
||||||
|
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
|
||||||
|
<option value="<?= $mi ?>" <?= isset($ep['m']) && (int) $ep['m'] === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<select name="end_d" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
|
||||||
|
<option value="">일</option>
|
||||||
|
<?php for ($di = 1; $di <= 31; $di++): ?>
|
||||||
|
<option value="<?= $di ?>" <?= isset($ep['d']) && (int) $ep['d'] === $di ? 'selected' : '' ?>><?= $di ?>일</option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 pb-0.5">
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
|
||||||
|
<a href="<?= mgmt_url('bag-prices') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||||
|
<button type="button" onclick="window.print()" class="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
<div class="border border-gray-300 overflow-auto mt-2">
|
||||||
@@ -27,13 +107,21 @@
|
|||||||
<th>적용시작</th>
|
<th>적용시작</th>
|
||||||
<th>적용종료</th>
|
<th>적용종료</th>
|
||||||
<th class="w-20">상태</th>
|
<th class="w-20">상태</th>
|
||||||
<th class="w-36">작업</th>
|
<th class="w-36 no-print">작업</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="text-right">
|
<tbody class="text-right">
|
||||||
<?php foreach ($list as $row): ?>
|
<?php
|
||||||
|
$startNo = 1;
|
||||||
|
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
|
||||||
|
$currentPage = (int) $pager->getCurrentPage();
|
||||||
|
$perPage = (int) $pager->getPerPage();
|
||||||
|
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?php foreach (($list ?? []) as $idx => $row): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><?= esc($row->bp_idx) ?></td>
|
<td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
|
||||||
<td class="text-center font-mono"><?= esc($row->bp_bag_code) ?></td>
|
<td class="text-center font-mono"><?= esc($row->bp_bag_code) ?></td>
|
||||||
<td class="text-left pl-2"><?= esc($row->bp_bag_name) ?></td>
|
<td class="text-left pl-2"><?= esc($row->bp_bag_name) ?></td>
|
||||||
<td><?= number_format((float) $row->bp_order_price) ?></td>
|
<td><?= number_format((float) $row->bp_order_price) ?></td>
|
||||||
@@ -42,10 +130,10 @@
|
|||||||
<td class="text-center"><?= esc($row->bp_start_date) ?></td>
|
<td class="text-center"><?= esc($row->bp_start_date) ?></td>
|
||||||
<td class="text-center"><?= esc($row->bp_end_date ?? '현재') ?></td>
|
<td class="text-center"><?= esc($row->bp_end_date ?? '현재') ?></td>
|
||||||
<td class="text-center"><?= (int) $row->bp_state === 1 ? '사용' : '미사용' ?></td>
|
<td class="text-center"><?= (int) $row->bp_state === 1 ? '사용' : '미사용' ?></td>
|
||||||
<td class="text-center">
|
<td class="text-center no-print">
|
||||||
<a href="<?= base_url('admin/bag-prices/history/' . (int) $row->bp_idx) ?>" class="text-green-600 hover:underline text-sm mr-1">이력</a>
|
<a href="<?= mgmt_url('bag-prices/history/' . (int) $row->bp_idx) ?>" class="text-green-600 hover:underline text-sm mr-1">이력</a>
|
||||||
<a href="<?= base_url('admin/bag-prices/edit/' . (int) $row->bp_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
|
<a href="<?= mgmt_url('bag-prices/edit/' . (int) $row->bp_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
|
||||||
<form action="<?= base_url('admin/bag-prices/delete/' . (int) $row->bp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
|
<form action="<?= mgmt_url('bag-prices/delete/' . (int) $row->bp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -58,3 +146,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3 no-print"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<span class="text-sm font-bold text-gray-700">입고 처리</span>
|
<span class="text-sm font-bold text-gray-700">입고 처리</span>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||||
<form action="<?= base_url('admin/bag-receivings/store') ?>" method="POST" class="space-y-4">
|
<form action="<?= mgmt_url('bag-receivings/store') ?>" method="POST" class="space-y-4">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
||||||
<a href="<?= base_url('admin/bag-receivings') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= mgmt_url('bag-receivings') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '입고 현황']) ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">입고 현황</span>
|
<span class="text-sm font-bold text-gray-700">입고 현황</span>
|
||||||
<a href="<?= base_url('admin/bag-receivings/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">입고 처리</a>
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
<a href="<?= mgmt_url('bag-receivings/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">입고 처리</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="p-2 bg-white border-b border-gray-200">
|
<section class="p-2 bg-white border-b border-gray-200">
|
||||||
<form method="GET" action="<?= base_url('admin/bag-receivings') ?>" class="flex flex-wrap items-center gap-2">
|
<form method="GET" action="<?= mgmt_url('bag-receivings') ?>" class="flex flex-wrap items-center gap-2">
|
||||||
<label class="text-sm text-gray-600">입고일</label>
|
<label class="text-sm text-gray-600">입고일</label>
|
||||||
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
<label class="text-sm text-gray-600">~</label>
|
<label class="text-sm text-gray-600">~</label>
|
||||||
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
||||||
<a href="<?= base_url('admin/bag-receivings') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
<a href="<?= mgmt_url('bag-receivings') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
<div class="border border-gray-300 overflow-auto mt-2">
|
||||||
@@ -47,3 +51,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<span class="text-sm font-bold text-gray-700">판매 등록</span>
|
<span class="text-sm font-bold text-gray-700">판매 등록</span>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||||
<form action="<?= base_url('admin/bag-sales/store') ?>" method="POST" class="space-y-4">
|
<form action="<?= mgmt_url('bag-sales/store') ?>" method="POST" class="space-y-4">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
||||||
<a href="<?= base_url('admin/bag-sales') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= mgmt_url('bag-sales') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
<?= view('components/print_header', ['printTitle' => '판매/반품 관리']) ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700">판매/반품 관리</span>
|
<span class="text-sm font-bold text-gray-700">판매/반품 관리</span>
|
||||||
<a href="<?= base_url('admin/bag-sales/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">판매 등록</a>
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="<?= mgmt_url('bag-sales/export') . '?' . http_build_query(array_filter(['start_date' => $startDate ?? '', 'end_date' => $endDate ?? '', 'type' => $type ?? ''])) ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
|
||||||
|
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||||
|
<a href="<?= mgmt_url('bag-sales/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">판매 등록</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="p-2 bg-white border-b border-gray-200">
|
<section class="p-2 bg-white border-b border-gray-200">
|
||||||
<form method="GET" action="<?= base_url('admin/bag-sales') ?>" class="flex flex-wrap items-center gap-2">
|
<form method="GET" action="<?= mgmt_url('bag-sales') ?>" class="flex flex-wrap items-center gap-2">
|
||||||
<label class="text-sm text-gray-600">판매일</label>
|
<label class="text-sm text-gray-600">판매일</label>
|
||||||
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
<label class="text-sm text-gray-600">~</label>
|
<label class="text-sm text-gray-600">~</label>
|
||||||
@@ -18,7 +23,7 @@
|
|||||||
<option value="cancel" <?= ($type ?? '') === 'cancel' ? 'selected' : '' ?>>취소</option>
|
<option value="cancel" <?= ($type ?? '') === 'cancel' ? 'selected' : '' ?>>취소</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
||||||
<a href="<?= base_url('admin/bag-sales') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
<a href="<?= mgmt_url('bag-sales') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
<div class="border border-gray-300 overflow-auto mt-2">
|
||||||
@@ -61,3 +66,4 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx) ?>" class="text-blue-600 hover:underline text-sm">← <?= esc($kind->ck_name) ?></a>
|
<a href="<?= base_url('bag/code-details/' . (int) $kind->ck_idx) ?>" class="text-blue-600 hover:underline text-sm">← <?= esc($kind->ck_name) ?></a>
|
||||||
<span class="text-gray-400">|</span>
|
<span class="text-gray-400">|</span>
|
||||||
<span class="text-sm font-bold text-gray-700">세부코드 등록</span>
|
<span class="text-sm font-bold text-gray-700">세부코드 등록</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,9 +30,42 @@
|
|||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-24" name="cd_sort" type="number" value="<?= esc(old('cd_sort', '0')) ?>" min="0"/>
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-24" name="cd_sort" type="number" value="<?= esc(old('cd_sort', '0')) ?>" min="0"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (! empty($canPlatformScope)): ?>
|
||||||
|
<div class="space-y-2 border-t border-gray-200 pt-3">
|
||||||
|
<p class="text-sm font-bold text-gray-700">등록 범위</p>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="radio" name="cd_scope" value="platform" <?= old('cd_scope', 'platform') === 'platform' ? 'checked' : '' ?> class="cd-scope-radio"/>
|
||||||
|
플랫폼 공통 (CSV·시드와 동일 — 전 지자체, super/본부만 이후 수정)
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="radio" name="cd_scope" value="local" <?= old('cd_scope') === 'local' ? 'checked' : '' ?> class="cd-scope-radio"/>
|
||||||
|
지자체 전용 (해당 지자체 관리자가 수정·삭제)
|
||||||
|
</label>
|
||||||
|
<div id="cd-lg-wrap" class="<?= old('cd_scope') === 'local' ? '' : 'hidden' ?> flex flex-wrap items-center gap-2">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 w-28">소속 지자체</label>
|
||||||
|
<select name="cd_lg_idx" class="border border-gray-300 rounded px-3 py-1.5 text-sm min-w-[14rem]">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<?php foreach ($localGovernments as $gov): ?>
|
||||||
|
<option value="<?= (int) $gov->lg_idx ?>" <?= (string) old('cd_lg_idx') === (string) $gov->lg_idx ? 'selected' : '' ?>><?= esc($gov->lg_name) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.cd-scope-radio').forEach(function (r) {
|
||||||
|
r.addEventListener('change', function () {
|
||||||
|
var w = document.getElementById('cd-lg-wrap');
|
||||||
|
if (w) w.classList.toggle('hidden', document.querySelector('input[name="cd_scope"][value="local"]:checked') === null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
||||||
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx) ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= base_url('bag/code-details/' . (int) $kind->ck_idx) ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx) ?>" class="text-blue-600 hover:underline text-sm">← <?= esc($kind->ck_name) ?></a>
|
<a href="<?= base_url('bag/code-details/' . (int) $kind->ck_idx) ?>" class="text-blue-600 hover:underline text-sm">← <?= esc($kind->ck_name) ?></a>
|
||||||
<span class="text-gray-400">|</span>
|
<span class="text-gray-400">|</span>
|
||||||
<span class="text-sm font-bold text-gray-700">세부코드 수정</span>
|
<span class="text-sm font-bold text-gray-700">세부코드 수정</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
|
||||||
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx) ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= base_url('bag/code-details/' . (int) $kind->ck_idx) ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a href="<?= base_url('admin/code-kinds') ?>" class="text-blue-600 hover:underline text-sm">← 코드 종류</a>
|
|
||||||
<span class="text-gray-400">|</span>
|
|
||||||
<span class="text-sm font-bold text-gray-700">세부코드 — <?= esc($kind->ck_name) ?> (<?= esc($kind->ck_code) ?>)</span>
|
|
||||||
</div>
|
|
||||||
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx . '/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">세부코드 등록</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div class="border border-gray-300 overflow-auto mt-2">
|
|
||||||
<table class="w-full data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="w-16">번호</th>
|
|
||||||
<th class="w-24">코드</th>
|
|
||||||
<th>코드명</th>
|
|
||||||
<th class="w-20">정렬순서</th>
|
|
||||||
<th class="w-20">상태</th>
|
|
||||||
<th>등록일</th>
|
|
||||||
<th class="w-28">작업</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="text-right">
|
|
||||||
<?php foreach ($list as $row): ?>
|
|
||||||
<tr>
|
|
||||||
<td class="text-center"><?= esc($row->cd_idx) ?></td>
|
|
||||||
<td class="text-center font-mono"><?= esc($row->cd_code) ?></td>
|
|
||||||
<td class="text-left pl-2"><?= esc($row->cd_name) ?></td>
|
|
||||||
<td class="text-center"><?= (int) $row->cd_sort ?></td>
|
|
||||||
<td class="text-center"><?= (int) $row->cd_state === 1 ? '사용' : '미사용' ?></td>
|
|
||||||
<td class="text-left pl-2"><?= esc($row->cd_regdate ?? '') ?></td>
|
|
||||||
<td class="text-center">
|
|
||||||
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a>
|
|
||||||
<form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
|
||||||
<a href="<?= base_url('admin/code-kinds') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= base_url('bag/code-kinds') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
|
||||||
<a href="<?= base_url('admin/code-kinds') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
<a href="<?= base_url('bag/code-kinds') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user