65 Commits

Author SHA1 Message Date
taekyoungc
41b6ccc339 fix: 기본코드 등록/수정 화면 탭 안 중첩 셸 제거
- CodeKind/CodeDetail create·edit 가 view('admin/layout') 직접 사용 → 탭(iframe) 안에서 풀셸 중첩
- renderWorkPage 로 교체 + renderWorkPage 가 embed 요청이면 경로 무관하게 embed 레이아웃 사용하도록 일반화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:57 +09:00
taekyoungc
003cf28183 style: 활동 로그 조회 작업 필터 셀렉트 너비 확대
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:16:56 +09:00
taekyoungc
8d49247803 feat: 사용자 작업(감사) 로그 자동 기록 + 관리자 조회 화면
- Auditable 트레잇: 모델 insert/update/delete 시 activity_log 자동 기록
  (조회 제외, before/after JSON, 민감필드 마스킹, 사용자/IP)
- 도메인 모델 28개에 적용 (로그성 테이블 제외)
- 관리자 전용 조회: Admin\ActivityLog (기간/작업/대상/사용자 필터 + 상세 변경내역)
- 라우트 admin/access/activity-logs(+상세), 메뉴 '활동 로그' 추가
- 테넌트 분리: 슈퍼=전체, 지자체관리자=소속 지자체만

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:13:05 +09:00
taekyoungc
a277e083da fix: 로그인 후 뒤로가기 시 역할별 홈으로 복귀(엉뚱한 화면 방지)
이전 수정이 로그인 상태 사용자를 무조건 '/'(워크스페이스)로 보내,
관리자(/admin)에서 뒤로가기하면 다른 화면으로 가던 문제 수정.
역할별 홈으로 리다이렉트(지자체관리자→/admin, 슈퍼→지자체 선택여부에 따라 /admin 또는 선택화면, 일반→/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:22:49 +09:00
taekyoungc
933d174fa4 fix: 로그인 후 뒤로가기 시 로그인 화면 머무름 방지
- 로그인 페이지에 캐시 금지 헤더(no-store) 추가 → 뒤로가기 시 서버 재요청
- bfcache 복원(pageshow persisted) 시 새로고침 → 로그인 상태면 대시보드로 리다이렉트

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:15:17 +09:00
taekyoungc
4fef5556ca feat: 년 판매현황 엑셀 최상단에 지역/범위·단위·출력일 제목 블록 추가
- export_excel_2003_xml 에 헤더 위 제목행(topRows) 옵션 추가(하위호환)
- 년판매 export: '시도 구군 / 범위', '(단위 : 매/원)', '연도 판매현황 · 대행소', '출력일' 표기

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:59:53 +09:00
taekyoungc
9f16942366 feat: 기간별 판매현황 기본 조회 기간을 이번달 → 이번주(월~오늘)로
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:52:39 +09:00
taekyoungc
bdeef2c69d feat: 판매대장 크기 필터를 봉투/음식물/폐기물 카테고리별 그룹으로
- 크기 드롭다운을 카테고리별 optgroup(봉투(일반용)/음식물/폐기물 등)으로 묶음
- 선택값을 '카테고리|크기'(예: general|3L)로 바꿔 카테고리+크기 조합 필터링
- 엑셀저장에도 size 파라미터 반영(기존 누락 수정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:41:55 +09:00
taekyoungc
d366661b4b fix: embed(탭) GET 조회 폼에서 중첩 셸 표시 방지
GET 폼 제출 시 action 쿼리스트링에서 embed=1 이 빠져 전체 크롬이
탭 안에 다시 그려지는 문제 → 숨김 input(embed=1) 주입으로 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:29 +09:00
taekyoungc
5b903c9aa6 feat: 지정판매소 판매대장 크기(리터)별 품목 필터
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:29 +09:00
taekyoungc
acce968f9c feat: 전화접수 관리·주문 개선 + 매뉴얼 화면 바로가기
- 전화접수 관리: 배달일 기준 조회, 컬럼 보강(구분/포장 O·X/결제/수령/입금/총금액/취소),
  헤더 정렬, 배달일 수정 저장, 포장 명세·포장량 수정, 입금자 영수증 출력
- 전화 주문 접수: 행추가 → 일괄표(카테고리 그룹·포장 헤더)
- 매뉴얼 소제목에 '화면 열기' 바로가기(실제 GET 라우트만 연결)
- shop_order_item.soi_packed_qty 컬럼 추가(SQL)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:21 +09:00
taekyoungc
8e767ff234 feat(security): 중복 로그인 방지 (나중 로그인 우선)
- 로그인 시 세션 토큰 발급→회원 행(mb_session_token) 저장
- SessionGuardFilter(전역 before)로 매 요청 토큰 대조, 다르면 세션 무효화 후 로그인 이동
- DB 공유로 로컬·운영 등 서버 간에도 동작. 기존 로그인 세션은 재로그인 전까지 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:02:52 +09:00
taekyoungc
76260572b1 feat(security): 로그인 알림 — 직전 로그인과 IP 비교, 새 환경 로그인 시 경고
completeLogin에서 직전 성공 로그인 IP와 비교해 다르면 로그인 메시지에 경고 표시(이전 로그인 일시·IP 안내). 기존 member_log 활용, 외부 인프라 불필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:40:20 +09:00
taekyoungc
3b3f83461a feat(security): 비밀번호 정책 강화 — 8자+영문·숫자·특수 3종 + 안내문구
- 회원가입·비밀번호 변경·관리자 회원 등록/수정에 정책 적용
- 정규식 규칙을 배열 형식으로 적용(파이프 파싱 깨짐 → is_unique 등 검증 무력화 버그 수정)
- 비밀번호 입력란에 형식 안내문구 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:01:18 +09:00
taekyoungc
8ba9c650e8 feat: 전화접수 관리 — 접수 리스트 컬럼 보강·헤더 정렬, 배달일 수정 저장
- 접수 리스트에 접수일/배달일/금액 컬럼 추가, 헤더 클릭 정렬(번호·접수일·배달일·판매소·금액·상태, 오름/내림 토글)
- 우측 상세에서 배달일을 date 입력으로 수정 가능, 저장 시 so_delivery_date 변경 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:23:55 +09:00
taekyoungc
a7a2754fb7 feat: 전화접수 관리 기간조회·지정판매소 정보 패널 + 날짜 입력 월 한글화
- 전화접수 관리: 접수일 기간 조회 필터, 선택 주문의 지정판매소 정보(코드·담당자·전화·주소) + 접수 품목 내역 표시, 상단 '전화 주문 접수' 버튼 제거
- 전 화면 날짜 입력(date/month 등)에 lang=ko 지정 → 달력 월을 한글(6월)로 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:52 +09:00
taekyoungc
278e75284e style: 기타 입출고 카드 모서리 둥글게(overflow-hidden) — 헤더 삐져나옴 수정
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:43:20 +09:00
taekyoungc
ec1f8f6b03 style: 의견 목록 상태 셀렉트 너비 확대 + 추이 분석 표 모서리 둥글게(overflow-hidden)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:23:46 +09:00
taekyoungc
14b628b7ac feat: 의견 창 이동·크기 조절 + 글자수 2000자·카운터
- 의견 창 헤더 드래그 이동, 우하단 모서리 크기 조절(resize), 비차단 플로팅
- 입력 글자수 1000→2000자, '0/2000' 카운터 표시

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:48:43 +09:00
taekyoungc
59b33ccb42 feat: 사용자 의견(피드백) 기능 — 화면 캡처+이미지 첨부, 관리자 검토
- 전 화면 우하단 '의견' 위젯(embed/portal/admin 레이아웃) — 로그인 사용자
- 의견 + html2canvas 화면 캡처 + 이미지 첨부(최대 5장) 저장
- 화면 URL/계정/지자체 자동 수집(계정·지자체는 서버 세션 기준)
- 관리자 /admin/feedback 목록·상세·상태(접수/처리중/완료/보류)·메모, 테넌트 분리
- feedback/feedback_file 테이블, 첨부 이미지는 관리자 전용 라우트로 서빙

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:39:02 +09:00
taekyoungc
4e681d08cc fix: 업무현황 지도 첫 진입 시 판매소 미표시 수정 (ResizeObserver relayout)
워크스페이스 탭(iframe) 안에서 컨테이너 크기 확정 전 지도가 초기화돼 반만/일본으로 보이던 문제.
크기 변화마다 relayout+setBounds 재적용.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:39:02 +09:00
taekyoungc
c3e8ebfe6d style: 분할 보기 칸 헤더의 화면 제목(sh-name) 제거 (화면 자체 제목과 중복)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:19:58 +09:00
taekyoungc
ec34ae348c docs: 화면-매뉴얼 1:1 보강 (비밀번호 변경·LOT 디스켓 불출·수급 계획·기타 입출고 등)
- 모든 메뉴 화면이 알맞은 매뉴얼 섹션으로 연결되도록 screenHelp 정비
- 비밀번호 변경/LOT-No 디스켓 불출/쓰레기봉투 수급 계획/기타 입출고 섹션 신설
- 지정판매소 바코드·신규/취소 명단·조회 설명 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:24:58 +09:00
taekyoungc
9d7d1e8a94 feat: 리포트 첫 진입 자동조회 + 글자 확대(탭·대메뉴 포함, 버튼 고정)
- 수급 계획·반품/파기 현황·기간별 봉투 수불 현황: 첫 진입에도 기본 조건 자동 조회
- 글자 확대 시 탭 텍스트·상단 대메뉴(font-size)도 확대, A−/A+ 버튼은 위치·크기 고정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:24:58 +09:00
taekyoungc
24ac3f169e feat: 지자체 전환 현재 선택 표시 + 전화주문 결제구분 기본 가상계좌
- 지자체 전환 화면에서 현재 작업 지자체 라디오 체크 + '현재 선택' 배지
- 전화 주문 접수 결제구분 기본값을 가상계좌로 설정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:56:49 +09:00
taekyoungc
71534fb198 feat: 인쇄 A4 맞춤 + 재고현황 실사버튼 제거
- 회원관리·승인대기 표를 인쇄 시 A4 너비에 맞게(줄바꿈·관리 열 제외)
- 재고현황의 '실사 선별 조회' 버튼 제거(소메뉴로 이동 가능)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:56 +09:00
taekyoungc
e6a49e379a style: 표 카드 모서리 둥글게(overflow-hidden) 일괄 적용
헤더 바(bg-gray-50)가 둥근 테두리를 뚫고 나오던 카드 컨테이너에 overflow-hidden 추가(12개 파일).
order_change: 중복 플래시 출력도 함께 제거.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:46 +09:00
taekyoungc
77c7d3e119 fix: 플래시 메시지 중복 표시 제거
레이아웃(embed/portal)에서 이미 success/error 를 표시하므로 뷰 안의 중복 출력 제거.
일괄입고·스캐너입고·전화주문 화면에서 메시지가 한 번만 표시됨.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:38 +09:00
taekyoungc
b9dd24082c feat: 화면설명 소제목 스크롤·강조 + 글씨크기 메뉴 확대 + 드로어 개선
- screenHelp 앵커(?hl=)로 '이 화면 설명' 클릭 시 해당 소제목으로 스크롤·강조, 재오픈 시 재강조(postMessage)
- 글씨 크기(A−/A+)가 상단 대메뉴·좌측 사이드바까지 확대, 관리자 페이지에도 조절 기능 추가
- 화면 설명 드로어 양방향 리사이즈(좁히기 가능) + 기본 너비 2배

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:31 +09:00
taekyoungc
56dadb3478 fix: 워크스페이스 탭 중첩 셸 방지 + 세션 만료 시 로그인 리다이렉트
- EmbedRedirectFilter 추가: 임베드(embed=1) 요청의 리다이렉트 Location에 embed 유지(중첩 셸 방지)
- bag/* 전체에 loginAuth 적용, 임베드 대시보드 로그아웃 시 로그인으로 이동
- 기본코드 종류 선택 시 embed 유지, 일괄입고 오류 복귀를 명시 URL로(back() 제거)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:22 +09:00
taekyoungc
287691328e feat: 대시보드 바로가기 새 탭 열기 + 탭 새로고침 시각 피드백
- 업무 현황의 "자주 가는 화면"·"최근 방문 메뉴"·메뉴검색 결과 클릭 시
  워크스페이스 새 탭으로 열기(부모 wsOpenTab 호출, 밖이면 화면 이동 폴백)
- 탭 새로고침(↻): 아이콘 회전 + 화면 잠깐 페이드 후 복구로 새로고침 확인 가능하게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:53:22 +09:00
taekyoungc
fd3da428ab feat: 로고 클릭 시 업무 현황 대시보드로 이동
- 관리자 레이아웃 로고 링크 admin → / (업무 현황)
- 워크스페이스 내 로고 클릭은 전체 새로고침 대신 1분할로 전환 후 업무 현황 탭 열기/포커스

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:56:29 +09:00
taekyoungc
a8afaf4af2 style: 표/패널 UI 전면 통일 + 화면설명 드로어·글씨크기·탭 개선
표 디자인
- 모든 표를 가벼운 스타일로 통일(.data-table 경량화: 작은 회색 헤더·연한 구분선·hover)
- 표/패널 바깥 테두리 둥글게(rounded-lg) 일괄 적용, 표 래퍼에 패딩 카드(p-4) 통일
- 표 헤더·데이터 정렬을 전 화면 좌측 기준으로 통일
  - .data-table th/td text-align:left (전역), 흩어진 center/right 정렬 정리
  - 재디자인 Tailwind 표(포장단위·단가·기본코드·담당자·업체·판매대행소·무료대상자·지정판매소)도 셀 좌측화
- 기본정보관리 등 나머지 소메뉴 표를 기본 코드 관리 스타일(가벼운 표·상태 pill)로 재디자인

워크스페이스/공통
- "이 화면 설명" → 새 탭 대신 우측 드로어 팝업(현재 화면과 동시에 보기, Esc·드래그 폭조절)
- 상단바 글씨 크기 조절(A−/A+), 작업 내용에 zoom 적용
- 탭 최대치 도달 시 자동 삭제 대신 안내 토스트, "모두 닫기"(업무 현황 탭은 보존)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:26:36 +09:00
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
326 changed files with 41740 additions and 3229 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

@@ -174,4 +174,8 @@ blob-report/
/results/ /results/
/phpunit*.xml /phpunit*.xml
docs/ # 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
/docs/
# Claude Code 개인 권한 설정(비밀 포함) — 커밋 금지
.claude/settings.local.json

View File

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

View File

@@ -190,12 +190,23 @@ assets/ # 기획 문서 (엑셀)
| `/bag/code-kinds` | 기본코드 종류 | 종류 목록·세부코드 링크 (조회; CRUD는 관리자만) | | `/bag/code-kinds` | 기본코드 종류 | 종류 목록·세부코드 링크 (조회; CRUD는 관리자만) |
| `/bag/code-details/{ck_idx}` | 기본코드 세부 | 해당 종류의 세부코드 목록 (조회; CRUD는 관리자만) | | `/bag/code-details/{ck_idx}` | 기본코드 세부 | 해당 종류의 세부코드 목록 (조회; CRUD는 관리자만) |
| `/bag/purchase-inbound` | 발주 입고 관리 | 발주/입고 목록 + 등록 버튼 | | `/bag/purchase-inbound` | 발주 입고 관리 | 발주/입고 목록 + 등록 버튼 |
| `/bag/order/create` | 발주 등록 | 발주서 신규 작성 |
| `/bag/order/change` | 발주 변경 | 발주 변경 목록/수정 진입 |
| `/bag/order/revise/{bo_idx}` | 발주 수정 | 선택 발주 수정 화면 |
| `/bag/order/lot-seed` | LOT-No 디스켓 불출 | 발주 LOT 기준 seed 생성/다운로드 |
| `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 | | `/bag/issue` | 불출 관리 | 불출 목록 + 처리/취소 |
| `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 | | `/bag/inventory` | 재고 관리 | 봉투별 현재 재고 조회 |
| `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 | | `/bag/sales` | 판매 관리 | 주문/판매/반품 + 등록 |
| `/bag/order/phone` | 전화 주문 접수 | 전화 주문 접수표 작성/저장 |
| `/bag/order/phone/manage` | 전화 주문 접수 관리 | 접수 리스트 선택 후 품목 수량 수정/취소 |
| `/bag/sale/designated` | 지정판매소 판매 | 주문 선택 + 바코드 스캔 + 판매 저장 |
| `/bag/receiving/batch` | 일괄 입고 | 미입고 건 선택 일괄 입고 |
| `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 | | `/bag/sales-stats` | 판매 현황 | 기간별 판매 데이터 |
| `/bag/flow` | 봉투 수불 관리 | 봉투코드별 입출고 수불 요약 | | `/bag/flow` | 봉투 수불 관리 | 봉투코드별 입출고 수불 요약 |
| `/bag/analytics` | 통계 분석 관리 | Phase 6 예정 | | `/bag/analytics` | 통계 분석 (→ 전년 대비로 리다이렉트) | |
| `/bag/analytics/year-over-year` | 전년 대비 판매 분석 (w_gm604r) | |
| `/bag/analytics/monthly-trend` | 월별 판매 추이 분석 (w_gm606r) | |
| `/bag/analytics/seasonal-trend` | 계절별 판매 추이 분석 (w_gm607r) | |
| `/bag/window` | 창 | Phase 6 예정 | | `/bag/window` | 창 | Phase 6 예정 |
| `/bag/help` | 도움말 | 시스템 안내 | | `/bag/help` | 도움말 | 시스템 안내 |
@@ -249,6 +260,29 @@ assets/ # 기획 문서 (엑셀)
--- ---
## 주문/판매 실무 흐름 (현재 구현)
1. 전화 주문 접수: `/bag/order/phone`
2. 전화 주문 관리(수정/취소): `/bag/order/phone/manage`
3. 지정판매소 판매 처리(바코드 스캔): `/bag/sale/designated`
4. 판매/재고 반영: `bag_sale` 기록 + `bag_inventory` 차감 + 주문 수령상태(`so_received`) 갱신
---
## 바코드 생성/사용 시점 (현재 코드 기준)
상세 코드 체계(LOT·팩·낱장·품목코드·판매소번호): [`doc/봉투-LOT-바코드-코드체계.md`](doc/봉투-LOT-바코드-코드체계.md)
- **현재 코드 구현**
- 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다.
- 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다.
- 판매 단계(`/bag/sale/designated`)에서는 생성된 코드를 스캔하여 `in_stock -> sold` 상태로 전환합니다.
- **요구사항 문서 관점**
- 노션 요구사항에는 발주 단계에서 바코드 원시데이터 생성 후 제작업체 인쇄 흐름이 명시되어 있습니다.
- 현재 구현과 요구사항 간 시점 차이가 존재하므로, 운영 정책 확정 후 발주 단계 생성으로 이관 검토가 필요합니다.
---
## 모델 (25개) ## 모델 (25개)
| 모델 | 테이블 | 용도 | | 모델 | 테이블 | 용도 |

View File

@@ -27,6 +27,8 @@ class Filters extends BaseFilters
public array $aliases = [ public array $aliases = [
'adminAuth' => \App\Filters\AdminAuthFilter::class, 'adminAuth' => \App\Filters\AdminAuthFilter::class,
'loginAuth' => \App\Filters\LoginAuthFilter::class, 'loginAuth' => \App\Filters\LoginAuthFilter::class,
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
'sessionGuard' => \App\Filters\SessionGuardFilter::class,
'csrf' => CSRF::class, 'csrf' => CSRF::class,
'toolbar' => DebugToolbar::class, 'toolbar' => DebugToolbar::class,
'honeypot' => Honeypot::class, 'honeypot' => Honeypot::class,
@@ -77,10 +79,12 @@ class Filters extends BaseFilters
// 'honeypot', // 'honeypot',
// 'csrf', // 'csrf',
// 'invalidchars', // 'invalidchars',
'sessionGuard', // 중복 로그인 방지(나중 로그인 우선)
], ],
'after' => [ 'after' => [
// 'honeypot', // 'honeypot',
// 'secureheaders', // 'secureheaders',
'embedRedirect', // 임베드(탭) 리다이렉트에 embed=1 유지 → 중첩 셸 방지
], ],
]; ];
@@ -108,5 +112,10 @@ class Filters extends BaseFilters
* *
* @var array<string, array<string, list<string>>> * @var array<string, array<string, list<string>>>
*/ */
public array $filters = []; public array $filters = [
// 모든 업무(bag) 화면은 로그인 필요. 세션 만료 후 어떤 버튼을 눌러도
// 깨진 화면 대신 로그인으로 리다이렉트되도록 일괄 보호한다.
// (login/logout/register 는 bag/* 가 아니므로 영향 없음. 관리자 화면은 adminAuth 가 별도 처리)
'loginAuth' => ['before' => ['bag', 'bag/*']],
];
} }

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

88
app/Config/Manual.php Normal file
View File

