51 Commits

Author SHA1 Message Date
taekyoungc
912ffdbe23 feat: 워크스페이스 분할 보기(2·4분할) + 구분선 드래그 크기 조절
- 탭바에 분할 레이아웃 버튼 추가: 1분할 / 2분할(좌우) / 2분할(상하) / 4분할
  - iframe reparent 없이 absolute 위치만 재계산해 작업 상태 보존
  - 포커스된 칸에 탭 클릭으로 화면 배치, 칸 헤더(↻ 새로고침 · × 비우기)
  - 칸 안 클릭 시 해당 칸 포커스
- 분할 구분선 드래그로 칸 크기(비율) 조절, 더블클릭 50% 초기화
  - 드래그 중 투명 오버레이로 iframe 위에서도 이벤트 유지
  - 비율 12~88% 제한
- 레이아웃·칸 배치·비율을 세션에 저장/복원(계정별 격리 유지)
- 단축키를 포커스 칸 기준으로 동작하도록 정리
- 매뉴얼: [화면 구성·워크스페이스] 에 분할 보기·크기 조절 절 추가, 개요 안내 보강
- e2e: 분할 보기(2·4분할 전환) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:19:37 +09:00
taekyoungc
4d9343e980 feat: GBLS 리브랜딩 + 매뉴얼 보강 + 워크스페이스/코드관리 UX 개선
리브랜딩
- 서비스명 "종량제 시스템" → "GBLS", 헤더 로고에 풀네임(Garbage Bag Logistics System) 병기
  (gov-portal·공통 브랜드·로그인/welcome 셸·타이틀·푸터 전반)

매뉴얼
- 신규 페이지 [로그인·회원가입·계정](01_account.md): 가입 항목·관리자 승인·2차 인증 흐름
- [화면 구성·워크스페이스·단축키]에 계정 전환 시 탭 초기화 안내 추가

워크스페이스(탭)
- 탭 전환 시 좌측 사이드바 강조 동기화(메뉴 없는 화면은 강조 해제, 경로 폴백 매칭)
- 소메뉴 좌측 아이콘(▸/·) 전부 제거 — 활성 메뉴는 배경 강조로만 구분
- 탭을 사용자(mb_idx)별로 격리: 다른 아이디 로그인 시 이전 탭 복원 안 함
- 사이드바 FAQ 링크 제거(자주 묻는 질문은 매뉴얼에 통합)

기본 코드관리 화면
- 업무현황 카드 스타일로 재디자인(가벼운 표·상태/범위 pill·단일 구분선)
- render()에 $bare 옵션 추가 → 이미 카드형인 화면은 바깥 래퍼 생략

기타
- .claude/settings.local.json(개인 권한 설정) .gitignore 추가
- e2e: 워크스페이스(동기화·계정격리) + 매뉴얼(계정·단축키·검색) 케이스 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:43:24 +09:00
taekyoungc
7e32f579e8 feat: 워크스페이스 편의 개선 + 매뉴얼에 화면구성·단축키 페이지 추가
워크스페이스(탭)
- 탭 전환 시 좌측 사이드바(대메뉴/소메뉴) 강조 자동 동기화
  - nav 스크립트에 window.govPortalNav.syncByUrl() 공개, renderSidebar(overrideHref) 확장
- 키보드 단축키(Alt 기반): Alt+1~9 탭 이동, Alt+W 닫기, Alt+[ / Alt+] 이전·다음
  - iframe 내부 포커스에서도 동작하도록 같은 출처 문서에 핸들러 부착
- 탭 가운데(휠) 클릭으로 닫기, 잘린 탭 제목 전체 툴팁

매뉴얼
- 신규 페이지 [화면 구성·워크스페이스·단축키] (05_workspace.md, 목차 2번째)
  - 화면 구성, 탭 사용법·유지 범위, 단축키 표, 이동/도움말 안내
- 개요 페이지에서 새 페이지로 안내

e2e: 워크스페이스(사이드바 동기화·가운데클릭) + 매뉴얼(새 페이지·단축키·검색) 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:09:25 +09:00
taekyoungc
1a443de02e feat: 매뉴얼 검색·소메뉴 아이콘 개선·워크스페이스 탭 세션 유지
- 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가
  - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트
- 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관)
- 워크스페이스: 탭 목록을 sessionStorage에 저장·복원
  - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위)
  - 복원으로 무의미해진 beforeunload 새로고침 경고 제거
- e2e: 관리자 이동 후 탭 복원 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:52:53 +09:00
taekyoungc
e8d58b5837 화면별 매뉴얼·이 화면 설명 버튼·탭 새로고침/경고·최근방문 기록 보강.
- 매뉴얼: 화면(소메뉴)별 용어·버튼·필드 설명으로 확장 + 기본정보 페이지 신규,
  개요에 용어 사전 추가 (종량제 지식 없는 사용자 대상)
- "이 화면 설명" 버튼: 화면 경로→매뉴얼 매핑(Config\Manual::screenHelp,
  manual_help_url_for_path). 워크스페이스 탭은 새 탭으로, 직접 페이지는 새 창으로
- 워크스페이스: 개별 탭 새로고침(↻) 버튼, 탭 2개 이상일 때만 새로고침 경고,
  사이드바 하단 링크(매뉴얼 등)도 탭으로 열기
- 임베드: 탭 내 링크/폼 embed 유지(중첩 헤더 방지), 매뉴얼 리다이렉트 embed 유지
- 사이드바 하단: 종합그래프 → 사용자 매뉴얼 링크
- 최근 방문 메뉴: embed 페이지에도 방문 기록, 대시보드는 storage 이벤트로 실시간 갱신
- E2E qa_sweep 추가(주요 화면 콘솔/오버레이/매뉴얼/도움말 매핑 점검)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:04:41 +09:00
taekyoungc
c15e01bfa7 워크스페이스(탭) 도입 — 로그인 후 기본 화면을 탭 작업공간으로.
- /workspace: 헤더+사이드바+탭바+iframe 패널. 메뉴 클릭=탭 열기,
  전환해도 폼·스크롤·조회결과 등 작업 상태 유지(세션 동안)
- 로그인 후 / = 워크스페이스(첫 탭=대시보드). iframe 내부는 임베드 렌더
- 임베드 레이아웃(bag/layout/embed): 헤더·사이드바 없이 본문만
- 임베드 판정: ?embed=1 또는 Sec-Fetch-Dest=iframe (iframe 내 링크·폼·
  리다이렉트까지 중첩 크롬 없이 처리)
- iframe 안 세션만료 시 상위 창을 로그인으로 전환(auth/_shell)
- 포털 헤더에 워크스페이스 진입 링크, E2E(workspace.spec) 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:32:53 +09:00
taekyoungc
600a79788e 카카오 지오코딩 호출을 try/catch로 감싼다.
- 외부(카카오) SDK 예외가 미처리 예외로 전파되어 콘솔 오염·
  DevTools "예외에서 일시중지"로 화면이 멈추는 것을 방지
- geocodeChain 호출 및 마커 생성 콜백을 방어적으로 보호

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:53:02 +09:00
taekyoungc
6b1c118651 bfcache 복원 시 모달이 열린 채 남아 화면을 덮는 문제를 고친다.
- 전체화면 모달/팝업(.fixed.inset-0[id$=-modal|-popup])이 열린 상태로
  bfcache(뒤로가기/탭 복귀)에 저장·복원되면 회색 레이어가 클릭을 막던 문제
- pagehide(이탈 시)·pageshow(복원 시) 에서 해당 오버레이를 강제로 닫고
  body 스크롤 잠금 해제
- portal·admin·main 레이아웃에 공통 적용(모든 페이지 커버)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:26:19 +09:00
taekyoungc
abc8a405e1 메인 대시보드에 지정판매소 지도·메뉴검색을 추가한다.
- 카카오 지도(지도 2/3 + 판매소 목록 1/3, 높이 고정·스크롤), 목록 클릭 시 줌인
- 지오코딩 폴백(정밀→도로명→지번→키워드→행정동)으로 마커 표시
- 메뉴검색: 자동완성 드롭다운 + 기본 "최근 방문 메뉴"(localStorage, 뒤로가기/bfcache 갱신)
- 메뉴검색 박스 녹색(#009688), 지도와 높이 일치
- resolveLgLabel: 선택 지자체 실제 이름 사용, '(데모)' 문구 제거

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:10:54 +09:00
taekyoungc
ec3119799c gov-portal 디자인을 시스템 전체에 적용한다.
- 사이트 업무 페이지: 공통 셸 bag/layout/portal(헤더+대메뉴 클릭+좌측 사이드바 소메뉴)
- 관리자 페이지: admin/layout 을 동일 포털 셸로 재작성(관리자 메뉴 트리, 폴백)
- 메인(/): gov-portal 대시보드, 종량제 실데이터만(재고/주문/승인/활동로그)
- 로그인/회원가입/2차인증/TOTP: 공통 auth/_shell 로 통일, 사이트 공통 로고
- 버튼색 통일: btn-search 등 주요 버튼을 #243a5e(메뉴바 네이비보다 살짝 밝게),
  밝은 파랑 채움 버튼(#2b4c8c/#1e548a)도 동일 색으로
- gov_portal_nav_context() 임의 메뉴 트리 수용, 업무 셸은 실제 bag/* 링크 유지
- Admin\Menu 권한거부 리다이렉트 admin/dashboard(404) → admin 수정
- E2E redesign.spec.js 추가, 기능 무변경

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:47:36 +09:00
taekyoungc
707182ad2d 운영 PHP 8.2와 호환되도록 의존성 잠금을 정정한다.
- config.platform.php=8.2.30 고정 — 로컬(8.3)에서 8.3 전용 버전이 잠기는 문제 방지
- maennchen/zipstream-php 3.2.2(php^8.3) → 3.1.2(php^8.1)로 재잠금
- league/commonmark 유지, 운영 서버 composer install 가능하도록 정정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 01:18:48 +09:00
taekyoungc
8763876f19 사용자 매뉴얼·번호알기·gov-portal 대시보드와 메뉴 동선·수불 리포트를 보강한다.
- 사용자 매뉴얼: league/commonmark 기반 bag/manual(로그인 전용),
  ManualRenderer + Config\Manual manifest, 콘텐츠 8종, E2E
- 번호알기(봉투번호확인): bag/number-lookup, BagNumberLookup, E2E
- gov-portal 대시보드 시안(기본/strip)·기본코드관리 화면
- 메뉴 관리: 등록·수정 후 메뉴 화면 유지, 수정 버튼 클릭 시 상단 스크롤
- 수불/분석 리포트(LOT 수불·반품/파기·수급계획·추이) 표시 보강
- .gitignore: docs/ → /docs/ 앵커링(최상위 개발문서만 제외, app/Docs는 추적)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:46:51 +09:00
taekyoungc
0f1d414f37 사이트·관리자 봉투 물류 기능(수불·통계·레포트·재고·발주)과 DB·메뉴·E2E를 운영 반영한다.
통계 분석(전년대비·월별·계절별), 수급계획·LOT 수불, 지정판매소·실사·메뉴 링크 등을 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 16:15:15 +09:00
taekyoungc
21e7b91871 실사/불출/lot-seed 관련 사이트 라우트를 운영 반영한다.
운영 404를 유발한 inspection-select 및 inspection-work 라우트 누락을 포함해 사이트 경로 변경분을 Routes.php에 반영한다.

Made-with: Cursor
2026-04-29 15:15:48 +09:00
taekyoungc
c708d30660 실사 저장 안정화와 메뉴 운영 정책을 일관되게 반영한다.
실사 저장값이 페이지 이동 후 원복되지 않도록 저장/조회 경로를 보강하고, 코드 범위 보정과 bis 간 동기화를 추가했다. 또한 메뉴 관리를 레벨4 이상으로 제한하고 메뉴 변경 사항을 모든 지자체에 일괄 반영하도록 동기화 로직을 도입했다.

Made-with: Cursor
2026-04-29 14:59:49 +09:00
taekyoungc
215d4d2c7c 발주 변경·입고 화면을 사이트 흐름에 맞게 반영한다.
발주 등록/변경 및 스캐너·일괄·입고현황 화면의 라우팅과 화면 구성을 운영과 동일한 최신 형태로 정리한다.

Made-with: Cursor
2026-04-23 15:53:33 +09:00
taekyoungc
6db9d119c1 발주 최신 헤드 조회 메서드 누락을 복구한다.
/bag/order/create 경로에서 발생한 undefined method(whereLatestHead) 오류를 막기 위해 BagOrderModel에 조회 스코프를 추가한다.

Made-with: Cursor
2026-04-23 15:43:19 +09:00
taekyoungc
5c89c963ee 단가 기간이 겹칠 때 최신 등록 단가를 우선 적용한다.
단가 조회 공통 로직을 모델로 통합하고 발주·판매·주문·사이트 화면의 단가 계산이 모두 최신 등록 순서(bp_regdate, bp_idx DESC)를 따르도록 맞춘다.

Made-with: Cursor
2026-04-22 15:35:36 +09:00
taekyoungc
05c479397b 업체·담당자·단가·지정판매소 관리 화면의 조회 및 표시를 개선한다.
관리 화면에서 유형별 조회와 순번 표기를 통일하고, 지정판매소 주소/구군 표시와 포장단위 이력 표현을 사용자 관점으로 정리한다.

Made-with: Cursor
2026-04-22 15:35:28 +09:00
taekyoungc
647d5f919d 지정판매소 주소·지도 연동과 관련 설정을 반영
지정판매소 등록/수정/목록에 카카오 주소 검색 및 지도 연동 컴포넌트를 적용하고, 관련 모델·SQL 스크립트·테스트 설정을 함께 정리해 기능 동작 기반을 맞췄다.

Made-with: Cursor
2026-04-14 14:55:12 +09:00
taekyoungc
0b4c622b99 기본코드관리 2분할 조회와 무료용 목록 컬럼 정리
기본코드관리에서 코드종류 선택 시 같은 화면 우측에 세부코드가 즉시 보이도록 2분할 UI로 전환하고, 무료용 대상자 목록의 불필요한 구분 컬럼을 숨겨 화면 구성을 단순화했다.

Made-with: Cursor
2026-04-14 14:49:15 +09:00
taekyoungc
40db578e85 지정판매소 소메뉴 활성 상태를 단일 선택으로 보정
지정판매소 관련 형제 소메뉴가 동시에 활성화되던 문제를 해결하고, bag/admin 레이아웃 모두에서 현재 경로 기준으로 가장 구체적인 하위 메뉴 하나만 활성화되도록 통일했다.

Made-with: Cursor
2026-04-14 11:59:33 +09:00
taekyoungc
5d733ac0d8 Revert "운영 메뉴에서 지정판매소 활성 상태가 중복되지 않도록 보정"
This reverts commit 48e5578611.
2026-04-14 00:41:14 +09:00
taekyoungc
2629644f90 Revert "운영 Whoops 방지를 위해 메뉴 활성 계산 의존성을 단순화"
This reverts commit c8d1612f0e.
2026-04-14 00:41:14 +09:00
taekyoungc
c8d1612f0e 운영 Whoops 방지를 위해 메뉴 활성 계산 의존성을 단순화
레이아웃에서 내부 헬퍼 함수를 직접 호출하지 않고 공개 메뉴 매칭 함수만 사용하도록 변경해 운영 환경 차이에 따른 오류 가능성을 줄였습니다.
2026-04-14 00:38:51 +09:00
taekyoungc
48e5578611 운영 메뉴에서 지정판매소 활성 상태가 중복되지 않도록 보정
상단 메뉴 활성 판정을 최장 경로 1건 우선으로 통일해 조회 화면에서 관리 메뉴가 함께 활성화되는 문제를 막았습니다.
2026-04-14 00:33:24 +09:00
taekyoungc
078fa5d0c2 운영 URL에서도 bag 화면은 사이트 메뉴 레이아웃을 사용하도록 수정
요청 경로를 정규화해 bag 접두를 판별하도록 변경하고 지정판매소 경로의 관리자 레이아웃 강제 분기를 제거했습니다.
2026-04-14 00:28:02 +09:00
taekyoungc
734a55833b 지정판매소 현황·바코드 출력 기능을 전용 화면으로 확장
지정판매소 신규/취소 현황을 사용자 지자체 기준으로 고정 조회하도록 정리하고, 동별 요약과 컬럼 설명 툴팁을 추가했습니다.
또한 지정판매소 바코드 출력 메뉴를 전용 URL로 분리하고 선택 인쇄/출력 레이아웃을 GBMS 형태에 맞춰 구현했습니다.
2026-04-14 00:14:53 +09:00
taekyoungc
72578f200c chore: remove temporary db_diag from packaging units
Drop the public db diagnostic panel now that the runtime DB endpoint was identified.
2026-04-09 13:01:31 +09:00
taekyoungc
8e859f420d chore: show runtime DB server identity in db_diag
Add config and server-level DB identity fields (host/port/user, @@hostname/@@port/@@version) to packaging-units db_diag so production can verify the exact runtime DB endpoint.
2026-04-09 12:54:35 +09:00
taekyoungc
cd2d41b3d7 chore: add db diagnostic mode on packaging units page
Expose a temporary db_diag=1 view for /bag/packaging-units so we can verify runtime DB connectivity and required table counts directly on production.
2026-04-09 12:48:37 +09:00
taekyoungc
f22b1480a3 fix: add runtime logging for code-kind lookup failures
Capture detailed runtime context when /bag/code-kinds and /bag/code-details fail so production logs reveal the exact exception source and request/session scope.
2026-04-09 12:48:37 +09:00
taekyoungc
7580c31ab0 fix: restore site nav rendering with menu type fallback
Fallback to legacy site mt_idx=4 when site menu type mapping is inconsistent or missing so top navigation renders on trash.wxn.co.kr.

Made-with: Cursor
2026-04-08 17:31:55 +09:00
taekyoungc
6fddf15000 fix: keep selected menu type while applying site fallback
Preserve the selected site mt_idx in the UI and use a separate effective mt_idx for data fallback, so choosing site no longer appears as admin.

Made-with: Cursor
2026-04-08 17:29:40 +09:00
taekyoungc
b99c108aeb fix: fallback site menu mt_idx when mapping is inconsistent
When site menu resolves to an id with no rows, retry with legacy site mt_idx=4 and surface fallback state in debug output.

Made-with: Cursor
2026-04-08 17:26:04 +09:00
taekyoungc
f68f135446 chore: add temporary menu debug diagnostics
Show current lg_idx and resolved menu type values on admin menu page when debug=1 to diagnose empty menu rendering.

Made-with: Cursor
2026-04-08 17:23:10 +09:00
taekyoungc
0d512bd21d fix: normalize legacy menu type query parameter
Map legacy /admin/menus?mt_idx=2 requests to the actual site menu type id and apply the same normalization to JSON list responses.

Made-with: Cursor
2026-04-08 17:14:19 +09:00
taekyoungc
12cd052c40 chore: revert logo label suffix
Remove the temporary "1" suffix from the header brand label and title.

Made-with: Cursor
2026-04-08 15:52:47 +09:00
taekyoungc
aaf7b4c66e chore: update dashboard logo label text
Append "1" to the bag dashboard logo-adjacent "종량제 시스템" label across dashboard variants for consistent UI wording.

Made-with: Cursor
2026-04-08 15:51:08 +09:00
javamon1174
d551dfa87e .gitignore에 deploy.log 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:43:10 +09:00
taekyoungc
84026f8072 feat: add sales agency list search filters
판매 대행소 관리 목록에 번호·구분·코드·명 조회 조건을 추가하고 페이징에서도 검색 조건이 유지되도록 반영한다.

Made-with: Cursor
2026-04-08 11:59:00 +09:00
taekyoungc
1a8d4bb9da chore: untrack docs directory 2026-04-08 00:33:05 +09:00
taekyoungc
06aa401048 docs: add project docs and test updates 2026-04-08 00:23:55 +09:00
taekyoungc
06fedc866a feat: update auth and security flow 2026-04-08 00:23:21 +09:00
taekyoungc
b5eed31b94 feat: improve bag price and packaging unit management 2026-04-08 00:21:53 +09:00
taekyoungc
c2dc2fd38a feat: enhance order sales inventory workflows 2026-04-08 00:20:09 +09:00
taekyoungc
984ddb403e feat: improve admin master data management 2026-04-08 00:19:00 +09:00
taekyoungc
89f80edc5d refactor: unify bag and admin layout routing 2026-04-08 00:18:01 +09:00
taekyoungc
5b0c3fac97 Add tester_badmin account with role and level 2026-04-07 15:09:46 +09:00
taekyoungc
c4d30b204b docs: README 기본코드(코드 관리) URL·권한·SQL 보강
- bag 목록(/bag/code-kinds, code-details) vs admin CRUD 분리, loginAuth·canManageCodeMaster
- 선택 실행 SQL·CSV 동기화 도구, 보안·E2E·스크립트 목록 정리

Made-with: Cursor
2026-03-30 15:26:31 +09:00
taekyoungc
ab40a90f69 feat: 기본코드 bag 목록과 관리자 CRUD 분리
- /bag/code-kinds, /bag/code-details/{ck_idx} 조회 (LoginAuthFilter, Roles::canManageCodeMaster)
- admin에서는 종류·세부 목록 제거, 등록·수정·삭제만 유지 후 bag으로 리다이렉트
- 사이트 메뉴·기본코드 링크 SQL, CSV 동기화 스크립트·README 보강
- 관리자 대시보드: 발주·판매 테이블 미존재 시 통계 비활성화
- 회원 로그인 잠금(mb_login_fail_count, mb_locked_until) 및 관리자 잠금 해제

Made-with: Cursor
2026-03-30 15:07:09 +09:00
316 changed files with 43238 additions and 4525 deletions

View 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
View File

@@ -102,6 +102,7 @@ writable/debugbar/*
!writable/debugbar/index.html
php_errors.log
deploy.log
#-------------------------
# User Guide Temp Files
@@ -173,3 +174,8 @@ blob-report/
/results/
/phpunit*.xml
# 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
/docs/
# Claude Code 개인 권한 설정(비밀 포함) — 커밋 금지
.claude/settings.local.json

View File

@@ -73,6 +73,8 @@ assets/ # 기획 문서 (엑셀)
php spark serve --port=8045
```
- 로컬에서 **Apache + 여러 VirtualHost + PHP-FPM**을 쓰면 `localhost`·포트·FPM(예: 9001) 설정에 따라 500/503이 나기 쉽습니다. **일상 개발은 위 내장 서버를 기본**으로 두고, `.env``app.baseURL``http://localhost:8045/` 등과 맞추는 것을 권장합니다.
## 테스트 (Playwright E2E)
모든 작업 완료 후 반드시 Playwright 브라우저 테스트를 수행합니다.

112
README.md
View File

@@ -39,7 +39,7 @@ app/
├── Config/ # Routes, Database, Roles, Filters, Session 등 (45개)
├── Controllers/ # 28개 컨트롤러
│ ├── Auth.php # 로그인/로그아웃/회원가입
│ ├── Bag.php # 사이트 메뉴 페이지 (10개 메뉴)
│ ├── Bag.php # 사이트 메뉴 페이지 (기본정보·기본코드 목록 등)
│ ├── Home.php # 홈/대시보드
│ └── Admin/ # 관리자 컨트롤러 24개
│ ├── BagOrder.php # 발주 관리
@@ -69,7 +69,7 @@ app/
│ ├── bag/ # 사이트 메뉴 뷰 (17개 + 레이아웃)
│ ├── auth/ # 로그인/회원가입 (2개)
│ └── home/ # 대시보드 (1개)
├── Filters/ # AdminAuthFilter (관리자 접근 제어)
├── Filters/ # AdminAuthFilter, LoginAuthFilter (`/bag/code-kinds` 등)
├── Helpers/ # admin_helper, pii_encryption_helper
└── Database/ # Migrations, Seeds
public/ # 웹 루트
@@ -160,6 +160,7 @@ assets/ # 기획 문서 (엑셀)
- 역할 상수: `Config\Roles` -- `LEVEL_SUPER_ADMIN(4)`, `LEVEL_LOCAL_ADMIN(3)`, `LEVEL_SHOP(2)`, `LEVEL_CITIZEN(1)`
- `AdminAuthFilter`가 로그인 + 레벨 3/4 + 지자체 선택 여부 검증
- **기본코드 마스터 CRUD**는 `Roles::canManageCodeMaster()`(지자체관리자·Super Admin 등)로 제한. **종류·세부 목록 조회**는 로그인 사용자 전원 (`/bag/code-kinds`, `/bag/code-details/{ck_idx}`, `loginAuth` 필터)
## 멀티테넌시
@@ -185,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/order/create` | 발주 등록 | 발주서 신규 작성 |
| `/bag/order/change` | 발주 변경 | 발주 변경 목록/수정 진입 |
| `/bag/order/revise/{bo_idx}` | 발주 수정 | 선택 발주 수정 화면 |
| `/bag/order/lot-seed` | LOT-No 디스켓 불출 | 발주 LOT 기준 seed 생성/다운로드 |
| `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 |
| `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 |
| `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 |
| `/bag/order/phone` | 전화 주문 접수 | 전화 주문 접수표 작성/저장 |
| `/bag/order/phone/manage` | 전화 주문 접수 관리 | 접수 리스트 선택 후 품목 수량 수정/취소 |
| `/bag/sale/designated` | 지정판매소 판매 | 주문 선택 + 바코드 스캔 + 판매 저장 |
| `/bag/receiving/batch` | 일괄 입고 | 미입고 건 선택 일괄 입고 |
| `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 |
| `/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/help` | 도움말 | 시스템 안내 |
@@ -210,46 +224,62 @@ assets/ # 기획 문서 (엑셀)
| `/admin/menus/*` | 메뉴 관리 (트리 CRUD) |
| `/admin/local-governments/*` | 지자체 관리 (CRUD) |
| `/admin/select-local-government` | 작업 지자체 선택 (Super Admin) |
| `/admin/password-change` | 비밀번호 변경 |
| `/admin/designated-shops/*` | 지정판매소 관리 (CRUD) |
**기본정보관리 (Phase 2)**
**기본코드 CRUD만 관리자 경로 (목록·조회는 `/bag/*`)**
| 경로 | 기능 |
|------|------|
| `/admin/code-kinds/*` | 기본코드 종류 (CRUD) |
| `/admin/code-details/*` | 세부코드 (CRUD) |
| `/admin/bag-prices/*` | 봉투 단가 (CRUD + 이력) |
| `/admin/packaging-units/*` | 포장 단위 (CRUD + 이력) |
| `/admin/sales-agencies/*` | 판매 대행소 (CRUD) |
| `/admin/managers/*` | 담당자 (CRUD) |
| `/admin/companies/*` | 업체 (CRUD) |
| `/admin/free-recipients/*` | 무료용 대상자 (CRUD) |
| `/admin/code-kinds/*` | 기본코드 종류 **CRUD** (create/edit/store/update/delete; **목록 없음** — 조회는 `/bag/code-kinds`) |
| `/admin/code-details/*` | 세부코드 **CRUD** (**목록 없음** — 조회는 `/bag/code-details/{ck_idx}`) |
| (호환) `GET /admin/code-details/{ck_idx}` | `/bag/code-details/{ck_idx}` 로 리다이렉트 |
**발주/입고/재고 (Phase 3)**
### 업무 화면 (`/bag/*`, adminAuth 필터)
동일 `Admin\*` 컨트롤러·뷰를 쓰며 메인 사이트 레이아웃으로 렌더된다. `GET /admin/managers` 등 옛 업무 URL은 `301`·`POST``307``/bag/...`에 리다이렉트된다.
| 경로 | 기능 |
|------|------|
| `/admin/bag-orders/*` | 발주 관리 (등록/상세/취소/삭제) |
| `/admin/bag-receivings/*` | 입고 관리 (등록, 재고 자동 반영) |
| `/admin/bag-inventory` | 재고 현황 조회 |
| `/bag/password-change` | 비밀번호 변경 |
| `/bag/designated-shops/*` | 지정판매소 관리 (CRUD) |
| `/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` | 기간별 판매현황 |
| `/admin/reports/supply-demand` | 봉투 수불 현황 |
---
## 바코드 생성/사용 시점 (현재 코드 기준)
상세 코드 체계(LOT·팩·낱장·품목코드·판매소번호): [`doc/봉투-LOT-바코드-코드체계.md`](doc/봉투-LOT-바코드-코드체계.md)
- **현재 코드 구현**
- 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다.
- 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다.
- 판매 단계(`/bag/sale/designated`)에서는 생성된 코드를 스캔하여 `in_stock -> sold` 상태로 전환합니다.
- **요구사항 문서 관점**
- 노션 요구사항에는 발주 단계에서 바코드 원시데이터 생성 후 제작업체 인쇄 흐름이 명시되어 있습니다.
- 현재 구현과 요구사항 간 시점 차이가 존재하므로, 운영 정책 확정 후 발주 단계 생성으로 이관 검토가 필요합니다.
---
@@ -289,7 +319,7 @@ assets/ # 기획 문서 (엑셀)
| 항목 | 구현 |
|------|------|
| 인증 | 세션 기반 로그인, AdminAuthFilter로 관리자 접근 제어 |
| 인증 | 세션 기반 로그인, AdminAuthFilter로 관리자 접근 제어, 기본코드 목록 경로는 LoginAuthFilter(`loginAuth`) |
| RBAC | 4단계 역할 (Config\Roles), 메뉴별 역할 노출 |
| PII 암호화 | `pii_encryption_helper` (AES, `ENC:` prefix) - 이메일/전화번호 |
| 비밀번호 | `password_hash()` + `password_verify()` (bcrypt) |
@@ -349,6 +379,8 @@ SQL 스크립트 실행 순서:
| 17 | `seed_test_accounts.sql` | 테스터 계정 4개 |
| 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) 개발 서버 실행
```bash
@@ -384,6 +416,7 @@ npx playwright test -g "로그인 페이지"
| ID | 역할 | Level |
|----|------|-------|
| `tester_badmin` | 본부 관리자 | 5 |
| `tester_admin` | Super Admin | 4 |
| `tester_local` | 지자체관리자 (중구청) | 3 |
| `tester_shop` | 지정판매소 | 2 |
@@ -397,7 +430,7 @@ npx playwright test -g "로그인 페이지"
| admin.spec.js | 10 | 관리자 패널 접근 |
| public.spec.js | 4 | 공개 페이지 |
| 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 | 봉투 단가 |
| packaging-unit.spec.js | 3 | 포장 단위 |
| phase2-entities.spec.js | 8 | 대행소/담당자/업체/무료대상자 |
@@ -410,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`):
| 코드 | 코드명 | 코드 | 코드명 |
@@ -473,6 +514,9 @@ A~T 총 20종의 코드 체계 (`code_kind` + `code_detail`):
| `menu_site_seed_from_csv.sql` | 사이트 네비게이션 시드 |
| `local_government_init_daegu.sql` | 대구 8개 구군 지자체 |
| `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 |
| `packaging_unit_tables.sql` | packaging_unit, packaging_unit_history |
| `sales_agency_tables.sql` | sales_agency |

View File

@@ -40,7 +40,7 @@ class App extends BaseConfig
* something else. If you have configured your web server to remove this file
* from your site URIs, set this variable to an empty string.
*/
public string $indexPage = 'index.php';
public string $indexPage = '';
/**
* --------------------------------------------------------------------------

View File

@@ -7,17 +7,19 @@ use CodeIgniter\Config\BaseConfig;
/**
* 로그인·2차 인증(TOTP) 관련 설정
*
* .env 예:
* auth.requireTotp = true
* auth.totpIssuer = "쓰레기봉투 물류시스템"
* .env 의 auth.requireTotp 가 Config 기본값보다 우선합니다. 끄려면 반드시 false 로 두세요.
* 예:
* auth.requireTotp = false
* auth.requireTotp = true # 운영에서 2FA 켤 때
* auth.totpIssuer = "종량제 시스템"
*/
class Auth extends BaseConfig
{
/** 운영·스테이징 true 권장. 로컬 개발 시 false 로 1단계만 로그인 가능 */
public bool $requireTotp = true;
/** false 이면 로그인 시 TOTP·등록 유도 없음. 운영에서 켤 때 .env 에 auth.requireTotp = true */
public bool $requireTotp = false;
/** 인증 앱에 표시되는 발급자(issuer) */
public string $totpIssuer = '쓰레기봉투 물류시스템';
public string $totpIssuer = '종량제 시스템';
/** TOTP 연속 실패 시 세션 종료 전 허용 횟수 */
public int $totpMaxAttempts = 5;

View File

@@ -27,8 +27,40 @@ class Encryption extends BaseConfig
public function __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) : '';
$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;
}
}
}
/**

View File

@@ -26,6 +26,7 @@ class Filters extends BaseFilters
*/
public array $aliases = [
'adminAuth' => \App\Filters\AdminAuthFilter::class,
'loginAuth' => \App\Filters\LoginAuthFilter::class,
'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class,

26
app/Config/Kakao.php Normal file
View 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
View 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',
];
}