@@ -0,0 +1,88 @@
<?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.
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
*
* 값에 "slug#소제목힌트" 형식으로 적으면, 매뉴얼 창이 열릴 때 해당 소제목으로
* 자동 스크롤하고 잠시 강조 표시한다(힌트는 그 페이지의 H2/H3 텍스트 일부와 일치).
*
* @var array<string, string>
*/
public array $screenHelp = [
'bag/order/phone' => 'sales#전화 주문 접수',
'bag/order/lot-seed' => 'order#LOT-No 디스켓 불출',
'bag/order' => 'order#발주 등록',
'bag/password-change' => 'account#비밀번호 변경',
'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/misc-flow' => 'reports#기타 입출고',
'bag/reports/returns' => 'reports#반품/파기 현황',
'bag/reports/lot-flow' => 'reports#LOT 수불 조회',
'bag/reports/supply-demand' => 'reports#쓰레기봉투 수급 계획',
'bag/reports/shop-sales' => 'reports#지정 판매소별 판매현황',
'bag/reports/daily-summary' => 'reports#일계표',
'bag/reports/period-sales' => 'reports#그 밖의 현황',
'bag/reports/yearly-sales' => 'reports#그 밖의 현황',
'bag/reports/sales-ledger' => 'reports#그 밖의 현황',
'bag/reports/hometax-export' => '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

@@ -6,12 +6,20 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes * @var RouteCollection $routes
*/ */
$routes->get('/', 'Home::index'); $routes->get('/', 'Home::index');
$routes->get('workspace', 'Home::workspace');
$routes->get('dashboard', 'Home::dashboard'); $routes->get('dashboard', 'Home::dashboard');
$routes->get('dashboard/simple', 'Home::dashboardSimple');
$routes->get('dashboard/compact', 'Home::dashboardCompact');
$routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock'); $routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock');
$routes->get('dashboard/modern', 'Home::dashboardModern'); $routes->get('dashboard/modern', 'Home::dashboardModern');
$routes->get('dashboard/dense', 'Home::dashboardDense'); $routes->get('dashboard/dense', 'Home::dashboardDense');
$routes->get('dashboard/charts', 'Home::dashboardCharts'); $routes->get('dashboard/charts', 'Home::dashboardCharts');
$routes->get('dashboard/blend', 'Home::dashboardBlend'); $routes->get('dashboard/blend', 'Home::dashboardBlend');
$routes->get('dashboard/lite', 'Home::dashboardLite');
$routes->get('dashboard/gov-portal', 'Home::dashboardGovPortal');
$routes->get('dashboard/gov-portal/code-kinds', 'Home::dashboardGovPortalCodeKinds');
$routes->get('dashboard/gov-portal-strip', 'Home::dashboardGovPortalStrip');
$routes->get('dashboard/gov-portal-strip/code-kinds', 'Home::dashboardGovPortalStripCodeKinds');
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry'); $routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise'); $routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
@@ -26,28 +34,80 @@ $routes->get('bag/code-details/(:num)', 'Bag::codeDetails/$1');
// 옛 주소 호환: 세부 목록만 사이트로 이동 // 옛 주소 호환: 세부 목록만 사이트로 이동
$routes->get('admin/code-details/(:num)', 'Admin\CodeDetail::index/$1'); $routes->get('admin/code-details/(:num)', 'Admin\CodeDetail::index/$1');
$routes->get('bag/purchase-inbound', 'Bag::purchaseInbound'); $routes->get('bag/purchase-inbound', 'Bag::purchaseInbound');
$routes->get('bag/issue', 'Bag::issue'); $routes->get('bag/issue', 'Bag::issueLegacy');
$routes->get('bag/issue/cancel', 'Bag::issue');
$routes->get('bag/inventory', 'Bag::inventory'); $routes->get('bag/inventory', 'Bag::inventory');
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
$routes->post('bag/inventory/inspection-select/save', 'Bag::inspectionSelectSave');
$routes->post('bag/inventory/inspection-select/confirm', 'Bag::inspectionSelectConfirm');
$routes->get('bag/inventory/inspection/(:num)', 'Bag::inspectionDetail/$1');
$routes->post('bag/inventory/inspection/(:num)/save', 'Bag::inspectionSave/$1');
$routes->post('bag/inventory/inspection/(:num)/apply', 'Bag::inspectionApply/$1');
$routes->get('bag/sales', 'Bag::sales'); $routes->get('bag/sales', 'Bag::sales');
$routes->get('bag/sales-stats', 'Bag::salesStats'); $routes->get('bag/sales-stats', 'Bag::salesStats');
$routes->get('bag/flow', 'Bag::flow'); $routes->get('bag/flow', 'Bag::flow');
$routes->get('bag/flow/export', 'Bag::flowExport');
$routes->get('bag/analytics', 'Bag::analytics'); $routes->get('bag/analytics', 'Bag::analytics');
$routes->get('bag/analytics/year-over-year', 'Bag::analyticsYearOverYear');
$routes->get('bag/analytics/monthly-trend', 'Bag::analyticsMonthlyTrend');
$routes->get('bag/analytics/seasonal-trend', 'Bag::analyticsSeasonalTrend');
$routes->get('bag/window', 'Bag::window'); $routes->get('bag/window', 'Bag::window');
$routes->get('bag/help', 'Bag::help'); $routes->get('bag/help', 'Bag::help');
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
$routes->get('manual', 'Bag::manual');
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
});
$routes->get('bag/number-lookup', 'Bag::numberLookup');
$routes->post('bag/number-lookup/resolve', 'Bag::numberLookupResolve');
// 사이트 메뉴 CRUD (사이트 레이아웃) // 사이트 메뉴 CRUD (사이트 레이아웃)
$routes->get('bag/inventory/adjust', 'Bag::inventoryAdjust');
$routes->post('bag/inventory/adjust', 'Bag::inventoryAdjustStore');
$routes->get('bag/issue/create', 'Bag::issueCreate'); $routes->get('bag/issue/create', 'Bag::issueCreate');
$routes->post('bag/issue/store', 'Bag::issueStore'); $routes->post('bag/issue/store', 'Bag::issueStore');
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1'); $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/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/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$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/store', 'Bag::orderStore');
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1'); $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->get('bag/receiving/create', 'Bag::receivingCreate');
$routes->post('bag/receiving/store', 'Bag::receivingStore'); $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->get('bag/sale/create', 'Bag::saleCreate');
$routes->post('bag/sale/store', 'Bag::saleStore'); $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->get('bag/shop-order/create', 'Bag::shopOrderCreate');
$routes->post('bag/shop-order/store', 'Bag::shopOrderStore'); $routes->post('bag/shop-order/store', 'Bag::shopOrderStore');
@@ -83,7 +143,13 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops/export', 'Admin\DesignatedShop::export'); $routes->get('designated-shops/export', 'Admin\DesignatedShop::export');
$routes->get('designated-shops/map', 'Admin\DesignatedShop::map'); $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/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', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
@@ -102,6 +168,7 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('bag-orders/export', 'Admin\BagOrder::export'); $routes->get('bag-orders/export', 'Admin\BagOrder::export');
$routes->get('bag-orders', 'Admin\BagOrder::index'); $routes->get('bag-orders', 'Admin\BagOrder::index');
$routes->get('bag-orders/create', 'Admin\BagOrder::create'); $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->post('bag-orders/store', 'Admin\BagOrder::store');
$routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1'); $routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1');
$routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1'); $routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1');
@@ -145,14 +212,19 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('reports/shop-sales', 'Admin\SalesReport::shopSales'); $routes->get('reports/shop-sales', 'Admin\SalesReport::shopSales');
$routes->get('reports/hometax-export', 'Admin\SalesReport::hometaxExport'); $routes->get('reports/hometax-export', 'Admin\SalesReport::hometaxExport');
$routes->get('reports/returns', 'Admin\SalesReport::returns'); $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/lot-flow', 'Admin\SalesReport::lotFlow');
$routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow'); $routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow');
$routes->post('reports/misc-flow', 'Admin\SalesReport::miscFlowStore'); $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->get('password-change', 'Admin\PasswordChange::index');
$routes->post('password-change', 'Admin\PasswordChange::update'); $routes->post('password-change', 'Admin\PasswordChange::update');
}); });
// 사용자 의견 접수 (로그인 사용자)
$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
// Auth // Auth
$routes->get('login', 'Auth::showLoginForm'); $routes->get('login', 'Auth::showLoginForm');
$routes->post('login', 'Auth::login'); $routes->post('login', 'Auth::login');
@@ -168,6 +240,13 @@ $routes->post('register', 'Auth::register');
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void { $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index'); $routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
$routes->post('select-local-government', 'Admin\SelectLocalGovernment::store'); $routes->post('select-local-government', 'Admin\SelectLocalGovernment::store');
// 사용자 의견 검토
$routes->get('feedback', 'Admin\Feedback::index');
$routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1');
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');
$routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1');
$routes->get('/', 'Admin\Dashboard::index'); $routes->get('/', 'Admin\Dashboard::index');
$routes->get('users', 'Admin\User::index'); $routes->get('users', 'Admin\User::index');
$routes->get('users/create', 'Admin\User::create'); $routes->get('users/create', 'Admin\User::create');
@@ -177,6 +256,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1'); $routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1'); $routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
$routes->get('access/login-history', 'Admin\Access::loginHistory'); $routes->get('access/login-history', 'Admin\Access::loginHistory');
$routes->get('access/activity-logs', 'Admin\ActivityLog::index');
$routes->get('access/activity-logs/(:num)', 'Admin\ActivityLog::show/$1');
$routes->get('access/approvals', 'Admin\Access::approvals'); $routes->get('access/approvals', 'Admin\Access::approvals');
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1'); $routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1'); $routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\ActivityLogModel;
use Config\Roles;
/**
* 사용자 작업(감사) 로그 조회 — 생성/수정/삭제 이력.
* 관리자 전용(adminAuth). 슈퍼/본부는 전체, 지자체관리자는 소속 지자체 사용자만.
*/
class ActivityLog extends BaseController
{
private ActivityLogModel $logModel;
/** 테이블명 → 한글 라벨 */
private const TABLE_LABELS = [
'member' => '회원',
'member_approval_request' => '회원 승인요청',
'company' => '업체',
'designated_shop' => '지정판매소',
'sales_agency' => '판매 대행소',
'free_recipient' => '무료 수령처',
'manager' => '담당자',
'local_government' => '지자체',
'code_kind' => '기본코드 종류',
'code_detail' => '기본코드',
'packaging_unit' => '포장 단위',
'packaging_unit_history' => '포장 단위 이력',
'bag_price' => '단가',
'bag_price_history' => '단가 이력',
'bag_order' => '발주',
'bag_order_item' => '발주 품목',
'bag_receiving' => '입고',
'bag_inventory' => '재고',
'bag_sale' => '판매',
'bag_issue' => '무료용 불출',
'bag_issue_item_code' => '불출 품목코드',
'shop_order' => '전화 주문',
'shop_order_item' => '전화 주문 품목',
'menu' => '메뉴',
'menu_type' => '메뉴 유형',
'feedback' => '사용자 의견',
'feedback_file' => '의견 첨부',
'blockchain_ledger' => '원장',
];
/** action → 한글 라벨 */
private const ACTION_LABELS = [
'create' => '등록',
'update' => '수정',
'delete' => '삭제',
];
public function __construct()
{
$this->logModel = model(ActivityLogModel::class);
}
public function index(): string
{
helper('admin');
$start = (string) ($this->request->getGet('start') ?? '');
$end = (string) ($this->request->getGet('end') ?? '');
$action = (string) ($this->request->getGet('action') ?? '');
$table = (string) ($this->request->getGet('table') ?? '');
$user = trim((string) ($this->request->getGet('user') ?? ''));
$builder = $this->logModel
->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left');
// 테넌트 분리: 슈퍼/본부는 전체, 그 외 관리자는 소속 지자체 사용자만
$level = (int) session()->get('mb_level');
if (! Roles::isSuperAdminEquivalent($level)) {
$lgIdx = admin_effective_lg_idx();
$builder->where('member.mb_lg_idx', $lgIdx);
}
if ($start !== '') {
$builder->where('activity_log.al_regdate >=', $start . ' 00:00:00');
}
if ($end !== '') {
$builder->where('activity_log.al_regdate <=', $end . ' 23:59:59');
}
if (isset(self::ACTION_LABELS[$action])) {
$builder->where('activity_log.al_action', $action);
}
if ($table !== '' && isset(self::TABLE_LABELS[$table])) {
$builder->where('activity_log.al_table', $table);
}
if ($user !== '') {
$builder->groupStart()
->like('member.mb_id', $user)
->orLike('member.mb_name', $user)
->groupEnd();
}
$list = $builder->orderBy('activity_log.al_idx', 'DESC')->paginate(20);
$pager = $this->logModel->pager;
return view('admin/layout', [
'title' => '활동 로그',
'content' => view('admin/access/activity_log', [
'list' => $list,
'pager' => $pager,
'start' => $start,
'end' => $end,
'action' => $action,
'table' => $table,
'user' => $user,
'tableLabels' => self::TABLE_LABELS,
'actionLabels' => self::ACTION_LABELS,
]),
]);
}
public function show(int $id): string
{
helper('admin');
$row = $this->logModel
->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left')
->where('activity_log.al_idx', $id)
->first();
if ($row === null) {
return view('admin/layout', [
'title' => '활동 로그',
'content' => '<div class="p-6 text-gray-500">로그를 찾을 수 없습니다.</div>',
]);
}
// 테넌트 분리 검증
$level = (int) session()->get('mb_level');
if (! Roles::isSuperAdminEquivalent($level)) {
$lgIdx = admin_effective_lg_idx();
if ((int) ($row->mb_lg_idx ?? 0) !== (int) $lgIdx) {
return view('admin/layout', [
'title' => '활동 로그',
'content' => '<div class="p-6 text-gray-500">접근 권한이 없습니다.</div>',
]);
}
}
$before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : [];
$after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : [];
return view('admin/layout', [
'title' => '활동 로그 상세',
'content' => view('admin/access/activity_log_detail', [
'row' => $row,
'before' => $before,
'after' => $after,
'tableLabels' => self::TABLE_LABELS,
'actionLabels' => self::ACTION_LABELS,
]),
]);
}
}

View File

@@ -43,8 +43,9 @@ class BagInventory extends BaseController
]; ];
} }
export_csv( export_xlsx(
'재고현황_' . date('Ymd') . '.csv', '재고현황_' . date('Ymd') . '.xlsx',
'재고현황',
['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'], ['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'],
$rows $rows
); );

View File

@@ -4,17 +4,56 @@ namespace App\Controllers\Admin;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\BagIssueModel; use App\Models\BagIssueModel;
use App\Models\BagIssueItemCodeModel;
use App\Models\BagInventoryModel; use App\Models\BagInventoryModel;
use App\Models\CodeKindModel; use App\Models\CodeKindModel;
use App\Models\CodeDetailModel; use App\Models\CodeDetailModel;
use App\Models\FreeRecipientModel;
use App\Models\PackagingUnitModel;
class BagIssue extends BaseController class BagIssue extends BaseController
{ {
private BagIssueModel $issueModel; private BagIssueModel $issueModel;
private BagIssueItemCodeModel $issueItemCodeModel;
public function __construct() public function __construct()
{ {
$this->issueModel = model(BagIssueModel::class); $this->issueModel = model(BagIssueModel::class);
$this->issueItemCodeModel = model(BagIssueItemCodeModel::class);
}
/**
* 낱장 수량을 품목코드 단위로 분해한다.
*
* @return array<int,array{issueCode:string,qty:int}>
*/
private function buildIssueCodeRows(int $bi2Idx, int $sheetQty, array $packUnit): array
{
$sheetQty = max(0, $sheetQty);
if ($sheetQty <= 0) {
return [];
}
$chunkSize = max(
1,
(int) ($packUnit['totalPerBox'] ?? 0),
(int) ($packUnit['packPerSheet'] ?? 0)
);
$rows = [];
$remaining = $sheetQty;
$seq = 1;
while ($remaining > 0) {
$qty = min($chunkSize, $remaining);
$rows[] = [
'issueCode' => sprintf('%d-%06d-%03d', (int) date('y'), $bi2Idx, $seq),
'qty' => $qty,
];
$remaining -= $qty;
$seq++;
}
return $rows;
} }
public function index() public function index()
@@ -62,48 +101,219 @@ class BagIssue extends BaseController
'bi2_issue_type' => 'required|max_length[20]', 'bi2_issue_type' => 'required|max_length[20]',
'bi2_issue_date' => 'required|valid_date[Y-m-d]', 'bi2_issue_date' => 'required|valid_date[Y-m-d]',
'bi2_dest_name' => 'required|max_length[100]', 'bi2_dest_name' => 'required|max_length[100]',
'bi2_bag_code' => 'required|max_length[50]', // 사이트 다건 입력(item_bag_code/item_qty)과 기존 관리자 단건 입력을 함께 허용
'bi2_qty' => 'required|is_natural_no_zero', 'bi2_bag_code' => 'permit_empty|max_length[50]',
'bi2_qty' => 'permit_empty|is_natural_no_zero',
]; ];
if (! $this->validate($rules)) { if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$bagCode = $this->request->getPost('bi2_bag_code');
$qty = (int) $this->request->getPost('bi2_qty');
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null; $issueType = trim((string) $this->request->getPost('bi2_issue_type'));
$bagName = $detail ? $detail->cd_name : ''; $destType = trim((string) ($this->request->getPost('bi2_dest_type') ?? ''));
$destName = trim((string) ($this->request->getPost('bi2_dest_name') ?? ''));
$destDongCode = trim((string) ($this->request->getPost('bi2_dest_dong_code') ?? ''));
if ($destType === '') {
$destType = '동사무소';
}
if ($issueType === '공공용' && $destType === '동사무소') {
$destType = '구청';
}
if ($issueType === '무료용' && $destDongCode !== '') {
$existsFreeDong = model(FreeRecipientModel::class)
->where('fr_lg_idx', $lgIdx)
->where('fr_state', 1)
->where('fr_dong_code', $destDongCode)
->first();
if (! $existsFreeDong) {
return redirect()->back()->withInput()->with('error', '선택한 불출처는 무료용 대상이 아닙니다.');
}
}
$invRows = model(BagInventoryModel::class)
->where('bi_lg_idx', $lgIdx)
->where('bi_qty >', 0)
->findAll();
$inventoryMap = [];
foreach ($invRows as $inv) {
$code = (string) ($inv->bi_bag_code ?? '');
if ($code === '') {
continue;
}
$inventoryMap[$code] = [
'qty' => (int) ($inv->bi_qty ?? 0),
'name' => (string) ($inv->bi_bag_name ?? ''),
];
}
$unitRows = model(PackagingUnitModel::class)
->where('pu_lg_idx', $lgIdx)
->where('pu_state', 1)
->findAll();
$packMap = [];
foreach ($unitRows as $unit) {
$code = (string) ($unit->pu_bag_code ?? '');
if ($code === '') {
continue;
}
$packMap[$code] = [
'packPerSheet' => max(1, (int) ($unit->pu_pack_per_sheet ?? 1)),
'totalPerBox' => max(1, (int) ($unit->pu_total_per_box ?? 1)),
];
}
$items = [];
$itemCodes = $this->request->getPost('item_bag_code');
$itemQtys = $this->request->getPost('item_qty');
$itemPacks = $this->request->getPost('item_pack');
$itemCodes = is_array($itemCodes) ? $itemCodes : [];
$itemQtys = is_array($itemQtys) ? $itemQtys : [];
$itemPacks = is_array($itemPacks) ? $itemPacks : [];
$count = max(count($itemCodes), count($itemQtys), count($itemPacks));
for ($i = 0; $i < $count; $i++) {
$bagCode = trim((string) ($itemCodes[$i] ?? ''));
$qtyRaw = (int) ($itemQtys[$i] ?? 0);
$pack = trim((string) ($itemPacks[$i] ?? 'sheet'));
if ($bagCode === '' || $qtyRaw <= 0) {
continue;
}
if (! in_array($pack, ['box', 'pack', 'sheet'], true)) {
$pack = 'sheet';
}
$packUnit = $packMap[$bagCode] ?? ['packPerSheet' => 1, 'totalPerBox' => 1];
$sheetQty = $qtyRaw;
if ($pack === 'box') {
$sheetQty = $qtyRaw * (int) $packUnit['totalPerBox'];
} elseif ($pack === 'pack') {
$sheetQty = $qtyRaw * (int) $packUnit['packPerSheet'];
}
$sheetQty = max(1, (int) $sheetQty);
$detail = $kindO
? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx)
: null;
$bagName = $detail ? (string) ($detail->cd_name ?? '') : (string) ($inventoryMap[$bagCode]['name'] ?? '');
if ($bagName === '') {
$bagName = (string) $bagCode;
}
$items[] = [
'bagCode' => $bagCode,
'bagName' => $bagName,
'pack' => $pack,
'rawQty' => $qtyRaw,
'sheetQty' => $sheetQty,
];
}
// 기존 관리자 단건 폼과의 호환
if ($items === []) {
$singleBagCode = trim((string) $this->request->getPost('bi2_bag_code'));
$singleQty = (int) $this->request->getPost('bi2_qty');
if ($singleBagCode !== '' && $singleQty > 0) {
$detail = $kindO
? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $singleBagCode, $lgIdx)
: null;
$bagName = $detail ? (string) ($detail->cd_name ?? '') : (string) ($inventoryMap[$singleBagCode]['name'] ?? '');
if ($bagName === '') {
$bagName = (string) $singleBagCode;
}
$items[] = [
'bagCode' => $singleBagCode,
'bagName' => $bagName,
'pack' => 'sheet',
'rawQty' => $singleQty,
'sheetQty' => $singleQty,
];
}
}
if ($items === []) {
return redirect()->back()->withInput()->with('error', '불출 품목을 1건 이상 입력해 주세요.');
}
$requiredByBag = [];
foreach ($items as $item) {
$code = (string) $item['bagCode'];
if (! isset($requiredByBag[$code])) {
$requiredByBag[$code] = 0;
}
$requiredByBag[$code] += (int) $item['sheetQty'];
}
foreach ($requiredByBag as $code => $requiredQty) {
$available = (int) ($inventoryMap[$code]['qty'] ?? 0);
if ($available <= 0) {
return redirect()->back()->withInput()->with('error', '입고 재고가 없는 봉투코드는 불출할 수 없습니다: ' . $code);
}
if ($available < $requiredQty) {
return redirect()->back()->withInput()->with('error', '재고가 부족합니다: ' . $code . ' (재고 ' . number_format($available) . ', 요청 ' . number_format($requiredQty) . ')');
}
}
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$db->transStart(); $db->transStart();
$hasIssueCodeTable = $db->tableExists('bag_issue_item_code');
$issueYear = (int) $this->request->getPost('bi2_year');
$issueQuarter = (int) $this->request->getPost('bi2_quarter');
$issueDate = (string) $this->request->getPost('bi2_issue_date');
$createdCount = 0;
helper('audit');
foreach ($items as $item) {
$issueData = [ $issueData = [
'bi2_lg_idx' => $lgIdx, 'bi2_lg_idx' => $lgIdx,
'bi2_year' => (int) $this->request->getPost('bi2_year'), 'bi2_year' => $issueYear,
'bi2_quarter' => (int) $this->request->getPost('bi2_quarter'), 'bi2_quarter' => $issueQuarter,
'bi2_issue_type' => $this->request->getPost('bi2_issue_type'), 'bi2_issue_type' => $issueType,
'bi2_issue_date' => $this->request->getPost('bi2_issue_date'), 'bi2_issue_date' => $issueDate,
'bi2_dest_type' => $this->request->getPost('bi2_dest_type') ?? '', 'bi2_dest_type' => $destType,
'bi2_dest_name' => $this->request->getPost('bi2_dest_name'), 'bi2_dest_name' => $destName,
'bi2_bag_code' => $bagCode, 'bi2_bag_code' => (string) $item['bagCode'],
'bi2_bag_name' => $bagName, 'bi2_bag_name' => (string) $item['bagName'],
'bi2_qty' => $qty, 'bi2_qty' => (int) $item['sheetQty'],
'bi2_status' => 'normal', 'bi2_status' => 'normal',
'bi2_regdate' => date('Y-m-d H:i:s'), 'bi2_regdate' => date('Y-m-d H:i:s'),
]; ];
$this->issueModel->insert($issueData); $this->issueModel->insert($issueData);
$bi2Idx = (int) $this->issueModel->getInsertID(); $bi2Idx = (int) $this->issueModel->getInsertID();
helper('audit');
audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx])); 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(); $db->transComplete();
return redirect()->to(mgmt_url('bag-issues'))->with('success', '불출 처리되었습니다.'); if (! $db->transStatus()) {
return redirect()->back()->withInput()->with('error', '불출 처리 중 오류가 발생했습니다.');
}
return redirect()->to(mgmt_url('bag-issues'))->with('success', $createdCount . '건 불출 처리되었습니다.');
} }
public function cancel(int $id) public function cancel(int $id)
@@ -116,12 +326,38 @@ class BagIssue extends BaseController
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$db->transStart(); $db->transStart();
$hasIssueCodeTable = $db->tableExists('bag_issue_item_code');
$before = (array) $item; $before = (array) $item;
$this->issueModel->update($id, ['bi2_status' => 'cancelled']); $this->issueModel->update($id, ['bi2_status' => 'cancelled']);
helper('audit'); helper('audit');
audit_log('update', 'bag_issue', $id, $before, ['bi2_status' => 'cancelled']); 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(); $db->transComplete();

View File

@@ -9,8 +9,11 @@ use App\Models\BagPriceModel;
use App\Models\PackagingUnitModel; use App\Models\PackagingUnitModel;
use App\Models\CompanyModel; use App\Models\CompanyModel;
use App\Models\SalesAgencyModel; use App\Models\SalesAgencyModel;
use App\Models\BagReceivingModel;
use App\Models\CodeKindModel; use App\Models\CodeKindModel;
use App\Models\CodeDetailModel; use App\Models\CodeDetailModel;
use App\Models\LocalGovernmentModel;
use App\Libraries\Blockchain\SqlLedger;
class BagOrder extends BaseController class BagOrder extends BaseController
{ {
private BagOrderModel $orderModel; private BagOrderModel $orderModel;
@@ -30,36 +33,76 @@ class BagOrder extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$builder = $this->orderModel->where('bo_lg_idx', $lgIdx); $startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
$endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
// 기간 필터 if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
$startDate = $this->request->getGet('start_date'); $startMonth = date('Y-m');
$endDate = $this->request->getGet('end_date'); }
$status = $this->request->getGet('status'); if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
if ($startDate) $builder->where('bo_order_date >=', $startDate); $endMonth = $startMonth;
if ($endDate) $builder->where('bo_order_date <=', $endDate); }
if ($status) $builder->where('bo_status', $status); if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
[$startMonth, $endMonth] = [$endMonth, $startMonth];
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->paginate(20);
$pager = $this->orderModel->pager;
// 발주별 품목 합계
$itemSummary = [];
foreach ($list as $order) {
$items = $this->itemModel->where('boi_bo_idx', $order->bo_idx)->findAll();
$totalQty = 0; $totalAmt = 0;
foreach ($items as $it) { $totalQty += (int) $it->boi_qty_sheet; $totalAmt += (float) $it->boi_amount; }
$itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)];
} }
// 제작업체/대행소 이름 매핑 $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$companyMap = []; $agencyMap = []; $bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $c) $companyMap[$c->cp_idx] = $c->cp_name; $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $a) { if (! in_array($receiveType, ['all', 'received', 'pending'], true)) {
$agencyMap[$a->sa_idx] = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? ''); $receiveType = 'all';
} }
return $this->renderWorkPage('발주 현황', 'admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status', 'pager')); $companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$companyMap = [];
foreach ($companies as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
$agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? '');
}
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$bagNameMap = [];
foreach ($bagCodes as $code) {
$bagNameMap[(string) $code->cd_code] = (string) $code->cd_name;
}
$reportData = $this->buildOrderStatusRows(
$lgIdx,
$startMonth,
$endMonth,
$companyIdx,
$bagCode,
$receiveType,
$companyMap,
$agencyMap,
$bagNameMap
);
return $this->renderWorkPage(
'발주 현황',
'admin/bag_order/index',
[
'startMonth' => $startMonth,
'endMonth' => $endMonth,
'companyIdx' => $companyIdx,
'bagCode' => $bagCode,
'receiveType' => $receiveType,
'companyOptions' => $companies,
'bagCodeOptions' => $bagCodes,
'rows' => $reportData['rows'],
'groupRows' => $reportData['groupRows'],
'grandTotals' => $reportData['grandTotals'],
]
);
} }
public function export() public function export()
@@ -70,44 +113,240 @@ class BagOrder extends BaseController
return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.');
} }
$builder = $this->orderModel->where('bo_lg_idx', $lgIdx); $startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
$startDate = $this->request->getGet('start_date'); $endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
$endDate = $this->request->getGet('end_date'); if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
$status = $this->request->getGet('status'); $startMonth = date('Y-m');
if ($startDate) $builder->where('bo_order_date >=', $startDate); }
if ($endDate) $builder->where('bo_order_date <=', $endDate); if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
if ($status) $builder->where('bo_status', $status); $endMonth = $startMonth;
}
if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
[$startMonth, $endMonth] = [$endMonth, $startMonth];
}
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(); $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
if (! in_array($receiveType, ['all', 'received', 'pending'], true)) {
$receiveType = 'all';
}
$companyMap = [];
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
$agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? '');
}
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$bagNameMap = [];
foreach ($bagCodes as $code) {
$bagNameMap[(string) $code->cd_code] = (string) $code->cd_name;
}
$reportData = $this->buildOrderStatusRows(
$lgIdx,
$startMonth,
$endMonth,
$companyIdx,
$bagCode,
$receiveType,
$companyMap,
$agencyMap,
$bagNameMap
);
$rows = []; $rows = [];
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; foreach ($reportData['rows'] as $row) {
foreach ($list as $row) { if (! empty($row['is_subtotal'])) {
$items = $this->itemModel->where('boi_bo_idx', $row->bo_idx)->findAll(); $rows[] = [
$totalQty = 0; '',
$totalAmt = 0; '',
foreach ($items as $it) { (string) ($row['label'] ?? '소계'),
$totalQty += (int) $it->boi_qty_sheet; (int) ($row['order_qty'] ?? 0),
$totalAmt += (float) $it->boi_amount; (int) ($row['received_qty'] ?? 0),
(int) ($row['pending_qty'] ?? 0),
(float) ($row['amount'] ?? 0),
'',
];
continue;
} }
$rows[] = [ $rows[] = [
$row->bo_idx, (string) ($row['order_date'] ?? ''),
$row->bo_lot_no, (string) ($row['company_name'] ?? ''),
$row->bo_order_date, (string) ($row['bag_name'] ?? ''),
count($items), (int) ($row['order_qty'] ?? 0),
$totalQty, (int) ($row['received_qty'] ?? 0),
$totalAmt, (int) ($row['pending_qty'] ?? 0),
$statusMap[$row->bo_status] ?? $row->bo_status, (float) ($row['amount'] ?? 0),
(string) ($row['agency_name'] ?? ''),
'',
]; ];
} }
export_csv( $gt = $reportData['grandTotals'] ?? [];
'발주현황_' . date('Ymd') . '.csv', $rows[] = [
['번호', 'LOT번호', '발주일', '품목수', '총수량', '총금액', '상태'], '',
'',
'총계',
(int) ($gt['order_qty'] ?? 0),
(int) ($gt['received_qty'] ?? 0),
(int) ($gt['pending_qty'] ?? 0),
(float) ($gt['amount'] ?? 0),
'',
'',
];
export_xlsx(
'발주현황_' . date('Ymd'),
'발주현황',
['발주일자', '제작업체', '품명', '발주수량', '입고수량', '미입고수량', '발주금액', '입고처', '비고'],
$rows $rows
); );
} }
/**
* 발주 현황(품목 기준) 행 및 소계를 만든다.
*/
private function buildOrderStatusRows(
int $lgIdx,
string $startMonth,
string $endMonth,
int $companyIdx,
string $bagCode,
string $receiveType,
array $companyMap,
array $agencyMap,
array $bagNameMap
): array {
$startDate = $startMonth . '-01';
$endDate = date('Y-m-t', strtotime($endMonth . '-01 00:00:00'));
$builder = $this->orderModel
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->where('bo_order_date >=', $startDate)
->where('bo_order_date <=', $endDate)
->whereIn('bo_status', ['normal', 'cancelled'])
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC');
if ($companyIdx > 0) {
$builder->where('bo_company_idx', $companyIdx);
}
$orders = $builder->findAll();
if (empty($orders)) {
return ['rows' => [], 'groupRows' => [], 'grandTotals' => ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0]];
}
$orderIds = array_map(static fn($order) => (int) $order->bo_idx, $orders);
$itemsByOrder = [];
if (! empty($orderIds)) {
$allItems = $this->itemModel
->whereIn('boi_bo_idx', $orderIds)
->orderBy('boi_bo_idx', 'DESC')
->orderBy('boi_idx', 'ASC')
->findAll();
foreach ($allItems as $item) {
$boIdx = (int) ($item->boi_bo_idx ?? 0);
if (! isset($itemsByOrder[$boIdx])) {
$itemsByOrder[$boIdx] = [];
}
$itemsByOrder[$boIdx][] = $item;
}
}
$receivedMap = [];
$receivingRows = model(BagReceivingModel::class)
->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty')
->where('br_lg_idx', $lgIdx)
->whereIn('br_bo_idx', $orderIds)
->groupBy('br_bo_idx, br_bag_code')
->findAll();
foreach ($receivingRows as $received) {
$key = (int) ($received->br_bo_idx ?? 0) . '|' . (string) ($received->br_bag_code ?? '');
$receivedMap[$key] = (int) ($received->recv_qty ?? 0);
}
$rows = [];
$groupRows = [];
$grandTotals = ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0.0];
foreach ($orders as $order) {
$boIdx = (int) ($order->bo_idx ?? 0);
$items = $itemsByOrder[$boIdx] ?? [];
$groupCount = 0;
$groupTotalOrder = 0;
$groupTotalReceived = 0;
$groupTotalPending = 0;
$groupTotalAmount = 0.0;
foreach ($items as $item) {
$itemBagCode = (string) ($item->boi_bag_code ?? '');
if ($bagCode !== '' && $itemBagCode !== $bagCode) {
continue;
}
$orderQty = (int) ($item->boi_qty_sheet ?? 0);
$recvQty = (int) ($receivedMap[$boIdx . '|' . $itemBagCode] ?? 0);
if ($recvQty > $orderQty) {
$recvQty = $orderQty;
}
$pendingQty = max(0, $orderQty - $recvQty);
if ($receiveType === 'received' && $recvQty <= 0) {
continue;
}
if ($receiveType === 'pending' && $pendingQty <= 0) {
continue;
}
$amount = (float) ($item->boi_amount ?? 0);
$rows[] = [
'bo_idx' => $boIdx,
'order_date' => (string) ($order->bo_order_date ?? ''),
'company_name' => (string) ($companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ''),
'bag_name' => (string) ($item->boi_bag_name ?? ($bagNameMap[$itemBagCode] ?? $itemBagCode)),
'order_qty' => $orderQty,
'received_qty' => $recvQty,
'pending_qty' => $pendingQty,
'amount' => $amount,
'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
];
$groupCount++;
$groupTotalOrder += $orderQty;
$groupTotalReceived += $recvQty;
$groupTotalPending += $pendingQty;
$groupTotalAmount += $amount;
}
if ($groupCount > 0) {
$groupRows[$boIdx] = $groupCount;
$rows[] = [
'bo_idx' => $boIdx,
'is_subtotal' => true,
'label' => '소계',
'order_qty' => $groupTotalOrder,
'received_qty' => $groupTotalReceived,
'pending_qty' => $groupTotalPending,
'amount' => $groupTotalAmount,
];
$grandTotals['order_qty'] += $groupTotalOrder;
$grandTotals['received_qty'] += $groupTotalReceived;
$grandTotals['pending_qty'] += $groupTotalPending;
$grandTotals['amount'] += $groupTotalAmount;
}
}
return ['rows' => $rows, 'groupRows' => $groupRows, 'grandTotals' => $grandTotals];
}
public function create() public function create()
{ {
helper('admin'); helper('admin');
@@ -119,18 +358,105 @@ class BagOrder extends BaseController
// 봉투 종류 + 단가 + 포장단위 // 봉투 종류 + 단가 + 포장단위
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$prices = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_state', 1)->findAll(); $priceMapRows = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx);
$units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll(); $units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll();
$companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll(); $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
$associations = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll();
$agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll(); $agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
return $this->renderWorkPage('발주 등록', 'admin/bag_order/create', compact('bagCodes', 'prices', 'units', 'companies', 'agencies')); $companyMap = [];
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach ($agencies as $agency) {
$agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
}
$recentOrders = $this->orderModel
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC')
->findAll(12);
$bagNameMap = [];
foreach ($bagCodes as $codeDetail) {
$bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
}
$priceMap = [];
foreach ($priceMapRows as $bagCode => $price) {
$priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
}
$unitMap = [];
foreach ($units as $unit) {
$unitMap[(string) $unit->pu_bag_code] = [
'boxPerPack' => (int) $unit->pu_box_per_pack,
'packPerSheet' => (int) $unit->pu_pack_per_sheet,
'totalPerBox' => (int) $unit->pu_total_per_box,
];
}
$bagReferenceRows = [];
foreach ($bagCodes as $codeDetail) {
$bagCode = (string) $codeDetail->cd_code;
$unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
$bagReferenceRows[] = [
'code' => $bagCode,
'name' => (string) ($bagNameMap[$bagCode] ?? ''),
'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
'boxPerPack' => (int) $unit['boxPerPack'],
'packPerSheet' => (int) $unit['packPerSheet'],
'totalPerBox' => (int) $unit['totalPerBox'],
];
}
return $this->renderWorkPage(
'발주 등록',
'admin/bag_order/create',
compact(
'bagCodes',
'units',
'companies',
'associations',
'agencies',
'recentOrders',
'companyMap',
'agencyMap',
'bagReferenceRows'
)
);
}
public function revise(int $id)
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
$order = $this->orderModel->find($id);
if (! $order || (int) $order->bo_lg_idx !== $lgIdx) {
return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
}
return redirect()->to(site_url('bag/order/revise/' . $id));
} }
public function store() public function store()
{ {
helper('admin'); helper('admin');
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->back()->withInput()->with('error', '지자체를 선택해 주세요.');
}
$sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0);
$sourceOrder = null;
if ($sourceIdx > 0) {
$sourceOrder = $this->orderModel->find($sourceIdx);
if (! $sourceOrder || (int) $sourceOrder->bo_lg_idx !== $lgIdx) {
return redirect()->back()->withInput()->with('error', '수정 대상 발주를 찾을 수 없습니다.');
}
}
$rules = [ $rules = [
'bo_order_date' => 'required|valid_date[Y-m-d]', 'bo_order_date' => 'required|valid_date[Y-m-d]',
@@ -141,65 +467,114 @@ class BagOrder extends BaseController
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
$qtySheets = $this->request->getPost('item_qty_sheet') ?? [];
$qtyBoxes = $this->request->getPost('item_qty_box') ?? []; // 구 화면 호환
$postedUnitPrices = $this->request->getPost('item_unit_price');
$changeKind = (string) ($this->request->getPost('bo_change_mode') ?? 'meta');
if (! in_array($changeKind, ['price', 'meta', 'delete'], true)) {
$changeKind = 'meta';
}
$itemCount = count($bagCodes);
$normalizedItems = [];
for ($i = 0; $i < $itemCount; $i++) {
$code = trim((string) ($bagCodes[$i] ?? ''));
$qtySheet = (int) ($qtySheets[$i] ?? 0);
$qtyBox = (int) ($qtyBoxes[$i] ?? 0);
if ($code === '' || ($qtySheet <= 0 && $qtyBox <= 0)) {
continue;
}
$normalizedItems[] = ['code' => $code, 'qtySheet' => $qtySheet, 'qtyBox' => $qtyBox];
}
if (empty($normalizedItems)) {
return redirect()->back()->withInput()->with('error', '최소 1개 이상의 봉투 수량을 입력해 주세요.');
}
$priceByCode = [];
if ($sourceOrder !== null && $changeKind === 'price' && is_array($postedUnitPrices)) {
for ($pi = 0; $pi < count($bagCodes); $pi++) {
$c = trim((string) ($bagCodes[$pi] ?? ''));
if ($c === '') {
continue;
}
$raw = $postedUnitPrices[$pi] ?? null;
if ($raw !== null && $raw !== '' && is_numeric($raw)) {
$priceByCode[$c] = round((float) $raw, 2);
}
}
}
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$db->transStart(); $db->transStart();
// UUID 생성 try {
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', if ($sourceOrder) {
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), $uuid = (string) $sourceOrder->bo_uuid;
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, $maxVerRow = $this->orderModel->selectMax('bo_version')->where('bo_uuid', $uuid)->first();
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); $latestVersion = ($maxVerRow !== null && isset($maxVerRow->bo_version)) ? (int) $maxVerRow->bo_version : 0;
$version = $latestVersion + 1;
// LOT 번호 생성 $lotNo = (string) $sourceOrder->bo_lot_no;
$lotNo = 'LOT-' . date('Ymd') . '-' . strtoupper(substr(md5($uuid), 0, 6)); } else {
$uuid = $this->generateUuidV4();
$version = 1;
$lotNo = $this->generateLotNo6();
}
$orderData = [ $orderData = [
'bo_uuid' => $uuid, 'bo_uuid' => $uuid,
'bo_version' => 1, 'bo_version' => $version,
'bo_lg_idx' => $lgIdx, 'bo_lg_idx' => $lgIdx,
'bo_gugun_code' => $this->request->getPost('bo_gugun_code') ?? '', 'bo_gugun_code' => $this->resolveGugunCodeFromLg($lgIdx),
'bo_dong_code' => $this->request->getPost('bo_dong_code') ?? '', 'bo_dong_code' => '',
'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null, 'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null,
'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null, 'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null,
'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0), 'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0),
'bo_order_date' => $this->request->getPost('bo_order_date'), 'bo_order_date' => $this->request->getPost('bo_order_date'),
'bo_bag_types' => '',
'bo_unit_prices' => '',
'bo_qty_boxes' => '',
'bo_lot_no' => $lotNo, 'bo_lot_no' => $lotNo,
'bo_status' => 'normal', 'bo_status' => 'normal',
'bo_orderer_idx' => session()->get('mb_idx'), 'bo_orderer_idx' => session()->get('mb_idx'),
'bo_regdate' => date('Y-m-d H:i:s'), 'bo_regdate' => date('Y-m-d H:i:s'),
]; ];
// SHA-256 해시 // 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
$orderData['bo_hash'] = hash('sha256', json_encode($orderData)); $orderData['bo_hash'] = '';
$this->orderModel->insert($orderData); $this->orderModel->insert($orderData);
$boIdx = (int) $this->orderModel->getInsertID(); $boIdx = (int) $this->orderModel->getInsertID();
// CT-05: 감사 로그
helper('audit');
audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx]));
// 품목 저장 // 품목 저장
$bagCodes = $this->request->getPost('item_bag_code') ?? []; $hashItems = [];
$qtyBoxes = $this->request->getPost('item_qty_box') ?? []; $bagTypesForHeader = [];
foreach ($bagCodes as $i => $code) { $unitPricesForHeader = [];
if (empty($code) || empty($qtyBoxes[$i])) continue; $qtyBoxesForHeader = [];
$qtyBox = (int) $qtyBoxes[$i]; foreach ($normalizedItems as $item) {
$code = $item['code'];
$qtySheetInput = (int) ($item['qtySheet'] ?? 0);
$qtyBoxInput = (int) ($item['qtyBox'] ?? 0);
// 포장단위에서 낱장 환산 // 포장단위에서 낱장 환산
$unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first(); $unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
$totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1; $totalPerBox = $unit ? max(1, (int) $unit->pu_total_per_box) : 1;
$qtySheet = $qtyBox * $totalPerBox; $qtySheet = $qtySheetInput > 0 ? $qtySheetInput : ($qtyBoxInput * $totalPerBox);
if ($qtySheet <= 0) {
continue;
}
$qtyBox = intdiv($qtySheet, $totalPerBox);
// 단가 // 단가 (발주 변경·단가 구분 시 POST 단가 우선)
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first(); $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, $code);
$unitPrice = $price ? (float) $price->bp_order_price : 0; $unitPrice = $price ? (float) $price->bp_order_price : 0;
if ($sourceOrder !== null && isset($priceByCode[$code])) {
$unitPrice = $priceByCode[$code];
}
// 봉투명 // 봉투명
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null; $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null;
$this->itemModel->insert([ $itemData = [
'boi_bo_idx' => $boIdx, 'boi_bo_idx' => $boIdx,
'boi_bag_code' => $code, 'boi_bag_code' => $code,
'boi_bag_name' => $detail ? $detail->cd_name : '', 'boi_bag_name' => $detail ? $detail->cd_name : '',
@@ -207,14 +582,204 @@ class BagOrder extends BaseController
'boi_qty_box' => $qtyBox, 'boi_qty_box' => $qtyBox,
'boi_qty_sheet' => $qtySheet, 'boi_qty_sheet' => $qtySheet,
'boi_amount' => $unitPrice * $qtySheet, 'boi_amount' => $unitPrice * $qtySheet,
]); ];
$this->itemModel->insert($itemData);
$hashItems[] = $itemData;
$bagTypesForHeader[] = [
'code' => $itemData['boi_bag_code'],
'name' => $itemData['boi_bag_name'],
];
$unitPricesForHeader[] = [
'code' => $itemData['boi_bag_code'],
'unit_price' => $itemData['boi_unit_price'],
];
$qtyBoxesForHeader[] = [
'code' => $itemData['boi_bag_code'],
'qty_box' => $itemData['boi_qty_box'],
];
} }
$db->transComplete(); $orderData['bo_bag_types'] = json_encode($bagTypesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
$orderData['bo_unit_prices'] = json_encode($unitPricesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
$orderData['bo_qty_boxes'] = json_encode($qtyBoxesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
// 최종 발주 데이터(헤더+품목) 해시
$hashPayload = $orderData;
$hashPayload['bo_idx'] = $boIdx;
$hashPayload['items'] = $hashItems;
$hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$orderHash = hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx);
$this->orderModel->update($boIdx, [
'bo_bag_types' => $orderData['bo_bag_types'],
'bo_unit_prices' => $orderData['bo_unit_prices'],
'bo_qty_boxes' => $orderData['bo_qty_boxes'],
'bo_hash' => $orderHash,
]);
$beforeHash = $sourceOrder ? (string) ($sourceOrder->bo_hash ?? '') : '';
$seedFilePath = $this->generateBarcodeSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash);
$blockPayload = [
'bo_idx' => $boIdx,
'bo_uuid' => $uuid,
'bo_version' => $version,
'bo_lot_no' => $lotNo,
'bo_hash' => $orderHash,
'seed_file' => $seedFilePath,
'hash_chain' => $beforeHash !== '' ? [$beforeHash, $orderHash] : [$orderHash],
'order' => $orderData,
'items' => $hashItems,
];
$ledger = new SqlLedger();
$ledger->appendBlock(
$sourceOrder ? 'ORDER_UPDATE' : 'ORDER_CREATE',
$blockPayload,
$uuid,
$version,
session()->get('mb_idx') ? (int) session()->get('mb_idx') : null,
$lgIdx
);
// CT-05: 감사 로그
helper('audit');
if ($sourceOrder) {
audit_log(
'update',
'bag_order',
$boIdx,
['bo_idx' => (int) $sourceOrder->bo_idx, 'bo_hash' => $beforeHash, 'bo_version' => (int) $sourceOrder->bo_version],
array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath])
);
} else {
audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath]));
}
if (! $db->transComplete()) {
throw new \RuntimeException('Transaction did not complete');
}
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'BagOrder::store: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
return redirect()->back()->withInput()->with('error', '발주 저장 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요.');
}
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo);
} }
/** 효과 지자체(`local_government`)의 행정 구·군 코드(lg_code) */
private function resolveGugunCodeFromLg(int $lgIdx): string
{
$lg = model(LocalGovernmentModel::class)->find($lgIdx);
return $lg ? trim((string) ($lg->lg_code ?? '')) : '';
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private function generateLotNo6(): string
{
// 문서의 "LOT 번호 6 Byte" 요구를 맞추기 위해 영숫자 6자리로 생성한다.
// 충돌 가능성을 낮추기 위해 최대 20회 재시도 후 timestamp 기반으로 fallback.
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for ($attempt = 0; $attempt < 20; $attempt++) {
$lot = '';
for ($i = 0; $i < 6; $i++) {
$lot .= $chars[random_int(0, strlen($chars) - 1)];
}
$exists = $this->orderModel->where('bo_lot_no', $lot)->countAllResults() > 0;
if (! $exists) {
return $lot;
}
}
return strtoupper(substr(base_convert((string) time(), 10, 36), -6));
}
/**
* @param array<string,mixed> $orderData
* @param array<int,array<string,mixed>> $items
*/
private function generateBarcodeSeedFile(string $uuid, int $version, string $lotNo, array $orderData, array $items, string $orderHash): string
{
$baseDir = WRITEPATH . 'barcode-seeds';
if (! is_dir($baseDir)) {
mkdir($baseDir, 0775, true);
}
$keyDir = WRITEPATH . 'keys';
if (! is_dir($keyDir)) {
mkdir($keyDir, 0775, true);
}
$privateKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_private.pem';
$publicKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_public.pem';
if (! is_file($privateKeyPath) || ! is_file($publicKeyPath)) {
$config = [
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$resource = openssl_pkey_new($config);
if ($resource !== false) {
$privatePem = '';
openssl_pkey_export($resource, $privatePem);
$details = openssl_pkey_get_details($resource);
$publicPem = $details['key'] ?? '';
if ($privatePem !== '' && $publicPem !== '') {
file_put_contents($privateKeyPath, $privatePem);
file_put_contents($publicKeyPath, $publicPem);
}
}
}
$payload = [
'uuid' => $uuid,
'version' => $version,
'lot_no' => $lotNo,
'order_hash' => $orderHash,
'order' => $orderData,
'items' => $items,
];
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
$aesKey = random_bytes(32);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($payloadJson, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
$cipherRaw = $payloadJson;
}
$encryptedKey = '';
$publicPem = is_file($publicKeyPath) ? file_get_contents($publicKeyPath) : '';
if (is_string($publicPem) && $publicPem !== '') {
openssl_public_encrypt($aesKey, $encryptedKey, $publicPem, OPENSSL_PKCS1_OAEP_PADDING);
}
$seed = [
'algorithm' => ['symmetric' => 'AES-256-CBC', 'asymmetric' => 'RSA-2048'],
'lot_no' => $lotNo,
'uuid' => $uuid,
'version' => $version,
'iv_b64' => base64_encode($iv),
'key_b64' => $encryptedKey !== '' ? base64_encode($encryptedKey) : '',
'cipher_b64' => base64_encode((string) $cipherRaw),
'payload_hash' => hash('sha256', $payloadJson),
'created_at' => date('c'),
];
$fileName = $lotNo . '_v' . $version . '.seed.json';
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $fileName;
file_put_contents($fullPath, json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $fullPath;
}
public function detail(int $id) public function detail(int $id)
{ {
helper('admin'); helper('admin');
@@ -250,9 +815,11 @@ class BagOrder extends BaseController
} }
$before = (array) $order; $before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]); $beforeHash = (string) ($order->bo_hash ?? '');
$this->appendLedgerForStatusChange($order, $id, 'ORDER_CANCEL', 'cancelled', $beforeHash);
$after = (array) $this->orderModel->find($id);
helper('audit'); helper('audit');
audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']); audit_log('update', 'bag_order', $id, $before, $after);
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.'); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.');
} }
@@ -266,10 +833,117 @@ class BagOrder extends BaseController
} }
$before = (array) $order; $before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]); $beforeHash = (string) ($order->bo_hash ?? '');
$this->appendLedgerForStatusChange($order, $id, 'ORDER_DELETE', 'deleted', $beforeHash);
$after = (array) $this->orderModel->find($id);
helper('audit'); helper('audit');
audit_log('delete', 'bag_order', $id, $before, ['bo_status' => 'deleted']); audit_log('delete', 'bag_order', $id, $before, $after);
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 삭제 처리되었습니다.'); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
} }
/**
* 상태 변경 시(취소/삭제) 무결성 검증을 위해 bo_hash 재계산 후
* SQL-Ledger(append-only)에 블록을 추가한다.
*
* @param object $order
* @param int $boIdx
* @param string $txType ORDER_CANCEL|ORDER_DELETE
* @param string $newStatus cancelled|deleted
* @param string $previousHash
*/
private function appendLedgerForStatusChange(object $order, int $boIdx, string $txType, string $newStatus, string $previousHash): void
{
// 품목은 상태 변경 시 그대로이므로, 동일 payload 형태로 items array를 만든다.
$items = $this->itemModel->where('boi_bo_idx', $boIdx)->findAll();
$hashItems = [];
foreach ($items as $it) {
$hashItems[] = [
'boi_bo_idx' => (int) $it->boi_bo_idx,
'boi_bag_code' => (string) $it->boi_bag_code,
'boi_bag_name' => (string) ($it->boi_bag_name ?? ''),
'boi_unit_price' => (float) $it->boi_unit_price,
'boi_qty_box' => (int) $it->boi_qty_box,
'boi_qty_sheet' => (int) $it->boi_qty_sheet,
'boi_amount' => (float) $it->boi_amount,
];
}
$newOrder = $order;
$newOrder->bo_status = $newStatus;
$newHash = $this->computeOrderHash($boIdx, $newOrder, $hashItems);
$actorIdx = session()->get('mb_idx') ? (int) session()->get('mb_idx') : null;
$lgIdx = (int) ($order->bo_lg_idx ?? 0);
$seedFilePath = '';
$ledgerPayload = [
'bo_idx' => $boIdx,
'bo_uuid' => (string) $order->bo_uuid,
'bo_version' => (int) $order->bo_version,
'bo_lot_no' => (string) $order->bo_lot_no,
'bo_hash' => $newHash,
'seed_file' => $seedFilePath,
'hash_chain' => [$previousHash, $newHash],
'order' => [
'bo_status' => $newStatus,
'bo_hash' => $newHash,
],
'items' => $hashItems,
];
$ledger = new SqlLedger();
$ledger->appendBlock(
$txType,
$ledgerPayload,
(string) $order->bo_uuid,
(int) $order->bo_version,
$actorIdx,
$lgIdx
);
// order row에 hash 반영
$this->orderModel->update($boIdx, [
'bo_status' => $newStatus,
'bo_moddate' => date('Y-m-d H:i:s'),
'bo_hash' => $newHash,
]);
}
/**
* store()에서 생성하는 bo_hash와 동일한 "헤더+items" 규격을 사용해 SHA-256을 계산한다.
*
* @param int $boIdx
* @param object $order
* @param array<int,array<string,mixed>> $hashItems
*/
private function computeOrderHash(int $boIdx, object $order, array $hashItems): string
{
$orderData = [
'bo_uuid' => (string) $order->bo_uuid,
'bo_version' => (int) $order->bo_version,
'bo_lg_idx' => (int) $order->bo_lg_idx,
'bo_gugun_code' => (string) ($order->bo_gugun_code ?? ''),
'bo_dong_code' => (string) ($order->bo_dong_code ?? ''),
'bo_company_idx' => $order->bo_company_idx !== null ? (int) $order->bo_company_idx : null,
'bo_agency_idx' => $order->bo_agency_idx !== null ? (int) $order->bo_agency_idx : null,
'bo_fee_rate' => (float) ($order->bo_fee_rate ?? 0),
'bo_order_date' => (string) $order->bo_order_date,
'bo_bag_types' => (string) ($order->bo_bag_types ?? ''),
'bo_unit_prices' => (string) ($order->bo_unit_prices ?? ''),
'bo_qty_boxes' => (string) ($order->bo_qty_boxes ?? ''),
'bo_lot_no' => (string) $order->bo_lot_no,
'bo_hash' => '',
'bo_status' => (string) $order->bo_status,
'bo_orderer_idx' => $order->bo_orderer_idx !== null ? (int) $order->bo_orderer_idx : null,
'bo_regdate' => (string) ($order->bo_regdate ?? ''),
];
$hashPayload = $orderData;
$hashPayload['bo_idx'] = $boIdx;
$hashPayload['items'] = $hashItems;
$hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx);
}
} }