View File

@@ -42,6 +42,52 @@ class Roles extends BaseConfig
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·본부 관리자)
*/

View File

@@ -6,42 +6,221 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes
*/
$routes->get('/', 'Home::index');
$routes->get('workspace', 'Home::workspace');
$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/modern', 'Home::dashboardModern');
$routes->get('dashboard/dense', 'Home::dashboardDense');
$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/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
// 사이트 메뉴 (/bag/*)
$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/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/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-stats', 'Bag::salesStats');
$routes->get('bag/flow', 'Bag::flow');
$routes->get('bag/flow/export', 'Bag::flowExport');
$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/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/inventory/adjust', 'Bag::inventoryAdjust');
$routes->post('bag/inventory/adjust', 'Bag::inventoryAdjustStore');
$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
$routes->get('login', 'Auth::showLoginForm');
$routes->post('login', 'Auth::login');
@@ -63,6 +242,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('users/store', 'Admin\User::store');
$routes->get('users/edit/(:num)', 'Admin\User::edit/$1');
$routes->post('users/update/(:num)', 'Admin\User::update/$1');
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
$routes->get('access/login-history', 'Admin\Access::loginHistory');
$routes->get('access/approvals', 'Admin\Access::approvals');
@@ -84,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/delete/(:num)', 'Admin\LocalGovernment::delete/$1');
// 비밀번호 변경 (P2-20)
$routes->get('password-change', 'Admin\PasswordChange::index');
$routes->post('password-change', 'Admin\PasswordChange::update');
// 기본코드 종류 관리 (P2-01)
$routes->get('code-kinds', 'Admin\CodeKind::index');
// 기본코드 종류 관리 (P2-01) — 등록·수정·삭제는 관리자 전용
$routes->get('code-kinds/create', 'Admin\CodeKind::create');
$routes->post('code-kinds/store', 'Admin\CodeKind::store');
$routes->get('code-kinds/edit/(:num)', 'Admin\CodeKind::edit/$1');
@@ -97,119 +272,32 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('code-kinds/delete/(:num)', 'Admin\CodeKind::delete/$1');
// 세부코드 관리 (P2-02)
$routes->get('code-details/(:num)', 'Admin\CodeDetail::index/$1');
$routes->get('code-details/(:num)/create', 'Admin\CodeDetail::create/$1');
$routes->post('code-details/store', 'Admin\CodeDetail::store');
$routes->get('code-details/edit/(:num)', 'Admin\CodeDetail::edit/$1');
$routes->post('code-details/update/(:num)', 'Admin\CodeDetail::update/$1');
$routes->post('code-details/delete/(:num)', 'Admin\CodeDetail::delete/$1');
// 봉투 단가 관리 (P2-03/04)
$routes->get('bag-prices', 'Admin\BagPrice::index');
$routes->get('bag-prices/create', 'Admin\BagPrice::create');
$routes->post('bag-prices/store', 'Admin\BagPrice::store');
$routes->get('bag-prices/edit/(:num)', 'Admin\BagPrice::edit/$1');
$routes->post('bag-prices/update/(:num)', 'Admin\BagPrice::update/$1');
$routes->post('bag-prices/delete/(:num)', 'Admin\BagPrice::delete/$1');
$routes->get('bag-prices/history/(:num)', 'Admin\BagPrice::history/$1');
// 발주 관리 (P3-01~05)
$routes->get('bag-orders/export', 'Admin\BagOrder::export');
$routes->get('bag-orders', 'Admin\BagOrder::index');
$routes->get('bag-orders/create', 'Admin\BagOrder::create');
$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');
// 입고 관리 (P3-06~09)
$routes->get('bag-receivings', 'Admin\BagReceiving::index');
$routes->get('bag-receivings/create', 'Admin\BagReceiving::create');
$routes->post('bag-receivings/store', 'Admin\BagReceiving::store');
// 재고 현황 (P3-10)
$routes->get('bag-inventory/export', 'Admin\BagInventory::export');
$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/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');
// 무료용 불출 관리 (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');
$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/lot-flow', 'Admin\SalesReport::lotFlow');
$routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow');
$routes->post('reports/misc-flow', 'Admin\SalesReport::miscFlowStore');
// 판매 대행소 관리 (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/export', 'Admin\DesignatedShop::export');
$routes->get('designated-shops/map', 'Admin\DesignatedShop::map');
$routes->get('designated-shops/status', 'Admin\DesignatedShop::status');
$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');
// 구 업무 URL → /bag/* (실제 처리는 bag 그룹). GET 301, POST 307.
$adminToBagPrefixes = [
'managers',
'sales-agencies',
'companies',
'free-recipients',
'designated-shops',
'bag-prices',
'bag-orders',
'bag-receivings',
'bag-inventory',
'shop-orders',
'bag-sales',
'bag-issues',
'packaging-units',
'reports',
'password-change',
];
foreach ($adminToBagPrefixes as $p) {
$routes->match(['get', 'post'], $p, 'Admin\WorkMovedToBag::toBag/' . $p);
$routes->match(['get', 'post'], $p . '/(:any)', 'Admin\WorkMovedToBag::toBag/' . $p . '/$1');
}
});

View File

@@ -11,24 +11,23 @@ class BagInventory extends BaseController
{
helper('admin');
$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;
$list = $invModel->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->paginate(20);
$pager = $invModel->pager;
return view('admin/layout', [
'title' => '재고 현황',
'content' => view('admin/bag_inventory/index', ['list' => $list, 'pager' => $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(site_url('admin/bag-inventory'))->with('error', '지자체를 선택해 주세요.');
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();
@@ -44,8 +43,9 @@ class BagInventory extends BaseController
];
}
export_csv(
'재고현황_' . date('Ymd') . '.csv',
export_xlsx(
'재고현황_' . date('Ymd') . '.xlsx',
'재고현황',
['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'],
$rows
);

View File

@@ -4,50 +4,90 @@ namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\BagIssueModel;
use App\Models\BagIssueItemCodeModel;
use App\Models\BagInventoryModel;
use App\Models\CodeKindModel;
use App\Models\CodeDetailModel;
use App\Models\FreeRecipientModel;
use App\Models\PackagingUnitModel;
class BagIssue extends BaseController
{
private BagIssueModel $issueModel;
private BagIssueItemCodeModel $issueItemCodeModel;
public function __construct()
{
$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()
{
helper('admin');
$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);
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
if ($startDate) $builder->where('bi2_issue_date >=', $startDate);
if ($endDate) $builder->where('bi2_issue_date <=', $endDate);
if ($startDate) {
$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')->paginate(20);
$list = $builder->orderBy('bi2_issue_date', 'DESC')->orderBy('bi2_idx', 'DESC')->paginate(20);
$pager = $this->issueModel->pager;
return view('admin/layout', [
'title' => '무료용 불출 관리',
'content' => view('admin/bag_issue/index', compact('list', 'startDate', 'endDate', 'pager')),
]);
return $this->renderWorkPage('무료용 불출 관리', 'admin/bag_issue/index', compact('list', 'startDate', 'endDate', 'pager'));
}
public function create()
{
helper('admin');
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
$lgIdx = admin_effective_lg_idx();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
return view('admin/layout', [
'title' => '무료용 불출 처리',
'content' => view('admin/bag_issue/create', compact('bagCodes')),
]);
return $this->renderWorkPage('무료용 불출 처리', 'admin/bag_issue/create', compact('bagCodes'));
}
public function store()
@@ -61,73 +101,266 @@ class BagIssue extends BaseController
'bi2_issue_type' => 'required|max_length[20]',
'bi2_issue_date' => 'required|valid_date[Y-m-d]',
'bi2_dest_name' => 'required|max_length[100]',
'bi2_bag_code' => 'required|max_length[50]',
'bi2_qty' => 'required|is_natural_no_zero',
// 사이트 다건 입력(item_bag_code/item_qty)과 기존 관리자 단건 입력을 함께 허용
'bi2_bag_code' => 'permit_empty|max_length[50]',
'bi2_qty' => 'permit_empty|is_natural_no_zero',
];
if (! $this->validate($rules)) {
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();
$issueType = trim((string) $this->request->getPost('bi2_issue_type'));
$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') ?? ''));
$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;
$bagName = $detail ? $detail->cd_name : '';
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->transStart();
$hasIssueCodeTable = $db->tableExists('bag_issue_item_code');
$issueData = [
'bi2_lg_idx' => $lgIdx,
'bi2_year' => (int) $this->request->getPost('bi2_year'),
'bi2_quarter' => (int) $this->request->getPost('bi2_quarter'),
'bi2_issue_type' => $this->request->getPost('bi2_issue_type'),
'bi2_issue_date' => $this->request->getPost('bi2_issue_date'),
'bi2_dest_type' => $this->request->getPost('bi2_dest_type') ?? '',
'bi2_dest_name' => $this->request->getPost('bi2_dest_name'),
'bi2_bag_code' => $bagCode,
'bi2_bag_name' => $bagName,
'bi2_qty' => $qty,
'bi2_status' => 'normal',
'bi2_regdate' => date('Y-m-d H:i:s'),
];
$this->issueModel->insert($issueData);
$bi2Idx = (int) $this->issueModel->getInsertID();
// CT-05: 감사 로그
$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');
audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx]));
foreach ($items as $item) {
$issueData = [
'bi2_lg_idx' => $lgIdx,
'bi2_year' => $issueYear,
'bi2_quarter' => $issueQuarter,
'bi2_issue_type' => $issueType,
'bi2_issue_date' => $issueDate,
'bi2_dest_type' => $destType,
'bi2_dest_name' => $destName,
'bi2_bag_code' => (string) $item['bagCode'],
'bi2_bag_name' => (string) $item['bagName'],
'bi2_qty' => (int) $item['sheetQty'],
'bi2_status' => 'normal',
'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]));
// 재고 감산
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
if ($hasIssueCodeTable) {
$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();
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)
{
helper('admin');
$item = $this->issueModel->find($id);
if (!$item || (int) $item->bi2_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/bag-issues'))->with('error', '불출 내역을 찾을 수 없습니다.');
if (! $item || (int) $item->bi2_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('bag-issues'))->with('error', '불출 내역을 찾을 수 없습니다.');
}
$db = \Config\Database::connect();
$db->transStart();
$hasIssueCodeTable = $db->tableExists('bag_issue_item_code');
$before = (array) $item;
$this->issueModel->update($id, ['bi2_status' => 'cancelled']);
// CT-05: 감사 로그
helper('audit');
audit_log('update', 'bag_issue', $id, $before, ['bi2_status' => 'cancelled']);
// 재고 복원
model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, (int) $item->bi2_qty);
$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();
return redirect()->to(site_url('admin/bag-issues'))->with('success', '불출이 취소되었습니다.');
return redirect()->to(mgmt_url('bag-issues'))->with('success', '불출이 취소되었습니다.');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\BagPriceModel;
use App\Models\BagPriceHistoryModel;
use App\Models\CodeKindModel;
use App\Models\BagPriceModel;
use App\Models\CodeDetailModel;
use App\Models\CodeKindModel;
class BagPrice extends BaseController
{
@@ -23,36 +23,143 @@ class BagPrice extends BaseController
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) {
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
if ($lgIdx === null) {
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);
// 기간 필터 (P2-04)
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
if ($startDate) {
$builder->where('bp_start_date >=', $startDate);
}
if ($endDate) {
if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
$qStart = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
$qEnd = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
if (strcmp((string) $qStart, (string) $qEnd) > 0) {
[$qStart, $qEnd] = [$qEnd, $qStart];
}
$builder->where('bp_start_date <=', $qEnd);
$builder->groupStart()
->where('bp_end_date IS NULL')
->orWhere('bp_end_date <=', $endDate)
->orWhere('bp_end_date >=', $qStart)
->groupEnd();
}
$list = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->paginate(20);
$pager = $this->priceModel->pager;
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', [
'title' => '봉투 단가 관리',
'content' => view('admin/bag_price/index', [
'list' => $list,
'startDate' => $startDate,
'endDate' => $endDate,
'pager' => $pager,
]),
if ($bagCode !== null && $bagCode !== '') {
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
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,
'pager' => $this->priceModel->pager,
'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,
]);
}
@@ -60,22 +167,18 @@ class BagPrice extends BaseController
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) {
return redirect()->to(site_url('admin/bag-prices'))->with('error', '지자체를 선택해 주세요.');
if (! $lgIdx) {
return redirect()->to(mgmt_url('bag-prices'))->with('error', '지자체를 선택해 주세요.');
}
// 봉투명 코드(O) 목록
$kindModel = model(CodeKindModel::class);
$kind = $kindModel->where('ck_code', 'O')->first();
$bagCodes = [];
$kind = $kindModel->where('ck_code', 'O')->first();
$bagCodes = [];
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', [
'title' => '봉투 단가 등록',
'content' => view('admin/bag_price/create', ['bagCodes' => $bagCodes]),
]);
return $this->renderWorkPage('봉투 단가 등록', 'admin/bag_price/create', ['bagCodes' => $bagCodes]);
}
public function store()
@@ -96,13 +199,12 @@ class BagPrice extends BaseController
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);
$kind = $kindModel->where('ck_code', 'O')->first();
$bagName = '';
$kind = $kindModel->where('ck_code', 'O')->first();
$bagName = '';
if ($kind) {
$detail = model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_code', $bagCode)->first();
$detail = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kind->ck_idx, (string) $bagCode, $lgIdx);
$bagName = $detail ? $detail->cd_name : '';
}
@@ -120,36 +222,34 @@ class BagPrice extends BaseController
'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)
{
helper('admin');
$item = $this->priceModel->find($id);
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
$lgIdx = admin_effective_lg_idx();
$item = $this->priceModel->find($id);
if (! $item || (int) $item->bp_lg_idx !== $lgIdx) {
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
}
$kindModel = model(CodeKindModel::class);
$kind = $kindModel->where('ck_code', 'O')->first();
$bagCodes = [];
$kind = $kindModel->where('ck_code', 'O')->first();
$bagCodes = [];
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', [
'title' => '봉투 단가 수정',
'content' => view('admin/bag_price/edit', ['item' => $item, 'bagCodes' => $bagCodes]),
]);
return $this->renderWorkPage('봉투 단가 수정', 'admin/bag_price/edit', ['item' => $item, 'bagCodes' => $bagCodes]);
}
public function update(int $id)
{
helper('admin');
$item = $this->priceModel->find($id);
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
if (! $item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
}
$rules = [
@@ -165,7 +265,6 @@ class BagPrice extends BaseController
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
// 이력 기록
$db = \Config\Database::connect();
$db->transStart();
@@ -175,12 +274,12 @@ class BagPrice extends BaseController
$newVal = (string) $this->request->getPost($field);
if ($oldVal !== $newVal) {
$this->historyModel->insert([
'bph_bp_idx' => $id,
'bph_field' => $field,
'bph_old_value' => $oldVal,
'bph_new_value' => $newVal,
'bph_changed_at'=> date('Y-m-d H:i:s'),
'bph_changed_by'=> session()->get('mb_idx'),
'bph_bp_idx' => $id,
'bph_field' => $field,
'bph_old_value' => $oldVal,
'bph_new_value' => $newVal,
'bph_changed_at' => date('Y-m-d H:i:s'),
'bph_changed_by' => session()->get('mb_idx'),
]);
}
}
@@ -197,34 +296,32 @@ class BagPrice extends BaseController
$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)
{
helper('admin');
$item = $this->priceModel->find($id);
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
if (! $item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
}
$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)
{
helper('admin');
$item = $this->priceModel->find($bpIdx);
if (!$item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
if (! $item || (int) $item->bp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('bag-prices'))->with('error', '단가 정보를 찾을 수 없습니다.');
}
$list = $this->historyModel->where('bph_bp_idx', $bpIdx)->orderBy('bph_changed_at', 'DESC')->findAll();
return view('admin/layout', [
'title' => '단가 변경 이력 — ' . $item->bp_bag_name,
'content' => view('admin/bag_price/history', ['item' => $item, 'list' => $list]),
]);
return $this->renderWorkPage('단가 변경 이력 — ' . $item->bp_bag_name, 'admin/bag_price/history', ['item' => $item, 'list' => $list]);
}
}