View File

@@ -27,14 +27,139 @@ class BagPrice extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$list = $this->priceModel->where('bp_lg_idx', $lgIdx) $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);
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 >=', $qStart)
->groupEnd();
}
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');
}
}
}
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_bag_code', 'ASC')
->orderBy('bp_start_date', 'DESC') ->orderBy('bp_start_date', 'DESC')
->paginate(20); ->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', [ return $this->renderWorkPage('봉투 단가 관리', 'admin/bag_price/index', [
'list' => $list, 'list' => $list,
'pager' => $this->priceModel->pager, '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,
]); ]);
} }

View File

@@ -50,7 +50,7 @@ class BagReceiving extends BaseController
return redirect()->to(mgmt_url('bag-receivings'))->with('error', '지자체를 선택해 주세요.'); 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 $this->renderWorkPage('입고 처리', 'admin/bag_receiving/create', compact('orders')); return $this->renderWorkPage('입고 처리', 'admin/bag_receiving/create', compact('orders'));
} }

View File

@@ -133,7 +133,7 @@ class BagSale extends BaseController
$shop = model(DesignatedShopModel::class)->find($dsIdx); $shop = model(DesignatedShopModel::class)->find($dsIdx);
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null; $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null;
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $bagCode)->where('bp_state', 1)->first(); $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $bagCode);
$unitPrice = $price ? (float) $price->bp_consumer : 0; $unitPrice = $price ? (float) $price->bp_consumer : 0;
$actualQty = ($type === 'return') ? -$qty : $qty; $actualQty = ($type === 'return') ? -$qty : $qty;

View File

@@ -56,14 +56,11 @@ class CodeDetail extends BaseController
helper('admin'); helper('admin');
return view('admin/layout', [ return $this->renderWorkPage('세부코드 등록 — ' . $kind->ck_name, 'admin/code_detail/create', [
'title' => '세부코드 등록 — ' . $kind->ck_name,
'content' => view('admin/code_detail/create', [
'kind' => $kind, 'kind' => $kind,
'canPlatformScope' => $canPlatformScope, 'canPlatformScope' => $canPlatformScope,
'localGovernments' => $govs, 'localGovernments' => $govs,
'effectiveLgIdx' => admin_effective_lg_idx(), 'effectiveLgIdx' => admin_effective_lg_idx(),
]),
]); ]);
} }
@@ -156,12 +153,9 @@ class CodeDetail extends BaseController
$kind = $this->kindModel->find($item->cd_ck_idx); $kind = $this->kindModel->find($item->cd_ck_idx);
return view('admin/layout', [ return $this->renderWorkPage('세부코드 수정 — ' . ($kind->ck_name ?? ''), 'admin/code_detail/edit', [
'title' => '세부코드 수정 — ' . ($kind->ck_name ?? ''),
'content' => view('admin/code_detail/edit', [
'item' => $item, 'item' => $item,
'kind' => $kind, 'kind' => $kind,
]),
]); ]);
} }

View File

@@ -34,10 +34,7 @@ class CodeKind extends BaseController
return $r; return $r;
} }
return view('admin/layout', [ return $this->renderWorkPage('기본코드 종류 등록', 'admin/code_kind/create');
'title' => '기본코드 종류 등록',
'content' => view('admin/code_kind/create'),
]);
} }
public function store() public function store()
@@ -76,10 +73,7 @@ class CodeKind extends BaseController
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.'); return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
} }
return view('admin/layout', [ return $this->renderWorkPage('기본코드 종류 수정', 'admin/code_kind/edit', ['item' => $item]);
'title' => '기본코드 종류 수정',
'content' => view('admin/code_kind/edit', ['item' => $item]),
]);
} }
public function update(int $id) public function update(int $id)

View File

@@ -9,6 +9,11 @@ class Company extends BaseController
{ {
private CompanyModel $model; private CompanyModel $model;
private function companyTypeOptions(): array
{
return ['협회', '제작업체', '회수업체'];
}
public function __construct() public function __construct()
{ {
$this->model = model(CompanyModel::class); $this->model = model(CompanyModel::class);
@@ -22,10 +27,29 @@ class Company extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); 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; $pager = $this->model->pager;
return $this->renderWorkPage('업체 관리', '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() public function create()

View File

@@ -40,7 +40,10 @@ class Dashboard extends BaseController
FROM bag_order_item GROUP BY boi_bo_idx FROM bag_order_item GROUP BY boi_bo_idx
) sub ON sub.boi_bo_idx = bo.bo_idx ) sub ON sub.boi_bo_idx = bo.bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal' WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
", [$lgIdx])->getRow(); 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_count'] = (int) ($orderStats->cnt ?? 0);
$stats['order_amount'] = (int) ($orderStats->total_amount ?? 0); $stats['order_amount'] = (int) ($orderStats->total_amount ?? 0);
@@ -72,9 +75,12 @@ class Dashboard extends BaseController
SELECT bo_idx, bo_lot_no, bo_order_date, bo_status SELECT bo_idx, bo_lot_no, bo_order_date, bo_status
FROM bag_order FROM bag_order
WHERE bo_lg_idx = ? 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 ORDER BY bo_order_date DESC, bo_idx DESC
LIMIT 5 LIMIT 5
", [$lgIdx])->getResult(); ", [$lgIdx, $lgIdx])->getResult();
// 최근 판매 5건 // 최근 판매 5건
$stats['recent_sales'] = $db->query(" $stats['recent_sales'] = $db->query("

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\FeedbackFileModel;
use App\Models\FeedbackModel;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Roles;
/**
* 사용자 의견 검토(관리자). super·본부는 전체, 지자체관리자는 자기 지자체만.
*/
class Feedback extends BaseController
{
private function scopeLgIdx(): ?int
{
helper('admin');
$level = (int) session()->get('mb_level');
if (Roles::isSuperAdminEquivalent($level)) {
return null; // 전체
}
return admin_effective_lg_idx();
}
public function index(): string
{
$model = model(FeedbackModel::class);
$builder = $model->orderBy('fb_idx', 'DESC');
$scope = $this->scopeLgIdx();
if ($scope !== null) {
$builder->where('fb_lg_idx', $scope);
}
$status = (string) ($this->request->getGet('status') ?? '');
if (in_array($status, FeedbackModel::STATUSES, true)) {
$builder->where('fb_status', $status);
}
$list = $builder->paginate(20);
$pager = $model->pager;
return view('admin/layout', [
'title' => '사용자 의견',
'content' => view('admin/feedback/index', [
'list' => $list,
'pager' => $pager,
'status' => $status,
]),
]);
}
public function show(int $id): string|ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$files = model(FeedbackFileModel::class)
->where('ff_fb_idx', $id)
->orderBy('ff_idx', 'ASC')
->findAll();
return view('admin/layout', [
'title' => '의견 상세',
'content' => view('admin/feedback/show', ['row' => $row, 'files' => $files]),
]);
}
public function updateStatus(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$status = (string) $this->request->getPost('fb_status');
$memo = (string) $this->request->getPost('fb_admin_memo');
$update = ['fb_admin_memo' => mb_substr($memo, 0, 2000)];
if (in_array($status, FeedbackModel::STATUSES, true)) {
$update['fb_status'] = $status;
}
$model->update($id, $update);
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
}
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
public function image(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope) || empty($row->fb_screenshot)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $row->fb_screenshot);
if (! is_file($path)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$mime = str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg';
return $this->response
->setHeader('Content-Type', $mime)
->setHeader('Cache-Control', 'private, max-age=300')
->setBody((string) file_get_contents($path));
}
/** 첨부 이미지(feedback_file) 서빙 — 관리자만, 테넌트 검증 */
public function file(int $ffIdx): ResponseInterface
{
$ff = model(FeedbackFileModel::class)->find($ffIdx);
if ($ff === null) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$fb = model(FeedbackModel::class)->find((int) $ff->ff_fb_idx);
$scope = $this->scopeLgIdx();
if ($fb === null || ($scope !== null && (int) $fb->fb_lg_idx !== $scope)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $ff->ff_path);
if (! is_file($path)) {
return $this->response->setStatusCode(404)->setBody('not found');
}
$mime = (string) ($ff->ff_mime ?: (str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg'));
return $this->response
->setHeader('Content-Type', $mime)
->setHeader('Cache-Control', 'private, max-age=300')
->setBody((string) file_get_contents($path));
}
}

View File

@@ -16,6 +16,19 @@ class FreeRecipient extends BaseController
$this->model = model(FreeRecipientModel::class); $this->model = model(FreeRecipientModel::class);
} }
/**
* 무료용 대상 구분(스크린샷 기준): 사람뿐 아니라 동사무소 자체도 등록 가능.
*
* @return array<string,string>
*/
private function recipientTypeOptions(): array
{
return [
'office' => '읍.면.동 사무소',
'target' => '무료 대상자',
];
}
private function getCodeOptions(string $ckCode): array private function getCodeOptions(string $ckCode): array
{ {
helper('admin'); helper('admin');
@@ -33,16 +46,42 @@ class FreeRecipient extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); 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; $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 $this->renderWorkPage('무료용 대상자 관리', '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() public function create()
{ {
return $this->renderWorkPage('무료용 대상자 등록', 'admin/free_recipient/create', [ return $this->renderWorkPage('무료용 대상자 등록', 'admin/free_recipient/create', [
'typeCodes' => $this->getCodeOptions('H'), 'recipientTypeOptions' => $this->recipientTypeOptions(),
'dongCodes' => $this->getCodeOptions('D'), 'dongCodes' => $this->getCodeOptions('D'),
]); ]);
} }
@@ -85,7 +124,7 @@ class FreeRecipient extends BaseController
return $this->renderWorkPage('무료용 대상자 수정', 'admin/free_recipient/edit', [ return $this->renderWorkPage('무료용 대상자 수정', 'admin/free_recipient/edit', [
'item' => $item, 'item' => $item,
'typeCodes' => $this->getCodeOptions('H'), 'recipientTypeOptions' => $this->recipientTypeOptions(),
'dongCodes' => $this->getCodeOptions('D'), 'dongCodes' => $this->getCodeOptions('D'),
]); ]);
} }

View File