View File

@@ -22,36 +22,37 @@ class BagReceiving extends BaseController
{
helper('admin');
$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);
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
if ($startDate) $builder->where('br_receive_date >=', $startDate);
if ($endDate) $builder->where('br_receive_date <=', $endDate);
if ($startDate) {
$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')->paginate(20);
$list = $builder->orderBy('br_receive_date', 'DESC')->orderBy('br_idx', 'DESC')->paginate(20);
$pager = $this->recvModel->pager;
return view('admin/layout', [
'title' => '입고 현황',
'content' => view('admin/bag_receiving/index', compact('list', 'startDate', 'endDate', 'pager')),
]);
return $this->renderWorkPage('입고 현황', 'admin/bag_receiving/index', compact('list', 'startDate', 'endDate', 'pager'));
}
public function create()
{
helper('admin');
$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)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll();
$orders = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll();
return view('admin/layout', [
'title' => '입고 처리',
'content' => view('admin/bag_receiving/create', compact('orders')),
]);
return $this->renderWorkPage('입고 처리', 'admin/bag_receiving/create', compact('orders'));
}
public function store()
@@ -73,14 +74,12 @@ class BagReceiving extends BaseController
$bagCode = $this->request->getPost('br_bag_code');
$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();
$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();
$detail = $kindO ? model(\App\Models\CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(\App\Models\CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null;
$bagName = $detail ? $detail->cd_name : '';
$db = \Config\Database::connect();
@@ -100,11 +99,10 @@ class BagReceiving extends BaseController
'br_regdate' => date('Y-m-d H:i:s'),
]);
// 재고 가산
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $qtySheet);
$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 . '박스)');
}
}

View File

@@ -23,45 +23,56 @@ class BagSale extends BaseController
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->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')->paginate(20);
$pager = $this->saleModel->pager;
return view('admin/layout', [
'title' => '판매/반품 관리',
'content' => view('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(site_url('admin/bag-sales'))->with('error', '지자체를 선택해 주세요.');
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->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);
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')->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();
$typeMap = ['sale' => '판매', 'return' => '반품', 'cancel' => '취소'];
$rows = [];
$rows = [];
foreach ($list as $row) {
$rows[] = [
$row->bs_idx,
@@ -87,16 +98,15 @@ class BagSale extends BaseController
{
helper('admin');
$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();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
return view('admin/layout', [
'title' => '판매 등록',
'content' => view('admin/bag_sale/create', compact('shops', 'bagCodes')),
]);
return $this->renderWorkPage('판매 등록', 'admin/bag_sale/create', compact('shops', 'bagCodes'));
}
public function store()
@@ -120,10 +130,10 @@ class BagSale extends BaseController
$qty = (int) $this->request->getPost('bs_qty');
$type = $this->request->getPost('bs_type');
$shop = model(DesignatedShopModel::class)->find($dsIdx);
$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;
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $bagCode)->where('bp_state', 1)->first();
$shop = model(DesignatedShopModel::class)->find($dsIdx);
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null;
$price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $bagCode);
$unitPrice = $price ? (float) $price->bp_consumer : 0;
$actualQty = ($type === 'return') ? -$qty : $qty;
@@ -132,31 +142,30 @@ class BagSale extends BaseController
$db->transStart();
$saleData = [
'bs_lg_idx' => $lgIdx,
'bs_ds_idx' => $dsIdx,
'bs_ds_name' => $shop ? $shop->ds_name : '',
'bs_sale_date' => $this->request->getPost('bs_sale_date'),
'bs_bag_code' => $bagCode,
'bs_bag_name' => $detail ? $detail->cd_name : '',
'bs_qty' => $actualQty,
'bs_unit_price'=> $unitPrice,
'bs_amount' => $unitPrice * abs($actualQty),
'bs_type' => $type,
'bs_regdate' => date('Y-m-d H:i:s'),
'bs_lg_idx' => $lgIdx,
'bs_ds_idx' => $dsIdx,
'bs_ds_name' => $shop ? $shop->ds_name : '',
'bs_sale_date' => $this->request->getPost('bs_sale_date'),
'bs_bag_code' => $bagCode,
'bs_bag_name' => $detail ? $detail->cd_name : '',
'bs_qty' => $actualQty,
'bs_unit_price' => $unitPrice,
'bs_amount' => $unitPrice * abs($actualQty),
'bs_type' => $type,
'bs_regdate' => date('Y-m-d H:i:s'),
];
$this->saleModel->insert($saleData);
$bsIdx = (int) $this->saleModel->getInsertID();
// CT-05: 감사 로그
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);
$db->transComplete();
$msg = ($type === 'sale') ? '판매 처리되었습니다.' : '반품 처리되었습니다.';
return redirect()->to(site_url('admin/bag-sales'))->with('success', $msg);
return redirect()->to(mgmt_url('bag-sales'))->with('success', $msg);
}
}

View File

@@ -1,10 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\CodeKindModel;
use App\Models\CodeDetailModel;
use App\Models\LocalGovernmentModel;
use CodeIgniter\HTTP\RedirectResponse;
use Config\Roles;
class CodeDetail extends BaseController
{
@@ -17,41 +22,57 @@ class CodeDetail extends BaseController
$this->detailModel = model(CodeDetailModel::class);
}
public function index(int $ckIdx)
private function redirectIfCannotManageCodeMaster(): ?RedirectResponse
{
$kind = $this->kindModel->find($ckIdx);
if ($kind === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
if (! Roles::canManageCodeMaster((int) session()->get('mb_level'))) {
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 관리 권한이 없습니다.');
}
$list = $this->detailModel->where('cd_ck_idx', $ckIdx)->orderBy('cd_sort', 'ASC')->paginate(20);
$pager = $this->detailModel->pager;
return null;
}
return view('admin/layout', [
'title' => '세부코드 관리 — ' . $kind->ck_name . ' (' . $kind->ck_code . ')',
'content' => view('admin/code_detail/index', [
'kind' => $kind,
'list' => $list,
'pager' => $pager,
]),
]);
/** @deprecated 사이트 URL 유지용 — 세부 목록은 /bag/code-details/{ck_idx} */
public function index(int $ckIdx): RedirectResponse
{
return redirect()->to(site_url('bag/code-details/' . $ckIdx));
}
public function create(int $ckIdx)
{
if ($r = $this->redirectIfCannotManageCodeMaster()) {
return $r;
}
$kind = $this->kindModel->find($ckIdx);
if ($kind === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
}
$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', [
'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()
{
if ($r = $this->redirectIfCannotManageCodeMaster()) {
return $r;
}
$rules = [
'cd_ck_idx' => 'required|is_natural_no_zero',
'cd_code' => 'required|max_length[50]',
@@ -64,24 +85,73 @@ class CodeDetail extends BaseController
}
$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([
'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_sort' => (int) ($this->request->getPost('cd_sort') ?: 0),
'cd_state' => 1,
'cd_regdate' => date('Y-m-d H:i:s'),
]);
return redirect()->to(site_url('admin/code-details/' . $ckIdx))->with('success', '세부코드가 등록되었습니다.');
return redirect()->to(site_url('bag/code-details/' . $ckIdx))->with('success', '세부코드가 등록되었습니다.');
}
public function edit(int $id)
{
if ($r = $this->redirectIfCannotManageCodeMaster()) {
return $r;
}
$item = $this->detailModel->find($id);
if ($item === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
}
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);
@@ -97,9 +167,18 @@ class CodeDetail extends BaseController
public function update(int $id)
{
if ($r = $this->redirectIfCannotManageCodeMaster()) {
return $r;
}
$item = $this->detailModel->find($id);
if ($item === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
}
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 = [
@@ -118,19 +197,28 @@ class CodeDetail extends BaseController
'cd_state' => (int) $this->request->getPost('cd_state'),
]);
return redirect()->to(site_url('admin/code-details/' . $item->cd_ck_idx))->with('success', '세부코드가 수정되었습니다.');
return redirect()->to(site_url('bag/code-details/' . $item->cd_ck_idx))->with('success', '세부코드가 수정되었습니다.');
}
public function delete(int $id)
{
if ($r = $this->redirectIfCannotManageCodeMaster()) {
return $r;
}
$item = $this->detailModel->find($id);
if ($item === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
}
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;
$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', '세부코드가 삭제되었습니다.');
}
}

View File

@@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\CodeKindModel;
use App\Models\CodeDetailModel;
use CodeIgniter\HTTP\RedirectResponse;
use Config\Roles;
class CodeKind extends BaseController
@@ -16,30 +19,21 @@ class CodeKind extends BaseController
$this->kindModel = model(CodeKindModel::class);
}
public function index()
private function redirectIfCannotManageCodeKindMaster(): ?RedirectResponse
{
$list = $this->kindModel->orderBy('ck_code', 'ASC')->paginate(20);
$pager = $this->kindModel->pager;
// 세부코드 수 매핑
$detailModel = model(CodeDetailModel::class);
$countMap = [];
foreach ($list as $row) {
$countMap[$row->ck_idx] = $detailModel->where('cd_ck_idx', $row->ck_idx)->countAllResults(false);
if (! Roles::canManageCodeKindMaster((int) session()->get('mb_level'))) {
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류는 super admin·본부 관리자만 관리할 수 있습니다.');
}
return view('admin/layout', [
'title' => '기본코드 종류 관리',
'content' => view('admin/code_kind/index', [
'list' => $list,
'countMap' => $countMap,
'pager' => $pager,
]),
]);
return null;
}
public function create()
{
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
return $r;
}
return view('admin/layout', [
'title' => '기본코드 종류 등록',
'content' => view('admin/code_kind/create'),
@@ -48,6 +42,10 @@ class CodeKind extends BaseController
public function store()
{
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
return $r;
}
$rules = [
'ck_code' => 'required|max_length[20]|is_unique[code_kind.ck_code]',
'ck_name' => 'required|max_length[100]',
@@ -64,14 +62,18 @@ class CodeKind extends BaseController
'ck_regdate' => date('Y-m-d H:i:s'),
]);
return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 등록되었습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 등록되었습니다.');
}
public function edit(int $id)
{
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
return $r;
}
$item = $this->kindModel->find($id);
if ($item === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
}
return view('admin/layout', [
@@ -82,9 +84,13 @@ class CodeKind extends BaseController
public function update(int $id)
{
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
return $r;
}
$item = $this->kindModel->find($id);
if ($item === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
}
$rules = [
@@ -101,24 +107,28 @@ class CodeKind extends BaseController
'ck_state' => (int) $this->request->getPost('ck_state'),
]);
return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 수정되었습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 수정되었습니다.');
}
public function delete(int $id)
{
$item = $this->kindModel->find($id);
if ($item === null) {
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
if ($r = $this->redirectIfCannotManageCodeKindMaster()) {
return $r;
}
$item = $this->kindModel->find($id);
if ($item === null) {
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
}
// 세부코드가 있으면 삭제 불가
$detailCount = model(CodeDetailModel::class)->where('cd_ck_idx', $id)->countAllResults();
if ($detailCount > 0) {
return redirect()->to(site_url('admin/code-kinds'))
return redirect()->to(site_url('bag/code-kinds'))
->with('error', '세부코드가 ' . $detailCount . '개 존재하여 삭제할 수 없습니다. 먼저 세부코드를 삭제해 주세요.');
}
$this->kindModel->delete($id);
return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.');
return redirect()->to(site_url('bag/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.');
}
}

View File

@@ -9,6 +9,11 @@ class Company extends BaseController
{
private CompanyModel $model;
private function companyTypeOptions(): array
{
return ['협회', '제작업체', '회수업체'];
}
public function __construct()
{
$this->model = model(CompanyModel::class);
@@ -18,25 +23,38 @@ class Company extends BaseController
{
helper('admin');
$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', '지자체를 선택해 주세요.');
}
$list = $this->model->where('cp_lg_idx', $lgIdx)->orderBy('cp_idx', 'DESC')->paginate(20);
$companyType = trim((string) ($this->request->getGet('cp_type') ?? ''));
$typeOptions = $this->companyTypeOptions();
$builder = $this->model->where('cp_lg_idx', $lgIdx);
if ($companyType !== '' && in_array($companyType, $typeOptions, true)) {
$builder->where('cp_type', $companyType);
}
$list = $builder->orderBy('cp_idx', 'DESC')->paginate(20);
$pager = $this->model->pager;
return view('admin/layout', [
'title' => '업체 관리',
'content' => view('admin/company/index', ['list' => $list, 'pager' => $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()
{
return view('admin/layout', [
'title' => '업체 등록',
'content' => view('admin/company/create'),
]);
return $this->renderWorkPage('업체 등록', 'admin/company/create');
}
public function store()
@@ -66,29 +84,26 @@ class Company extends BaseController
'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)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/companies'))->with('error', '업체를 찾을 수 없습니다.');
if (! $item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('companies'))->with('error', '업체를 찾을 수 없습니다.');
}
return view('admin/layout', [
'title' => '업체 수정',
'content' => view('admin/company/edit', ['item' => $item]),
]);
return $this->renderWorkPage('업체 수정', 'admin/company/edit', ['item' => $item]);
}
public function update(int $id)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/companies'))->with('error', '업체를 찾을 수 없습니다.');
if (! $item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('companies'))->with('error', '업체를 찾을 수 없습니다.');
}
$rules = [
@@ -110,18 +125,19 @@ class Company extends BaseController
'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)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/companies'))->with('error', '업체를 찾을 수 없습니다.');
if (! $item || (int) $item->cp_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('companies'))->with('error', '업체를 찾을 수 없습니다.');
}
$this->model->delete($id);
return redirect()->to(site_url('admin/companies'))->with('success', '업체가 삭제되었습니다.');
return redirect()->to(mgmt_url('companies'))->with('success', '업체가 삭제되었습니다.');
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use CodeIgniter\Database\Exceptions\DatabaseException;
class Dashboard extends BaseController
{
@@ -22,65 +23,77 @@ class Dashboard extends BaseController
'issue_count_month'=> 0,
'recent_orders' => [],
'recent_sales' => [],
'stats_unavailable'=> false,
];
if ($lgIdx) {
$db = \Config\Database::connect();
// 총 발주 건수/금액
$orderStats = $db->query("
SELECT COUNT(*) as cnt,
COALESCE(SUM(sub.total_amt), 0) as total_amount
FROM bag_order bo
LEFT JOIN (
SELECT boi_bo_idx, SUM(boi_amount) as total_amt
FROM bag_order_item GROUP BY boi_bo_idx
) sub ON sub.boi_bo_idx = bo.bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
", [$lgIdx])->getRow();
$stats['order_count'] = (int) ($orderStats->cnt ?? 0);
$stats['order_amount'] = (int) ($orderStats->total_amount ?? 0);
try {
// 총 발주 건수/금액
$orderStats = $db->query("
SELECT COUNT(*) as cnt,
COALESCE(SUM(sub.total_amt), 0) as total_amount
FROM bag_order bo
LEFT JOIN (
SELECT boi_bo_idx, SUM(boi_amount) as total_amt
FROM bag_order_item GROUP BY boi_bo_idx
) sub ON sub.boi_bo_idx = bo.bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
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);
// 총 판매 건수/금액
$saleStats = $db->query("
SELECT COUNT(*) as cnt, COALESCE(SUM(bs_amount), 0) as total_amount
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_type = 'sale'
", [$lgIdx])->getRow();
$stats['sale_count'] = (int) ($saleStats->cnt ?? 0);
$stats['sale_amount'] = (int) ($saleStats->total_amount ?? 0);
// 현재 재고 품목 수
$invCount = $db->query("
SELECT COUNT(*) as cnt FROM bag_inventory WHERE bi_lg_idx = ? AND bi_qty > 0
", [$lgIdx])->getRow();
$stats['inventory_count'] = (int) ($invCount->cnt ?? 0);
// 현재 재고 품목 수
$invCount = $db->query("
SELECT COUNT(*) as cnt FROM bag_inventory WHERE bi_lg_idx = ? AND bi_qty > 0
", [$lgIdx])->getRow();
$stats['inventory_count'] = (int) ($invCount->cnt ?? 0);
// 이번 달 불출 건수
$monthStart = date('Y-m-01');
$issueCount = $db->query("
SELECT COUNT(*) as cnt FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_issue_date >= ?
", [$lgIdx, $monthStart])->getRow();
$stats['issue_count_month'] = (int) ($issueCount->cnt ?? 0);
// 이번 달 불출 건수
$monthStart = date('Y-m-01');
$issueCount = $db->query("
SELECT COUNT(*) as cnt FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_issue_date >= ?
", [$lgIdx, $monthStart])->getRow();
$stats['issue_count_month'] = (int) ($issueCount->cnt ?? 0);
// 최근 발주 5건
$stats['recent_orders'] = $db->query("
SELECT bo_idx, bo_lot_no, bo_order_date, bo_status
FROM bag_order
WHERE bo_lg_idx = ?
ORDER BY bo_order_date DESC, bo_idx DESC
LIMIT 5
", [$lgIdx])->getResult();
// 최근 발주 5건
$stats['recent_orders'] = $db->query("
SELECT bo_idx, bo_lot_no, bo_order_date, bo_status
FROM bag_order
WHERE bo_lg_idx = ?
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();
// 최근 판매 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', [

File diff suppressed because it is too large Load Diff

View File

@@ -16,37 +16,73 @@ class FreeRecipient extends BaseController
$this->model = model(FreeRecipientModel::class);
}
/**
* 무료용 대상 구분(스크린샷 기준): 사람뿐 아니라 동사무소 자체도 등록 가능.
*
* @return array<string,string>
*/
private function recipientTypeOptions(): array
{
return [
'office' => '읍.면.동 사무소',
'target' => '무료 대상자',
];
}
private function getCodeOptions(string $ckCode): array
{
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
helper('admin');
$lgIdx = admin_effective_lg_idx();
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
}
public function index()
{
helper('admin');
$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', '지자체를 선택해 주세요.');
}
$list = $this->model->where('fr_lg_idx', $lgIdx)->orderBy('fr_idx', 'DESC')->paginate(20);
$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', [
'title' => '무료용 대상자 관리',
'content' => view('admin/free_recipient/index', ['list' => $list, 'pager' => $pager]),
return $this->renderWorkPage('무료용 대상자 관리', 'admin/free_recipient/index', [
'list' => $list,
'pager' => $pager,
'recipientTypeOptions' => $this->recipientTypeOptions(),
'dongNameMap' => $dongNameMap,
'totalCount' => $totalCount,
'currentPage' => $currentPage,
'perPage' => $perPage,
]);
}
public function create()
{
return view('admin/layout', [
'title' => '무료용 대상자 등록',
'content' => view('admin/free_recipient/create', [
'typeCodes' => $this->getCodeOptions('H'),
'dongCodes' => $this->getCodeOptions('D'),
]),
return $this->renderWorkPage('무료용 대상자 등록', 'admin/free_recipient/create', [
'recipientTypeOptions' => $this->recipientTypeOptions(),
'dongCodes' => $this->getCodeOptions('D'),
]);
}
@@ -75,24 +111,21 @@ class FreeRecipient extends BaseController
'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)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
if (! $item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
}
return view('admin/layout', [
'title' => '무료용 대상자 수정',
'content' => view('admin/free_recipient/edit', [
'item' => $item,
'typeCodes' => $this->getCodeOptions('H'),
'dongCodes' => $this->getCodeOptions('D'),
]),
return $this->renderWorkPage('무료용 대상자 수정', 'admin/free_recipient/edit', [
'item' => $item,
'recipientTypeOptions' => $this->recipientTypeOptions(),
'dongCodes' => $this->getCodeOptions('D'),
]);
}
@@ -100,8 +133,8 @@ class FreeRecipient extends BaseController
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
if (! $item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
}
$rules = [
@@ -123,18 +156,19 @@ class FreeRecipient extends BaseController
'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)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
if (! $item || (int) $item->fr_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('free-recipients'))->with('error', '대상자를 찾을 수 없습니다.');
}
$this->model->delete($id);
return redirect()->to(site_url('admin/free-recipients'))->with('success', '무료용 대상자가 삭제되었습니다.');
return redirect()->to(mgmt_url('free-recipients'))->with('success', '무료용 대상자가 삭제되었습니다.');
}
}

View File

@@ -18,8 +18,20 @@ class Manager extends BaseController
private function getCodeOptions(string $ckCode): array
{
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
helper('admin');
$lgIdx = admin_effective_lg_idx();
$kind = model(CodeKindModel::class)->where('ck_code', $ckCode)->first();
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
}
private function managerCategoryOptions(): array
{
return [
'company' => '제작업체',
'district' => '구·군',
'agency' => '대행소',
];
}
public function index()
@@ -27,34 +39,44 @@ class Manager extends BaseController
helper('admin');
$lgIdx = admin_effective_lg_idx();
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')->paginate(20);
$category = (string) ($this->request->getGet('category') ?? '');
$categories = $this->managerCategoryOptions();
$builder = $this->model->where('mg_lg_idx', $lgIdx);
if ($category !== '' && isset($categories[$category])) {
$builder->where('mg_dept_code', $category);
}
$list = $builder->orderBy('mg_idx', 'DESC')->paginate(20);
$pager = $this->model->pager;
return view('admin/layout', [
'title' => '담당자 관리',
'content' => view('admin/manager/index', ['list' => $list, 'pager' => $pager]),
return $this->renderWorkPage('담당자 관리', 'admin/manager/index', [
'list' => $list,
'pager' => $pager,
'categories' => $categories,
'category' => $category,
]);
}
public function create()
{
return view('admin/layout', [
'title' => '담당자 등록',
'content' => view('admin/manager/create', [
'deptCodes' => $this->getCodeOptions('S'),
'positionCodes' => $this->getCodeOptions('T'),
]),
return $this->renderWorkPage('담당자 등록', 'admin/manager/create', [
'categories' => $this->managerCategoryOptions(),
'positionCodes' => $this->getCodeOptions('T'),
]);
}
public function store()
{
helper('admin');
helper(['admin', 'url']);
$rules = [
'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]',
'mg_tel' => 'permit_empty|max_length[20]',
'mg_phone' => 'permit_empty|max_length[20]',
'mg_email' => 'permit_empty|valid_email|max_length[100]',
@@ -66,7 +88,7 @@ class Manager extends BaseController
$this->model->insert([
'mg_lg_idx' => admin_effective_lg_idx(),
'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_tel' => $this->request->getPost('mg_tel') ?? '',
'mg_phone' => $this->request->getPost('mg_phone') ?? '',
@@ -75,37 +97,35 @@ class Manager extends BaseController
'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)
{
helper('admin');
helper(['admin', 'url']);
$item = $this->model->find($id);
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', [
'title' => '담당자 수정',
'content' => view('admin/manager/edit', [
'item' => $item,
'deptCodes' => $this->getCodeOptions('S'),
'positionCodes' => $this->getCodeOptions('T'),
]),
return $this->renderWorkPage('담당자 수정', 'admin/manager/edit', [
'item' => $item,
'categories' => $this->managerCategoryOptions(),
'positionCodes' => $this->getCodeOptions('T'),
]);
}
public function update(int $id)
{
helper('admin');
helper(['admin', 'url']);
$item = $this->model->find($id);
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 = [
'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]',
'mg_state' => 'required|in_list[0,1]',
];
if (! $this->validate($rules)) {
@@ -114,7 +134,7 @@ class Manager extends BaseController
$this->model->update($id, [
'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_tel' => $this->request->getPost('mg_tel') ?? '',
'mg_phone' => $this->request->getPost('mg_phone') ?? '',
@@ -122,18 +142,19 @@ class Manager extends BaseController
'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)
{
helper('admin');
helper(['admin', 'url']);
$item = $this->model->find($id);
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);
return redirect()->to(site_url('admin/managers'))->with('success', '담당자가 삭제되었습니다.');
return redirect()->to(mgmt_url('managers'))->with('success', '담당자가 삭제되었습니다.');
}
}

View File

@@ -18,11 +18,29 @@ class Menu extends BaseController
$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()
{
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
@@ -30,18 +48,39 @@ class Menu extends BaseController
->with('error', '메뉴를 관리하려면 먼저 지자체를 선택하세요.');
}
$types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
$mtIdx = (int) ($this->request->getGet('mt_idx') ?? 0);
if ($mtIdx <= 0 && ! empty($types)) {
// 기본 선택: 사이트 메뉴(mt_code=site), 없으면 첫 번째 타입
$siteType = $this->typeModel->where('mt_code', 'site')->first();
$mtIdx = $siteType ? (int) $siteType->mt_idx : (int) $types[0]->mt_idx;
}
$list = $mtIdx > 0 ? $this->menuModel->getAllByType($mtIdx, $lgIdx) : [];
$requestedMtIdx = (int) ($this->request->getGet('mt_idx') ?? 0);
$mtIdx = $this->resolveMtIdx($requestedMtIdx, $types);
$effectiveMtIdx = $mtIdx;
$debugMode = $this->request->getGet('debug') === '1';
$fallbackApplied = false;
$list = $effectiveMtIdx > 0 ? $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx) : [];
$currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
$currentTypeCode = (string) ($currentType->mt_code ?? '');
// 현재 지자체에 메뉴가 없으면, mt_idx별로 기본 지자체(lg_idx=1)의 메뉴를 한 번 복사한다.
if ($mtIdx > 0 && empty($list)) {
$this->menuModel->copyDefaultsFromLg($mtIdx, 1, $lgIdx);
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
if ($effectiveMtIdx > 0 && empty($list)) {
$this->menuModel->copyDefaultsFromLg($effectiveMtIdx, 1, $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);
}
$currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
return view('admin/layout', [
'title' => '메뉴 관리',
'content' => view('admin/menu/index', [
'types' => $types,
'mtIdx' => $mtIdx,
'mtCode' => $currentType->mt_code ?? '',
'mtCode' => $currentTypeCode,
'list' => $list,
'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()
{
if ($deny = $this->denyUnlessLevel4Plus(true)) {
return $deny;
}
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
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) {
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);
return $this->response->setJSON(['status' => 1, 'data' => $list]);
}
@@ -86,6 +142,9 @@ class Menu extends BaseController
*/
public function store()
{
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
@@ -96,10 +155,10 @@ class Menu extends BaseController
$mmDep = (int) $this->request->getPost('mm_dep');
$mmName = trim((string) $this->request->getPost('mm_name'));
if ($mtIdx <= 0) {
return redirect()->back()->with('error', '메뉴 종류를 선택하세요.');
return $this->menusRedirect($mtIdx)->with('error', '메뉴 종류를 선택하세요.');
}
if ($mmName === '') {
return redirect()->back()->with('error', '메뉴명을 입력하세요.');
return $this->menusRedirect($mtIdx)->with('error', '메뉴명을 입력하세요.');
}
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
$data = [
@@ -118,7 +177,9 @@ class Menu extends BaseController
if ($mmPidx > 0) {
$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)
{
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
@@ -133,10 +197,12 @@ class Menu extends BaseController
}
$row = $this->menuModel->find($id);
if (! $row) {
return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.');
return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '메뉴를 찾을 수 없습니다.');
}
if ((int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
return $this->menusRedirect((int) $row->mt_idx)
->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
}
$data = [
'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',
];
$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)
{
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
@@ -160,13 +231,16 @@ class Menu extends BaseController
}
$row = $this->menuModel->find($id);
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);
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()
{
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.');
}
$ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_idx');
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);
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', '순서가 적용되었습니다.');
}
/**
@@ -210,4 +295,57 @@ class Menu extends BaseController
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 이상만 접근할 수 있습니다.');
}
}

View File

@@ -23,8 +23,8 @@ class PackagingUnit extends BaseController
{
helper('admin');
$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->unitModel->where('pu_lg_idx', $lgIdx);
@@ -38,31 +38,26 @@ class PackagingUnit extends BaseController
$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')->paginate(20);
$list = $builder->orderBy('pu_bag_code', 'ASC')->orderBy('pu_start_date', 'DESC')->paginate(20);
$pager = $this->unitModel->pager;
return view('admin/layout', [
'title' => '포장 단위 관리',
'content' => view('admin/packaging_unit/index', [
'list' => $list, 'startDate' => $startDate, 'endDate' => $endDate, 'pager' => $pager,
]),
return $this->renderWorkPage('포장 단위 관리', 'admin/packaging_unit/index', [
'list' => $list, 'startDate' => $startDate, 'endDate' => $endDate, 'pager' => $pager,
]);
}
public function create()
{
helper('admin');
if (!admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/packaging-units'))->with('error', '지자체를 선택해 주세요.');
if (! admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('packaging-units'))->with('error', '지자체를 선택해 주세요.');
}
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
$lgIdx = admin_effective_lg_idx();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
return view('admin/layout', [
'title' => '포장 단위 등록',
'content' => view('admin/packaging_unit/create', ['bagCodes' => $bagCodes]),
]);
return $this->renderWorkPage('포장 단위 등록', 'admin/packaging_unit/create', ['bagCodes' => $bagCodes]);
}
public function store()
@@ -83,10 +78,10 @@ class PackagingUnit extends BaseController
}
$bagCode = $this->request->getPost('pu_bag_code');
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagName = '';
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 : '';
}
@@ -107,32 +102,30 @@ class PackagingUnit extends BaseController
'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)
{
helper('admin');
$item = $this->unitModel->find($id);
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
}
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
$lgIdx = admin_effective_lg_idx();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
return view('admin/layout', [
'title' => '포장 단위 수정',
'content' => view('admin/packaging_unit/edit', ['item' => $item, 'bagCodes' => $bagCodes]),
]);
return $this->renderWorkPage('포장 단위 수정', 'admin/packaging_unit/edit', ['item' => $item, 'bagCodes' => $bagCodes]);
}
public function update(int $id)
{
helper('admin');
$item = $this->unitModel->find($id);
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
}
$rules = [
@@ -150,18 +143,35 @@ class PackagingUnit extends BaseController
$db = \Config\Database::connect();
$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) {
$oldVal = (string) $item->$field;
$newVal = (string) $this->request->getPost($field);
$oldRaw = $item->$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) {
$this->historyModel->insert([
'puh_pu_idx' => $id,
'puh_field' => $field,
'puh_old_value' => $oldVal,
'puh_new_value' => $newVal,
'puh_changed_at'=> date('Y-m-d H:i:s'),
'puh_changed_by'=> session()->get('mb_idx'),
'puh_pu_idx' => $id,
'puh_field' => $fieldLabels[$field] ?? $field,
'puh_old_value' => $oldVal,
'puh_new_value' => $newVal,
'puh_changed_at' => date('Y-m-d H:i:s'),
'puh_changed_by' => session()->get('mb_idx'),
]);
}
}
@@ -180,34 +190,33 @@ class PackagingUnit extends BaseController
]);
$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)
{
helper('admin');
$item = $this->unitModel->find($id);
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
}
$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)
{
helper('admin');
$item = $this->unitModel->find($puIdx);
if (!$item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
if (! $item || (int) $item->pu_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('packaging-units'))->with('error', '포장 단위를 찾을 수 없습니다.');
}
$list = $this->historyModel->where('puh_pu_idx', $puIdx)->orderBy('puh_changed_at', 'DESC')->findAll();
return view('admin/layout', [
'title' => '포장 단위 변경 이력 — ' . $item->pu_bag_name,
'content' => view('admin/packaging_unit/history', ['item' => $item, 'list' => $list]),
]);
return $this->renderWorkPage('포장 단위 변경 이력 — ' . $item->pu_bag_name, 'admin/packaging_unit/history', ['item' => $item, 'list' => $list]);
}
}

View File

@@ -9,14 +9,14 @@ class PasswordChange extends BaseController
{
public function index()
{
return view('admin/layout', [
'title' => '비밀번호 변경',
'content' => view('admin/password_change/index'),
]);
helper('admin');
return $this->renderWorkPage('비밀번호 변경', 'admin/password_change/index');
}
public function update()
{
helper('admin');
$rules = [
'current_password' => 'required',
'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),
]);
return redirect()->to(site_url('admin/password-change'))->with('success', '비밀번호가 변경되었습니다.');
return redirect()->to(mgmt_url('password-change'))->with('success', '비밀번호가 변경되었습니다.');
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
@@ -7,6 +9,8 @@ use App\Models\SalesAgencyModel;
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;
public function __construct()
@@ -18,106 +22,157 @@ class SalesAgency extends BaseController
{
helper('admin');
$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', '지자체를 선택해 주세요.');
}
$list = $this->model->where('sa_lg_idx', $lgIdx)->orderBy('sa_idx', 'DESC')->paginate(20);
$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') ?? ''));
$builder = $this->model->where('sa_lg_idx', $lgIdx);
if ($saKind !== '') {
$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;
return view('admin/layout', [
'title' => '판매 대행소 관리',
'content' => view('admin/sales_agency/index', ['list' => $list, 'pager' => $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()
{
return view('admin/layout', [
'title' => '판매 대행소 등록',
'content' => view('admin/sales_agency/create'),
]);
helper('admin');
if (! admin_effective_lg_idx()) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
return $this->renderWorkPage('판매 대행소 등록', 'admin/sales_agency/create');
}
public function store()
{
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 = [
'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]',
'sa_kind' => 'required|max_length[50]',
'sa_code' => 'required|max_length[50]',
'sa_name' => 'required|max_length[100]',
];
if (! $this->validate($rules)) {
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([
'sa_lg_idx' => admin_effective_lg_idx(),
'sa_name' => $this->request->getPost('sa_name'),
'sa_biz_no' => $this->request->getPost('sa_biz_no') ?? '',
'sa_rep_name' => $this->request->getPost('sa_rep_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_lg_idx' => $lgIdx,
'sa_kind' => trim((string) $this->request->getPost('sa_kind')),
'sa_code' => $code,
'sa_name' => trim((string) $this->request->getPost('sa_name')),
'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)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
if (! $item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
}
return view('admin/layout', [
'title' => '판매 대행소 수정',
'content' => view('admin/sales_agency/edit', ['item' => $item]),
]);
return $this->renderWorkPage('판매 대행소 수정', 'admin/sales_agency/edit', ['item' => $item]);
}
public function update(int $id)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
$lgIdx = admin_effective_lg_idx();
$item = $this->model->find($id);
if (! $item || ! $lgIdx || (int) $item->sa_lg_idx !== $lgIdx) {
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
}
if (! $this->model->hasKindCodeColumns()) {
return redirect()->back()->withInput()->with('error', self::SCHEMA_ERROR);
}
$rules = [
'sa_name' => 'required|max_length[100]',
'sa_state' => 'required|in_list[0,1]',
'sa_kind' => 'required|max_length[50]',
'sa_code' => 'required|max_length[50]',
'sa_name' => 'required|max_length[100]',
];
if (! $this->validate($rules)) {
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, [
'sa_name' => $this->request->getPost('sa_name'),
'sa_biz_no' => $this->request->getPost('sa_biz_no') ?? '',
'sa_rep_name' => $this->request->getPost('sa_rep_name') ?? '',
'sa_tel' => $this->request->getPost('sa_tel') ?? '',
'sa_addr' => $this->request->getPost('sa_addr') ?? '',
'sa_state' => (int) $this->request->getPost('sa_state'),
'sa_kind' => trim((string) $this->request->getPost('sa_kind')),
'sa_code' => $code,
'sa_name' => trim((string) $this->request->getPost('sa_name')),
]);
return redirect()->to(site_url('admin/sales-agencies'))->with('success', '판매 대행소가 수정되었습니다.');
return redirect()->to(mgmt_url('sales-agencies'))->with('success', '판매 대행소가 수정되었습니다.');
}
public function delete(int $id)
{
helper('admin');
$item = $this->model->find($id);
if (!$item || (int) $item->sa_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
$lgIdx = admin_effective_lg_idx();
$item = $this->model->find($id);
if (! $item || ! $lgIdx || (int) $item->sa_lg_idx !== $lgIdx) {
return redirect()->to(mgmt_url('sales-agencies'))->with('error', '대행소를 찾을 수 없습니다.');
}
$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

View File

@@ -26,37 +26,52 @@ class ShopOrder extends BaseController
{
helper('admin');
$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);
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
if ($startDate) $builder->where('so_delivery_date >=', $startDate);
if ($endDate) $builder->where('so_delivery_date <=', $endDate);
if ($startDate) {
$builder->where('so_delivery_date >=', $startDate);
}
if ($endDate) {
$builder->where('so_delivery_date <=', $endDate);
}
$list = $builder->orderBy('so_idx', 'DESC')->paginate(20);
$list = $builder->orderBy('so_idx', 'DESC')->paginate(20);
$pager = $this->orderModel->pager;
return view('admin/layout', [
'title' => '주문 접수 관리',
'content' => view('admin/shop_order/index', compact('list', 'startDate', 'endDate', 'pager')),
]);
return $this->renderWorkPage('주문 접수 관리', 'admin/shop_order/index', compact('list', 'startDate', 'endDate', 'pager'));
}
public function create()
{
helper('admin');
$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();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true) : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$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', [
'title' => '주문 접수',
'content' => view('admin/shop_order/create', compact('shops', 'bagCodes')),
]);
return $this->renderWorkPage('주문 접수', 'admin/shop_order/create', compact('shops', 'bagCodes', 'priceMap', 'unitMap'));
}
public function store()
@@ -65,9 +80,9 @@ class ShopOrder extends BaseController
$lgIdx = admin_effective_lg_idx();
$rules = [
'so_ds_idx' => 'required|is_natural_no_zero',
'so_delivery_date'=> 'required|valid_date[Y-m-d]',
'so_payment_type' => 'required|in_list[이체,가상계좌]',
'so_ds_idx' => 'required|is_natural_no_zero',
'so_delivery_date' => 'required|valid_date[Y-m-d]',
'so_payment_type' => 'required|in_list[이체,가상계좌]',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
@@ -77,57 +92,78 @@ class ShopOrder extends BaseController
$db->transStart();
$dsIdx = (int) $this->request->getPost('so_ds_idx');
$shop = model(DesignatedShopModel::class)->find($dsIdx);
$shop = model(DesignatedShopModel::class)->find($dsIdx);
$this->orderModel->insert([
'so_lg_idx' => $lgIdx,
'so_ds_idx' => $dsIdx,
'so_ds_name' => $shop ? $shop->ds_name : '',
'so_order_date' => date('Y-m-d'),
'so_delivery_date'=> $this->request->getPost('so_delivery_date'),
'so_payment_type' => $this->request->getPost('so_payment_type'),
'so_status' => 'normal',
'so_orderer_idx' => session()->get('mb_idx'),
'so_regdate' => date('Y-m-d H:i:s'),
]);
$orderData = [
'so_lg_idx' => $lgIdx,
'so_ds_idx' => $dsIdx,
'so_ds_name' => $shop ? $shop->ds_name : '',
'so_order_date' => date('Y-m-d'),
'so_delivery_date' => $this->request->getPost('so_delivery_date'),
'so_payment_type' => $this->request->getPost('so_payment_type'),
'so_status' => 'normal',
'so_orderer_idx' => session()->get('mb_idx'),
'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();
if ($soIdx <= 0) {
$db->transRollback();
return redirect()->back()->withInput()->with('error', '주문번호 생성에 실패했습니다. DB 스키마를 확인해 주세요.');
}
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
$qtys = $this->request->getPost('item_qty') ?? [];
$totalQty = 0; $totalAmt = 0;
$totalQty = 0;
$totalAmt = 0;
foreach ($bagCodes as $i => $code) {
if (empty($code) || empty($qtys[$i])) continue;
if (empty($code) || empty($qtys[$i])) {
continue;
}
$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;
$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();
$boxCount = 0; $packCount = 0; $sheetCount = $qty;
$boxCount = 0;
$packCount = 0;
$sheetCount = $qty;
if ($unit && (int) $unit->pu_total_per_box > 0) {
$boxCount = intdiv($qty, (int) $unit->pu_total_per_box);
$remainder = $qty % (int) $unit->pu_total_per_box;
$boxCount = intdiv($qty, (int) $unit->pu_total_per_box);
$remainder = $qty % (int) $unit->pu_total_per_box;
if ((int) $unit->pu_pack_per_sheet > 0) {
$packCount = intdiv($remainder, (int) $unit->pu_pack_per_sheet);
$packCount = intdiv($remainder, (int) $unit->pu_pack_per_sheet);
$sheetCount = $remainder % (int) $unit->pu_pack_per_sheet;
}
}
$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;
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null;
$this->itemModel->insert([
'soi_so_idx' => $soIdx,
'soi_bag_code' => $code,
'soi_bag_name' => $detail ? $detail->cd_name : '',
'soi_unit_price' => $unitPrice,
'soi_qty' => $qty,
'soi_amount' => $amount,
'soi_box_count' => $boxCount,
'soi_pack_count' => $packCount,
'soi_sheet_count'=> $sheetCount,
'soi_so_idx' => $soIdx,
'soi_bag_code' => $code,
'soi_bag_name' => $detail ? $detail->cd_name : '',
'soi_unit_price' => $unitPrice,
'soi_qty' => $qty,
'soi_amount' => $amount,
'soi_box_count' => $boxCount,
'soi_pack_count' => $packCount,
'soi_sheet_count' => $sheetCount,
]);
$totalQty += $qty;
@@ -137,18 +173,19 @@ class ShopOrder extends BaseController
$this->orderModel->update($soIdx, ['so_total_qty' => $totalQty, 'so_total_amount' => $totalAmt]);
$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)
{
helper('admin');
$order = $this->orderModel->find($id);
if (!$order || (int) $order->so_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(site_url('admin/shop-orders'))->with('error', '주문을 찾을 수 없습니다.');
if (! $order || (int) $order->so_lg_idx !== admin_effective_lg_idx()) {
return redirect()->to(mgmt_url('shop-orders'))->with('error', '주문을 찾을 수 없습니다.');
}
$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', '주문이 취소되었습니다.');
}
}

View File

@@ -121,8 +121,10 @@ class User extends BaseController
if (! $member) {
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
}
$member->mb_email = pii_decrypt($member->mb_email ?? '');
$member->mb_phone = pii_decrypt($member->mb_phone ?? '');
$email = pii_decrypt($member->mb_email ?? '');
$phone = pii_decrypt($member->mb_phone ?? '');
$member->mb_email = $email;
$member->mb_phone = $phone;
return view('admin/layout', [
'title' => '회원 수정',
'content' => view('admin/user/edit', [
@@ -177,6 +179,23 @@ class User extends BaseController
return redirect()->to(site_url('admin/users'))->with('success', '회원 정보가 수정되었습니다.');
}
/**
* 로그인 실패 누적 잠금(mb_locked_until) 해제 — 비밀번호는 그대로 두고 재시도만 가능하게 함
*/
public function unlockLogin(int $id)
{
$member = $this->memberModel->find($id);
if (! $member) {
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
}
$this->memberModel->update($id, [
'mb_login_fail_count' => 0,
'mb_locked_until' => null,
]);
return redirect()->back()->with('success', '로그인 잠금이 해제되었습니다.');
}
/**
* 현재 로그인한 관리자가 부여 가능한 역할 목록.
* super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만.

View 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);
}
}

View File

@@ -22,7 +22,10 @@ class Auth extends BaseController
return redirect()->to('/');
}
return view('auth/login');
return view('auth/login', [
'pageTitle' => '로그인 - GBLS',
'cardMax' => 'max-w-md',
]);
}
public function login()
@@ -156,7 +159,9 @@ class Auth extends BaseController
}
return view('auth/login_two_factor', [
'memberId' => $member->mb_id,
'memberId' => $member->mb_id,
'pageTitle' => '2차 인증 - GBLS',
'cardMax' => 'max-w-md',
]);
}
@@ -239,6 +244,8 @@ class Auth extends BaseController
'memberId' => $member->mb_id,
'qrDataUri' => $qrDataUri,
'secret' => $secret,
'pageTitle' => '2차 인증 등록 - GBLS',
'cardMax' => 'max-w-lg',
]);
}
@@ -341,6 +348,8 @@ class Auth extends BaseController
return view('auth/register', [
'localGovernments' => $localGovernments,
'pageTitle' => '회원가입 - GBLS',
'cardMax' => 'max-w-md',
]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -42,4 +42,49 @@ abstract class BaseController extends Controller
// Preload any models, libraries, etc, here.
// $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,
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Controllers;
use App\Libraries\GovPortalCodeKindsPage;
use App\Models\LocalGovernmentModel;
class Home extends BaseController
@@ -9,18 +10,261 @@ class Home extends BaseController
public function index()
{
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');
}
/**
* 로그인 후 원래 메인 화면 (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()
{
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(),
]),
]);
}
/**
@@ -62,15 +306,128 @@ class Home extends BaseController
}
/**
* dense(표·KPI) + charts(Chart.js) 혼합. URL: /dashboard/blend
* /dashboard 와 동일 본문(호환 URL)
*/
public function dashboardBlend()
{
return view('bag/lg_dashboard_blend', [
'lgLabel' => $this->resolveLgLabel(),
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);
}
/**
* 재고 조회(수불) 화면 (목업)
*/
@@ -100,9 +457,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string
{
try {
$idx = session()->get('mb_lg_idx');
if ($idx === null || $idx === '') {
return '로그인 지자체 (미지정)';
helper('admin');
$idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
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);
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -112,6 +474,7 @@ class Home extends BaseController
// 테이블 미생성 등
}
return '북구 (데모)';
return '지자체';
}
}

View 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. 화면별 설명은 어디에?
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
- **발주·입고** / **재고·실사** / **판매·반품·불출·주문** / **현황·리포트·수불** / **기본정보(판매소·단가·코드)**
각 화면 설명은 **그 화면 고유의 용어·입력 항목·버튼·작업 순서**만 담았습니다.

View 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. 비밀번호·계정 문제
- **비밀번호를 바꾸거나 분실**한 경우, 계정·권한 변경은 **담당 관리자**가 처리합니다. 관리자에게 문의하세요.
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.

View 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. 도움말 보는 법
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.

View File

@@ -0,0 +1,36 @@
# 핵심 업무 흐름
봉투 한 묶음이 시스템에서 거치는 전체 흐름입니다. 처음 사용하신다면 이 순서대로 익히는 것을 권장합니다.
## 전체 흐름
```
발주 ─→ 입고 ─→ 재고(실사) ─→ 판매 / 불출 ─→ 판매현황 · 수불 · 통계
```
| 단계 | 무엇을 하나 | 주요 메뉴 |
|---|---|---|
| ① 발주 | 봉투 종류·수량을 제작업체에 주문 | 발주 입고 관리 발주 등록 |
| ② 입고 | 도착한 물량을 시스템에 등록(스캐너/일괄) | 발주 입고 관리 입고 |
| ③ 재고 | 현재 보유 수량 확인, 실사로 실수량 보정 | 재고 관리 |
| ④ 판매 | 지정판매소에 판매·반품 처리 | 판매 관리 |
| ④ 불출 | 무료 대상자에게 무상 지급 | 불출 관리 |
| ⑤ 현황 | 일·기간·연간 판매 및 수불·통계 조회 | 판매 현황 / 봉투 수불 / 통계 분석 |
## 각 단계 한 줄 요약
1. **발주** — 봉투 품목·수량·납기를 입력해 발주서를 만들면 추적용 **LOT 번호**가 부여됩니다.
2. **입고** — 발주분이 도착하면 입고로 등록합니다. 이때 박스·팩·낱장 단위의 **바코드**가 생성됩니다.
3. **재고** — 품목별 현재 재고를 조회하고, 정기적으로 **실사**(선별 → 등록 → 적용)로 실제 수량과 맞춥니다.
4. **판매/불출** — 지정판매소 판매·반품, 또는 무료 대상자 불출로 재고가 감소합니다.
5. **현황·통계** — 일계표·기간별·연간 판매와 봉투 수불, 전년대비/월별/계절 추이를 확인합니다.
## 봉투 추적 단위
봉투는 다음 계층으로 추적됩니다. 자세한 코드 규칙은 **[봉투·LOT·바코드 코드체계]** 문서를 참고하세요.
```
LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장)
```
> 코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 번호알기** 화면을 사용하세요.

View File

@@ -0,0 +1,66 @@
# 발주 · 입고
봉투를 제작업체에 **주문(발주)** 하고, 도착한 봉투를 창고에 **들여놓는(입고)** 단계입니다.
---
## 발주 등록 · *발주 입고 관리 발주 등록*
봉투를 **얼마나 주문할지** 입력해 발주서를 만드는 화면입니다. 저장하면 추적용 **LOT 번호**가 자동으로 붙습니다.
**이 화면의 용어**
- **발주가능봉투**: 조달청(나라장터)에 등록되어 주문할 수 있는 봉투 종류.
- **입고처**: 들어온 봉투를 받을 창고/장소.
- **조달수수료**: 발주 금액에 붙는 수수료율(%).
- **Box당 팩 / 팩당 낱장 / 1박스 총 낱장**: 포장 환산 정보(참고용 표).
**입력 항목**: 발주월, 발주일, 협회, 제작업체, 입고처, **봉투 품목별 수량(박스 단위)**.
**버튼**: `발주`(저장) · `변경 저장`(수정 시) · `취소`.
**작업 순서**
1. 발주월·발주일, 제작업체·입고처를 고릅니다.
2. 아래 봉투 종류별로 **주문할 박스 수량**을 입력합니다(금액·총 낱장은 자동 계산).
3. `발주` 를 누르면 발주가 생성되고 LOT 번호가 부여됩니다.
---
## 발주 현황 · *발주 입고 관리 발주 현황*
낸 발주를 **조회·관리**하는 목록 화면입니다.
**필터**: 발주기간(월~월) · 제작업체 · 품명 · **입고구분(전체/입고완료/미입고)**.
**표 컬럼**: 발주일자 · 제작업체 · 품명 · **발주수량 · 입고수량 · 미입고수량** · 발주금액 · 입고처 · 비고.
- **미입고수량** = 발주했지만 아직 안 들어온 수량.
**버튼**: `엑셀저장` · `인쇄` · `발주등록`. (목록에서 개별 발주의 상세·취소 가능)
---
## 입고 처리 · *발주 입고 관리 입고[스캐너] / 일괄입고*
도착한 봉투를 시스템에 **들여놓는** 화면입니다. 입고하면 박스·팩·낱장 **바코드가 생성**되고 재고가 늘어납니다.
**이 화면의 용어**
- **인계자(제작업체)** / **인수자(대행소)**: 봉투를 넘기는 쪽 / 받는 쪽.
- **입고량(매)**: 실제로 들어온 **낱장 수**("매" = 장).
- **LOT NO / 발주 NO**: 어떤 발주분인지 식별하는 번호.
**입고[스캐너]**: 발주 건을 보고 행마다 **입고량(매)** 을 직접 입력 → `입고처리`.
**일괄입고**: 여러 발주 건을 **체크박스로 골라** 한 번에 입고. 미입고량은 파란색으로 강조됩니다.
**작업 순서**
1. 제작업체·인수자·인계자·입고일을 고릅니다.
2. 들어온 만큼 **입고량(매)** 을 입력(또는 일괄 선택)합니다.
3. `입고처리` → 재고 반영. **재고 관리**에서 수량이 늘었는지 확인하세요.
---
## 입고 현황 · *발주 입고 관리 입고 현황*
입고 기록을 기간별로 조회합니다.
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
**버튼**: `엑셀저장` · `인쇄`.

View File

@@ -0,0 +1,39 @@
# 재고 · 실사
지금 창고에 **남은 봉투**를 확인하고, 컴퓨터 기록과 **실제 수량을 맞추는(실사)** 단계입니다.
---
## 재고 현황 · *재고 관리 재고 현황*
품목별로 **현재 남은 수량**을 봅니다.
**이 화면의 용어**
- **시군구재고**: 지자체(시·군·구) **창고**에 있는 재고.
- **대행소재고**: 배송 **대행소**가 보유 중인 재고.
- **계**: 둘을 합친 총 재고.
**필터**: 기준일자 · 대행소(전체/선택).
**표 컬럼**: 품목구분 · 봉투/스티커종류 · **계 · 시군구재고 · 대행소재고**.
**버튼**: `조회` · `엑셀저장` · `인쇄` · `실사선별조회`.
---
## 실사 (재고 확인) · *재고 관리 실사 선별 조회 / 실사 선별 관리*
**실사**는 시스템에 적힌 수량(전산재고)과 **창고에서 직접 센 수량(실사재고)** 을 비교해 차이를 바로잡는 작업입니다.
**이 화면의 용어**
- **전산재고**: 시스템 기록상 수량.
- **실사재고**: 현장에서 직접 센 수량(직접 입력).
- **차이**: 실사 전산. (양수 = 더 많음, 음수 = 부족)
- **박스 / 팩 / 낱장**: 셀 단위. 팩코드·낱장(시작~끝) 구간으로 표시됩니다.
**작업 순서**
1. **실사 선별**: 실사할 기간·품목을 골라 대상 목록을 만듭니다(팝업에서 작업일자·품목 선택).
2. **실사재고 입력**: 팩/박스별로 실제 센 수량을 입력하면 **차이**가 자동 표시됩니다.
3. **저장(적용)**: 검토 후 적용하면 차이가 재고에 반영됩니다.
**주요 표 컬럼**: 팩코드 · 포장량 · 재고(전산) · **실사재고(입력)** · 차이 · 낱장(시작) · 낱장(끝).
> 적용 전까지는 재고에 영향을 주지 않으므로, 세는 도중 중단해도 안전합니다.

View File

@@ -0,0 +1,83 @@
# 판매 · 반품 · 불출 · 주문
재고를 외부로 내보내는 단계입니다. **판매**(가게에 유상 공급)·**불출**(무료 배부)·**주문 접수**(전화 등).
---
## 지정판매소 판매 · *판매 관리 지정 판매소 판매*
동네 가게(지정판매소)에 봉투를 **판매**하고, 어떤 봉투를 줬는지 **바코드로 기록**합니다.
**이 화면의 용어**
- **판매소코드/상호/대표자**: 판매하는 가게 정보(검색해서 선택).
- **봉투코드(스캔)**: 내보내는 봉투의 바코드. 스캔/입력하면 어떤 LOT·포장단위인지 식별됩니다.
- **포장단위(Box/Pack/Sheet)**: 박스/팩/낱장.
**입력/순서**
1. 위에서 **판매소를 검색·선택**합니다(코드·상호·전화·주소로 검색).
2. 판매할 봉투 종류·수량을 고르거나 **봉투코드를 스캔**합니다.
3. `판매저장` → 재고가 줄고 판매 내역이 기록됩니다.
**표 컬럼**: (판매내역) 봉투종류·접수량·판매량·단가·판매금액 / (상세) 봉투종류·봉투코드·수량·포장단위.
---
## 지정 판매소 반품 / 판매·반품 취소 · *판매 관리*
- **반품**: 가게가 안 팔린 봉투를 **되돌려 받는** 것. 스캔/선택 후 저장하면 재고가 다시 늘어납니다.
- **판매 취소 / 반품 취소**: 잘못 처리한 건을 되돌립니다.
---
## 판매/반품 현황 · *판매 관리* 또는 *판매 현황*
기간별 판매·반품 내역을 봅니다.
**필터**: 조회기간.
**표 컬럼**: 판매소 · 판매일 · 봉투코드 · 봉투명 · 수량 · 단가 · 금액 · **구분(판매/반품/취소)**.
**버튼**: `조회` · `초기화` · `주문등록` · `판매등록`.
---
## 전화 주문 접수 · *판매 관리 전화 접수*
가게가 전화로 주문한 내용을 **접수**합니다(실제 출고/판매는 이후 단계).
**이 화면의 용어**
- **접수일 / 배달일**: 주문 받은 날 / 가져다줄 날(보통 다음날 자동).
- **결제구분**: 이체 / 가상계좌.
- **1박스·1팩(낱장/판매가)**: 포장별 수량·가격 참고값.
**입력/순서**
1. **판매소 검색·선택**(코드·사업자번호·상호·전화·주소).
2. 결제구분을 고르고, 봉투 **품목·주문수량·포장단위(박스/팩/낱장)** 를 입력(`행추가`로 여러 품목).
3. `등록` → 주문 접수 완료.
> **주문 접수(간편)**: 판매소·배달일·결제방법과 봉투별 수량만 입력하는 간단 버전.
---
## 무료용 불출 처리 · *불출 관리 무료용 불출 처리*
무료 대상자(동사무소 등)에게 봉투를 **무상으로 내보내는(불출)** 화면입니다.
**이 화면의 용어**
- **불출구분(무료용/공공용)**: 주민 무료 배부용 / 공공기관용.
- **불출처**: 봉투를 최종 전달할 곳(동사무소·구청·기타).
- **재고(낱장) / 환산(낱장)**: 현재 재고 / 입력 수량을 낱장으로 환산한 값.
**입력/순서**
1. 불출년도·분기, 불출구분, 불출일, **불출처(동)** 를 고릅니다.
2. **바코드 스캔** 또는 `행추가`로 봉투 종류·수량·포장단위를 입력합니다.
3. `저장` → 재고가 줄고 불출 내역이 기록됩니다.
**표 컬럼**: 봉투코드 · 봉투종류 · 수량 · 포장 · 재고(낱장) · 환산(낱장).
---
## 무료용 불출 취소 · *불출 관리 무료용 불출 취소*
잘못 불출한 건을 **되돌려 재고를 복원**합니다.
**필터**: 불출월 · 불출처 · 불출구분 · 봉투종류.
**순서**: 불출 목록에서 건을 고르고, 품목 내역에서 **취소할 항목을 체크 → 취소수량 입력** 후 처리.

View File

@@ -0,0 +1,63 @@
# 현황 · 리포트 · 수불
입고·판매·불출 기록을 **모아 보여주는** 조회 화면들입니다. 대부분 **기간을 지정해 조회**하고 `엑셀저장`·`인쇄`로 내보낼 수 있습니다.
---
## 기간별 봉투 수불 현황 · *봉투 수불 관리 기간별 봉투 수불 현황*
**수불(受拂)** = 들어오고 나간 움직임. 기간 동안 봉투가 얼마나 들어오고(입고) 나갔는지(판매·불출 등)를 한 표로 봅니다.
**이 화면의 용어**
- **전일재고**: 조회 시작일 **전날**의 재고.
- **입고**: 입고량 + 반품 + 기타.
- **출고**: 판매 + 무료불출 + 반품 + 기타.
- **잔량**: 전일재고 + 입고 출고.
**필터**: 조회기간 · 봉투형식 · 봉투구분 · 대행소 · **집계방식(일자별/기간별)**.
**표 컬럼**: 일자 · 품목 · 전일재고 · 입고(소계) · 출고(소계) · 잔량.
---
## 일계표 · *판매 현황 일계표*
하루치 판매를 **일계(당일)****누계(월 누적)** 로 집계합니다.
**용어**: **일계** = 그날 합계, **누계(월)** = 월초~당일 누적, **징수액** = 판매금액 수수료.
**필터**: 조회일자 · 대행소 · 구분.
**표**: 봉투종류별 — 일계(수량·판매금액·수수료·징수액) / 누계(월) 동일 항목.
---
## 지정 판매소별 판매현황 · *판매 현황 판매소별 판매현황*
판매소마다 **얼마나 팔았는지**(수량 또는 금액)를 월별로 비교합니다.
**필터**: 기간 · 읍면동 · 봉투종류 · **지표(수량/금액)**.
**표**: 판매소명 · 판매소코드 · 월별 값 · 합계.
---
## LOT 수불 조회 · *봉투 수불 관리 LOT 수불 조회*
특정 **봉투번호(바코드)** 또는 **LOT**의 입고·판매·반품 **이력**을 추적합니다.
**입력**: 봉투번호(바코드/팩코드/박스코드/낱장코드).
**표**: 일자 · 품목 · 포장단위 · **구분(입고/판매/반품)** · 수량 · LOT번호.
> 입력할 코드 형식이 헷갈리면 좌측 **[봉투·LOT·바코드 코드체계]** 또는 도움말의 **번호알기**를 참고하세요.
---
## 반품/파기 현황 · *봉투 수불 관리 반품/파기 현황*
**용어**: **반품** = 판매소가 되돌린 봉투(출고 탭) / **파기** = 반품분의 폐기(입고 탭).
**필터**: 조회기간 · 입출고구분.
**표**: 일자 · 판매소명 · 봉투종류 · 수량 · 구분(반품/파기).
---
## 그 밖의 현황
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
- **홈택스 처리**: 세금계산서용 데이터(엑셀) 생성.
- **통계 분석(전년대비·월별·계절 추이)**: 판매 추세를 그래프로.

View File

@@ -0,0 +1,69 @@
# 기본정보 (판매소 · 단가 · 코드)
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
---
## 지정판매소 관리 · *기본정보관리 지정 판매소 관리/조회*
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
**이 화면의 용어**
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
---
## 단가 관리 · *기본정보관리 단가 관리*
봉투 **가격**을 기간별로 관리합니다.
**이 화면의 용어**
- **발주단가**: 제작업체에 주는 가격(살 때).
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
- **판매단가**: 최종 소비자 판매가.
- **수수료율**: 판매수수료율(%).
- **적용시작/종료**: 그 단가가 유효한 기간.
**필터**: 봉투구분 · 봉투코드 · 조회기간.
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
> 조회 전용이며, 등록·수정은 `단가관리(CRUD)` 화면에서 합니다(이력 보존).
---
## 포장 단위 관리 · *기본정보관리 포장 단위 관리*
봉투 1박스·1팩에 **몇 장이 들어가는지** 정의합니다. 이 값으로 박스↔낱장이 환산됩니다.
**이 화면의 용어**
- **박스당 팩수**: 1박스 안의 팩 개수.
- **팩당 낱장수**: 1팩 안의 낱장(봉투) 수.
- **1박스 총 낱장** = 박스당 팩수 × 팩당 낱장수.
**표 컬럼**: 봉투코드 · 봉투명 · 박스당팩수 · 팩당낱장수 · 1박스총낱장 · 적용시작/종료 · 상태(사용/만료).
---
## 기본코드 관리 · *기본정보관리 기본 코드 관리*
시스템 곳곳의 **선택 항목(드롭다운)** 값을 관리합니다. 왼쪽에 **코드 종류**, 오른쪽에 그 종류의 **세부코드**가 나옵니다.
**이 화면의 용어**
- **코드 종류**: 분류(예: 봉투구분, 동코드, 결제구분, 불출구분).
- **세부코드**: 그 분류의 실제 값(예: 봉투구분 → 봉투/스티커).
**자주 쓰는 코드 종류**
- **봉투구분**(봉투/스티커) · **동코드**(지역 동) · **결제구분**(이체/가상계좌) · **불출구분**(무료용/공공용).
**표 컬럼**: 코드 · 코드명 · (세부코드 개수) · 상태(사용/미사용) · 작업(수정/삭제 — 관리자).
> 등록·수정·삭제는 슈퍼 관리자만 가능합니다. 조회는 누구나 가능합니다.
---
## 그 밖의 기본정보
- **판매 대행소 / 담당자 / 업체(제작·협회·회수) / 무료용 대상자 관리**: 각각 거래처·담당자·대상처를 등록·조회하는 목록 화면입니다(등록·수정은 관리자).

View 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
View File

@@ -0,0 +1,29 @@
# 자주 묻는 질문 · 문의
## 자주 묻는 질문
### Q. 로그인 후 업무 화면이 안 열려요.
슈퍼 관리자는 **작업할 지자체를 먼저 선택**해야 합니다. 상단 안내에 따라 지자체를 선택하세요. 일반/판매소 계정은 권한 범위 내 메뉴만 보입니다.
### Q. 메뉴가 안 보여요.
역할(권한)에 따라 노출 메뉴가 다릅니다. **시작하기 역할별 접근 한눈에 보기** 표를 확인하세요. 그래도 필요한 메뉴가 없으면 관리자에게 문의하세요.
### Q. 입고했는데 재고에 안 보여요.
입고가 정상 저장됐는지 **발주 입고 관리 입고 현황**에서 확인하고, **재고 관리 재고 현황**에서 품목·지자체 필터를 점검하세요.
### Q. 판매/불출을 잘못 처리했어요.
- 판매: **지정 판매소 판매 취소** 또는 **반품**으로 되돌립니다.
- 불출: **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
### Q. 봉투 코드(바코드)가 무슨 뜻인지 모르겠어요.
**도움말 번호알기(봉투번호확인)** 에 코드를 입력하면 바코드·인쇄숫자·인식번호로 분해해 보여줍니다. 형식은 **봉투·LOT·바코드 코드체계** 문서를 참고하세요.
### Q. 비밀번호를 바꾸고 싶어요.
**기본정보관리 PASSWORD 변경** 에서 변경할 수 있습니다.
### Q. 리포트를 엑셀/인쇄로 저장할 수 있나요?
대부분의 현황·리포트 화면에 **엑셀 내보내기**와 **인쇄** 기능이 있습니다. 이 매뉴얼 화면도 우측 상단 **인쇄** 버튼으로 출력할 수 있습니다.
## 문의
시스템 사용 중 문제가 있으면 시스템 운영 담당자 또는 소속 지자체 관리자에게 문의하세요.

View 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;
}
}

View File

@@ -6,8 +6,9 @@ use Config\Roles;
if (! function_exists('admin_effective_lg_idx')) {
/**
* 현재 로그인한 관리자가 작업 대상으로 사용하는 지자체 PK.
* Super/본부 관리자 → admin_selected_lg_idx, 지자체 관리자 → mb_lg_idx, 그 외 null.
* 관리자 화면·사이트 메뉴·Bag 등에서 쓰는 작업 지자체 PK.
* Super/본부 → admin_selected_lg_idx(미선택 시 null).
* 지자체관리자·지정판매소·일반 사용자 → mb_lg_idx(없으면 null).
*/
function admin_effective_lg_idx(): ?int
{
@@ -16,7 +17,9 @@ if (! function_exists('admin_effective_lg_idx')) {
$idx = session()->get('admin_selected_lg_idx');
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');
return $idx !== null && $idx !== '' ? (int) $idx : null;
}
@@ -24,6 +27,23 @@ 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')) {
/**
* 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열).
@@ -130,22 +150,31 @@ if (! function_exists('get_site_nav_tree')) {
function get_site_nav_tree(): array
{
try {
$lgIdx = session()->get('mb_lg_idx');
// 시민 등 지자체 정보가 세션에 없으면 기본 지자체(1) 기준으로 메뉴를 보여 준다.
if ($lgIdx === null || $lgIdx === '') {
$lgIdx = 1;
}
$typeRow = model(\App\Models\MenuTypeModel::class)->getByCode('site');
if (! $typeRow) {
return [];
}
$lgIdx = resolve_site_menu_lg_idx();
$mbLevel = (int) session()->get('mb_level');
$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)의 메뉴를 한 번 복사한 뒤 다시 시도
if (empty($flat)) {
$menuModel->copyDefaultsFromLg((int) $typeRow->mt_idx, 1, (int) $lgIdx);
$flat = $menuModel->getVisibleByLevel((int) $typeRow->mt_idx, $mbLevel, (int) $lgIdx);
$menuModel->copyDefaultsFromLg($siteMtIdx, 1, (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)) {
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) : '';
}
}

View File

@@ -67,3 +67,441 @@ if (! function_exists('csv_encode_row')) {
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;
}
}

View File

@@ -8,6 +8,7 @@ declare(strict_types=1);
*
* 저장 형식: 암호화된 값은 "ENC:" + base64(암호문) 으로 저장. "ENC:" 없으면 평문(기존)으로 간주.
*/
if (! function_exists('pii_encrypt')) {
function pii_encrypt(?string $value): string
{
@@ -21,9 +22,8 @@ if (! function_exists('pii_encrypt')) {
}
$encrypter = service('encrypter');
$encrypted = $encrypter->encrypt($value);
return 'ENC:' . base64_encode($encrypted);
} catch (Throwable) {
} catch (Throwable $e) {
return $value;
}
}
@@ -44,13 +44,23 @@ if (! function_exists('pii_decrypt')) {
return $value;
}
$encrypter = service('encrypter');
$raw = base64_decode(substr($value, 4), true);
if ($raw === false) {
return $value;
$payload = substr($value, 4);
// 현재 포맷: 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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' => '',
];
}
}

View 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);
}
}

View 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);
}
}

View 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,
],
];
}
}

View 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;
}
}

View 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',
];
}

View File

@@ -10,9 +10,26 @@ class BagOrderModel extends Model
protected $primaryKey = 'bo_idx';
protected $returnType = 'object';
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 = [
'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_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
'bo_regdate', 'bo_moddate',
];

View File

@@ -16,4 +16,45 @@ class BagPriceModel extends Model
'bp_start_date', 'bp_end_date', 'bp_state',
'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();
}
}

View 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',
];
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
@@ -12,6 +14,8 @@ class CodeDetailModel extends Model
protected $useTimestamps = false;
protected $allowedFields = [
'cd_ck_idx',
'cd_source',
'cd_lg_idx',
'cd_code',
'cd_name',
'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 ($activeOnly) {
$builder->where('cd_state', 1);
if ($effectiveLgIdx === null || $effectiveLgIdx < 1) {
return $this->where('cd_lg_idx', 0);
}
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();
}
}