@@ -25,6 +25,15 @@ class Manager extends BaseController
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
} }
private function managerCategoryOptions(): array
{
return [
'company' => '제작업체',
'district' => '구·군',
'agency' => '대행소',
];
}
public function index() public function index()
{ {
helper('admin'); helper('admin');
@@ -35,16 +44,29 @@ class Manager extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); 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; $pager = $this->model->pager;
return $this->renderWorkPage('담당자 관리', 'admin/manager/index', ['list' => $list, 'pager' => $pager]); return $this->renderWorkPage('담당자 관리', 'admin/manager/index', [
'list' => $list,
'pager' => $pager,
'categories' => $categories,
'category' => $category,
]);
} }
public function create() public function create()
{ {
return $this->renderWorkPage('담당자 등록', 'admin/manager/create', [ return $this->renderWorkPage('담당자 등록', 'admin/manager/create', [
'deptCodes' => $this->getCodeOptions('S'), 'categories' => $this->managerCategoryOptions(),
'positionCodes' => $this->getCodeOptions('T'), 'positionCodes' => $this->getCodeOptions('T'),
]); ]);
} }
@@ -54,6 +76,7 @@ class Manager extends BaseController
helper(['admin', 'url']); helper(['admin', 'url']);
$rules = [ $rules = [
'mg_name' => 'required|max_length[50]', 'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]',
'mg_tel' => 'permit_empty|max_length[20]', 'mg_tel' => 'permit_empty|max_length[20]',
'mg_phone' => 'permit_empty|max_length[20]', 'mg_phone' => 'permit_empty|max_length[20]',
'mg_email' => 'permit_empty|valid_email|max_length[100]', 'mg_email' => 'permit_empty|valid_email|max_length[100]',
@@ -65,7 +88,7 @@ class Manager extends BaseController
$this->model->insert([ $this->model->insert([
'mg_lg_idx' => admin_effective_lg_idx(), 'mg_lg_idx' => admin_effective_lg_idx(),
'mg_name' => $this->request->getPost('mg_name'), 'mg_name' => $this->request->getPost('mg_name'),
'mg_dept_code' => $this->request->getPost('mg_dept_code') ?? '', 'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '', 'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '', 'mg_tel' => $this->request->getPost('mg_tel') ?? '',
'mg_phone' => $this->request->getPost('mg_phone') ?? '', 'mg_phone' => $this->request->getPost('mg_phone') ?? '',
@@ -87,7 +110,7 @@ class Manager extends BaseController
return $this->renderWorkPage('담당자 수정', 'admin/manager/edit', [ return $this->renderWorkPage('담당자 수정', 'admin/manager/edit', [
'item' => $item, 'item' => $item,
'deptCodes' => $this->getCodeOptions('S'), 'categories' => $this->managerCategoryOptions(),
'positionCodes' => $this->getCodeOptions('T'), 'positionCodes' => $this->getCodeOptions('T'),
]); ]);
} }
@@ -102,6 +125,7 @@ class Manager extends BaseController
$rules = [ $rules = [
'mg_name' => 'required|max_length[50]', 'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]',
'mg_state' => 'required|in_list[0,1]', 'mg_state' => 'required|in_list[0,1]',
]; ];
if (! $this->validate($rules)) { if (! $this->validate($rules)) {
@@ -110,7 +134,7 @@ class Manager extends BaseController
$this->model->update($id, [ $this->model->update($id, [
'mg_name' => $this->request->getPost('mg_name'), 'mg_name' => $this->request->getPost('mg_name'),
'mg_dept_code' => $this->request->getPost('mg_dept_code') ?? '', 'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '', 'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '', 'mg_tel' => $this->request->getPost('mg_tel') ?? '',
'mg_phone' => $this->request->getPost('mg_phone') ?? '', 'mg_phone' => $this->request->getPost('mg_phone') ?? '',

View File

@@ -18,11 +18,29 @@ class Menu extends BaseController
$this->typeModel = model(MenuTypeModel::class); $this->typeModel = model(MenuTypeModel::class);
} }
/**
* 메뉴 등록·수정·삭제·순서변경 후 항상 같은 메뉴 관리 화면(mt_idx 유지)으로 돌아간다.
* redirect()->back() 은 목록의 새 탭(target="_blank") 링크 클릭으로 세션 직전 URL(_ci_previous_url)이
* 메뉴 대상 페이지로 덮어써지면 그 페이지로 이탈하므로, 명시적으로 메뉴 화면 URL 을 사용한다.
*/
private function menusRedirect(int $mtIdx): \CodeIgniter\HTTP\RedirectResponse
{
$url = base_url('admin/menus');
if ($mtIdx > 0) {
$url .= '?mt_idx=' . $mtIdx;
}
return redirect()->to($url);
}
/** /**
* 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리. * 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리.
*/ */
public function index() public function index()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
helper('admin'); helper('admin');
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
@@ -60,6 +78,11 @@ class Menu extends BaseController
} }
} }
if ($effectiveMtIdx > 0 && $currentTypeCode === 'site') {
$this->menuModel->pruneInventoryManagementMenus($effectiveMtIdx, $lgIdx);
$list = $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx);
}
// 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화 // 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화
if (! empty($list)) { if (! empty($list)) {
$tree = build_menu_tree($list); $tree = build_menu_tree($list);
@@ -93,6 +116,9 @@ class Menu extends BaseController
*/ */
public function list() public function list()
{ {
if ($deny = $this->denyUnlessLevel4Plus(true)) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']); return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']);
@@ -103,6 +129,10 @@ class Menu extends BaseController
if ($mtIdx <= 0) { if ($mtIdx <= 0) {
return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']); return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']);
} }
$type = $this->typeModel->find($mtIdx);
if ($type && (string) ($type->mt_code ?? '') === 'site') {
$this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
}
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx); $list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
return $this->response->setJSON(['status' => 1, 'data' => $list]); return $this->response->setJSON(['status' => 1, 'data' => $list]);
} }
@@ -112,6 +142,9 @@ class Menu extends BaseController
*/ */
public function store() public function store()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -122,10 +155,10 @@ class Menu extends BaseController
$mmDep = (int) $this->request->getPost('mm_dep'); $mmDep = (int) $this->request->getPost('mm_dep');
$mmName = trim((string) $this->request->getPost('mm_name')); $mmName = trim((string) $this->request->getPost('mm_name'));
if ($mtIdx <= 0) { if ($mtIdx <= 0) {
return redirect()->back()->with('error', '메뉴 종류를 선택하세요.'); return $this->menusRedirect($mtIdx)->with('error', '메뉴 종류를 선택하세요.');
} }
if ($mmName === '') { if ($mmName === '') {
return redirect()->back()->with('error', '메뉴명을 입력하세요.'); return $this->menusRedirect($mtIdx)->with('error', '메뉴명을 입력하세요.');
} }
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep); $mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
$data = [ $data = [
@@ -144,7 +177,9 @@ class Menu extends BaseController
if ($mmPidx > 0) { if ($mmPidx > 0) {
$this->menuModel->updateCnode($mmPidx, 1); $this->menuModel->updateCnode($mmPidx, 1);
} }
return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); $this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
$this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx);
return $this->menusRedirect($mtIdx)->with('success', '메뉴가 등록되었습니다.');
} }
/** /**
@@ -152,6 +187,9 @@ class Menu extends BaseController
*/ */
public function update(int $id) public function update(int $id)
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -159,10 +197,12 @@ class Menu extends BaseController
} }
$row = $this->menuModel->find($id); $row = $this->menuModel->find($id);
if (! $row) { if (! $row) {
return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.'); return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '메뉴를 찾을 수 없습니다.');
} }
if ((int) $row->lg_idx !== $lgIdx) { if ((int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.'); return $this->menusRedirect((int) $row->mt_idx)
->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
} }
$data = [ $data = [
'mm_name' => (string) $this->request->getPost('mm_name'), 'mm_name' => (string) $this->request->getPost('mm_name'),
@@ -171,7 +211,9 @@ class Menu extends BaseController
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
]; ];
$this->menuModel->update($id, $data); $this->menuModel->update($id, $data);
return redirect()->back()->with('success', '메뉴가 수정되었습니다.'); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
} }
/** /**
@@ -179,6 +221,9 @@ class Menu extends BaseController
*/ */
public function delete(int $id) public function delete(int $id)
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -186,13 +231,16 @@ class Menu extends BaseController
} }
$row = $this->menuModel->find($id); $row = $this->menuModel->find($id);
if (! $row || (int) $row->lg_idx !== $lgIdx) { if (! $row || (int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.'); return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.');
} }
$result = $this->menuModel->deleteSafe($id); $result = $this->menuModel->deleteSafe($id);
if ($result['ok']) { if ($result['ok']) {
return redirect()->back()->with('success', '메뉴가 삭제되었습니다.'); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 삭제되었습니다.');
} }
return redirect()->back()->with('error', $result['msg']); return $this->menusRedirect((int) $row->mt_idx)->with('error', $result['msg']);
} }
/** /**
@@ -200,17 +248,28 @@ class Menu extends BaseController
*/ */
public function move() public function move()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.'); ->with('error', '지자체를 선택하세요.');
} }
$ids = $this->request->getPost('mm_idx'); $ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_idx');
if (! is_array($ids) || empty($ids)) { if (! is_array($ids) || empty($ids)) {
return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.'); return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
} }
$firstId = (int) ($ids[0] ?? 0);
$firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null;
$this->menuModel->setOrder($ids, $lgIdx); $this->menuModel->setOrder($ids, $lgIdx);
return redirect()->back()->with('success', '순서가 적용되었습니다.'); if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
}
$mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
} }
/** /**
@@ -266,4 +325,27 @@ class Menu extends BaseController
return (int) $types[0]->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

@@ -143,14 +143,31 @@ class PackagingUnit extends BaseController
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$db->transStart(); $db->transStart();
$trackFields = ['pu_box_per_pack', 'pu_pack_per_sheet']; $trackFields = ['pu_box_per_pack', 'pu_pack_per_sheet', 'pu_start_date', 'pu_end_date', 'pu_state'];
$fieldLabels = [
'pu_box_per_pack' => '박스당 팩 수',
'pu_pack_per_sheet' => '팩당 낱장 수',
'pu_start_date' => '적용시작일',
'pu_end_date' => '적용종료일',
'pu_state' => '상태',
];
foreach ($trackFields as $field) { foreach ($trackFields as $field) {
$oldVal = (string) $item->$field; $oldRaw = $item->$field;
$newVal = (string) $this->request->getPost($field); $newRaw = $this->request->getPost($field);
if ($field === 'pu_end_date') {
$oldRaw = $oldRaw ?: '';
$newRaw = $newRaw ?: '';
}
if ($field === 'pu_state') {
$oldRaw = (int) $oldRaw === 1 ? '사용' : '미사용';
$newRaw = (int) $newRaw === 1 ? '사용' : '미사용';
}
$oldVal = (string) $oldRaw;
$newVal = (string) $newRaw;
if ($oldVal !== $newVal) { if ($oldVal !== $newVal) {
$this->historyModel->insert([ $this->historyModel->insert([
'puh_pu_idx' => $id, 'puh_pu_idx' => $id,
'puh_field' => $field, 'puh_field' => $fieldLabels[$field] ?? $field,
'puh_old_value' => $oldVal, 'puh_old_value' => $oldVal,
'puh_new_value' => $newVal, 'puh_new_value' => $newVal,
'puh_changed_at' => date('Y-m-d H:i:s'), 'puh_changed_at' => date('Y-m-d H:i:s'),

View File

@@ -19,14 +19,15 @@ class PasswordChange extends BaseController
helper('admin'); helper('admin');
$rules = [ $rules = [
'current_password' => 'required', 'current_password' => 'required',
'new_password' => 'required|min_length[4]|max_length[255]', 'new_password' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'new_password_confirm' => 'required|matches[new_password]', 'new_password_confirm' => 'required|matches[new_password]',
]; ];
$messages = [ $messages = [
'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'], 'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'],
'new_password' => [ 'new_password' => [
'required' => '새 비밀번호를 입력해 주세요.', 'required' => '새 비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.', 'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
], ],
'new_password_confirm' => [ 'new_password_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.', 'required' => '비밀번호 확인을 입력해 주세요.',

View File

@@ -55,11 +55,7 @@ class SalesAgency extends BaseController
'sa_idx' => $saIdx, 'sa_idx' => $saIdx,
]; ];
$queryForPager = array_filter($queryForPager, static fn ($v) => $v !== null && $v !== ''); $queryForPager = array_filter($queryForPager, static fn ($v) => $v !== null && $v !== '');
$pagerPath = mgmt_url('sales-agencies'); apply_pager_path($pager, mgmt_path('sales-agencies'), $queryForPager);
if ($queryForPager !== []) {
$pagerPath .= '?' . http_build_query($queryForPager);
}
$pager->setPath($pagerPath);
return $this->renderWorkPage('판매 대행소 관리', 'admin/sales_agency/index', [ return $this->renderWorkPage('판매 대행소 관리', 'admin/sales_agency/index', [
'list' => $list, 'list' => $list,

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,13 @@ class SelectLocalGovernment extends BaseController
->orderBy('lg_name', 'ASC') ->orderBy('lg_name', 'ASC')
->findAll(); ->findAll();
$currentLgIdx = (int) (session()->get('admin_selected_lg_idx') ?? 0);
return view('admin/layout', [ return view('admin/layout', [
'title' => '지자체 선택', 'title' => '지자체 선택',
'content' => view('admin/select_local_government/index', [ 'content' => view('admin/select_local_government/index', [
'list' => $list, 'list' => $list,
'currentLgIdx' => $currentLgIdx,
]), ]),
]); ]);
} }

View File

@@ -57,8 +57,21 @@ class ShopOrder extends BaseController
$shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll(); $shops = model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $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 $this->renderWorkPage('주문 접수', 'admin/shop_order/create', compact('shops', 'bagCodes')); return $this->renderWorkPage('주문 접수', 'admin/shop_order/create', compact('shops', 'bagCodes', 'priceMap', 'unitMap'));
} }
public function store() public function store()
@@ -81,7 +94,7 @@ class ShopOrder extends BaseController
$dsIdx = (int) $this->request->getPost('so_ds_idx'); $dsIdx = (int) $this->request->getPost('so_ds_idx');
$shop = model(DesignatedShopModel::class)->find($dsIdx); $shop = model(DesignatedShopModel::class)->find($dsIdx);
$this->orderModel->insert([ $orderData = [
'so_lg_idx' => $lgIdx, 'so_lg_idx' => $lgIdx,
'so_ds_idx' => $dsIdx, 'so_ds_idx' => $dsIdx,
'so_ds_name' => $shop ? $shop->ds_name : '', 'so_ds_name' => $shop ? $shop->ds_name : '',
@@ -91,8 +104,24 @@ class ShopOrder extends BaseController
'so_status' => 'normal', 'so_status' => 'normal',
'so_orderer_idx' => session()->get('mb_idx'), 'so_orderer_idx' => session()->get('mb_idx'),
'so_regdate' => date('Y-m-d H:i:s'), 'so_regdate' => date('Y-m-d H:i:s'),
]); ];
// shop_order.so_channel 이 아직 반영되지 않은 DB와의 호환 처리
if ($db->fieldExists('so_channel', 'shop_order')) {
$orderData['so_channel'] = 'phone';
}
$insertOk = $this->orderModel->insert($orderData);
if ($insertOk === false) {
$db->transRollback();
$errors = $this->orderModel->errors();
$msg = ! empty($errors) ? implode(' / ', array_values($errors)) : '주문 저장에 실패했습니다.';
return redirect()->back()->withInput()->with('error', $msg);
}
$soIdx = (int) $this->orderModel->getInsertID(); $soIdx = (int) $this->orderModel->getInsertID();
if ($soIdx <= 0) {
$db->transRollback();
return redirect()->back()->withInput()->with('error', '주문번호 생성에 실패했습니다. DB 스키마를 확인해 주세요.');
}
$bagCodes = $this->request->getPost('item_bag_code') ?? []; $bagCodes = $this->request->getPost('item_bag_code') ?? [];
$qtys = $this->request->getPost('item_qty') ?? []; $qtys = $this->request->getPost('item_qty') ?? [];
@@ -105,7 +134,7 @@ class ShopOrder extends BaseController
} }
$qty = (int) $qtys[$i]; $qty = (int) $qtys[$i];
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first(); $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $code);
$unitPrice = $price ? (float) $price->bp_consumer : 0; $unitPrice = $price ? (float) $price->bp_consumer : 0;
$amount = $unitPrice * $qty; $amount = $unitPrice * $qty;

View File

@@ -83,13 +83,13 @@ class User extends BaseController
{ {
$rules = [ $rules = [
'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]', 'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]', 'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'mb_name' => 'required|max_length[50]', 'mb_name' => 'required|max_length[50]',
'mb_email' => 'permit_empty|valid_email|max_length[100]', 'mb_email' => 'permit_empty|valid_email|max_length[100]',
'mb_phone' => 'permit_empty|max_length[20]', 'mb_phone' => 'permit_empty|max_length[20]',
'mb_level' => 'required', 'mb_level' => 'required',
]; ];
if (! $this->validate($rules)) { if (! $this->validate($rules, ['mb_passwd' => ['min_length' => '비밀번호는 8자 이상이어야 합니다.', 'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.']])) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$allowedLevels = array_keys($this->getAssignableLevels()); $allowedLevels = array_keys($this->getAssignableLevels());
@@ -154,9 +154,9 @@ class User extends BaseController
]; ];
$passwd = $this->request->getPost('mb_passwd'); $passwd = $this->request->getPost('mb_passwd');
if ($passwd !== '') { if ($passwd !== '') {
$rules['mb_passwd'] = 'min_length[4]|max_length[255]'; $rules['mb_passwd'] = ['min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'];
} }
if (! $this->validate($rules)) { if (! $this->validate($rules, ['mb_passwd' => ['min_length' => '비밀번호는 8자 이상이어야 합니다.', 'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.']])) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$allowedLevels = array_keys($this->getAssignableLevels()); $allowedLevels = array_keys($this->getAssignableLevels());

View File

@@ -19,10 +19,40 @@ class Auth extends BaseController
public function showLoginForm() public function showLoginForm()
{ {
if (session()->get('logged_in')) { if (session()->get('logged_in')) {
return redirect()->to('/'); // 이미 로그인 상태면 역할별 홈으로(뒤로가기로 로그인 화면에 와도 작업 화면으로 복귀)
return redirect()->to($this->loggedInHomeUrl());
} }
return view('auth/login'); // 로그인 후 뒤로가기 시 브라우저 캐시(bfcache)로 로그인 화면이 다시 보이는 것을 방지.
// 캐시 금지 → 뒤로가기 시 서버 재요청 → 로그인 상태면 위에서 작업 화면으로 리다이렉트.
$this->response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
$this->response->setHeader('Pragma', 'no-cache');
$this->response->setHeader('Expires', '0');
return view('auth/login', [
'pageTitle' => '로그인 - GBLS',
'cardMax' => 'max-w-md',
]);
}
/**
* 로그인 상태 사용자의 역할별 기본 홈 URL.
* (로그인 성공 시 이동 지점과 일치 — 단, 슈퍼/본부는 지자체를 이미 선택했으면 작업 화면으로)
*/
private function loggedInHomeUrl(): string
{
$level = (int) session()->get('mb_level');
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return site_url('admin');
}
if (\Config\Roles::isSuperAdminEquivalent($level)) {
$hasLg = (int) (session()->get('admin_selected_lg_idx') ?? 0) > 0;
return site_url($hasLg ? 'admin' : 'admin/select-local-government');
}
return site_url('/');
} }
public function login() public function login()
@@ -157,6 +187,8 @@ class Auth extends BaseController
return view('auth/login_two_factor', [ return view('auth/login_two_factor', [
'memberId' => $member->mb_id, 'memberId' => $member->mb_id,
'pageTitle' => '2차 인증 - GBLS',
'cardMax' => 'max-w-md',
]); ]);
} }
@@ -239,6 +271,8 @@ class Auth extends BaseController
'memberId' => $member->mb_id, 'memberId' => $member->mb_id,
'qrDataUri' => $qrDataUri, 'qrDataUri' => $qrDataUri,
'secret' => $secret, 'secret' => $secret,
'pageTitle' => '2차 인증 등록 - GBLS',
'cardMax' => 'max-w-lg',
]); ]);
} }
@@ -341,6 +375,8 @@ class Auth extends BaseController
return view('auth/register', [ return view('auth/register', [
'localGovernments' => $localGovernments, 'localGovernments' => $localGovernments,
'pageTitle' => '회원가입 - GBLS',
'cardMax' => 'max-w-md',
]); ]);
} }
@@ -348,7 +384,7 @@ class Auth extends BaseController
{ {
$rules = [ $rules = [
'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]', 'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]', 'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
'mb_passwd_confirm' => 'required|matches[mb_passwd]', 'mb_passwd_confirm' => 'required|matches[mb_passwd]',
'mb_name' => 'required|max_length[100]', 'mb_name' => 'required|max_length[100]',
'mb_email' => 'permit_empty|valid_email|max_length[100]', 'mb_email' => 'permit_empty|valid_email|max_length[100]',
@@ -366,8 +402,9 @@ class Auth extends BaseController
], ],
'mb_passwd' => [ 'mb_passwd' => [
'required' => '비밀번호를 입력해 주세요.', 'required' => '비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.', 'min_length' => '비밀번호는 8자 이상이어야 합니다.',
'max_length' => '비밀번호는 255자 이하여야 합니다.', 'max_length' => '비밀번호는 255자 이하여야 합니다.',
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
], ],
'mb_passwd_confirm' => [ 'mb_passwd_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.', 'required' => '비밀번호 확인을 입력해 주세요.',
@@ -539,6 +576,8 @@ class Auth extends BaseController
private function completeLogin(object $member, array $logData): RedirectResponse private function completeLogin(object $member, array $logData): RedirectResponse
{ {
$this->clearPending2faSession(); $this->clearPending2faSession();
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
$sessionToken = bin2hex(random_bytes(16));
$sessionData = [ $sessionData = [
'mb_idx' => $member->mb_idx, 'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id, 'mb_id' => $member->mb_id,
@@ -546,6 +585,7 @@ class Auth extends BaseController
'mb_level' => $member->mb_level, 'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null, 'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true, 'logged_in' => true,
'session_token' => $sessionToken,
]; ];
session()->set($sessionData); session()->set($sessionData);
@@ -553,18 +593,42 @@ class Auth extends BaseController
'mb_latestdate' => date('Y-m-d H:i:s'), 'mb_latestdate' => date('Y-m-d H:i:s'),
'mb_login_fail_count' => 0, 'mb_login_fail_count' => 0,
'mb_locked_until' => null, 'mb_locked_until' => null,
'mb_session_token' => $sessionToken,
]); ]);
// 로그인 알림 — 직전 성공 로그인과 IP 비교(새 IP면 경고). 현재 로그인 기록 INSERT 이전에 조회.
$loginNotice = '';
try {
$curIp = (string) $this->request->getIPAddress();
$prev = model(MemberLogModel::class)
->where('mb_idx', (int) $member->mb_idx)
->where('mll_success', 1)
->orderBy('mll_idx', 'DESC')
->first();
if ($prev) {
$prevIp = (string) ($prev->mll_ip ?? '');
$prevAt = (string) ($prev->mll_regdate ?? '');
if ($prevIp !== '' && $prevIp !== $curIp) {
$loginNotice = ' ⚠ 새로운 환경(IP ' . $curIp . ')에서 로그인되었습니다. 본인이 아니면 비밀번호를 변경하세요. (이전 로그인: ' . $prevAt . ' · ' . $prevIp . ')';
} elseif ($prevAt !== '') {
$loginNotice = ' (이전 로그인: ' . $prevAt . ')';
}
}
} catch (\Throwable $e) {
// 알림 계산 실패는 로그인에 영향 주지 않음
}
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx); $this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
$successMsg = '로그인되었습니다.' . $loginNotice;
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) { if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('admin'))->with('success', $successMsg);
} }
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) { if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('admin/select-local-government'))->with('success', $successMsg);
} }
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.'); return redirect()->to(site_url('/'))->with('success', $successMsg);
} }
private function buildLogData(string $mbId, ?int $mbIdx): array private function buildLogData(string $mbId, ?int $mbIdx): array

File diff suppressed because it is too large Load Diff

View File