View File

@@ -12,21 +12,31 @@ class DesignatedShopModel extends Model
protected $useTimestamps = false;
protected $allowedFields = [
'ds_lg_idx',
'ds_sa_idx',
'ds_mb_idx',
'ds_shop_no',
'ds_name',
'ds_biz_no',
'ds_rep_name',
'ds_biz_type',
'ds_biz_kind',
'ds_va_number',
'ds_va_bank',
'ds_va_account',
'ds_zip',
'ds_addr',
'ds_addr_jibun',
'ds_addr_detail',
'ds_tel',
'ds_rep_phone',
'ds_email',
'ds_gugun_code',
'ds_zone_code',
'ds_branch_no',
'ds_designated_at',
'ds_state',
'ds_state_changed_at',
'ds_change_reason',
'ds_regdate',
];
}

View File

@@ -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();
}
}

View File

@@ -16,4 +16,29 @@ class PackagingUnitModel extends Model
'pu_start_date', 'pu_end_date', 'pu_state',
'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;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
@@ -11,7 +13,34 @@ class SalesAgencyModel extends Model
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'sa_lg_idx', 'sa_name', 'sa_biz_no', 'sa_rep_name',
'sa_tel', 'sa_addr', 'sa_state', 'sa_regdate',
'sa_lg_idx',
'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');
}
}

View File

@@ -12,7 +12,7 @@ class ShopOrderModel extends Model
protected $useTimestamps = false;
protected $allowedFields = [
'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',
];
}

View File

@@ -3,7 +3,7 @@
<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-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>
<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>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
</section>
<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() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -64,7 +64,7 @@
<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>
<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>
</form>
</div>

View File

@@ -4,18 +4,18 @@
<span class="text-sm font-bold text-gray-700">무료용 불출 관리</span>
<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="<?= 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>
<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>
</section>
<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>
<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>
<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>
<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>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
@@ -31,7 +31,6 @@
<th>봉투코드</th>
<th>봉투명</th>
<th>수량</th>
<th class="w-20">상태</th>
<th class="w-24">작업</th>
</tr>
</thead>
@@ -47,9 +46,8 @@
<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><?= number_format((int) $row->bi2_qty) ?></td>
<td class="text-center"><?= esc($row->bi2_status) ?></td>
<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() ?>
<button type="submit" class="text-orange-600 hover:underline text-sm">취소</button>
</form>
@@ -57,7 +55,7 @@
</tr>
<?php endforeach; ?>
<?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; ?>
</tbody>
</table>

View File

@@ -1,83 +1,443 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">발주 등록</span>
</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">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label>
<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/>
<?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() ?>
<div class="border border-gray-300 bg-white p-2">
<div class="flex flex-wrap items-center gap-4 text-sm">
<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 class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label>
<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')) ?>"/>
<span class="text-sm text-gray-500">%</span>
</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_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">
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<section class="xl:col-span-5 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>
<div class="overflow-auto max-h-[410px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-16">순번</th>
<th>봉투</th>
<th class="w-32">박스수</th>
<th class="w-28">발주일</th>
<th>제작업체</th>
<th>입고처</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php for ($i = 0; $i < 3; $i++): ?>
<?php foreach (($recentOrders ?? []) as $history): ?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]">
<option value="">선택</option>
<?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>
<td class="text-center"><?= esc((string) $history->bo_order_date) ?></td>
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
</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>
</table>
</div>
</div>
</section>
<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>
<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>
<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 class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm order-input-table" id="order-item-table">
<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 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>
</form>
</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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
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>

View File

@@ -1,6 +1,6 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/bag-orders') ?>" class="text-blue-600 hover:underline text-sm">&larr; 발주 목록</a>
<a href="<?= mgmt_url('bag-orders') ?>" class="text-blue-600 hover:underline text-sm">&larr; 발주 목록</a>
<span class="text-gray-400">|</span>
<span class="text-sm font-bold text-gray-700">발주 상세 <?= esc($order->bo_lot_no) ?></span>
</div>

View File

@@ -1,81 +1,200 @@
<?= view('components/print_header', ['printTitle' => '발주 현황']) ?>
<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">
<span class="text-sm font-bold text-gray-700">발주 현황</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/bag-orders/export') . '?' . http_build_query(array_filter(['start_date' => $startDate ?? '', 'end_date' => $endDate ?? '', 'status' => $status ?? ''])) ?>" 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>
<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="<?= 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/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>
</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">
<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"/>
<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"/>
<label class="text-sm text-gray-600">상태</label>
<select name="status" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<option value="normal" <?= ($status ?? '') === 'normal' ? 'selected' : '' ?>>정상</option>
<option value="cancelled" <?= ($status ?? '') === 'cancelled' ? 'selected' : '' ?>>취소</option>
<option value="deleted" <?= ($status ?? '') === 'deleted' ? 'selected' : '' ?>>삭제</option>
</select>
<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>
<section class="no-print p-2 bg-white border-b border-gray-200">
<!-- GBMS 발주현황: 발주기간은 [시작] ~ [끝] 한 줄 고정, 필터 블록은 가로 나열 후 좁으면 블록 단위로만 줄바꿈 -->
<form method="GET" action="<?= mgmt_url('bag-orders') ?>" class="flex flex-wrap items-end gap-x-5 gap-y-3 w-full">
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">발주 기간</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">
<?php foreach ($bagOrderYmChoices as $ym): ?>
<option value="<?= esc($ym) ?>" <?= ($startMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
<?php endforeach; ?>
</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>
<a href="<?= mgmt_url('bag-orders') ?>" class="text-sm text-gray-500 hover:underline whitespace-nowrap">초기화</a>
</div>
</form>
</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>
<tr>
<th class="w-16">번호</th>
<th>LOT번호</th>
<th>발주일</th>
<th>제작업체</th>
<th>입고처</th>
<th>품목수</th>
<th>총수량</th>
<th>총금액</th>
<th class="w-20">상태</th>
<th class="w-44">작업</th>
<th class="w-32">발주일자</th>
<th class="min-w-[10rem]">제작 업체</th>
<th class="min-w-[12rem]">품 명</th>
<th class="w-28">발주 수량</th>
<th class="w-28">입고 수량</th>
<th class="w-28">미입고수량</th>
<th class="w-32">발주 금액</th>
<th class="min-w-[9rem]">입고처</th>
<th class="min-w-[8rem]">비 고</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($list as $row): ?>
<?php $printedGroup = []; ?>
<?php foreach (($rows ?? []) as $row): ?>
<?php if (! empty($row['is_subtotal'])): ?>
<tr class="bg-gray-50 font-semibold">
<td colspan="3" class="text-center"><?= esc((string) ($row['label'] ?? '소계')) ?></td>
<td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
<td></td>
<td></td>
</tr>
<?php continue; ?>
<?php endif; ?>
<?php
$boIdx = (int) ($row['bo_idx'] ?? 0);
$showGroup = ! isset($printedGroup[$boIdx]);
$rowspan = (int) (($groupRows[$boIdx] ?? 1));
if ($showGroup) {
$printedGroup[$boIdx] = true;
}
?>
<tr>
<td class="text-center"><?= esc($row->bo_idx) ?></td>
<td class="text-center font-mono"><?= esc($row->bo_lot_no) ?></td>
<td class="text-center"><?= esc($row->bo_order_date) ?></td>
<td class="text-left pl-2"><?= esc($companyMap[$row->bo_company_idx] ?? '') ?></td>
<td class="text-left pl-2"><?= esc($agencyMap[$row->bo_agency_idx] ?? '') ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['count'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['amount'] ?? 0)) ?></td>
<td class="text-center">
<?php
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
echo esc($statusMap[$row->bo_status] ?? $row->bo_status);
?>
</td>
<td class="text-center">
<a href="<?= base_url('admin/bag-orders/detail/' . (int) $row->bo_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">상세</a>
<form action="<?= base_url('admin/bag-orders/cancel/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-orange-600 hover:underline text-sm mr-1">취소</button>
</form>
<form action="<?= base_url('admin/bag-orders/delete/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form>
</td>
<?php if ($showGroup): ?>
<td class="text-center align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-left pl-2 align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<?php endif; ?>
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
<td></td>
</tr>
<?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; ?>
</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>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
<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>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">봉투 단가 등록</span>
</section>
<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() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -48,7 +48,7 @@
<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>
<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>
</form>
</div>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
</section>
<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() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -49,7 +49,7 @@
<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>
<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>
</form>
</div>

View File

@@ -1,6 +1,6 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/bag-prices') ?>" class="text-blue-600 hover:underline text-sm">&larr; 단가 목록</a>
<a href="<?= mgmt_url('bag-prices') ?>" class="text-blue-600 hover:underline text-sm">&larr; 단가 목록</a>
<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>
</div>

View File