@@ -48,17 +48,43 @@ abstract class BaseController extends Controller
* *
* @param array<string, mixed> $contentData * @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 protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
{ {
$content = view($contentView, $contentData); $content = view($contentView, $contentData);
helper('admin');
$path = function_exists('current_nav_request_path') ? current_nav_request_path() : '';
if ($path === '') {
$uri = service('request')->getUri(); $uri = service('request')->getUri();
$seg1 = $uri->getSegment(1); $path = trim((string) $uri->getPath(), '/');
$seg2 = $uri->getSegment(2); }
while (str_starts_with($path, 'index.php/')) {
$path = substr($path, strlen('index.php/'));
}
// 워크스페이스 탭(iframe) 안이면 경로와 무관하게 임베드 레이아웃 → 셸 중첩 방지
// (예: bag/code-kinds 탭에서 admin/code-kinds/create 등록 화면을 열 때)
if ($this->isEmbeddedRequest()) {
return view('bag/layout/embed', [
'title' => $title,
'content' => $content,
]);
}
// 지정판매소 관리는 관리자 전용 기능으로, /bag 경로여도 관리자 레이아웃을 유지한다. if ($path === 'bag' || str_starts_with($path, 'bag/')) {
$forceAdminLayoutOnBag = ($seg1 === 'bag' && $seg2 === 'designated-shops'); return view('bag/layout/portal', [
if ($seg1 === 'bag' && ! $forceAdminLayoutOnBag) {
return view('bag/layout/main', [
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,
]); ]);

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\FeedbackFileModel;
use App\Models\FeedbackModel;
use CodeIgniter\HTTP\ResponseInterface;
/**
* 사용자 의견 접수. 로그인 사용자가 화면에서 남긴 의견 + 스크린샷을 저장한다.
* (계정·지자체는 클라이언트 값을 믿지 않고 세션에서 채운다.)
*/
class Feedback extends BaseController
{
public function store(): ResponseInterface
{
if (! session()->get('logged_in')) {
return $this->response->setStatusCode(401)->setJSON(['ok' => false, 'message' => '로그인이 필요합니다.']);
}
$content = trim((string) $this->request->getPost('content'));
if ($content === '') {
return $this->response->setJSON(['ok' => false, 'message' => '의견 내용을 입력해 주세요.']);
}
if (mb_strlen($content) > 2000) {
$content = mb_substr($content, 0, 2000);
}
helper('admin');
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
if (! $lgIdx) {
$raw = session()->get('mb_lg_idx');
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
$model = model(FeedbackModel::class);
$id = $model->insert([
'fb_lg_idx' => $lgIdx,
'fb_mb_idx' => (int) session()->get('mb_idx'),
'fb_mb_name' => (string) (session()->get('mb_name') ?? ''),
'fb_mb_level' => (int) session()->get('mb_level'),
'fb_content' => $content,
'fb_page_url' => mb_substr((string) $this->request->getPost('page_url'), 0, 255),
'fb_page_title' => mb_substr((string) $this->request->getPost('page_title'), 0, 255),
'fb_user_agent' => mb_substr((string) $this->request->getHeaderLine('User-Agent'), 0, 255),
'fb_status' => 'new',
], true);
if (! $id) {
return $this->response->setJSON(['ok' => false, 'message' => '저장 중 오류가 발생했습니다.']);
}
$dir = WRITEPATH . 'uploads/feedback/';
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$fileModel = model(FeedbackFileModel::class);
// 1) 화면 캡처(dataURL) — 실패해도 의견 본문은 이미 저장됨
$shot = (string) $this->request->getPost('screenshot');
if ($shot !== '' && preg_match('#^data:image/(png|jpeg);base64,#', $shot, $m) === 1) {
$ext = $m[1] === 'png' ? 'png' : 'jpg';
$bin = base64_decode(substr($shot, strpos($shot, ',') + 1), true);
if ($bin !== false && strlen($bin) <= 5 * 1024 * 1024) {
$file = 'fb_' . (int) $id . '_cap_' . bin2hex(random_bytes(4)) . '.' . $ext;
if (@file_put_contents($dir . $file, $bin) !== false) {
$model->update($id, ['fb_screenshot' => $file]); // 목록 캡처여부 표시용
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'capture', 'ff_path' => $file, 'ff_mime' => 'image/' . ($ext === 'png' ? 'png' : 'jpeg')]);
}
}
}
// 2) 사용자가 첨부한 이미지(여러 장)
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
$count = 0;
foreach ($this->request->getFileMultiple('files') ?? [] as $f) {
if ($count >= 5 || $f === null || ! $f->isValid() || $f->hasMoved()) {
continue;
}
$mime = (string) $f->getMimeType();
if (! isset($allowed[$mime]) || $f->getSize() > 5 * 1024 * 1024) {
continue;
}
$file = 'fb_' . (int) $id . '_up_' . bin2hex(random_bytes(4)) . '.' . $allowed[$mime];
try {
$f->move($dir, $file);
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'upload', 'ff_path' => $file, 'ff_mime' => $mime]);
$count++;
} catch (\Throwable $e) {
// 개별 파일 실패는 건너뜀
}
}
return $this->response->setJSON(['ok' => true]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Controllers; namespace App\Controllers;
use App\Libraries\GovPortalCodeKindsPage;
use App\Models\LocalGovernmentModel; use App\Models\LocalGovernmentModel;
class Home extends BaseController class Home extends BaseController
@@ -9,12 +10,228 @@ class Home extends BaseController
public function index() public function index()
{ {
if (session()->get('logged_in')) { if (session()->get('logged_in')) {
return $this->dashboard(); helper('admin');
// 워크스페이스 탭(iframe) 안: 대시보드 본문을 임베드로.
if ($this->isEmbeddedRequest()) {
return view('bag/layout/embed', [
'title' => '업무 현황',
'bare' => true,
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
]);
}
// 로그인 후 기본 화면 = 워크스페이스(탭). 메뉴를 탭으로 열어 작업 상태 유지.
return view('bag/layout/workspace');
}
// 워크스페이스 탭(iframe) 안에서 세션이 만료된 경우: 공개 랜딩 대신 로그인으로 보내
// 로그인 페이지의 프레임 이탈 스크립트가 상위 창 전체를 로그인으로 전환하게 한다.
if ($this->isEmbeddedRequest()) {
return redirect()->to(base_url('login'));
} }
return view('welcome_message'); return view('welcome_message');
} }
/**
* 워크스페이스 — 메뉴를 탭(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) 본문 * 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
*/ */
@@ -28,6 +245,34 @@ class Home extends BaseController
]); ]);
} }
/**
* 로그인 후 메인 — 단순형 요약 대시보드. 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(),
]),
]);
}
/** /**
* 디자인 시안(기존 /dashboard 연결 화면) * 디자인 시안(기존 /dashboard 연결 화면)
*/ */
@@ -74,6 +319,121 @@ class Home extends BaseController
return $this->dashboard(); 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);
}
/** /**
* 재고 조회(수불) 화면 (목업) * 재고 조회(수불) 화면 (목업)
*/ */
@@ -103,9 +463,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string protected function resolveLgLabel(): string
{ {
try { try {
$idx = session()->get('mb_lg_idx'); helper('admin');
if ($idx === null || $idx === '') { $idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
return '로그인 지자체 (미지정)'; if ($idx === null) {
$raw = session()->get('mb_lg_idx');
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
if ($idx === null) {
return '지자체 미지정';
} }
$row = model(LocalGovernmentModel::class)->find((int) $idx); $row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') { if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -115,7 +480,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,69 @@
# 로그인 · 회원가입 · 계정
시스템을 쓰려면 **계정(아이디)** 이 필요합니다. 이 페이지는 회원가입부터 로그인, 2차 인증, 로그아웃까지의 과정을 설명합니다.
## 1. 회원가입
로그인 화면 아래쪽 **[회원가입]** 을 눌러 신청합니다. 입력 항목은 다음과 같습니다.
| 항목 | 필수 | 설명 |
|---|:---:|---|
| **아이디** | 필수 | 로그인에 쓰는 ID. **4자 이상**, 이미 쓰는 아이디는 사용할 수 없습니다. |
| **비밀번호** | 필수 | **4자 이상**. 안전을 위해 영문·숫자·기호를 섞는 것을 권장합니다. |
| **비밀번호 확인** | 필수 | 위 비밀번호와 똑같이 한 번 더 입력(오타 방지). |
| **이름** | 필수 | 담당자 이름. |
| **이메일** | 선택 | 안내용. 입력 시 형식 검사를 합니다. |
| **연락처** | 선택 | 전화번호. |
| **지자체** | 선택 | 소속 지자체. 해당되면 선택합니다. |
| **사용자 역할** | 필수 | 신청할 권한(아래 표 참고). 실제 권한은 **관리자 승인 시 확정**됩니다. |
> 이메일·연락처 같은 개인정보는 **암호화되어 저장**됩니다.
### 신청할 수 있는 역할
| 역할 | 주로 하는 일 |
|---|---|
| **일반 사용자** | 기본 조회 |
| **지정판매소** | 봉투 판매·반품, 자기 판매 현황 조회 |
| **지자체 관리자** | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
### 가입 후 — 관리자 승인 대기
회원가입을 제출하면 **바로 로그인되지 않고 "승인 대기" 상태**가 됩니다.
- **관리자가 승인하면** 그때부터 로그인할 수 있습니다.
- 승인 전에 로그인하면 *"관리자 승인 후 로그인 가능합니다."* 안내가 나옵니다.
- 승인이 **반려**되면 *"승인이 반려되었습니다. 관리자에게 문의해 주세요."* 가 나옵니다 — 담당 관리자에게 문의하세요.
## 2. 로그인
로그인 화면에서 **아이디**와 **비밀번호**를 입력합니다.
- 성공하면 **워크스페이스**(탭 작업공간)로 들어갑니다.
- 아이디·비밀번호가 틀리면 안내 메시지가 나옵니다. (승인 대기/반려 상태도 위와 같이 안내됩니다.)
### 2차 인증(OTP)
보안 설정에 따라 비밀번호 확인 뒤 **2차 인증** 단계가 나올 수 있습니다.
- **이미 OTP를 등록한 경우** — 스마트폰 인증 앱(예: Google Authenticator)에 표시되는 **6자리 숫자**를 입력합니다.
- **처음 사용하는 경우** — 화면의 **QR코드 또는 설정 키**를 인증 앱에 등록한 뒤, 앱에 나온 6자리 숫자로 설정을 완료합니다. 이후 로그인부터 OTP를 입력하게 됩니다.
> OTP 숫자는 일정 시간마다 바뀌므로, **현재 표시된 숫자**를 입력해야 합니다. 휴대폰 시간이 자동(네트워크 시간)으로 맞춰져 있어야 정확합니다.
## 3. 로그아웃
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
## 4. 비밀번호 변경 · *비밀번호 변경*
로그인한 본인이 직접 비밀번호를 바꾸는 화면입니다.
**입력**: 현재 비밀번호 → 새 비밀번호 → 새 비밀번호 확인.
**순서**: 현재 비밀번호가 맞아야 변경되며, 새 비밀번호는 확인란과 동일해야 합니다.
> 주기적으로 변경하고, 다른 사이트와 같은 비밀번호는 피하세요.
## 5. 비밀번호 분실 · 계정·권한 문제
- **비밀번호를 분실**해 로그인 자체가 안 되는 경우에는 **담당 관리자**가 초기화해 줍니다. 관리자에게 문의하세요.
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.

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

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,104 @@
# 현황 · 리포트 · 수불
입고·판매·불출 기록을 **모아 보여주는** 조회 화면들입니다. 대부분 **기간을 지정해 조회**하고 `엑셀저장`·`인쇄`로 내보낼 수 있습니다.
---
## 기간별 봉투 수불 현황 · *봉투 수불 관리 기간별 봉투 수불 현황*
**수불(受拂)** = 들어오고 나간 움직임. 기간 동안 봉투가 얼마나 들어오고(입고) 나갔는지(판매·불출 등)를 한 표로 봅니다.
**이 화면의 용어**
- **전일재고**: 조회 시작일 **전날**의 재고.
- **입고**: 입고량 + 반품 + 기타.
- **출고**: 판매 + 무료불출 + 반품 + 기타.
- **잔량**: 전일재고 + 입고 출고.
**필터**: 조회기간 · 봉투형식 · 봉투구분 · 대행소 · **집계방식(일자별/기간별)**.
**표 컬럼**: 일자 · 품목 · 전일재고 · 입고(소계) · 출고(소계) · 잔량.
---
## 일계표 · *판매 현황 일계표*
하루치 판매를 **일계(당일)****누계(월 누적)** 로 집계합니다.
**용어**: **일계** = 그날 합계, **누계(월)** = 월초~당일 누적, **징수액** = 판매금액 수수료.
**필터**: 조회일자 · 대행소 · 구분.
**표**: 봉투종류별 — 일계(수량·판매금액·수수료·징수액) / 누계(월) 동일 항목.
---
## 지정 판매소별 판매현황 · *판매 현황 판매소별 판매현황*
판매소마다 **얼마나 팔았는지**(수량 또는 금액)를 월별로 비교합니다.
**필터**: 기간 · 읍면동 · 봉투종류 · **지표(수량/금액)**.
**표**: 판매소명 · 판매소코드 · 월별 값 · 합계.
---
## LOT 수불 조회 · *봉투 수불 관리 LOT 수불 조회*
특정 **봉투번호(바코드)** 또는 **LOT**의 입고·판매·반품 **이력**을 추적합니다.
**입력**: 봉투번호(바코드/팩코드/박스코드/낱장코드).
**표**: 일자 · 품목 · 포장단위 · **구분(입고/판매/반품)** · 수량 · LOT번호.
> 입력할 코드 형식이 헷갈리면 좌측 **[봉투·LOT·바코드 코드체계]** 또는 도움말의 **번호알기**를 참고하세요.
---
## 반품/파기 현황 · *봉투 수불 관리 반품/파기 현황*
**용어**: **반품** = 판매소가 되돌린 봉투(출고 탭) / **파기** = 반품분의 폐기(입고 탭).
**필터**: 조회기간 · 입출고구분.
**표**: 일자 · 판매소명 · 봉투종류 · 수량 · 구분(반품/파기).
---
## 기타 입출고 · *봉투 수불 관리 기타 입출고*
정상적인 **발주입고 · 판매 · 불출** 흐름에 속하지 않는 **그 밖의 재고 증감**을 수동으로 기록하는 화면입니다.
(예: 분실·파손, 실사 차이 보정, 행정상 조정, 타 부서 이관 등)
**구분**
- **입고(in)**: 재고가 **늘어나는** 조정 (예: 누락분 추가, 반입).
- **출고(out)**: 재고가 **줄어드는** 조정 (예: 파손·분실 차감).
**등록 입력값** *(모두 필수)*
- **구분**: 입고 / 출고
- **봉투**: 봉투코드(봉투구분으로 먼저 좁혀서 선택)
- **수량**: 1 이상
- **일자**: 처리 기준일
- **사유**: 왜 조정하는지(필수, 최대 200자) — 추후 추적·감사를 위해 구체적으로 적습니다.
**조회·표시**
- **수불년월 · 구분**으로 묶여 좌측 **목록(마스터)** → 우측 **상세 내역**으로 표시됩니다.
- 봉투구분으로 필터링할 수 있습니다.
**반영**: 등록한 입고/출고 수량은 **재고와 봉투 수불 집계에 그대로 반영**되므로, 정확한 사유와 수량으로 신중히 입력하세요. 잘못 등록한 행은 삭제로 정정합니다.
> 정상 업무(발주·판매·불출)는 각 전용 화면에서 처리하고, **여기서는 그 흐름으로 설명되지 않는 예외적 증감만** 기록합니다.
---
## 쓰레기봉투 수급 계획 · *봉투 수불 관리 쓰레기봉투 수급 계획*
봉투 품목별로 **현재고가 며칠 버티는지, 언제·얼마나 발주할지**를 보여 주는 수급·발주 계획표입니다. 처음 들어가면 기본 조건으로 자동 조회됩니다.
**필터**
- **기준일**: 계산 기준 날짜.
- **제작기일(리드타임, 일)**: 발주 후 입고까지 걸리는 기간(기본 40일). 재고 소진 전에 미리 발주하려는 여유.
- **재고/판매 범위**: 기존(수기·바코드 미등록) / 바코드(등록) / 전체.
**표 구성(3그룹)**: ① 최근 발주 내역 ② 현재고 및 예상 판매일수 ③ 추가발주 예정내역.
**핵심 계산**: **발주예정일 = 기준일 + 소진일수 보유일수**. 발주예정일이 지난(긴급) 품목은 **빨간색**으로 표시되고, 그 시점에 맞춘 **추가 발주 제안 장수**가 함께 나옵니다.
> 발주 타이밍을 놓치지 않도록 돕는 화면입니다. 빨간 품목부터 발주를 검토하세요.
---
## 그 밖의 현황
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
- **홈택스 처리**: 세금계산서용 데이터(엑셀) 생성.
- **통계 분석(전년대비·월별·계절 추이)**: 판매 추세를 그래프로.

View File

@@ -0,0 +1,74 @@
# 기본정보 (판매소 · 단가 · 코드)
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
---
## 지정판매소 관리 · *기본정보관리 지정 판매소 관리/조회*
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
**이 화면의 용어**
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
**함께 제공되는 화면**
- **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다.
- **구별 신규/취소 명단**: 기간 내 새로 등록되거나 취소된 판매소 명단을 조회·엑셀저장합니다.
- **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다.
---
## 단가 관리 · *기본정보관리 단가 관리*
봉투 **가격**을 기간별로 관리합니다.
**이 화면의 용어**
- **발주단가**: 제작업체에 주는 가격(살 때).
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
- **판매단가**: 최종 소비자 판매가.
- **수수료율**: 판매수수료율(%).
- **적용시작/종료**: 그 단가가 유효한 기간.
**필터**: 봉투구분 · 봉투코드 · 조회기간.
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
> 조회 전용이며, 등록·수정은 `단가관리(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,54 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* 워크스페이스 탭(iframe, embed=1) 안에서 발생한 리다이렉트가 embed 파라미터를 잃지 않도록
* 응답 Location 헤더에 embed=1 을 유지시킨다.
*
* 배경: 폼 전송 후 redirect()->to(...) 하면 Location 에 embed 가 빠지고, iframe 안에서
* 그 주소로 다시 GET 하면(특히 HTTP 환경에서 Sec-Fetch-Dest 미전송) 전체 셸(헤더·사이드바)이
* 한 번 더 렌더되어 "화면 안에 전체 화면이 또" 생긴다. 이를 전역에서 한 번에 방지한다.
*/
class EmbedRedirectFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
// no-op
}
/**
* @param array<string>|null $arguments
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// 임베드 요청에서 시작된 리다이렉트만 처리
if ($request->getGet('embed') === null) {
return;
}
$location = $response->getHeaderLine('Location');
if ($location === '') {
return; // 리다이렉트 응답이 아님
}
// 이미 embed 파라미터가 있으면 그대로 둔다
if (preg_match('/[?&]embed=/', $location) === 1) {
return;
}
// 인증 흐름(로그인/로그아웃/회원가입)은 상위 프레임에서 처리하므로 embed 를 붙이지 않는다
if (preg_match('#/(login|logout|register)(/|\?|$)#', $location) === 1) {
return;
}
$sep = strpos($location, '?') !== false ? '&' : '?';
$response->setHeader('Location', $location . $sep . 'embed=1');
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Models\MemberModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* 중복 로그인 방지(나중 로그인 우선).
* 로그인 시 발급한 세션 토큰(session_token)을 회원 행(mb_session_token)과 매 요청마다 대조해,
* 다른 곳에서 로그인해 토큰이 바뀌면 이 세션을 무효화하고 로그인으로 보낸다.
* (DB를 공유하므로 로컬·운영 등 서로 다른 서버 간에도 동작)
*/
class SessionGuardFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
if (! $session->get('logged_in')) {
return null; // 비로그인 요청은 통과
}
$mbIdx = (int) ($session->get('mb_idx') ?? 0);
if ($mbIdx <= 0) {
return null;
}
$member = model(MemberModel::class)->find($mbIdx);
if ($member === null) {
return null;
}
$dbToken = (string) ($member->mb_session_token ?? '');
$myToken = (string) ($session->get('session_token') ?? '');
// DB에 토큰이 있고 내 세션 토큰과 다르면 → 다른 곳에서 더 늦게 로그인한 것
if ($dbToken !== '' && $dbToken !== $myToken) {
$session->destroy();
// AJAX/JSON 요청은 401, 일반 요청은 로그인으로
if (strtolower((string) $request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest'
|| str_contains(strtolower((string) $request->getHeaderLine('Accept')), 'application/json')) {
return service('response')->setStatusCode(401)->setJSON([
'ok' => false, 'message' => '다른 곳에서 로그인되어 로그아웃되었습니다.',
]);
}
return redirect()->to(site_url('login'))->with('error', '다른 곳에서 로그인되어 자동 로그아웃되었습니다.');
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// no-op
}
}

View File

@@ -264,13 +264,12 @@ if (! function_exists('normalize_menu_link_for_url')) {
} }
} }
if (! function_exists('mgmt_url')) { if (! function_exists('mgmt_path')) {
/** /**
* 업무 화면 링크: 정식 URL 은 /bag/* (adminAuth). 포장 단위 CRUD 는 packaging-units/manage 로 치환. * 업무 화면 상대 경로 (페이저 setPath 등). 선행 슬래시 없음.
*/ */
function mgmt_url(string $path): string function mgmt_path(string $path): string
{ {
helper('url');
$path = trim($path, '/'); $path = trim($path, '/');
// bag/packaging-units 는 조회 전용(Bag) — CRUD 는 /bag/packaging-units/manage/* 로 분리 // bag/packaging-units 는 조회 전용(Bag) — CRUD 는 /bag/packaging-units/manage/* 로 분리
if ($path === 'packaging-units') { if ($path === 'packaging-units') {
@@ -279,7 +278,35 @@ if (! function_exists('mgmt_url')) {
$path = 'packaging-units/manage/' . substr($path, strlen('packaging-units/')); $path = 'packaging-units/manage/' . substr($path, strlen('packaging-units/'));
} }
return site_url('bag/' . $path); 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));
}
} }
} }
@@ -367,6 +394,10 @@ if (! function_exists('menu_link_candidate_paths')) {
$cands[] = 'admin/packaging-units' . ($m[1] ?? ''); $cands[] = 'admin/packaging-units' . ($m[1] ?? '');
} elseif (preg_match('#^admin/packaging-units(/.*)?$#', $p, $m)) { } elseif (preg_match('#^admin/packaging-units(/.*)?$#', $p, $m)) {
$cands[] = 'bag/packaging-units/manage' . ($m[1] ?? ''); $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/')) { } elseif (str_starts_with($p, 'admin/')) {
$cands[] = 'bag/' . substr($p, strlen('admin/')); $cands[] = 'bag/' . substr($p, strlen('admin/'));
} elseif (str_starts_with($p, 'bag/')) { } elseif (str_starts_with($p, 'bag/')) {
@@ -475,6 +506,68 @@ if (! function_exists('site_nav_link_matches_current')) {
} }
} }
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')) { if (! function_exists('session_user_nav_display')) {
/** /**
* 상단 메뉴바용: 로그인 사용자 이름·역할 표시 * 상단 메뉴바용: 로그인 사용자 이름·역할 표시
@@ -501,3 +594,384 @@ if (! function_exists('session_user_nav_display')) {
]; ];
} }
} }
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 ?? [];
$bestVal = '';
$bestLen = -1;
foreach ($map as $prefix => $val) {
$p = strtolower((string) $prefix);
if ($path === $p || str_starts_with($path . '/', $p . '/')) {
if (strlen($p) > $bestLen) {
$bestLen = strlen($p);
$bestVal = (string) $val;
}
}
}
if ($bestVal === '') {
return '';
}
// 값은 "slug" 또는 "slug#소제목힌트" 형식. 힌트가 있으면 ?hl= 로 전달해 해당 소제목으로 스크롤·강조.
[$slug, $hint] = array_pad(explode('#', $bestVal, 2), 2, null);
$url = base_url('bag/manual/' . $slug);
if ($hint !== null && trim($hint) !== '') {
$url .= '?hl=' . rawurlencode(trim($hint));
}
return $url;
}
}

View File

@@ -67,3 +67,451 @@ if (! function_exists('csv_encode_row')) {
return implode(',', $escaped) . "\r\n"; 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 데이터 행(각 행은 배열, 값은 문자열로 출력)
* @param array $topRows 헤더 위에 추가할 제목/부제 행(각 행은 배열). 비우면 없음.
*/
function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows, array $topRows = []): 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>';
// 상단 제목/부제 행(있을 때만)
foreach ($topRows as $topRow) {
$parts[] = '<Row>';
foreach (array_values((array) $topRow) as $cell) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$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

@@ -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

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagInventoryModel extends Model class BagInventoryModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_inventory'; protected $table = 'bag_inventory';
protected $primaryKey = 'bi_idx'; protected $primaryKey = 'bi_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BagIssueItemCodeModel extends Model
{
use \App\Models\Traits\Auditable;
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

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagIssueModel extends Model class BagIssueModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_issue'; protected $table = 'bag_issue';
protected $primaryKey = 'bi2_idx'; protected $primaryKey = 'bi2_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagOrderItemModel extends Model class BagOrderItemModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_order_item'; protected $table = 'bag_order_item';
protected $primaryKey = 'boi_idx'; protected $primaryKey = 'boi_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,13 +6,32 @@ use CodeIgniter\Model;
class BagOrderModel extends Model class BagOrderModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_order'; protected $table = 'bag_order';
protected $primaryKey = 'bo_idx'; protected $primaryKey = 'bo_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
/**
* 동일 발주 UUID에 대해 bo_version이 최대인 행만 (수정으로 생긴 이전 버전 행은 목록·이력에서 제외).
* DB에는 버전별 행이 그대로 남고, 조회 시에만 필터한다.
*/
public function whereLatestHead(int $lgIdx): self
{
$lg = (int) $lgIdx;
return $this->where(
"(bo_uuid, bo_version) IN (SELECT bo_uuid, MAX(bo_version) FROM {$this->table} WHERE bo_lg_idx = {$lg} GROUP BY bo_uuid)",
null,
false
);
}
protected $allowedFields = [ protected $allowedFields = [
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code', 'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date', 'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
'bo_regdate', 'bo_moddate', 'bo_regdate', 'bo_moddate',
]; ];

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagPriceHistoryModel extends Model class BagPriceHistoryModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_price_history'; protected $table = 'bag_price_history';
protected $primaryKey = 'bph_idx'; protected $primaryKey = 'bph_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagPriceModel extends Model class BagPriceModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_price'; protected $table = 'bag_price';
protected $primaryKey = 'bp_idx'; protected $primaryKey = 'bp_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -16,4 +18,45 @@ class BagPriceModel extends Model
'bp_start_date', 'bp_end_date', 'bp_state', 'bp_start_date', 'bp_end_date', 'bp_state',
'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx', 'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx',
]; ];
/**
* 같은 봉투코드에 단가 기간이 겹쳐도 "나중 등록 단가"가 우선되도록
* 활성 단가를 등록일/PK 역순으로 정렬해 봉투코드별 1건만 남긴다.
*
* @return array<string, object>
*/
public function latestActiveMapByBagCode(int $lgIdx): array
{
$rows = $this->where('bp_lg_idx', $lgIdx)
->where('bp_state', 1)
->orderBy('bp_regdate', 'DESC')
->orderBy('bp_idx', 'DESC')
->findAll();
$map = [];
foreach ($rows as $row) {
$code = (string) ($row->bp_bag_code ?? '');
if ($code === '' || isset($map[$code])) {
continue;
}
$map[$code] = $row;
}
return $map;
}
public function latestActiveByBagCode(int $lgIdx, string $bagCode): ?object
{
$bagCode = trim($bagCode);
if ($bagCode === '') {
return null;
}
return $this->where('bp_lg_idx', $lgIdx)
->where('bp_bag_code', $bagCode)
->where('bp_state', 1)
->orderBy('bp_regdate', 'DESC')
->orderBy('bp_idx', 'DESC')
->first();
}
} }

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagReceivingModel extends Model class BagReceivingModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_receiving'; protected $table = 'bag_receiving';
protected $primaryKey = 'br_idx'; protected $primaryKey = 'br_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagSaleModel extends Model class BagSaleModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'bag_sale'; protected $table = 'bag_sale';
protected $primaryKey = 'bs_idx'; protected $primaryKey = 'bs_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BlockchainLedgerModel extends Model
{
use \App\Models\Traits\Auditable;
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

@@ -8,6 +8,8 @@ use CodeIgniter\Model;
class CodeDetailModel extends Model class CodeDetailModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'code_detail'; protected $table = 'code_detail';
protected $primaryKey = 'cd_idx'; protected $primaryKey = 'cd_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -53,7 +55,11 @@ class CodeDetailModel extends Model
$this->where('cd_state', 1); $this->where('cd_state', 1);
} }
return $this->orderBy('cd_sort', 'ASC')->orderBy('cd_idx', 'ASC')->findAll(); // 동일 정렬값일 때는 코드값 기준으로 안정적으로 정렬한다.
return $this->orderBy('cd_sort', 'ASC')
->orderBy('cd_code', 'ASC')
->orderBy('cd_idx', 'ASC')
->findAll();
} }
/** /**

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class CodeKindModel extends Model class CodeKindModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'code_kind'; protected $table = 'code_kind';
protected $primaryKey = 'ck_idx'; protected $primaryKey = 'ck_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class CompanyModel extends Model class CompanyModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'company'; protected $table = 'company';
protected $primaryKey = 'cp_idx'; protected $primaryKey = 'cp_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,27 +6,39 @@ use CodeIgniter\Model;
class DesignatedShopModel extends Model class DesignatedShopModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'designated_shop'; protected $table = 'designated_shop';
protected $primaryKey = 'ds_idx'; protected $primaryKey = 'ds_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'ds_lg_idx', 'ds_lg_idx',
'ds_sa_idx',
'ds_mb_idx', 'ds_mb_idx',
'ds_shop_no', 'ds_shop_no',
'ds_name', 'ds_name',
'ds_biz_no', 'ds_biz_no',
'ds_rep_name', 'ds_rep_name',
'ds_biz_type',
'ds_biz_kind',
'ds_va_number', 'ds_va_number',
'ds_va_bank',
'ds_va_account',
'ds_zip', 'ds_zip',
'ds_addr', 'ds_addr',
'ds_addr_jibun', 'ds_addr_jibun',
'ds_addr_detail',
'ds_tel', 'ds_tel',
'ds_rep_phone', 'ds_rep_phone',
'ds_email', 'ds_email',
'ds_gugun_code', 'ds_gugun_code',
'ds_zone_code',
'ds_branch_no',
'ds_designated_at', 'ds_designated_at',
'ds_state', 'ds_state',
'ds_state_changed_at',
'ds_change_reason',
'ds_regdate', 'ds_regdate',
]; ];
} }

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
/**
* 사용자 의견 첨부 이미지(캡처/업로드).
*/
class FeedbackFileModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'feedback_file';
protected $primaryKey = 'ff_idx';
protected $returnType = 'object';
protected $useTimestamps = true;
protected $createdField = 'ff_regdate';
protected $updatedField = '';
protected $dateFormat = 'datetime';
protected $allowedFields = ['ff_fb_idx', 'ff_kind', 'ff_path', 'ff_mime'];
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
/**
* 사용자 의견(피드백) 모델.
*/
class FeedbackModel extends Model
{
use \App\Models\Traits\Auditable;
protected $table = 'feedback';
protected $primaryKey = 'fb_idx';
protected $returnType = 'object';
protected $useTimestamps = true;
protected $createdField = 'fb_regdate';
protected $updatedField = 'fb_updated';
protected $dateFormat = 'datetime';
protected $allowedFields = [
'fb_lg_idx', 'fb_mb_idx', 'fb_mb_name', 'fb_mb_level',
'fb_content', 'fb_page_url', 'fb_page_title', 'fb_screenshot',
'fb_user_agent', 'fb_status', 'fb_admin_memo',
];
/** 허용 상태 값 */
public const STATUSES = ['new', 'in_progress', 'done', 'hold'];
public static function statusLabel(string $s): string
{
return [
'new' => '접수',
'in_progress' => '처리중',
'done' => '완료',
'hold' => '보류',
][$s] ?? $s;
}
}

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class FreeRecipientModel extends Model class FreeRecipientModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'free_recipient'; protected $table = 'free_recipient';
protected $primaryKey = 'fr_idx'; protected $primaryKey = 'fr_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class LocalGovernmentModel extends Model class LocalGovernmentModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'local_government'; protected $table = 'local_government';
protected $primaryKey = 'lg_idx'; protected $primaryKey = 'lg_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class ManagerModel extends Model class ManagerModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'manager'; protected $table = 'manager';
protected $primaryKey = 'mg_idx'; protected $primaryKey = 'mg_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MemberApprovalRequestModel extends Model class MemberApprovalRequestModel extends Model
{ {
use \App\Models\Traits\Auditable;
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved'; public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected'; public const STATUS_REJECTED = 'rejected';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MemberModel extends Model class MemberModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'member'; protected $table = 'member';
protected $primaryKey = 'mb_idx'; protected $primaryKey = 'mb_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -28,6 +30,7 @@ class MemberModel extends Model
'mb_leavedate', 'mb_leavedate',
'mb_login_fail_count', 'mb_login_fail_count',
'mb_locked_until', 'mb_locked_until',
'mb_session_token',
]; ];
/** /**

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuModel extends Model class MenuModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'menu'; protected $table = 'menu';
protected $primaryKey = 'mm_idx'; protected $primaryKey = 'mm_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -189,4 +191,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

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuTypeModel extends Model class MenuTypeModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'menu_type'; protected $table = 'menu_type';
protected $primaryKey = 'mt_idx'; protected $primaryKey = 'mt_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitHistoryModel extends Model class PackagingUnitHistoryModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'packaging_unit_history'; protected $table = 'packaging_unit_history';
protected $primaryKey = 'puh_idx'; protected $primaryKey = 'puh_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitModel extends Model class PackagingUnitModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'packaging_unit'; protected $table = 'packaging_unit';
protected $primaryKey = 'pu_idx'; protected $primaryKey = 'pu_idx';
protected $returnType = 'object'; protected $returnType = 'object';
@@ -16,4 +18,29 @@ class PackagingUnitModel extends Model
'pu_start_date', 'pu_end_date', 'pu_state', 'pu_start_date', 'pu_end_date', 'pu_state',
'pu_regdate', 'pu_moddate', 'pu_reg_mb_idx', 'pu_regdate', 'pu_moddate', 'pu_reg_mb_idx',
]; ];
/**
* 동일 봉투코드에 행이 여러 개여도 최신 등록 1건만 사용.
*
* @return array<string, object>
*/
public function latestActiveMapByBagCode(int $lgIdx): array
{
$rows = $this->where('pu_lg_idx', $lgIdx)
->where('pu_state', 1)
->orderBy('pu_regdate', 'DESC')
->orderBy('pu_idx', 'DESC')
->findAll();
$map = [];
foreach ($rows as $row) {
$code = (string) ($row->pu_bag_code ?? '');
if ($code === '' || isset($map[$code])) {
continue;
}
$map[$code] = $row;
}
return $map;
}
} }

View File

@@ -8,6 +8,8 @@ use CodeIgniter\Model;
class SalesAgencyModel extends Model class SalesAgencyModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'sales_agency'; protected $table = 'sales_agency';
protected $primaryKey = 'sa_idx'; protected $primaryKey = 'sa_idx';
protected $returnType = 'object'; protected $returnType = 'object';

View File

@@ -6,12 +6,14 @@ use CodeIgniter\Model;
class ShopOrderItemModel extends Model class ShopOrderItemModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'shop_order_item'; protected $table = 'shop_order_item';
protected $primaryKey = 'soi_idx'; protected $primaryKey = 'soi_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price', 'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price',
'soi_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count', 'soi_qty', 'soi_packed_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count',
]; ];
} }

View File

@@ -6,13 +6,15 @@ use CodeIgniter\Model;
class ShopOrderModel extends Model class ShopOrderModel extends Model
{ {
use \App\Models\Traits\Auditable;
protected $table = 'shop_order'; protected $table = 'shop_order';
protected $primaryKey = 'so_idx'; protected $primaryKey = 'so_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date', 'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date',
'so_payment_type', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount', 'so_payment_type', 'so_channel', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount',
'so_status', 'so_orderer_idx', 'so_regdate', 'so_status', 'so_orderer_idx', 'so_regdate',
]; ];
} }

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Models\Traits;
/**
* 모델 CRUD(생성/수정/삭제) 자동 감사 로그 트레잇.
*
* 모델에 `use \App\Models\Traits\Auditable;` 만 추가하면
* insert/update/delete 시 activity_log 에 "누가·무엇을·언제" 자동 기록한다.
* (조회(select)는 기록하지 않음)
*
* - 변경 전(before)/후(after) 데이터를 JSON 으로 보관
* - 비밀번호·OTP 비밀키·이메일·전화 등 민감 필드는 마스킹
* - 기록 실패는 audit_log() 내부에서 흡수되어 본 로직에 영향 없음
*/
trait Auditable
{
/** 변경/삭제 전 원본 보관 (PK => row) */
protected array $auditBeforeRows = [];
/**
* 모델 이벤트(콜백) 등록.
* BaseModel 이 이미 $afterInsert 등 속성을 선언하므로(속성 충돌 방지),
* 트레잇에서는 속성 대신 initialize()에서 런타임에 콜백을 덧붙인다.
*/
protected function initialize()
{
$this->allowCallbacks = true;
$this->afterInsert[] = 'auditAfterInsert';
$this->beforeUpdate[] = 'auditBeforeUpdate';
$this->afterUpdate[] = 'auditAfterUpdate';
$this->beforeDelete[] = 'auditBeforeDelete';
$this->afterDelete[] = 'auditAfterDelete';
}
/** 마스킹 대상 필드명 */
protected array $auditMaskFields = ['mb_passwd', 'mb_totp_secret', 'mb_session_token', 'mb_email', 'mb_phone'];
private function auditMask(?array $row): ?array
{
if ($row === null) {
return null;
}
foreach ($this->auditMaskFields as $f) {
if (array_key_exists($f, $row) && (string) $row[$f] !== '') {
$row[$f] = '***';
}
}
return $row;
}
/** find 콜백 재귀를 피하려고 쿼리빌더로 원본 행을 직접 조회 */
private function auditFetchRaw(int|string $id): ?array
{
try {
$row = $this->db->table($this->table)
->where($this->primaryKey, $id)
->get()
->getRowArray();
return $row ?: null;
} catch (\Throwable $e) {
return null;
}
}
public function auditAfterInsert(array $eventData): array
{
helper('audit');
$after = is_array($eventData['data'] ?? null) ? $eventData['data'] : null;
audit_log('create', $this->table, (int) ($eventData['id'] ?? 0), null, $this->auditMask($after));
return $eventData;
}
public function auditBeforeUpdate(array $eventData): array
{
$this->auditBeforeRows = [];
$ids = $eventData['id'] ?? null;
if ($ids !== null) {
foreach ((array) $ids as $one) {
$row = $this->auditFetchRaw($one);
if ($row !== null) {
$this->auditBeforeRows[(string) $one] = $row;
}
}
}
return $eventData;
}
public function auditAfterUpdate(array $eventData): array
{
helper('audit');
$ids = $eventData['id'] ?? null;
$changes = is_array($eventData['data'] ?? null) ? $eventData['data'] : [];
if ($ids !== null) {
foreach ((array) $ids as $one) {
$before = $this->auditBeforeRows[(string) $one] ?? null;
$after = $before !== null ? array_merge($before, $changes) : $changes;
audit_log('update', $this->table, (int) $one, $this->auditMask($before), $this->auditMask($after));
}
} else {
// 조건(where) 기반 일괄 수정: 대상 PK 미상 → 변경값만 기록
audit_log('update', $this->table, 0, null, $this->auditMask($changes));
}
$this->auditBeforeRows = [];
return $eventData;
}
public function auditBeforeDelete(array $eventData): array
{
$this->auditBeforeRows = [];
$ids = $eventData['id'] ?? null;
if ($ids !== null) {
foreach ((array) $ids as $one) {
$row = $this->auditFetchRaw($one);
if ($row !== null) {
$this->auditBeforeRows[(string) $one] = $row;
}
}
}
return $eventData;
}
public function auditAfterDelete(array $eventData): array
{
helper('audit');
$ids = $eventData['id'] ?? null;
if ($ids !== null) {
foreach ((array) $ids as $one) {
$before = $this->auditBeforeRows[(string) $one] ?? null;
audit_log('delete', $this->table, (int) $one, $this->auditMask($before), null);
}
} else {
audit_log('delete', $this->table, 0, null, null);
}
$this->auditBeforeRows = [];
return $eventData;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* @var list<object> $list
* @var string $start
* @var string $end
* @var string $action
* @var string $table
* @var string $user
* @var array<string,string> $tableLabels
* @var array<string,string> $actionLabels
*/
$actionBadge = static function (string $a): string {
$map = [
'create' => 'background:#dcfce7;color:#166534;',
'update' => 'background:#dbeafe;color:#1e40af;',
'delete' => 'background:#fee2e2;color:#991b1b;',
];
return $map[$a] ?? 'background:#f3f4f6;color:#374151;';
};
?>
<?= view('components/print_header', ['printTitle' => '활동 로그']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<form method="GET" action="<?= base_url('admin/access/activity-logs') ?>" class="flex flex-wrap items-center gap-2 text-sm">
<label class="font-bold text-gray-700">조회기간</label>
<input type="date" name="start" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($start) ?>"/>
<span>~</span>
<input type="date" name="end" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($end) ?>"/>
<select name="action" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="">작업 전체</option>
<?php foreach ($actionLabels as $k => $lbl): ?>
<option value="<?= esc($k, 'attr') ?>" <?= $action === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
<?php endforeach; ?>
</select>
<select name="table" class="border border-gray-300 rounded px-2 py-1 text-sm max-w-[12rem]">
<option value="">대상 전체</option>
<?php foreach ($tableLabels as $k => $lbl): ?>
<option value="<?= esc($k, 'attr') ?>" <?= $table === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="user" placeholder="사용자(아이디/이름)" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($user) ?>"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition border border-transparent">조회</button>
<a href="<?= base_url('admin/access/activity-logs') ?>" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="text-left">일시</th>
<th class="text-left">사용자</th>
<th class="text-center">작업</th>
<th class="text-left">대상</th>
<th class="text-center">대상 번호</th>
<th class="text-left">IP</th>
<th class="text-center no-print">상세</th>
</tr>
</thead>
<tbody>
<?php foreach ($list as $row): ?>
<?php
$act = (string) ($row->al_action ?? '');
$tbl = (string) ($row->al_table ?? '');
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
?>
<tr>
<td class="text-left pl-2 whitespace-nowrap"><?= esc((string) ($row->al_regdate ?? '')) ?></td>
<td class="text-left pl-2"><?= $who !== '' ? esc($who) : '<span class="text-gray-400">시스템</span>' ?></td>
<td class="text-center">
<span style="<?= $actionBadge($act) ?>display:inline-block;padding:1px 8px;border-radius:9999px;font-size:11px;font-weight:700;">
<?= esc($actionLabels[$act] ?? $act) ?>
</span>
</td>
<td class="text-left pl-2"><?= esc($tableLabels[$tbl] ?? $tbl) ?></td>
<td class="text-center"><?= (int) ($row->al_record_id ?? 0) ?: '-' ?></td>
<td class="text-left pl-2 text-gray-500"><?= esc((string) ($row->al_ip ?? '')) ?></td>
<td class="text-center no-print">
<a href="<?= base_url('admin/access/activity-logs/' . (int) $row->al_idx) ?>" class="text-blue-700 hover:underline">보기</a>
</td>
</tr>
<?php endforeach; ?>
<?php if ($list === []): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-6">조회된 로그가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3 no-print"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* @var object $row
* @var array<string,mixed> $before
* @var array<string,mixed> $after
* @var array<string,string> $tableLabels
* @var array<string,string> $actionLabels
*/
$act = (string) ($row->al_action ?? '');
$tbl = (string) ($row->al_table ?? '');
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
// 변경된 키 강조용: before/after 값이 다른 키
$allKeys = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
$fmt = static function ($v): string {
if (is_array($v)) {
return json_encode($v, JSON_UNESCAPED_UNICODE);
}
return (string) ($v ?? '');
};
?>
<section class="p-4">
<div class="flex items-center justify-between mb-3 no-print">
<a href="<?= base_url('admin/access/activity-logs') ?>" class="text-sm text-blue-700 hover:underline">&larr; 목록으로</a>
<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">인쇄</button>
</div>
<table class="w-full data-table text-sm mb-5" style="max-width:680px;">
<tbody>
<tr><th class="text-left bg-gray-50 w-32">일시</th><td class="text-left pl-2"><?= esc((string) ($row->al_regdate ?? '')) ?></td></tr>
<tr><th class="text-left bg-gray-50">사용자</th><td class="text-left pl-2"><?= $who !== '' ? esc($who) : '시스템' ?></td></tr>
<tr><th class="text-left bg-gray-50">작업</th><td class="text-left pl-2"><?= esc($actionLabels[$act] ?? $act) ?></td></tr>
<tr><th class="text-left bg-gray-50">대상</th><td class="text-left pl-2"><?= esc($tableLabels[$tbl] ?? $tbl) ?> (<?= esc($tbl) ?>) · 번호 <?= (int) ($row->al_record_id ?? 0) ?></td></tr>
<tr><th class="text-left bg-gray-50">IP</th><td class="text-left pl-2"><?= esc((string) ($row->al_ip ?? '')) ?></td></tr>
</tbody>
</table>
<h3 class="font-bold text-gray-700 mb-2">변경 내역 (전 → 후)</h3>
<?php if ($allKeys === []): ?>
<p class="text-gray-400 text-sm">상세 데이터가 없습니다.</p>
<?php else: ?>
<div class="border border-gray-300 rounded-lg overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="text-left w-40">항목</th>
<th class="text-left">변경 전</th>
<th class="text-left">변경 후</th>
</tr>
</thead>
<tbody>
<?php foreach ($allKeys as $k): ?>
<?php
$b = array_key_exists($k, $before) ? $fmt($before[$k]) : null;
$a = array_key_exists($k, $after) ? $fmt($after[$k]) : null;
$changed = ($act === 'update') && ($b !== $a);
?>
<tr<?= $changed ? ' style="background:#fffbeb;"' : '' ?>>
<td class="text-left pl-2 font-semibold text-gray-700"><?= esc((string) $k) ?></td>
<td class="text-left pl-2 <?= $changed ? 'text-red-700' : 'text-gray-500' ?>"><?= $b === null ? '<span class="text-gray-300">—</span>' : esc($b) ?></td>
<td class="text-left pl-2 <?= $changed ? 'text-blue-700 font-semibold' : 'text-gray-700' ?>"><?= $a === null ? '<span class="text-gray-300">—</span>' : esc($a) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>

View File

@@ -5,8 +5,26 @@
<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> <button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
</div> </div>
</section> </section>
<div class="border border-gray-300 p-4 mt-2"> <style>
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="mb-4 flex flex-wrap items-center gap-2 text-sm"> /* 인쇄 시 승인 대기 표를 A4 너비에 맞춤(잘림 방지) */
@media print {
@page { size: A4 portrait; margin: 10mm; }
.approval-print-wrap { border: 0 !important; padding: 0 !important; overflow: visible !important; }
.approval-print-wrap table.data-table { width: 100% !important; table-layout: fixed; font-size: 9px; }
.approval-print-wrap .data-table th,
.approval-print-wrap .data-table td {
white-space: normal !important; /* nowrap 해제 → 줄바꿈 허용 */
word-break: break-word;
overflow-wrap: anywhere;
padding: 2px 3px !important;
vertical-align: top;
}
/* '관리'(승인/반려 버튼) 열은 인쇄에 불필요 → 제외해 가로 폭 확보 */
.approval-print-wrap .col-actions { display: none !important; }
}
</style>
<div class="approval-print-wrap border border-gray-300 rounded-lg p-4 mt-2">
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="no-print mb-4 flex flex-wrap items-center gap-2 text-sm">
<label for="status" class="font-bold text-gray-700 shrink-0">상태</label> <label for="status" class="font-bold text-gray-700 shrink-0">상태</label>
<select id="status" name="status" class="border border-gray-300 rounded px-3 py-1.5 text-sm min-w-[12rem] w-48 max-w-full"> <select id="status" name="status" class="border border-gray-300 rounded px-3 py-1.5 text-sm min-w-[12rem] w-48 max-w-full">
<option value="pending" <?= ($status ?? 'pending') === 'pending' ? 'selected' : '' ?>>승인 대기</option> <option value="pending" <?= ($status ?? 'pending') === 'pending' ? 'selected' : '' ?>>승인 대기</option>
@@ -18,14 +36,14 @@
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>요청일</th> <th class="text-center">요청일</th>
<th>아이디</th> <th class="text-center">아이디</th>
<th>이름</th> <th class="text-center">이름</th>
<th>지자체</th> <th class="text-center">지자체</th>
<th>요청 역할</th> <th class="text-center">요청 역할</th>
<th>상태</th> <th class="text-center">상태</th>
<th>처리일</th> <th class="text-center">처리일</th>
<th>관리</th> <th class="text-center col-actions">관리</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,7 +63,7 @@
<?php if (($row->mar_status ?? '') === 'rejected'): ?>반려<?php endif; ?> <?php if (($row->mar_status ?? '') === 'rejected'): ?>반려<?php endif; ?>
</td> </td>
<td class="text-center"><?= esc($row->mar_processed_at ?? '-') ?></td> <td class="text-center"><?= esc($row->mar_processed_at ?? '-') ?></td>
<td class="text-center"> <td class="text-center col-actions">
<?php if (($row->mar_status ?? '') === 'pending'): ?> <?php if (($row->mar_status ?? '') === 'pending'): ?>
<div class="flex items-center justify-center gap-1"> <div class="flex items-center justify-center gap-1">
<form action="<?= base_url('admin/access/approve/' . $row->mar_idx) ?>" method="post" class="inline"> <form action="<?= base_url('admin/access/approve/' . $row->mar_idx) ?>" method="post" class="inline">

View File

@@ -13,15 +13,15 @@
<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> <button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
</div> </div>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>일시</th> <th class="text-left">일시</th>
<th>아이디</th> <th class="text-left">아이디</th>
<th>성공</th> <th class="text-center">성공</th>
<th>IP</th> <th class="text-left">IP</th>
<th>메시지</th> <th class="text-left">메시지</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -8,15 +8,15 @@
</div> </div>
</div> </div>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>봉투코드</th> <th class="text-center">봉투코드</th>
<th>봉투명</th> <th class="text-left">봉투명</th>
<th>현재재고(낱장)</th> <th class="text-right">현재재고(낱장)</th>
<th>최종갱신</th> <th class="text-center">최종갱신</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span> <span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-issues/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('bag-issues/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -18,21 +18,20 @@
<a href="<?= mgmt_url('bag-issues') ?>" class="text-sm text-gray-500 hover:underline">초기화</a> <a href="<?= mgmt_url('bag-issues') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16 text-center">번호</th>
<th>연도</th> <th class="text-center">연도</th>
<th>분기</th> <th class="text-center">분기</th>
<th>구분</th> <th class="text-center">구분</th>
<th>불출일</th> <th class="text-center">불출일</th>
<th>불출처</th> <th>불출처</th>
<th>봉투코드</th> <th class="text-center">봉투코드</th>
<th>봉투명</th> <th class="text-left">봉투명</th>
<th>수량</th> <th class="text-right">수량</th>
<th class="w-20">상태</th> <th class="w-24 text-center">작업</th>
<th class="w-24">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
@@ -47,7 +46,6 @@
<td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td> <td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td> <td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td>
<td><?= number_format((int) $row->bi2_qty) ?></td> <td><?= number_format((int) $row->bi2_qty) ?></td>
<td class="text-center"><?= esc($row->bi2_status) ?></td>
<td class="text-center"> <td class="text-center">
<form action="<?= mgmt_url('bag-issues/cancel/' . (int) $row->bi2_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');"> <form action="<?= mgmt_url('bag-issues/cancel/' . (int) $row->bi2_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -57,7 +55,7 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr> <tr><td colspan="10" class="text-center text-gray-400 py-4">등록된 불출이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

@@ -1,83 +1,443 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">발주 등록</span> <span class="text-sm font-bold text-gray-700">발주 등록</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl">
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="space-y-4"> <?php
$oldBagCodes = old('item_bag_code');
$oldQtyBoxes = old('item_qty_box');
$oldQtySheets = old('item_qty_sheet');
$oldBagCodes = is_array($oldBagCodes) ? $oldBagCodes : [];
$oldQtyBoxes = is_array($oldQtyBoxes) ? $oldQtyBoxes : [];
$oldQtySheets = is_array($oldQtySheets) ? $oldQtySheets : [];
$defaultOrderDate = old('bo_order_date', date('Y-m-d'));
$defaultOrderMonth = old('bo_order_month_ui', substr($defaultOrderDate, 0, 7));
$bagMeta = [];
foreach (($bagReferenceRows ?? []) as $row) {
$bagMeta[$row['code']] = [
'name' => $row['name'],
'orderPrice' => (float) $row['orderPrice'],
'boxPerPack' => (int) $row['boxPerPack'],
'packPerSheet' => (int) $row['packPerSheet'],
'totalPerBox' => max(1, (int) $row['totalPerBox']),
];
}
$initialSelectedItems = [];
$maxOldCount = max(count($oldBagCodes), count($oldQtySheets), count($oldQtyBoxes));
for ($i = 0; $i < $maxOldCount; $i++) {
$code = trim((string) ($oldBagCodes[$i] ?? ''));
if ($code === '' || ! isset($bagMeta[$code])) {
continue;
}
$fallbackQtyBox = (int) ($oldQtyBoxes[$i] ?? 0);
$rawQtySheet = (int) ($oldQtySheets[$i] ?? 0);
$fallbackTotalPerBox = (int) ($bagMeta[$code]['totalPerBox'] ?? 1);
if ($fallbackQtyBox <= 0 && $rawQtySheet > 0) {
$fallbackQtyBox = intdiv($rawQtySheet, max(1, $fallbackTotalPerBox));
}
$initialSelectedItems[] = [
'code' => $code,
'qtyBox' => max(0, $fallbackQtyBox),
];
}
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
?>
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="mt-2 space-y-2">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2"> <div class="border border-gray-300 rounded-lg bg-white p-2">
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label> <div class="flex flex-wrap items-center gap-4 text-sm">
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bo_order_date" type="date" value="<?= esc(old('bo_order_date', date('Y-m-d'))) ?>" required/> <div class="flex items-center gap-2">
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
<input id="bo_order_month_ui" name="bo_order_month_ui" type="month" value="<?= esc($defaultOrderMonth) ?>" class="border border-gray-300 rounded px-2 py-1" />
</div>
<div class="flex items-center gap-2">
<label for="bo_order_date" class="font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1" />
</div>
<p class="text-blue-600 font-bold">※ 발주수량은 박스단위로 입력해 주세요. (발주일은 미래일도 선택 가능)</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label> <section class="xl:col-span-5 border border-gray-300 rounded-lg bg-white">
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>"/> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<span class="text-sm text-gray-500">%</span> <div class="overflow-auto max-h-[410px]">
</div> <table class="w-full data-table text-sm">
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">제작업체</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_company_idx">
<option value="">선택</option>
<?php foreach ($companies as $cp): ?>
<option value="<?= esc($cp->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $cp->cp_idx ? 'selected' : '' ?>>
<?= esc($cp->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">입고처</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_agency_idx">
<option value="">선택</option>
<?php foreach ($agencies as $ag): ?>
<option value="<?= esc($ag->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $ag->sa_idx ? 'selected' : '' ?>>
[<?= esc($ag->sa_kind ?? '') ?>] <?= esc($ag->sa_code ?? '') ?> — <?= esc($ag->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">발주 품목</label>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">순번</th> <th class="w-28 text-center">발주일</th>
<th>봉투</th> <th class="text-left">제작업체</th>
<th class="w-32">박스수</th> <th class="text-left">입고처</th>
<th class="w-16 text-center">상태</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php for ($i = 0; $i < 3; $i++): ?> <?php foreach (($recentOrders ?? []) as $history): ?>
<tr> <tr>
<td class="text-center"><?= $i + 1 ?></td> <td class="text-center"><?= esc((string) $history->bo_order_date) ?></td>
<td> <td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]"> <td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<option value="">선택</option> <td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty_box[]" type="number" min="0" value="0"/>
</td>
</tr> </tr>
<?php endfor; ?> <?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<section class="xl:col-span-7 border border-gray-300 rounded-lg bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 Form</div>
<div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex items-center gap-2 text-sm">
<label for="bo_fee_rate" class="w-20 font-bold text-gray-700">수수료</label>
<input id="bo_fee_rate" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>" class="border border-gray-300 rounded px-2 py-1 w-24 text-right" />
<span>%</span>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($associations ?? []) as $association): ?>
<option value="<?= esc((string) $association->cp_idx) ?>" <?= (int) old('bo_association_idx') === (int) $association->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $association->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_company_idx" class="w-20 font-bold text-gray-700">제작업체</label>
<select id="bo_company_idx" name="bo_company_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= esc((string) $company->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $company->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_agency_idx" class="w-20 font-bold text-gray-700">입고처</label>
<select id="bo_agency_idx" name="bo_agency_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($agencies ?? []) as $agency): ?>
<option value="<?= esc((string) $agency->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $agency->sa_idx ? 'selected' : '' ?>>
[<?= esc((string) ($agency->sa_kind ?? '')) ?>] <?= esc((string) ($agency->sa_code ?? '')) ?> — <?= esc((string) $agency->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button> <table class="w-full data-table text-sm order-input-table" id="order-item-table">
<thead>
<tr>
<th class="w-12 text-center">번호</th>
<th class="w-16 text-center">선택</th>
<th class="text-left">품명</th>
<th class="w-28 text-right">수량(BOX)</th>
<th class="w-24 text-right">단가</th>
<th class="w-24 text-right">환산수량</th>
<th class="w-28 text-right">금액</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> <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>
</form>
</div> </div>
</section>
</div>
<section class="border border-gray-300 rounded-lg 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 text-center">번호</th>
<th class="w-20 text-center">선택</th>
<th>봉투 종류</th>
<th class="w-24 text-right">발주단가</th>
<th class="w-24 text-right">Box당 팩</th>
<th class="w-24 text-right">팩당 낱장</th>
<th class="w-28 text-right">1박스 총 낱장</th>
</tr>
</thead>
<tbody>
<?php foreach (($bagReferenceRows ?? []) as $idx => $row): ?>
<tr data-reference-row data-code="<?= esc((string) $row['code']) ?>" class="cursor-pointer">
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center">
<button type="button" class="js-toggle-bag border border-gray-300 rounded px-2 py-0.5 text-xs hover:bg-gray-100" data-code="<?= esc((string) $row['code']) ?>">선택</button>
</td>
<td class="text-left pl-2"><?= esc((string) $row['name']) ?></td>
<td class="text-right pr-2"><?= number_format((float) $row['orderPrice'], 2) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['boxPerPack']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['packPerSheet']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['totalPerBox']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($bagReferenceRows)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 봉투 기준 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</form>
<style>
.order-input-table tbody tr,
.order-reference-table tbody tr {
height: 34px;
}
.order-input-table tbody td,
.order-reference-table tbody td {
padding-top: 4px;
padding-bottom: 4px;
}
</style>
<script>
(() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const initialSelectedItems = <?= json_encode($initialSelectedItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const selectedBody = document.getElementById('selected-order-items-body');
const referenceRows = Array.from(document.querySelectorAll('[data-reference-row]'));
const sumBoxQtyEl = document.getElementById('sum-box-qty');
const sumSheetQtyEl = document.getElementById('sum-sheet-qty');
const sumAmountEl = document.getElementById('sum-amount');
const monthInput = document.getElementById('bo_order_month_ui');
const orderDateInput = document.getElementById('bo_order_date');
const orderForm = document.querySelector('form[action*="bag-orders/store"]');
const selectedItems = new Map();
let activeCode = null;
const formatNumber = (value) => new Intl.NumberFormat('ko-KR').format(Number.isFinite(value) ? value : 0);
const escapeHtml = (value) => String(value ?? '')
.replaceAll('&', '&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

@@ -5,7 +5,7 @@
<span class="text-sm font-bold text-gray-700">발주 상세 <?= esc($order->bo_lot_no) ?></span> <span class="text-sm font-bold text-gray-700">발주 상세 <?= esc($order->bo_lot_no) ?></span>
</div> </div>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-4xl">
<table class="w-full text-sm"> <table class="w-full text-sm">
<tbody> <tbody>
<tr class="border-b"> <tr class="border-b">
@@ -53,16 +53,16 @@
</table> </table>
</div> </div>
<div class="border border-gray-300 overflow-auto mt-4"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-4">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>봉투코드</th> <th class="text-center">봉투코드</th>
<th>봉투명</th> <th class="text-left">봉투명</th>
<th>단가</th> <th class="text-right">단가</th>
<th>박스수</th> <th class="text-right">박스수</th>
<th>낱장수</th> <th class="text-right">낱장수</th>
<th>금액</th> <th class="text-right">금액</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">

View File

@@ -1,81 +1,200 @@
<?= view('components/print_header', ['printTitle' => '발주 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> // 발주기간: native month 입력은 로케일에 따라 Jan 등 영문 표기될 수 있어 YYYY-MM select + 한글 라벨 사용
$bagOrderYmChoices = [];
$bagOrderYmCenterY = (int) date('Y');
for ($by = $bagOrderYmCenterY - 4; $by <= $bagOrderYmCenterY + 2; $by++) {
for ($bm = 1; $bm <= 12; $bm++) {
$bagOrderYmChoices[] = sprintf('%04d-%02d', $by, $bm);
}
}
foreach ([(string) ($startMonth ?? ''), (string) ($endMonth ?? '')] as $ymExtra) {
if (preg_match('/^\d{4}-\d{2}$/', $ymExtra) && ! in_array($ymExtra, $bagOrderYmChoices, true)) {
$bagOrderYmChoices[] = $ymExtra;
}
}
sort($bagOrderYmChoices);
$bagOrderYmLabel = static function (string $ym): string {
if (preg_match('/^(\d{4})-(\d{2})$/', $ym, $m)) {
return $m[1] . '년 ' . (int) $m[2] . '월';
}
return $ym;
};
?>
<?= view('components/print_header', ['printTitle' => '봉투 발주 현황']) ?>
<section class="no-print border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">발주 현황</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="<?= mgmt_url('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> <button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('bag-orders/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">발주 등록</a> <a href="<?= mgmt_url('bag-orders/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">발주 등록</a>
</div> </div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('bag-orders') ?>" class="flex flex-wrap items-center gap-2"> <section class="no-print p-2 bg-white border-b border-gray-200">
<label class="text-sm text-gray-600">발주일</label> <!-- GBMS 발주현황: 발주기간은 [시작] ~ [끝] 한 줄 고정, 필터 블록은 가로 나열 후 좁으면 블록 단위로만 줄바꿈 -->
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <form method="GET" action="<?= mgmt_url('bag-orders') ?>" class="flex flex-wrap items-end gap-x-5 gap-y-3 w-full">
<label class="text-sm text-gray-600">~</label> <div class="flex flex-nowrap items-center gap-2 shrink-0">
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <label class="text-sm text-gray-600 whitespace-nowrap">발주 기간</label>
<label class="text-sm text-gray-600">상태</label> <select name="start_month" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11.5rem] max-w-[13rem] shrink-0">
<select name="status" class="border border-gray-300 rounded px-2 py-1 text-sm"> <?php foreach ($bagOrderYmChoices as $ym): ?>
<option value="">전체</option> <option value="<?= esc($ym) ?>" <?= ($startMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
<option value="normal" <?= ($status ?? '') === 'normal' ? 'selected' : '' ?>>정상</option> <?php endforeach; ?>
<option value="cancelled" <?= ($status ?? '') === 'cancelled' ? 'selected' : '' ?>>취소</option>
<option value="deleted" <?= ($status ?? '') === 'deleted' ? 'selected' : '' ?>>삭제</option>
</select> </select>
<span class="text-sm text-gray-500 select-none">~</span>
<select name="end_month" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11.5rem] max-w-[13rem] shrink-0">
<?php foreach ($bagOrderYmChoices as $ym): ?>
<option value="<?= esc($ym) ?>" <?= ($endMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">제작 업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11rem] max-w-[14rem]">
<option value="0">전 체</option>
<?php foreach (($companyOptions ?? []) as $company): ?>
<option value="<?= (int) $company->cp_idx ?>" <?= (int) ($companyIdx ?? 0) === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $company->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">품 명</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11rem] max-w-[16rem]">
<option value="">전 체</option>
<?php foreach (($bagCodeOptions ?? []) as $bag): ?>
<option value="<?= esc((string) $bag->cd_code) ?>" <?= (string) ($bagCode ?? '') === (string) $bag->cd_code ? 'selected' : '' ?>>
<?= esc((string) $bag->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">입고 구분</label>
<select name="receive_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-[8.5rem]">
<option value="all" <?= ($receiveType ?? 'all') === 'all' ? 'selected' : '' ?>>전 체</option>
<option value="received" <?= ($receiveType ?? 'all') === 'received' ? 'selected' : '' ?>>입고완료</option>
<option value="pending" <?= ($receiveType ?? 'all') === 'pending' ? 'selected' : '' ?>>미입고</option>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0 sm:ml-auto">
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('bag-orders') ?>" class="text-sm text-gray-500 hover:underline">초기화</a> <a href="<?= mgmt_url('bag-orders') ?>" class="text-sm text-gray-500 hover:underline whitespace-nowrap">초기화</a>
</div>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <div class="bag-order-print-wrap border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="bag-order-print-table w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-32 text-center">발주일자</th>
<th>LOT번호</th> <th class="min-w-[10rem] text-left">제작 업체</th>
<th>발주일</th> <th class="min-w-[12rem] text-left">품 명</th>
<th>제작업체</th> <th class="w-28 text-right">발주 수량</th>
<th>입고처</th> <th class="w-28 text-right">입고 수량</th>
<th>품목수</th> <th class="w-28 text-right">미입고수량</th>
<th>총수량</th> <th class="w-32 text-right">발주 금액</th>
<th>총금액</th> <th class="min-w-[9rem] text-left">입고처</th>
<th class="w-20">상태</th> <th class="min-w-[8rem] text-left">비 고</th>
<th class="w-44">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php $printedGroup = []; ?>
<tr> <?php foreach (($rows ?? []) as $row): ?>
<td class="text-center"><?= esc($row->bo_idx) ?></td> <?php if (! empty($row['is_subtotal'])): ?>
<td class="text-center font-mono"><?= esc($row->bo_lot_no) ?></td> <tr class="bg-gray-50 font-semibold">
<td class="text-center"><?= esc($row->bo_order_date) ?></td> <td colspan="3" class="text-center"><?= esc((string) ($row['label'] ?? '소계')) ?></td>
<td class="text-left pl-2"><?= esc($companyMap[$row->bo_company_idx] ?? '') ?></td> <td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<td class="text-left pl-2"><?= esc($agencyMap[$row->bo_agency_idx] ?? '') ?></td> <td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['count'] ?? 0)) ?></td> <td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['qty'] ?? 0)) ?></td> <td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['amount'] ?? 0)) ?></td> <td></td>
<td class="text-center"> <td></td>
</tr>
<?php continue; ?>
<?php endif; ?>
<?php <?php
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; $boIdx = (int) ($row['bo_idx'] ?? 0);
echo esc($statusMap[$row->bo_status] ?? $row->bo_status); $showGroup = ! isset($printedGroup[$boIdx]);
$rowspan = (int) (($groupRows[$boIdx] ?? 1));
if ($showGroup) {
$printedGroup[$boIdx] = true;
}
?> ?>
</td> <tr>
<td class="text-center"> <?php if ($showGroup): ?>
<a href="<?= mgmt_url('bag-orders/detail/' . (int) $row->bo_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">상세</a> <td class="text-center align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<form action="<?= mgmt_url('bag-orders/cancel/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');"> <td class="text-left pl-2 align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<?= csrf_field() ?> <?php endif; ?>
<button type="submit" class="text-orange-600 hover:underline text-sm mr-1">취소</button> <td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
</form> <td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<form action="<?= mgmt_url('bag-orders/delete/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');"> <td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<?= csrf_field() ?> <td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button> <td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
</form> <td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
</td> <td></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="10" class="text-center text-gray-400 py-4">등록된 발주가 없습니다.</td></tr> <?php if (empty($rows ?? [])): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-6">조회 조건에 해당하는 발주 내역이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
<tfoot>
<tr class="bg-gray-100 font-bold">
<td colspan="3" class="text-center">총계</td>
<td class="text-right"><?= number_format((int) ($grandTotals['order_qty'] ?? 0)) ?></td>
<td class="text-right"><?= number_format((int) ($grandTotals['received_qty'] ?? 0)) ?></td>
<td class="text-right"><?= number_format((int) ($grandTotals['pending_qty'] ?? 0)) ?></td>
<td class="text-right"><?= number_format((float) ($grandTotals['amount'] ?? 0)) ?></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
<?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

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">봉투 단가 등록</span> <span class="text-sm font-bold text-gray-700">봉투 단가 등록</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-prices/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('bag-prices/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

View File

@@ -1,7 +1,7 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span> <span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl"> <div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>

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