@@ -1,21 +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">
<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">
<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="<?= 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>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/bag-prices') ?>" class="flex flex-wrap items-center gap-2">
<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"/>
<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"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= base_url('admin/bag-prices') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
<section class="no-print border border-gray-200 rounded-lg bg-white p-3 mt-2">
<form method="get" action="<?= mgmt_url('bag-prices') ?>" class="flex flex-wrap items-end gap-3" autocomplete="off">
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">봉투구분</label>
<select name="bag_kind_e" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[9rem]">
<option value="">전체</option>
<?php foreach ($bag_kind_options ?? [] as $cd): ?>
<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>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
@@ -31,13 +107,21 @@
<th>적용시작</th>
<th>적용종료</th>
<th class="w-20">상태</th>
<th class="w-36">작업</th>
<th class="w-36 no-print">작업</th>
</tr>
</thead>
<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>
<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-left pl-2"><?= esc($row->bp_bag_name) ?></td>
<td><?= number_format((float) $row->bp_order_price) ?></td>
@@ -46,10 +130,10 @@
<td class="text-center"><?= esc($row->bp_start_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">
<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="<?= base_url('admin/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('삭제하시겠습니까?');">
<td class="text-center no-print">
<a href="<?= mgmt_url('bag-prices/history/' . (int) $row->bp_idx) ?>" class="text-green-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="<?= mgmt_url('bag-prices/delete/' . (int) $row->bp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form>
@@ -62,4 +146,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
<?php if (isset($pager)): ?><div class="mt-3 no-print"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">입고 처리</span>
</section>
<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() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -47,7 +47,7 @@
<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>
<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>
</form>
</div>

View File

@@ -4,18 +4,18 @@
<span class="text-sm font-bold text-gray-700">입고 현황</span>
<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="<?= 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>
<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>
</section>
<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>
<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>
<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>
<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>
</section>
<div class="border border-gray-300 overflow-auto mt-2">

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">판매 등록</span>
</section>
<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() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -50,7 +50,7 @@
<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>
<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>
</form>
</div>

View File

@@ -3,14 +3,14 @@
<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-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>
<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="<?= 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>
<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>
</section>
<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>
<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>
@@ -23,7 +23,7 @@
<option value="cancel" <?= ($type ?? '') === 'cancel' ? 'selected' : '' ?>>취소</option>
</select>
<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>
</section>
<div class="border border-gray-300 overflow-auto mt-2">

View File

@@ -1,6 +1,6 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<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">&larr; <?= esc($kind->ck_name) ?></a>
<a href="<?= base_url('bag/code-details/' . (int) $kind->ck_idx) ?>" class="text-blue-600 hover:underline text-sm">&larr; <?= esc($kind->ck_name) ?></a>
<span class="text-gray-400">|</span>
<span class="text-sm font-bold text-gray-700">세부코드 등록</span>
</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"/>
</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">
<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>
</form>
</div>

View File

@@ -1,6 +1,6 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<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">&larr; <?= esc($kind->ck_name) ?></a>
<a href="<?= base_url('bag/code-details/' . (int) $kind->ck_idx) ?>" class="text-blue-600 hover:underline text-sm">&larr; <?= esc($kind->ck_name) ?></a>
<span class="text-gray-400">|</span>
<span class="text-sm font-bold text-gray-700">세부코드 수정</span>
</div>
@@ -39,7 +39,7 @@
<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>
<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>
</form>
</div>

View File

@@ -1,49 +0,0 @@
<?= view('components/print_header', ['printTitle' => '세부코드 관리 - ' . esc($kind->ck_name)]) ?>
<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">&larr; 코드 종류</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>
<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="<?= 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>
</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>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -17,7 +17,7 @@
<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>
<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>
</form>
</div>

View File

@@ -25,7 +25,7 @@
<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>
<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>
</form>
</div>

View File

@@ -1,48 +0,0 @@
<?= view('components/print_header', ['printTitle' => '기본코드 종류 관리']) ?>
<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>
<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="<?= base_url('admin/code-kinds/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>
</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-20">코드</th>
<th>코드명</th>
<th class="w-24">세부코드 수</th>
<th class="w-20">상태</th>
<th>등록일</th>
<th class="w-40">작업</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($list as $row): ?>
<tr>
<td class="text-center"><?= esc($row->ck_idx) ?></td>
<td class="text-center font-mono font-bold"><?= esc($row->ck_code) ?></td>
<td class="text-left pl-2"><?= esc($row->ck_name) ?></td>
<td class="text-center">
<a href="<?= base_url('admin/code-details/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</a>
</td>
<td class="text-center"><?= (int) $row->ck_state === 1 ? '사용' : '미사용' ?></td>
<td class="text-left pl-2"><?= esc($row->ck_regdate ?? '') ?></td>
<td class="text-center">
<a href="<?= base_url('admin/code-details/' . (int) $row->ck_idx) ?>" class="text-green-600 hover:underline text-sm mr-1">세부코드</a>
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" 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>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">업체 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/companies/store') ?>" method="POST" class="space-y-4">
<form action="<?= mgmt_url('companies/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -42,7 +42,7 @@
<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>
<a href="<?= base_url('admin/companies') ?>" 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('companies') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">업체 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/companies/update/' . (int) $item->cp_idx) ?>" method="POST" class="space-y-4">
<form action="<?= mgmt_url('companies/update/' . (int) $item->cp_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
@@ -50,7 +50,7 @@
<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>
<a href="<?= base_url('admin/companies') ?>" 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('companies') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -4,10 +4,23 @@
<span class="text-sm font-bold text-gray-700">업체 관리</span>
<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="<?= base_url('admin/companies/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('companies/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>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('companies') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">업체유형</label>
<select name="cp_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-44">
<option value="">전 체</option>
<?php foreach (($typeOptions ?? []) as $type): ?>
<option value="<?= esc($type) ?>" <?= (string) ($cpType ?? '') === (string) $type ? 'selected' : '' ?>><?= esc($type) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('companies') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
@@ -24,9 +37,17 @@
</tr>
</thead>
<tbody>
<?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>
<td class="text-center"><?= esc($row->cp_idx) ?></td>
<td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center"><?= esc($row->cp_type) ?></td>
<td class="text-left pl-2"><?= esc($row->cp_name) ?></td>
<td class="text-center"><?= esc($row->cp_biz_no) ?></td>
@@ -35,8 +56,8 @@
<td class="text-left pl-2"><?= esc($row->cp_addr) ?></td>
<td class="text-center"><?= (int) $row->cp_state === 1 ? '사용' : '미사용' ?></td>
<td class="text-center">
<a href="<?= base_url('admin/companies/edit/' . (int) $row->cp_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
<form action="<?= base_url('admin/companies/delete/' . (int) $row->cp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<a href="<?= mgmt_url('companies/edit/' . (int) $row->cp_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
<form action="<?= mgmt_url('companies/delete/' . (int) $row->cp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form>

View File

@@ -6,6 +6,15 @@
</div>
<?php else: ?>
<?php if (! empty($s['stats_unavailable'])): ?>
<div class="border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 mb-4">
발주·판매·재고·불출 통계 테이블이 아직 없거나 조회에 실패했습니다. MySQL에서
<code class="text-xs bg-white px-1 rounded">writable/database/order_tables.sql</code>과
<code class="text-xs bg-white px-1 rounded">writable/database/sales_tables.sql</code>을
<code class="text-xs bg-white px-1 rounded">jongryangje_dev</code> DB에 실행해 주세요.
</div>
<?php endif; ?>
<!-- 통계 카드 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="border border-gray-300 p-4 bg-white">
@@ -36,7 +45,7 @@
<div>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-bold text-gray-700">최근 발주 5건</h3>
<a href="<?= base_url('admin/bag-orders') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
<a href="<?= base_url('bag/bag-orders') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
@@ -54,7 +63,7 @@
?>
<tr>
<td class="font-mono text-sm">
<a href="<?= base_url('admin/bag-orders/detail/' . (int) $order->bo_idx) ?>" class="text-blue-600 hover:underline"><?= esc($order->bo_lot_no) ?></a>
<a href="<?= base_url('bag/bag-orders/detail/' . (int) $order->bo_idx) ?>" class="text-blue-600 hover:underline"><?= esc($order->bo_lot_no) ?></a>
</td>
<td><?= esc($order->bo_order_date) ?></td>
<td>
@@ -81,7 +90,7 @@
<div>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-bold text-gray-700">최근 판매 5건</h3>
<a href="<?= base_url('admin/bag-sales') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
<a href="<?= base_url('bag/bag-sales') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">

View File

@@ -0,0 +1,137 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
<style>
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; }
.ds-bc-table th { background: #e9ecef; color: #2d3748; }
.ds-bc-table td { background: #fff; }
.ds-bc-table td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-table td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-check { width: 14px; height: 14px; }
</style>
<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>
<div class="flex items-center gap-2">
<button type="button" id="ds-bc-print-btn" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">인쇄</button>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form id="ds-bc-filter-form" method="get" action="<?= mgmt_url('designated-shops/barcode') ?>" class="flex flex-wrap items-end gap-3">
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel ?? '현재 지자체') ?>
</div>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">읍·면·동</label>
<select name="ds_zone_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="">전체</option>
<?php foreach (($zones ?? []) as $z): ?>
<?php $zc = trim((string) ($z->zone_code ?? '')); ?>
<option value="<?= esc($zc) ?>" <?= ($zoneFilter ?? '') === $zc ? 'selected' : '' ?>><?= esc($zc) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회순서</label>
<select name="order_by" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="shop_no" <?= ($orderBy ?? 'shop_no') === 'shop_no' ? 'selected' : '' ?>>판매소 코드</option>
<option value="name" <?= ($orderBy ?? '') === 'name' ? 'selected' : '' ?>>판매소명</option>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<section class="mx-2 mt-2 mb-2">
<form id="ds-bc-print-form" method="post" action="<?= mgmt_url('designated-shops/barcode/print') ?>" target="ds-bc-print-frame">
<?= csrf_field() ?>
<input type="hidden" name="zone_label" value="<?= esc(($zoneFilter ?? '') !== '' ? (string) $zoneFilter : '전체') ?>">
<div class="mb-1 text-xs text-gray-600">
<label class="inline-flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ds-bc-check-all" class="ds-bc-check"> 전체선택</label>
<span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
</div>
<div class="overflow-auto border border-gray-300 bg-white">
<table class="ds-bc-table">
<thead>
<tr>
<th class="w-14">출력</th>
<th class="w-36">판매소 코드</th>
<th>판매소명</th>
<th class="w-24">대표자명</th>
<th class="w-32">사업자번호</th>
<th>사업장 주소</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($list ?? []) as $row): ?>
<?php
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '사용' : '정지';
?>
<tr>
<td class="text-center"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
<td class="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
<td class="name-cell text-blue-700" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
<td class="addr-cell" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
<td class="<?= $st === 1 ? 'text-blue-700' : 'text-red-600' ?>"><?= esc($stLabel) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회된 지정판매소가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</form>
<iframe name="ds-bc-print-frame" class="hidden" style="display:none;width:0;height:0;border:0;" aria-hidden="true"></iframe>
</section>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 mx-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>
<script>
(function () {
var all = document.getElementById('ds-bc-check-all');
var countEl = document.getElementById('ds-bc-selected-count');
var printBtn = document.getElementById('ds-bc-print-btn');
var printForm = document.getElementById('ds-bc-print-form');
var rows = Array.prototype.slice.call(document.querySelectorAll('.ds-bc-row-check'));
if (!all || !countEl || !rows.length) return;
function refreshCount() {
var n = rows.filter(function (el) { return el.checked; }).length;
countEl.textContent = String(n);
all.checked = n > 0 && n === rows.length;
all.indeterminate = n > 0 && n < rows.length;
}
all.addEventListener('change', function () {
rows.forEach(function (el) { el.checked = all.checked; });
refreshCount();
});
rows.forEach(function (el) { el.addEventListener('change', refreshCount); });
if (printBtn && printForm) {
printBtn.addEventListener('click', function () {
var selected = rows.filter(function (el) { return el.checked; }).length;
if (selected < 1) {
window.alert('출력할 지정판매소를 1개 이상 선택해 주세요.');
return;
}
printForm.action = "<?= esc(mgmt_url('designated-shops/barcode/print')) ?>?autoprint=1";
printForm.submit();
});
}
refreshCount();
})();
</script>

View File

@@ -0,0 +1,101 @@
<?php
$rows = $rows ?? [];
$zoneLabel = trim((string) ($zoneLabel ?? '전체'));
$printedAt = trim((string) ($printedAt ?? date('Y.m.d')));
$chunks = array_chunk($rows, 12);
$totalPages = count($chunks);
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>지정판매소 바코드</title>
<style>
body { margin: 0; font-family: Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; color: #222; background: #fff; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; padding: 14mm 12mm 12mm; box-sizing: border-box; }
.title { text-align: center; font-size: 42px; letter-spacing: 1px; font-weight: 500; margin: 0 0 14px; }
.meta { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: 4px; font-size: 13px; margin-bottom: 8px; }
.meta .center { font-weight: 700; }
.cards { display: flex; flex-wrap: wrap; align-content: flex-start; }
.card { width: 33.3333%; padding: 0 8px 12px; box-sizing: border-box; }
.barcode-wrap { min-height: 40px; }
.barcode-svg { width: 100%; max-width: 270px; height: 22px; }
.code-text { text-align: center; margin-top: 1px; font-size: 16px; letter-spacing: 0.35px; }
.name-text { text-align: center; margin-top: 5px; font-size: 14px; line-height: 1.2; word-break: keep-all; }
@media print {
@page { size: A4; margin: 0; }
.page { page-break-after: always; }
.page:last-child { page-break-after: auto; }
}
</style>
</head>
<body>
<?= view('components/print_header', [
'printTitle' => '지정판매소 바코드',
'printExtraLines' => [
'구역: ' . $zoneLabel,
'출력일: ' . $printedAt,
],
]) ?>
<?php if ($rows === []): ?>
<div class="page">
<h1 class="title">지정판매소 바코드</h1>
<p style="text-align:center; margin-top:30px; color:#666;">출력할 지정판매소가 없습니다.</p>
</div>
<?php else: ?>
<?php foreach ($chunks as $pageIndex => $pageRows): ?>
<section class="page">
<h1 class="title">지정판매소 바코드</h1>
<div class="meta">
<span>출 력 일 자: <?= esc($printedAt) ?></span>
<span class="center"><?= esc($zoneLabel) ?></span>
<span>페&nbsp;&nbsp;이&nbsp;&nbsp;지: <?= (int) ($pageIndex + 1) ?> / <?= (int) $totalPages ?></span>
</div>
<div class="cards">
<?php foreach ($pageRows as $row): ?>
<?php
$code = trim((string) ($row->ds_shop_no ?? ''));
$nm = trim((string) ($row->ds_name ?? ''));
$rep = trim((string) ($row->ds_rep_name ?? ''));
$label = trim($nm . ($rep !== '' ? ('-' . $rep) : ''));
?>
<div class="card">
<div class="barcode-wrap">
<svg class="barcode-svg" data-barcode="<?= esc($code, 'attr') ?>"></svg>
</div>
<div class="code-text"><?= esc($code) ?></div>
<div class="name-text"><?= esc($label) ?></div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var svgs = document.querySelectorAll('svg[data-barcode]');
svgs.forEach(function (svg) {
var code = (svg.getAttribute('data-barcode') || '').trim();
if (!code) return;
try {
JsBarcode(svg, code, {
format: 'CODE128',
displayValue: false,
margin: 0,
height: 16,
width: 1.28
});
} catch (e) {
svg.outerHTML = '<div style="font-size:12px;color:#b91c1c;">바코드 생성 실패: ' + code + '</div>';
}
});
if (window.location.search.indexOf('autoprint=1') >= 0) {
setTimeout(function () { window.print(); }, 200);
}
});
</script>
</body>
</html>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">지정판매소 등록</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/designated-shops/store') ?>" method="POST" class="space-y-4">
<form id="designated-shop-create-form" action="<?= mgmt_url('designated-shops/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<?php if (! empty($localGovs)): ?>
@@ -23,14 +23,18 @@
<div class="text-sm">
<span class="font-semibold"><?= esc($currentLg->lg_name) ?></span>
<span class="text-gray-500 ml-2">(<?= esc($currentLg->lg_code) ?>)</span>
<span class="text-gray-500 ml-1"><?= esc(trim((string) ($currentLg->lg_sido ?? '') . ' ' . (string) ($currentLg->lg_gugun ?? ''))) ?></span>
</div>
<input type="hidden" name="ds_lg_idx" value="<?= esc($currentLg->lg_idx) ?>"/>
</div>
<?php endif; ?>
<input type="hidden" name="addr_search_sido" id="addr_search_sido" value="<?= esc((string) old('addr_search_sido', '')) ?>"/>
<input type="hidden" name="addr_search_sigungu" id="addr_search_sigungu" value="<?= esc((string) old('addr_search_sigungu', '')) ?>"/>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소번호</label>
<div class="text-sm text-gray-600">등록 시 자동 부여 (지자체코드 + 일련번호 3자리)</div>
<div class="text-sm text-gray-600">등록 시 자동 부여 (주소 기준 기본코드 B·C·D 조합 + 일련번호 3자리)</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@@ -49,23 +53,50 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_number" type="text" value="<?= esc(old('ds_va_number')) ?>"/>
<label class="block text-sm font-bold text-gray-700 w-28">업태</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_type" type="text" value="<?= esc(old('ds_biz_type')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">업종</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_kind" type="text" value="<?= esc(old('ds_biz_kind')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌(은행)</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_va_bank" type="text" value="<?= esc(old('ds_va_bank')) ?>" placeholder="예: 농협"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">계좌번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_account" type="text" value="<?= esc(old('ds_va_account')) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">주소</label>
<p class="text-xs text-gray-600 max-w-lg leading-relaxed">우편번호 옆 <strong>주소 검색</strong>으로만 지정합니다(직접 입력 불가). <strong>지도</strong>는 카카오맵에서 현재 입력된 주소를 검색해 엽니다. 관할이 아니면 검색 직후 안내 후 반영되지 않습니다. 동·호 등은 아래 <strong>상세주소</strong>에 입력하세요.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">우편번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="ds_zip" type="text" value="<?= esc(old('ds_zip')) ?>"/>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_zip" id="ds_zip" type="text" value="<?= esc(old('ds_zip')) ?>" maxlength="10" autocomplete="postal-code" readonly tabindex="-1"/>
<button type="button" id="btn-ds-kakao-postcode" class="no-print border border-btn-print-border text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition shrink-0">주소 검색</button>
<?= view('components/kakao_map_link_button', ['buttonId' => 'btn-ds-kakao-map-create']) ?>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">도로명주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr" type="text" value="<?= esc(old('ds_addr')) ?>"/>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr" id="ds_addr" type="text" value="<?= esc(old('ds_addr')) ?>" readonly tabindex="-1"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지번주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr_jibun" type="text" value="<?= esc(old('ds_addr_jibun')) ?>"/>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr_jibun" id="ds_addr_jibun" type="text" value="<?= esc(old('ds_addr_jibun')) ?>" readonly tabindex="-1"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상세주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full" name="ds_addr_detail" id="ds_addr_detail" type="text" value="<?= esc(old('ds_addr_detail')) ?>" maxlength="200" placeholder="동·호수·층 등"/>
</div>
<div class="flex flex-wrap items-center gap-2">
@@ -88,15 +119,49 @@
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구역</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="ds_zone_code" type="text" value="<?= esc(old('ds_zone_code')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종사업장번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_branch_no" type="text" value="<?= esc(old('ds_branch_no')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지정일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_designated_at" type="date" value="<?= esc(old('ds_designated_at')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">변경일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_state_changed_at" type="date" value="<?= esc(old('ds_state_changed_at')) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">변경사유</label>
<textarea class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 min-h-[4rem]" name="ds_change_reason" rows="3"><?= esc(old('ds_change_reason')) ?></textarea>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('admin/designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a>
<a href="<?= mgmt_url('designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a>
</div>
</form>
</div>
<?= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
<?= view('components/kakao_address_search', [
'buttonId' => 'btn-ds-kakao-postcode',
'zipName' => 'ds_zip',
'roadName' => 'ds_addr',
'jibunName' => 'ds_addr_jibun',
'sidoFieldName' => 'addr_search_sido',
'sigunguFieldName' => 'addr_search_sigungu',
'tenantScope' => $addrTenantScope ?? ['lg_sido' => '', 'lg_gugun' => ''],
'roadBaseOnly' => true,
'detailFieldName' => 'ds_addr_detail',
]) ?>

View File

@@ -0,0 +1,210 @@
<?php
$ry = (int) ($reportYear ?? (int) date('Y'));
$lg = $currentLg ?? null;
$lgSido = $lg !== null ? trim((string) ($lg->lg_sido ?? '')) : '';
$lgGugun = $lg !== null ? trim((string) ($lg->lg_gugun ?? '')) : '';
$lgName = $lg !== null ? trim((string) ($lg->lg_name ?? '')) : '';
$scopeLabel = $lgSido !== '' && $lgGugun !== ''
? $lgSido . ' ' . $lgGugun
: ($lgName !== '' ? $lgName : '—');
$exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . http_build_query(['year' => $ry]);
?>
<?= view('components/print_header', ['printTitle' => '지정 판매소 신규/취소 현황 (' . $ry . '년)']) ?>
<style>
.gbms-dnc-wrap { max-width: 100%; }
.gbms-dnc-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.gbms-dnc-table th,
.gbms-dnc-table td {
border: 1px solid #7a8aa0;
padding: 6px 10px;
text-align: center;
}
.gbms-dnc-table thead th {
background: linear-gradient(180deg, #e8eef6 0%, #d4dee9 100%);
font-weight: 700;
color: #1a2a3a;
}
.gbms-dnc-table thead th.gbms-sub {
background: #dce6f0;
font-weight: 600;
}
.gbms-dnc-table tbody td.text-left { text-align: left; }
.gbms-dnc-table tbody tr.gbms-total td {
font-weight: 700;
border: 2px solid #c62828;
background: #fff8f8;
}
.gbms-dnc-caption {
font-size: 13px;
font-weight: 700;
margin: 8px 0 6px;
color: #1a2a3a;
}
.gbms-unit-pill {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #0d47a1;
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 2px;
}
.gbms-tip {
position: relative;
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
}
.gbms-help {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #5c6f85;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
user-select: none;
cursor: help;
}
.gbms-help::after {
content: attr(data-tip);
position: absolute;
left: 50%;
top: calc(100% + 6px);
transform: translateX(-50%);
display: none;
min-width: 12rem;
max-width: 14rem;
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 30;
}
.gbms-help:hover::after,
.gbms-help:focus::after {
display: block;
}
@media print {
.gbms-dnc-table { font-size: 11px; }
.gbms-help { display: none !important; }
}
</style>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-800">[지정 판매소 신규/취소 현황]</span>
<div class="flex items-center gap-2">
<a href="<?= esc($exportUrl) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="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('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록</a>
</div>
</div>
</section>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('designated-shops/district-new-cancel') ?>" class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회년도</label>
<select name="year" class="border border-gray-400 rounded px-2 py-1.5 text-sm min-w-[7rem] bg-white">
<?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex-1 min-w-[12rem]">
<span class="block text-xs text-gray-600 mb-0.5">군·구 (소속 지자체)</span>
<div class="border border-gray-300 rounded px-3 py-1.5 text-sm bg-gray-50 text-gray-800 font-medium">
<?= esc($scopeLabel) ?>
</div>
</div>
<span class="gbms-unit-pill self-end mb-0.5">단위: 판매소</span>
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm font-medium shadow-sm hover:opacity-90">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 구·군 행은 효과 지자체의 기본코드(구 코드) 순서로 표시됩니다.
</p>
</section>
<div class="mx-2 mt-3 mb-4 gbms-dnc-wrap">
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div>
<div class="overflow-x-auto border border-gray-400 bg-white">
<table class="gbms-dnc-table">
<thead>
<tr>
<th rowspan="2" class="min-w-[6rem]">군·구</th>
<th rowspan="2">
<span class="gbms-tip">
종전
<span class="gbms-help" tabindex="0" aria-label="종전 설명" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(전년도말)</span>
</th>
<th colspan="2">사용</th>
<th rowspan="2">
<span class="gbms-tip">
현행
<span class="gbms-help" tabindex="0" aria-label="현행 설명" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(금년도말)</span>
</th>
</tr>
<tr>
<th class="gbms-sub">
<span class="gbms-tip">
지정
<span class="gbms-help" tabindex="0" aria-label="지정 설명" data-tip="조회년도 내 지정일이 속한 신규 지정 건수">?</span>
</span>
</th>
<th class="gbms-sub">
<span class="gbms-tip">
취소
<span class="gbms-help" tabindex="0" aria-label="취소 설명" data-tip="조회년도 내 폐업/해지 전환일이 속한 건수">?</span>
</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach (($districtRows ?? []) as $row): ?>
<tr>
<td class="text-left font-medium"><?= esc($row->region_label) ?></td>
<td><?= number_format((int) $row->prev_end) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format((int) $row->curr_end) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr>
<td colspan="5" class="text-center text-gray-500 py-8">표시할 구·군 또는 지정판매소 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="gbms-total">
<td class="text-left"><?= esc($districtTotal->region_label) ?></td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -5,20 +5,35 @@ if ($shop === null) {
return;
}
$v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($key) : ($shop->{$key} ?? $default);
$vaAccountDefault = (isset($shop->ds_va_account) && (string) $shop->ds_va_account !== '')
? (string) $shop->ds_va_account
: (string) ($shop->ds_va_number ?? '');
$dateField = static function (string $key) use ($shop, $v): string {
$s = (string) $v($key);
if ($s === '' || str_starts_with($s, '0000')) {
return '';
}
return $s;
};
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 수정</span>
</section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= base_url('admin/designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4">
<form id="designated-shop-edit-form" action="<?= mgmt_url('designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<input type="hidden" name="addr_search_sido" id="addr_search_sido" value="<?= esc((string) old('addr_search_sido', $currentLg !== null ? (string) ($currentLg->lg_sido ?? '') : '')) ?>"/>
<input type="hidden" name="addr_search_sigungu" id="addr_search_sigungu" value="<?= esc((string) old('addr_search_sigungu', $currentLg !== null ? (string) ($currentLg->lg_gugun ?? '') : '')) ?>"/>
<?php if ($currentLg !== null): ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지자체</label>
<div class="text-sm">
<span class="font-semibold"><?= esc($currentLg->lg_name) ?></span>
<span class="text-gray-500 ml-2">(<?= esc($currentLg->lg_code) ?>)</span>
<span class="text-gray-500 ml-1"><?= esc(trim((string) ($currentLg->lg_sido ?? '') . ' ' . (string) ($currentLg->lg_gugun ?? ''))) ?></span>
</div>
</div>
<?php endif; ?>
@@ -49,23 +64,50 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_number" type="text" value="<?= esc($v('ds_va_number')) ?>"/>
<label class="block text-sm font-bold text-gray-700 w-28">업태</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_type" type="text" value="<?= esc($v('ds_biz_type')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">업종</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_kind" type="text" value="<?= esc($v('ds_biz_kind')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌(은행)</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_va_bank" type="text" value="<?= esc($v('ds_va_bank')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">계좌번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_account" type="text" value="<?= esc((old('ds_va_account') !== null && old('ds_va_account') !== '') ? old('ds_va_account') : $vaAccountDefault) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">주소</label>
<p class="text-xs text-gray-600 max-w-lg leading-relaxed">우편·도로명·지번은 <strong>주소 검색</strong>으로만 바꿀 수 있습니다(직접 입력 불가). <strong>지도</strong>는 카카오맵에서 현재 입력된 주소를 검색해 엽니다. 관할이 아니면 검색 직후 안내 후 반영되지 않습니다. 동·호 등은 <strong>상세주소</strong>에 입력하세요.</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">우편번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="ds_zip" type="text" value="<?= esc($v('ds_zip')) ?>"/>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_zip" id="ds_zip" type="text" value="<?= esc($v('ds_zip')) ?>" maxlength="10" readonly tabindex="-1"/>
<button type="button" id="btn-ds-kakao-postcode-edit" class="no-print border border-btn-print-border text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition shrink-0">주소 검색</button>
<?= view('components/kakao_map_link_button', ['buttonId' => 'btn-ds-kakao-map-edit']) ?>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">도로명주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr" type="text" value="<?= esc($v('ds_addr')) ?>"/>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr" id="ds_addr" type="text" value="<?= esc($v('ds_addr')) ?>" readonly tabindex="-1"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지번주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr_jibun" type="text" value="<?= esc($v('ds_addr_jibun')) ?>"/>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr_jibun" id="ds_addr_jibun" type="text" value="<?= esc($v('ds_addr_jibun')) ?>" readonly tabindex="-1"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상세주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full" name="ds_addr_detail" id="ds_addr_detail" type="text" value="<?= esc($v('ds_addr_detail')) ?>" maxlength="200" placeholder="동·호수·층 등"/>
</div>
<div class="flex flex-wrap items-center gap-2">
@@ -83,6 +125,16 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_email" type="email" value="<?= esc($v('ds_email')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구역</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="ds_zone_code" type="text" value="<?= esc($v('ds_zone_code')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종사업장번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_branch_no" type="text" value="<?= esc($v('ds_branch_no')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지정일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_designated_at" type="date" value="<?= esc($v('ds_designated_at')) ?>"/>
@@ -97,9 +149,33 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">변경일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_state_changed_at" type="date" value="<?= esc($dateField('ds_state_changed_at')) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">변경사유</label>
<textarea class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 min-h-[4rem]" name="ds_change_reason" rows="3"><?= esc($v('ds_change_reason')) ?></textarea>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
<a href="<?= base_url('admin/designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a>
<a href="<?= mgmt_url('designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a>
</div>
</form>
</div>
<?= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
<?= view('components/kakao_address_search', [
'buttonId' => 'btn-ds-kakao-postcode-edit',
'zipName' => 'ds_zip',
'roadName' => 'ds_addr',
'jibunName' => 'ds_addr_jibun',
'sidoFieldName' => 'addr_search_sido',
'sigunguFieldName' => 'addr_search_sigungu',
'tenantScope' => $addrTenantScope ?? ['lg_sido' => '', 'lg_gugun' => ''],
'roadBaseOnly' => true,
'detailFieldName' => 'ds_addr_detail',
]) ?>

View File

@@ -1,24 +1,202 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 목록']) ?>
<?php
helper('admin');
$currentPath = current_nav_request_path();
if ($currentPath === 'bag/designated-shops') {
$readOnly = false;
} elseif ($currentPath === 'bag/designated-shops/browse') {
$readOnly = true;
} else {
$readOnly = ! empty($readOnly);
}
?>
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
<style>
/* 목록 위 → 지정판매소 정보 아래 (가로 2열 없음) */
.ds-split {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
flex: 1 1 auto;
}
.ds-list-panel {
flex: 0 1 auto;
width: 100%;
max-height: 42vh;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #ccc;
background: #fff;
}
.ds-detail-panel {
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 12rem;
border: 1px solid #ccc;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.ds-panel-title {
font-size: 12px;
font-weight: bold;
padding: 6px 10px;
background: linear-gradient(180deg, #fafafa 0%, #e9ecef 100%);
border-bottom: 1px solid #ccc;
color: #333;
}
.ds-summary-bar {
font-size: 12px;
padding: 6px 10px;
background: #fff3cd;
border: 1px solid #ffc107;
color: #333;
}
.ds-row-selected td { background-color: #cce5ff !important; }
.ds-detail-inner { padding: 10px; overflow: auto; flex: 1; }
/* 원본 지정판매소 정보: 라벨 고정폭 + 2열 값(우측 값이 더 넓음), 주소·개인전화는 전폭 */
.ds-detail-form {
font-size: 12px;
border: 1px solid #bbb;
background: #fff;
}
.ds-row {
display: grid;
gap: 0;
border-bottom: 1px solid #ccc;
}
.ds-detail-form > .ds-row:last-child { border-bottom: none; }
/* 그 외 2+2 동일 비율 (상호명 | 우편번호 등) */
.ds-row-4-even {
grid-template-columns: 5.5rem minmax(0, 1fr) 5.5rem minmax(0, 1fr);
}
/* 판매소번호 전폭 행 — 값을 우편·주소 필드처럼 넓게 */
.ds-value-shop-wide {
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
font-size: 13px;
padding-top: 8px;
padding-bottom: 8px;
}
/* 라벨 | 값(나머지 전체) — 도로명·지번·개인전화·이메일 */
.ds-row-wide {
grid-template-columns: 5.5rem minmax(0, 1fr);
}
.ds-row-wide-tall .ds-field-value {
min-height: 3.25rem;
align-content: start;
}
/* 도로명주소 + 카카오맵 버튼 */
.ds-field-value-with-map {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
}
.ds-field-value-with-map .ds-addr-text {
flex: 1 1 12rem;
min-width: 0;
word-break: break-word;
}
.ds-field-label {
background: #eef2f5;
border-right: 1px solid #ccc;
padding: 5px 8px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
}
.ds-field-value {
padding: 5px 8px;
background: #fff;
word-break: break-word;
border-right: 1px solid #ccc;
}
.ds-row-4-even > *:nth-child(4n) { border-right: none; }
.ds-row-wide > .ds-field-value { border-right: none; }
.ds-field-hint {
font-size: 11px;
color: #b91c1c;
margin-top: 4px;
line-height: 1.35;
}
@media (max-width: 720px) {
.ds-row-4-even { grid-template-columns: 5rem 1fr; }
}
.ds-detail-actions { padding: 10px; border-top: 1px solid #ccc; background: #eee; }
.ds-detail-info-wrap { overflow-x: auto; }
.ds-detail-info-wrap .data-table th { white-space: nowrap; }
.ds-detail-info-wrap th.ds-col-tight-head,
.ds-detail-info-wrap td.ds-col-tight-cell {
max-width: 6.5rem;
width: 6.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-list-panel .ds-col-tight {
max-width: 6rem;
width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-list-panel .ds-col-zip {
width: 4.5rem;
max-width: 5rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ds-list-panel .ds-col-addr-list {
max-width: 11rem;
min-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-list-panel .ds-col-detail-list {
max-width: 8rem;
min-width: 4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-ro-road-btn { margin-left: 6px; vertical-align: middle; }
</style>
<?php
$listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
?>
<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"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/designated-shops/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>
<a href="<?= base_url('admin/designated-shops/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>
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/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 type="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>
<?php endif; ?>
<?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/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>
<?php endif; ?>
</div>
</div>
</section>
<!-- P2-15: 다조건 검색 -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= base_url('admin/designated-shops') ?>" class="flex flex-wrap items-center gap-2">
<form method="GET" action="<?= mgmt_url($listBasePath) ?>" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 mr-1">지정판매소 검색</span>
<label class="text-sm text-gray-600">상호명</label>
<input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명 검색" class="border border-gray-300 rounded px-2 py-1 text-sm w-40"/>
<label class="text-sm text-gray-600">구코드</label>
<select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm">
<input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명" class="border border-gray-300 rounded px-2 py-1 text-sm w-36"/>
<label class="text-sm text-gray-600">구·군 코드</label>
<select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[14rem]">
<option value="">전체</option>
<?php foreach (($gugunCodes ?? []) as $gc): ?>
<option value="<?= esc($gc->ds_gugun_code) ?>" <?= ($dsGugunCode ?? '') === $gc->ds_gugun_code ? 'selected' : '' ?>><?= esc($gc->ds_gugun_code) ?></option>
<?php $gCode = (string) ($gc->ds_gugun_code ?? ''); ?>
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">상태</label>
@@ -29,48 +207,357 @@
<option value="3" <?= ($dsState ?? '') === '3' ? 'selected' : '' ?>>직권해지</option>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= base_url('admin/designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
<a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<?php
$sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
?>
<div class="ds-summary-bar no-print mx-2 mt-2 rounded-sm">
건수 : <?= (int) ($sc['total'] ?? 0) ?>
(정상 : <?= (int) ($sc[1] ?? 0) ?> / 폐업 : <?= (int) ($sc[2] ?? 0) ?> / 해지 : <?= (int) ($sc[3] ?? 0) ?>)
</div>
<div class="ds-split no-print mx-2 mb-2 mt-2 flex-1 min-h-0">
<div class="ds-list-panel">
<div class="ds-panel-title shrink-0">지정판매소 리스트</div>
<div class="overflow-auto flex-1 min-h-0">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-14">번호</th>
<th class="w-24">구·군</th>
<th class="w-24">지정일</th>
<th class="w-24">구역</th>
<th class="ds-col-tight">대표자명</th>
<th class="ds-col-tight">상호명</th>
<th class="ds-col-zip">우편번호</th>
<th class="text-left">주소</th>
<th class="w-28">사업자번호</th>
<th class="w-28">전화</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody id="ds-list-body" class="text-right">
<?php foreach ($list as $i => $row): ?>
<?php
$sn = (string) ($row->ds_shop_no ?? '');
if (preg_match('/(\d{3})$/', $sn, $m)) {
$shortNo = $m[1];
} elseif ($sn !== '' && strlen($sn) >= 3) {
$shortNo = substr($sn, -3);
} else {
$shortNo = $sn;
}
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '' : ($st === 2 ? '폐업' : '해지');
$ggCode = (string) ($row->ds_gugun_code ?? '');
$ggLabel = (string) (($gugunNameMap[$ggCode] ?? '') !== '' ? $gugunNameMap[$ggCode] : $ggCode);
$da = $row->ds_designated_at ?? null;
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
$zone = (string) ($row->ds_zone_code ?? '');
$zipList = trim((string) ($row->ds_zip ?? ''));
$roadL = trim((string) ($row->ds_addr ?? ''));
$jibunL = trim((string) ($row->ds_addr_jibun ?? ''));
$addrMainList = $roadL !== '' ? $roadL : $jibunL;
$addrDetailList = trim((string) ($row->ds_addr_detail ?? ''));
$addrCombinedList = trim($addrMainList . ' ' . $addrDetailList);
if ($addrCombinedList === '') {
$addrCombinedList = $addrMainList;
}
?>
<tr class="ds-list-row cursor-pointer" data-row-index="<?= (int) $i ?>" role="button" tabindex="0">
<td class="text-center"><?= esc($shortNo) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($ggLabel) ?></td>
<td class="text-center text-xs"><?= esc($daDisp) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($zone) ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-center text-xs ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td>
<td class="text-left pl-1 text-xs ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_biz_no ?? '') ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_tel ?? '') ?></td>
<td class="text-center <?= $st === 2 ? 'text-pink-600 font-medium' : ($st === 3 ? 'text-orange-700' : '') ?>"><?= esc($stLabel) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="ds-detail-panel">
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
<div class="ds-detail-inner" id="ds-detail-box">
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
<div id="ds-detail-fields" class="hidden">
<div class="ds-detail-info-wrap">
<table class="w-full data-table text-sm" id="ds-detail-info-table" aria-label="지정판매소 상세">
<thead>
<tr>
<th>판매소번호</th>
<th class="ds-col-tight-head">상호명</th>
<th>우편번호</th>
<th>사업자번호</th>
<th>일반전화</th>
<th class="ds-col-tight-head">대표자명</th>
<th>이메일</th>
<th>업태</th>
<th>업종</th>
<th>지정일자</th>
<th>지자체</th>
<th>도로명주소</th>
<th>지번주소</th>
<th>상세주소</th>
<th>개인전화</th>
<th>구·군</th>
<th>구역</th>
<th>가상계좌(은행)</th>
<th>계좌번호</th>
<th>종사업장번호</th>
<th>변경일자</th>
<th>영업상태</th>
<th>등록일시</th>
<th>변경사유</th>
<th class="no-print w-14">지도</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-left" data-ro="ds_shop_no">—</td>
<td class="text-left ds-col-tight-cell" data-ro="ds_name">—</td>
<td class="text-left" data-ro="ds_zip">—</td>
<td class="text-left" data-ro="ds_biz_no">—</td>
<td class="text-left" data-ro="ds_tel">—</td>
<td class="text-left ds-col-tight-cell" data-ro="ds_rep_name">—</td>
<td class="text-left" data-ro="ds_email">—</td>
<td class="text-left" data-ro="ds_biz_type">—</td>
<td class="text-left" data-ro="ds_biz_kind">—</td>
<td class="text-left" data-ro="ds_designated_at">—</td>
<td class="text-left" data-ro="lg_name">—</td>
<td class="text-left min-w-[10rem]"><span data-ro="ds_addr">—</span></td>
<td class="text-left" data-ro="ds_addr_jibun">—</td>
<td class="text-left" data-ro="ds_addr_detail">—</td>
<td class="text-left" data-ro="ds_rep_phone">—</td>
<td class="text-left" data-ro="gugun_name">—</td>
<td class="text-left" data-ro="ds_zone_code">—</td>
<td class="text-left" data-ro="ds_va_bank">—</td>
<td class="text-left" data-ro="ds_va_account">—</td>
<td class="text-left" data-ro="ds_branch_no">—</td>
<td class="text-left" data-ro="ds_state_changed_at">—</td>
<td class="text-left" data-ro="state_label">—</td>
<td class="text-left" data-ro="ds_regdate">—</td>
<td class="text-left min-w-[8rem]" data-ro="ds_change_reason">—</td>
<td class="text-center no-print">
<button type="button" class="border border-btn-print-border text-gray-700 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50" id="ds-ro-map-btn">지도</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<?php if (! $readOnly): ?>
<div class="ds-detail-actions no-print flex flex-wrap items-center gap-3 shrink-0">
<a id="ds-edit-link" href="#" class="text-blue-700 hover:underline text-sm font-medium pointer-events-none opacity-40">수정</a>
<form id="ds-delete-form" method="POST" action="" class="inline" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" id="ds-delete-btn" class="text-red-600 hover:underline text-sm pointer-events-none opacity-40" disabled>삭제</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 mx-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>
<?= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
<script type="application/json" id="ds-detail-json"><?= $detailRowsJson ?? '[]' ?></script>
<script>
(function () {
var raw = document.getElementById('ds-detail-json');
var rows = [];
try {
rows = JSON.parse(raw.textContent || '[]');
} catch (e) {
rows = [];
}
var readOnly = <?= json_encode($readOnly) ?>;
var body = document.getElementById('ds-list-body');
var placeholder = document.getElementById('ds-detail-placeholder');
var fieldsWrap = document.getElementById('ds-detail-fields');
var infoTable = document.getElementById('ds-detail-info-table');
var editLink = readOnly ? null : document.getElementById('ds-edit-link');
var delForm = readOnly ? null : document.getElementById('ds-delete-form');
var delBtn = readOnly ? null : document.getElementById('ds-delete-btn');
// mgmt_url() 이 path 를 trim 하므로 'edit/33' 이 아니라 'edit33' 로 붙지 않게 슬래시를 넣음
var editBase = <?= json_encode(mgmt_url('designated-shops/edit')) ?>;
var delBase = <?= json_encode(mgmt_url('designated-shops/delete')) ?>;
function textVal(v) {
return (v === '' || v == null) ? '—' : String(v);
}
function buildKakaoMapSearchQuery(d) {
var road = String(d.ds_addr || '').trim();
var jibun = String(d.ds_addr_jibun || '').trim();
var detail = String(d.ds_addr_detail || '').trim();
var q = road || jibun;
if (detail) {
q = q ? (q + ' ' + detail) : detail;
}
return q;
}
function fillDetailInfoTable(d) {
if (!infoTable) return;
infoTable.querySelectorAll('[data-ro]').forEach(function (el) {
var k = el.getAttribute('data-ro');
var v = d[k];
if (k === 'ds_va_account') {
v = d.ds_va_account || d.ds_va_number || '';
}
el.textContent = textVal(v);
});
window.__dsDetailForMap = d;
}
function selectIndex(idx) {
if (!rows.length || idx < 0 || idx >= rows.length) return;
var d = rows[idx];
Array.prototype.forEach.call(body.querySelectorAll('tr.ds-list-row'), function (tr) {
tr.classList.remove('ds-row-selected');
});
var tr = body.querySelector('tr[data-row-index="' + idx + '"]');
if (tr) tr.classList.add('ds-row-selected');
placeholder.classList.add('hidden');
fieldsWrap.classList.remove('hidden');
fillDetailInfoTable(d);
if (!readOnly && editLink && delForm && delBtn) {
var id = d.ds_idx;
editLink.href = editBase + '/' + id;
editLink.classList.remove('pointer-events-none', 'opacity-40');
delForm.action = delBase + '/' + id;
delBtn.disabled = false;
delBtn.classList.remove('pointer-events-none', 'opacity-40');
}
}
if (body) {
body.addEventListener('click', function (e) {
var tr = e.target.closest('tr.ds-list-row');
if (!tr) return;
var idx = parseInt(tr.getAttribute('data-row-index'), 10);
if (!isNaN(idx)) selectIndex(idx);
});
body.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
var tr = e.target.closest('tr.ds-list-row');
if (!tr) return;
e.preventDefault();
var idx = parseInt(tr.getAttribute('data-row-index'), 10);
if (!isNaN(idx)) selectIndex(idx);
});
}
var mapBtnRo = document.getElementById('ds-ro-map-btn');
if (mapBtnRo) {
mapBtnRo.addEventListener('click', function (ev) {
ev.preventDefault();
var d = window.__dsDetailForMap;
if (!d) return;
var q = buildKakaoMapSearchQuery(d);
if (!q) {
window.alert('표시할 주소 정보가 없습니다.');
return;
}
if (typeof window.openDesignatedShopKakaoMap === 'function') {
window.openDesignatedShopKakaoMap(q);
} else {
window.open('https://map.kakao.com/link/search/' + encodeURIComponent(q), '_blank', 'noopener,noreferrer');
}
});
}
if (rows.length > 0) {
selectIndex(0);
} else if (!readOnly && editLink && delBtn) {
editLink.classList.add('pointer-events-none', 'opacity-40');
delBtn.disabled = true;
delBtn.classList.add('pointer-events-none', 'opacity-40');
}
})();
</script>
<!-- 인쇄용: 전체 테이블 -->
<div class="hidden print:block print:p-4">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="w-16">번호</th>
<th>번호</th>
<th>지자체</th>
<th>판매소번호</th>
<th>구·군</th>
<th>지정일</th>
<th>구역</th>
<th>대표자명</th>
<th>상호명</th>
<th>대표자</th>
<th>우편번호</th>
<th>주소</th>
<th>사업자번호</th>
<th>전화</th>
<th>판매소번호</th>
<th>가상계좌</th>
<th>상태</th>
<th>등록일</th>
<th class="w-28">작업</th>
</tr>
</thead>
<tbody class="text-right">
<tbody>
<?php foreach ($list as $row): ?>
<?php
$snP = (string) ($row->ds_shop_no ?? '');
if (preg_match('/(\d{3})$/', $snP, $mP)) {
$shortNoP = $mP[1];
} elseif ($snP !== '' && strlen($snP) >= 3) {
$shortNoP = substr($snP, -3);
} else {
$shortNoP = $snP;
}
$daP = $row->ds_designated_at ?? null;
$daDispP = ($daP !== null && $daP !== '' && (string) $daP !== '0000-00-00') ? substr((string) $daP, 0, 10) : '';
$stP = (int) ($row->ds_state ?? 1);
$stLabP = $stP === 1 ? '정상' : ($stP === 2 ? '폐업' : '직권해지');
$zipP = trim((string) ($row->ds_zip ?? ''));
$roadP = trim((string) ($row->ds_addr ?? ''));
$jibP = trim((string) ($row->ds_addr_jibun ?? ''));
$addrP = $roadP !== '' ? $roadP : $jibP;
$detP = trim((string) ($row->ds_addr_detail ?? ''));
$addrCombinedP = trim($addrP . ' ' . $detP);
if ($addrCombinedP === '') {
$addrCombinedP = $addrP;
}
?>
<tr>
<td class="text-center"><?= esc($row->ds_idx) ?></td>
<td class="text-left pl-2"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_shop_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_rep_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_biz_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_va_number) ?></td>
<td class="text-center"><?= (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_regdate ?? '') ?></td>
<td class="text-center">
<a href="<?= base_url('admin/designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a>
<form action="<?= base_url('admin/designated-shops/delete/' . (int) $row->ds_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>
<td class="text-center"><?= esc($shortNoP) ?></td>
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
<td class="text-center"><?= esc($daDispP) ?></td>
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-left"><?= esc($zipP) ?></td>
<td class="text-left"><?= esc($addrCombinedP) ?></td>
<td class="text-left"><?= esc($row->ds_biz_no ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_tel ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_shop_no) ?></td>
<td class="text-left"><?= esc($row->ds_va_number) ?></td>
<td class="text-center"><?= esc($stLabP) ?></td>
<td class="text-left"><?= esc($row->ds_regdate ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

Some files were not shown because too many files have changed in this diff Show More