39 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-08 15:51:08 +09:00
246 changed files with 39049 additions and 2851 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개)
| 모델 | 테이블 | 용도 | | 모델 | 테이블 | 용도 |

26
app/Config/Kakao.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 카카오 Developers — 내 애플리케이션 — 앱 키 — JavaScript 키
* .env 예: kakao.javascriptKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
*/
class Kakao extends BaseConfig
{
public string $javascriptKey = '';
public function __construct()
{
parent::__construct();
$v = env('kakao.javascriptKey');
if (is_string($v) && $v !== '') {
$this->javascriptKey = $v;
}
}
}

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

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 사용자 매뉴얼(설명서) 목차 정의.
*
* - 배열 순서가 곧 목차(사이드바) 노출 순서입니다.
* - slug 는 URL 세그먼트이자 화이트리스트입니다. 여기에 없는 slug 는 404 입니다.
* - file 은 $dir 하위의 실제 마크다운 파일명입니다(사용자 입력으로 조합하지 않음).
*/
class Manual extends BaseConfig
{
/** 마크다운 콘텐츠 디렉터리 (웹 루트 밖). */
public string $dir = APPPATH . 'Docs/manual/';
/**
* @var array<string, array{title: string, file: string}>
*/
public array $pages = [
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
'account' => ['title' => '로그인·회원가입·계정', 'file' => '01_account.md'],
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
'sales' => ['title' => '판매·반품·불출·주문', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '현황·리포트·수불', 'file' => '50_reports.md'],
'basic' => ['title' => '기본정보(판매소·단가·코드)', 'file' => '60_basic_info.md'],
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'],
];
/**
* 화면 경로(접두) → 그 화면을 설명하는 매뉴얼 slug.
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
*
* @var array<string, string>
*/
public array $screenHelp = [
'bag/order/phone' => 'sales',
'bag/order' => 'order',
'bag/bag-orders' => 'order',
'bag/receiving' => 'order',
'bag/bag-receivings' => 'order',
'bag/inventory' => 'inventory',
'bag/sale' => 'sales',
'bag/sales' => 'sales',
'bag/issue' => 'sales',
'bag/bag-issues' => 'sales',
'bag/bag-sales' => 'sales',
'bag/shop-orders' => 'sales',
'bag/flow' => 'reports',
'bag/reports' => 'reports',
'bag/analytics' => 'reports',
'bag/designated-shops' => 'basic',
'bag/bag-prices' => 'basic',
'bag/prices' => 'basic',
'bag/packaging-units' => 'basic',
'bag/code-kinds' => 'basic',
'bag/code-details' => 'basic',
'bag/managers' => 'basic',
'bag/companies' => 'basic',
'bag/sales-agencies' => 'basic',
'bag/free-recipients' => 'basic',
'bag/number-lookup' => 'codes',
];
}

View File

@@ -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,79 @@ $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/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 +142,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 +167,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,9 +211,11 @@ $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');

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

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

@@ -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) {
@@ -30,18 +48,39 @@ class Menu extends BaseController
->with('error', '메뉴를 관리하려면 먼저 지자체를 선택하세요.'); ->with('error', '메뉴를 관리하려면 먼저 지자체를 선택하세요.');
} }
$types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll(); $types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
$mtIdx = (int) ($this->request->getGet('mt_idx') ?? 0); $requestedMtIdx = (int) ($this->request->getGet('mt_idx') ?? 0);
if ($mtIdx <= 0 && ! empty($types)) { $mtIdx = $this->resolveMtIdx($requestedMtIdx, $types);
// 기본 선택: 사이트 메뉴(mt_code=site), 없으면 첫 번째 타입 $effectiveMtIdx = $mtIdx;
$siteType = $this->typeModel->where('mt_code', 'site')->first(); $debugMode = $this->request->getGet('debug') === '1';
$mtIdx = $siteType ? (int) $siteType->mt_idx : (int) $types[0]->mt_idx; $fallbackApplied = false;
} $list = $effectiveMtIdx > 0 ? $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx) : [];
$list = $mtIdx > 0 ? $this->menuModel->getAllByType($mtIdx, $lgIdx) : []; $currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
$currentTypeCode = (string) ($currentType->mt_code ?? '');
// 현재 지자체에 메뉴가 없으면, mt_idx별로 기본 지자체(lg_idx=1)의 메뉴를 한 번 복사한다. // 현재 지자체에 메뉴가 없으면, mt_idx별로 기본 지자체(lg_idx=1)의 메뉴를 한 번 복사한다.
if ($mtIdx > 0 && empty($list)) { if ($effectiveMtIdx > 0 && empty($list)) {
$this->menuModel->copyDefaultsFromLg($mtIdx, 1, $lgIdx); $this->menuModel->copyDefaultsFromLg($effectiveMtIdx, 1, $lgIdx);
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx); $list = $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx);
}
// 운영 DB 불일치 대응: site 타입인데 mt_idx 매핑이 어긋난 경우(예: menu_type=2, menu는 4 사용)
if (empty($list) && $currentTypeCode === 'site' && $effectiveMtIdx !== 4) {
$fallbackMtIdx = 4;
$fallbackList = $this->menuModel->getAllByType($fallbackMtIdx, $lgIdx);
if (empty($fallbackList)) {
$this->menuModel->copyDefaultsFromLg($fallbackMtIdx, 1, $lgIdx);
$fallbackList = $this->menuModel->getAllByType($fallbackMtIdx, $lgIdx);
}
if (! empty($fallbackList)) {
$effectiveMtIdx = $fallbackMtIdx;
$list = $fallbackList;
$fallbackApplied = true;
}
}
if ($effectiveMtIdx > 0 && $currentTypeCode === 'site') {
$this->menuModel->pruneInventoryManagementMenus($effectiveMtIdx, $lgIdx);
$list = $this->menuModel->getAllByType($effectiveMtIdx, $lgIdx);
} }
// 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화 // 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화
@@ -50,16 +89,24 @@ class Menu extends BaseController
$list = flatten_menu_tree($tree); $list = flatten_menu_tree($tree);
} }
$currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
return view('admin/layout', [ return view('admin/layout', [
'title' => '메뉴 관리', 'title' => '메뉴 관리',
'content' => view('admin/menu/index', [ 'content' => view('admin/menu/index', [
'types' => $types, 'types' => $types,
'mtIdx' => $mtIdx, 'mtIdx' => $mtIdx,
'mtCode' => $currentType->mt_code ?? '', 'mtCode' => $currentTypeCode,
'list' => $list, 'list' => $list,
'levelNames' => config('Roles')->levelNames, 'levelNames' => config('Roles')->levelNames,
'debug_mode' => $debugMode,
'debug_info' => [
'lg_idx' => $lgIdx,
'requested_mt_idx' => $requestedMtIdx,
'resolved_mt_idx' => $mtIdx,
'effective_mt_idx' => $effectiveMtIdx,
'resolved_mt_code' => $currentTypeCode,
'list_count' => count($list),
'fallback_applied' => $fallbackApplied ? 'Y' : 'N',
],
]), ]),
]); ]);
} }
@@ -69,14 +116,23 @@ class Menu extends BaseController
*/ */
public function list() public function list()
{ {
if ($deny = $this->denyUnlessLevel4Plus(true)) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']); return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']);
} }
$mtIdx = (int) $this->request->getGet('mt_idx'); $types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
$requestedMtIdx = (int) $this->request->getGet('mt_idx');
$mtIdx = $this->resolveMtIdx($requestedMtIdx, $types);
if ($mtIdx <= 0) { if ($mtIdx <= 0) {
return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']); return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']);
} }
$type = $this->typeModel->find($mtIdx);
if ($type && (string) ($type->mt_code ?? '') === 'site') {
$this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
}
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx); $list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
return $this->response->setJSON(['status' => 1, 'data' => $list]); return $this->response->setJSON(['status' => 1, 'data' => $list]);
} }
@@ -86,6 +142,9 @@ class Menu extends BaseController
*/ */
public function store() public function store()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -96,10 +155,10 @@ class Menu extends BaseController
$mmDep = (int) $this->request->getPost('mm_dep'); $mmDep = (int) $this->request->getPost('mm_dep');
$mmName = trim((string) $this->request->getPost('mm_name')); $mmName = trim((string) $this->request->getPost('mm_name'));
if ($mtIdx <= 0) { if ($mtIdx <= 0) {
return redirect()->back()->with('error', '메뉴 종류를 선택하세요.'); return $this->menusRedirect($mtIdx)->with('error', '메뉴 종류를 선택하세요.');
} }
if ($mmName === '') { if ($mmName === '') {
return redirect()->back()->with('error', '메뉴명을 입력하세요.'); return $this->menusRedirect($mtIdx)->with('error', '메뉴명을 입력하세요.');
} }
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep); $mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
$data = [ $data = [
@@ -118,7 +177,9 @@ class Menu extends BaseController
if ($mmPidx > 0) { if ($mmPidx > 0) {
$this->menuModel->updateCnode($mmPidx, 1); $this->menuModel->updateCnode($mmPidx, 1);
} }
return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); $this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
$this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx);
return $this->menusRedirect($mtIdx)->with('success', '메뉴가 등록되었습니다.');
} }
/** /**
@@ -126,6 +187,9 @@ class Menu extends BaseController
*/ */
public function update(int $id) public function update(int $id)
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -133,10 +197,12 @@ class Menu extends BaseController
} }
$row = $this->menuModel->find($id); $row = $this->menuModel->find($id);
if (! $row) { if (! $row) {
return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.'); return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '메뉴를 찾을 수 없습니다.');
} }
if ((int) $row->lg_idx !== $lgIdx) { if ((int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.'); return $this->menusRedirect((int) $row->mt_idx)
->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
} }
$data = [ $data = [
'mm_name' => (string) $this->request->getPost('mm_name'), 'mm_name' => (string) $this->request->getPost('mm_name'),
@@ -145,7 +211,9 @@ class Menu extends BaseController
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
]; ];
$this->menuModel->update($id, $data); $this->menuModel->update($id, $data);
return redirect()->back()->with('success', '메뉴가 수정되었습니다.'); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
} }
/** /**
@@ -153,6 +221,9 @@ class Menu extends BaseController
*/ */
public function delete(int $id) public function delete(int $id)
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -160,13 +231,16 @@ class Menu extends BaseController
} }
$row = $this->menuModel->find($id); $row = $this->menuModel->find($id);
if (! $row || (int) $row->lg_idx !== $lgIdx) { if (! $row || (int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.'); return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.');
} }
$result = $this->menuModel->deleteSafe($id); $result = $this->menuModel->deleteSafe($id);
if ($result['ok']) { if ($result['ok']) {
return redirect()->back()->with('success', '메뉴가 삭제되었습니다.'); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 삭제되었습니다.');
} }
return redirect()->back()->with('error', $result['msg']); return $this->menusRedirect((int) $row->mt_idx)->with('error', $result['msg']);
} }
/** /**
@@ -174,17 +248,28 @@ class Menu extends BaseController
*/ */
public function move() public function move()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.'); ->with('error', '지자체를 선택하세요.');
} }
$ids = $this->request->getPost('mm_idx'); $ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_idx');
if (! is_array($ids) || empty($ids)) { if (! is_array($ids) || empty($ids)) {
return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.'); return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
} }
$firstId = (int) ($ids[0] ?? 0);
$firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null;
$this->menuModel->setOrder($ids, $lgIdx); $this->menuModel->setOrder($ids, $lgIdx);
return redirect()->back()->with('success', '순서가 적용되었습니다.'); if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
}
$mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
} }
/** /**
@@ -210,4 +295,57 @@ class Menu extends BaseController
return implode(',', array_values($levels)); return implode(',', array_values($levels));
} }
/**
* 요청된 mt_idx를 현재 DB 상태에 맞게 보정.
* - 유효한 mt_idx면 그대로 사용
* - 레거시 site 값(2) 요청 시 site 타입의 실제 mt_idx로 치환
* - 그 외 미지정/잘못된 값은 site 우선, 없으면 첫 타입으로 보정
*
* @param array<int,object> $types
*/
private function resolveMtIdx(int $requestedMtIdx, array $types): int
{
if (empty($types)) {
return 0;
}
$validTypeIds = array_map(static fn ($t): int => (int) ($t->mt_idx ?? 0), $types);
if ($requestedMtIdx > 0 && in_array($requestedMtIdx, $validTypeIds, true)) {
return $requestedMtIdx;
}
$siteType = $this->typeModel->where('mt_code', 'site')->first();
if ($siteType !== null) {
// 과거 링크(/admin/menus?mt_idx=2) 호환
if ($requestedMtIdx === 2 || $requestedMtIdx <= 0 || ! in_array($requestedMtIdx, $validTypeIds, true)) {
return (int) $siteType->mt_idx;
}
}
return (int) $types[0]->mt_idx;
}
/**
* 메뉴 관리는 레벨4 이상(슈퍼/본부 관리자)만 허용.
*
* @return \CodeIgniter\HTTP\RedirectResponse|\CodeIgniter\HTTP\ResponseInterface|null
*/
private function denyUnlessLevel4Plus(bool $json = false)
{
$level = (int) session()->get('mb_level');
if (Roles::isSuperAdminEquivalent($level)) {
return null;
}
if ($json) {
return $this->response->setJSON([
'status' => 0,
'msg' => '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.',
]);
}
return redirect()->to(base_url('admin'))
->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.');
}
} }

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

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

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

@@ -22,7 +22,10 @@ class Auth extends BaseController
return redirect()->to('/'); return redirect()->to('/');
} }
return view('auth/login'); return view('auth/login', [
'pageTitle' => '로그인 - GBLS',
'cardMax' => 'max-w-md',
]);
} }
public function login() public function login()
@@ -157,6 +160,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 +244,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 +348,8 @@ class Auth extends BaseController
return view('auth/register', [ return view('auth/register', [
'localGovernments' => $localGovernments, 'localGovernments' => $localGovernments,
'pageTitle' => '회원가입 - GBLS',
'cardMax' => 'max-w-md',
]); ]);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -48,17 +48,35 @@ 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/')) {
// 지정판매소 관리는 관리자 전용 기능으로, /bag 경로여도 관리자 레이아웃을 유지한다. $path = substr($path, strlen('index.php/'));
$forceAdminLayoutOnBag = ($seg1 === 'bag' && $seg2 === 'designated-shops'); }
if ($seg1 === 'bag' && ! $forceAdminLayoutOnBag) { if ($path === 'bag' || str_starts_with($path, 'bag/')) {
return view('bag/layout/main', [ // /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal 셸
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,
]); ]);

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,222 @@ class Home extends BaseController
public function index() public function index()
{ {
if (session()->get('logged_in')) { if (session()->get('logged_in')) {
return $this->dashboard(); helper('admin');
// 워크스페이스 탭(iframe) 안: 대시보드 본문을 임베드로.
if ($this->isEmbeddedRequest()) {
return view('bag/layout/embed', [
'title' => '업무 현황',
'bare' => true,
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
]);
}
// 로그인 후 기본 화면 = 워크스페이스(탭). 메뉴를 탭으로 열어 작업 상태 유지.
return view('bag/layout/workspace');
} }
return view('welcome_message'); return view('welcome_message');
} }
/**
* 워크스페이스 — 메뉴를 탭(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 +239,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 +313,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 +457,14 @@ class Home extends BaseController
protected function resolveLgLabel(): string protected function resolveLgLabel(): string
{ {
try { try {
$idx = session()->get('mb_lg_idx'); helper('admin');
if ($idx === null || $idx === '') { $idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
return '로그인 지자체 (미지정)'; if ($idx === null) {
$raw = session()->get('mb_lg_idx');
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
}
if ($idx === null) {
return '지자체 미지정';
} }
$row = model(LocalGovernmentModel::class)->find((int) $idx); $row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') { if ($row && isset($row->lg_name) && $row->lg_name !== '') {
@@ -115,7 +474,7 @@ class Home extends BaseController
// 테이블 미생성 등 // 테이블 미생성 등
} }
return '북구 (데모)'; return '지자체';
} }
} }

View File

@@ -0,0 +1,50 @@
# 시작하기 · 시스템 개요
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. **이 시스템을 처음 쓰는 분**도 화면을 이해하고 업무를 처리할 수 있도록, 화면마다 쓰이는 용어와 버튼의 의미를 설명합니다.
## 1. 이 시스템은 무엇을 하나요?
지자체가 주민에게 파는 **종량제 쓰레기봉투**가 ① 제작업체에 **주문(발주)** 되고 → ② 창고로 **들어오고(입고)** → ③ **재고**로 보관되다가 → ④ 동네 가게(지정판매소)에 **팔리거나(판매)** 무료 대상자에게 **나눠지는(불출)** 전 과정을 컴퓨터로 관리합니다. 마지막엔 ⑤ 얼마나 팔렸는지 **집계·정산(현황/리포트)** 합니다.
## 2. 꼭 알아야 할 기본 용어 (용어 사전)
| 용어 | 쉬운 설명 |
|---|---|
| **발주** | 봉투를 제작업체에 "이만큼 만들어 주세요"라고 **주문**하는 것 |
| **입고** | 주문한 봉투가 창고에 **도착해 들여놓는** 것 |
| **재고** | 지금 창고에 **남아 있는 봉투 수량** |
| **불출** | 봉투를 창고에서 **꺼내 내보내는** 것 (주로 무료 배부) |
| **수불(受拂)** | **들어오고(수입)·나가는(불출)** 움직임을 적은 장부 |
| **지정판매소** | 봉투를 파는 **동네 가게**(편의점·마트 등) |
| **대행소(판매대행소)** | 봉투 **배송·유통을 대행**하는 업체 |
| **실사** | 컴퓨터 기록과 **실제 창고 수량이 맞는지 직접 세어 확인**하는 것 |
| **박스 / 팩 / 낱장** | 포장 단위. **박스 > 팩 > 낱장(봉투 1장)**. 1박스 = 여러 팩, 1팩 = 여러 낱장 |
| **LOT(로트)** | 한 번의 발주 묶음에 부여되는 **추적용 일련번호** |
| **바코드(봉투번호)** | 박스·팩·낱장마다 붙는 **고유 번호**(스캔용) |
| **무료용 / 공공용** | 주민 무료 배부용 / 공공기관 사용용 봉투 구분 |
> 박스·팩·낱장·LOT·바코드의 자세한 규칙은 좌측 목차 **[봉투·LOT·바코드 코드체계]** 참고.
## 3. 화면 구성과 사용법
- 로그인하면 **워크스페이스**(탭 작업공간)가 열립니다. 상단에 대분류 메뉴, **대분류를 클릭하면 왼쪽에 소메뉴**가 펼쳐집니다.
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
> 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
## 4. 사용자 역할(권한)
| 역할 | 할 수 있는 일 |
|---|---|
| 일반 사용자 | 기본 조회 |
| 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
## 5. 화면별 설명은 어디에?
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
- **발주·입고** / **재고·실사** / **판매·반품·불출·주문** / **현황·리포트·수불** / **기본정보(판매소·단가·코드)**
각 화면 설명은 **그 화면 고유의 용어·입력 항목·버튼·작업 순서**만 담았습니다.

View File

@@ -0,0 +1,61 @@
# 로그인 · 회원가입 · 계정
시스템을 쓰려면 **계정(아이디)** 이 필요합니다. 이 페이지는 회원가입부터 로그인, 2차 인증, 로그아웃까지의 과정을 설명합니다.
## 1. 회원가입
로그인 화면 아래쪽 **[회원가입]** 을 눌러 신청합니다. 입력 항목은 다음과 같습니다.
| 항목 | 필수 | 설명 |
|---|:---:|---|
| **아이디** | 필수 | 로그인에 쓰는 ID. **4자 이상**, 이미 쓰는 아이디는 사용할 수 없습니다. |
| **비밀번호** | 필수 | **4자 이상**. 안전을 위해 영문·숫자·기호를 섞는 것을 권장합니다. |
| **비밀번호 확인** | 필수 | 위 비밀번호와 똑같이 한 번 더 입력(오타 방지). |
| **이름** | 필수 | 담당자 이름. |
| **이메일** | 선택 | 안내용. 입력 시 형식 검사를 합니다. |
| **연락처** | 선택 | 전화번호. |
| **지자체** | 선택 | 소속 지자체. 해당되면 선택합니다. |
| **사용자 역할** | 필수 | 신청할 권한(아래 표 참고). 실제 권한은 **관리자 승인 시 확정**됩니다. |
> 이메일·연락처 같은 개인정보는 **암호화되어 저장**됩니다.
### 신청할 수 있는 역할
| 역할 | 주로 하는 일 |
|---|---|
| **일반 사용자** | 기본 조회 |
| **지정판매소** | 봉투 판매·반품, 자기 판매 현황 조회 |
| **지자체 관리자** | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
### 가입 후 — 관리자 승인 대기
회원가입을 제출하면 **바로 로그인되지 않고 "승인 대기" 상태**가 됩니다.
- **관리자가 승인하면** 그때부터 로그인할 수 있습니다.
- 승인 전에 로그인하면 *"관리자 승인 후 로그인 가능합니다."* 안내가 나옵니다.
- 승인이 **반려**되면 *"승인이 반려되었습니다. 관리자에게 문의해 주세요."* 가 나옵니다 — 담당 관리자에게 문의하세요.
## 2. 로그인
로그인 화면에서 **아이디**와 **비밀번호**를 입력합니다.
- 성공하면 **워크스페이스**(탭 작업공간)로 들어갑니다.
- 아이디·비밀번호가 틀리면 안내 메시지가 나옵니다. (승인 대기/반려 상태도 위와 같이 안내됩니다.)
### 2차 인증(OTP)
보안 설정에 따라 비밀번호 확인 뒤 **2차 인증** 단계가 나올 수 있습니다.
- **이미 OTP를 등록한 경우** — 스마트폰 인증 앱(예: Google Authenticator)에 표시되는 **6자리 숫자**를 입력합니다.
- **처음 사용하는 경우** — 화면의 **QR코드 또는 설정 키**를 인증 앱에 등록한 뒤, 앱에 나온 6자리 숫자로 설정을 완료합니다. 이후 로그인부터 OTP를 입력하게 됩니다.
> OTP 숫자는 일정 시간마다 바뀌므로, **현재 표시된 숫자**를 입력해야 합니다. 휴대폰 시간이 자동(네트워크 시간)으로 맞춰져 있어야 정확합니다.
## 3. 로그아웃
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
## 4. 비밀번호·계정 문제
- **비밀번호를 바꾸거나 분실**한 경우, 계정·권한 변경은 **담당 관리자**가 처리합니다. 관리자에게 문의하세요.
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.

View File

@@ -0,0 +1,92 @@
# 화면 구성 · 워크스페이스 · 단축키
이 시스템은 **여러 작업 화면을 탭으로 열어 두고 오가며** 일할 수 있도록 만들어졌습니다(웹 브라우저의 탭과 비슷합니다). 이 페이지는 화면이 어떻게 구성되는지, 탭을 어떻게 쓰는지, 그리고 빠르게 쓰는 단축키를 설명합니다.
## 1. 전체 화면 구성
로그인하면 **워크스페이스**(탭 작업공간)가 기본으로 열립니다. 화면은 크게 네 부분입니다.
| 영역 | 위치 | 설명 |
|---|---|---|
| **상단 헤더** | 맨 위 | 로고, **대분류 메뉴**, 오른쪽에 소속 지자체·접속자·관리자·로그아웃 |
| **왼쪽 사이드바** | 좌측 | 현재 선택한 대분류의 **소메뉴 목록**. 아래에는 매뉴얼·FAQ 링크 |
| **탭바** | 본문 위 | 지금 열어 둔 작업 화면들의 **탭 목록** |
| **본문** | 가운데 | 현재 탭의 실제 작업 화면 |
> **대분류를 클릭하면** 왼쪽 사이드바에 그 안의 소메뉴가 펼쳐집니다. 현재 위치한 메뉴는 왼쪽에서 **▸** 로 표시됩니다.
## 2. 탭(워크스페이스) 사용법
- **메뉴를 클릭하면 탭으로 열립니다.** 같은 메뉴를 다시 누르면 새 탭을 또 만들지 않고 **이미 열린 탭으로 이동**합니다.
- **탭을 전환해도 작업 내용이 유지됩니다.** A 화면에 무언가 입력하다가 B 화면을 잠깐 보고 와도, A 탭의 입력은 그대로 남아 있습니다.
- **탭 ↔ 왼쪽 메뉴 연동:** 탭을 전환하면 왼쪽 사이드바의 강조 위치도 그 탭의 메뉴로 **자동으로 따라갑니다.**
- 탭이 많아지면 가로로 스크롤되며, **최대 12개**까지 열 수 있습니다. 12개를 넘기면 가장 오래된 탭이 자동으로 닫힙니다.
### 탭의 버튼
| 버튼 | 동작 |
|---|---|
| **↻** (탭 위) | 그 **탭만** 새로고침합니다. 다른 탭은 그대로 둡니다. |
| **×** (탭 위) | 그 탭을 닫습니다. |
| 탭을 **가운데(휠) 클릭** | 마우스 휠을 누르면 그 탭이 닫힙니다(브라우저와 동일). |
| 탭에 **마우스를 올리면** | 이름이 길어 잘렸을 때 **전체 제목**이 말풍선으로 보입니다. |
### 탭이 유지되는 범위
- **브라우저를 새로고침**하거나 **관리자 페이지에 갔다가 돌아와도** 열어 두었던 탭이 **다시 복원**됩니다.
- 단, **브라우저 탭(창)을 완전히 닫으면** 작업공간은 초기화됩니다. (이 유지는 "이번 접속 동안"만 적용됩니다.)
- **다른 아이디로 로그인하면** 이전 사용자의 탭은 복원되지 않고 **기본 화면으로 초기화**됩니다. (계정별로 분리됩니다.)
- 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.**
## 3. 분할 보기 (여러 화면 한눈에)
여러 화면을 **동시에 펼쳐 놓고** 비교하거나 함께 작업할 수 있습니다. 탭바 오른쪽의 **분할 버튼**으로 화면을 나눕니다.
| 버튼 | 모양 | 설명 |
|---|---|---|
| **1분할** | □ | 한 화면만 크게 (기본) |
| **2분할 (좌우)** | ◫ | 화면을 왼쪽/오른쪽 두 칸으로 |
| **2분할 (상하)** | ⬓ | 화면을 위/아래 두 칸으로 |
| **4분할** | ⊞ | 2×2 네 칸으로 |
### 칸에 화면 배치하기
- 분할하면 열어 둔 화면들이 칸에 자동으로 채워집니다. 빈 칸에는 안내 문구가 표시됩니다.
- **칸을 클릭하면 그 칸이 "선택"**(파란 테두리)됩니다. 이 상태에서 **위 탭바의 탭을 클릭**하면 그 화면이 **선택된 칸**에 들어갑니다.
- 각 칸 위의 **헤더**에는 화면 이름과 함께 **↻(이 칸 새로고침)·×(이 칸 비우기)** 버튼이 있습니다.
### 칸 크기 조절
- 칸 사이의 **구분선에 마우스를 올리면 ↔/↕ 커서**가 나타납니다. **드래그**하면 칸 크기(비율)를 자유롭게 조절할 수 있습니다.
- 구분선을 **더블클릭**하면 **50:50으로 초기화**됩니다.
- 조절한 분할 모양·크기도 새로고침·관리자 왕복 후 **그대로 복원**됩니다.
> 분할 상태에서도 각 화면의 작업 내용은 그대로 유지됩니다. 마음껏 나눴다 합쳤다 해도 입력하던 내용이 사라지지 않습니다.
## 4. 키보드 단축키
자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다.
동작은 **현재 선택된 칸**을 기준으로 적용됩니다(1분할이면 그 한 화면).
| 단축키 | 동작 |
|---|---|
| **Alt + 1 ~ 9** | **n번째 탭**을 선택된 칸에 표시 |
| **Alt + W** | 선택된 칸의 **탭 닫기** |
| **Alt + ]** | 선택된 칸을 **다음 탭**으로 |
| **Alt + [** | 선택된 칸을 **이전 탭**으로 |
> macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1).
>
> 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다.
## 5. 그 밖의 이동
- **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다.
- **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다.
- **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다.
## 6. 도움말 보는 법
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
# 봉투 · LOT · 바코드 코드체계
봉투번호(바코드)·LOT 번호·품목코드가 무엇을 뜻하는지 정리한 안내입니다. LOT 수불 조회나 번호알기 화면에서 코드를 입력할 때 참고하세요.
## 추적 단위 계층
```
LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장)
```
| 단위 | 예시 | 용도 |
|---|---|---|
| LOT | `OQXCKH` | 발주 단위, LOT 수불·시드 |
| 박스 | `OQXCKH-000008-B001` | 박스 단위 입출고 |
| 팩 | `OQXCKH-000008-P299` | 팩 단위(스캔 가능) |
| 낱장 | `OQXCKH-000008-P299-S00125` | 봉투 한 장 단위 판매·반품 |
> `000008`은 "8번째 봉투"가 아니라 **입고 건 번호를 6자리로 채운 값**입니다.
## 바코드 형식 읽는 법
`OQXCKH-000008-P299-S00125` 를 예로 들면:
| 구간 | 값 | 의미 |
|---|---|---|
| 접두 | `OQXCKH` | 발주 LOT 번호 |
| 입고번호 | `000008` | 입고 건 번호(6자리) |
| 팩 | `P299` | 그 입고 건의 299번째 팩 |
| 낱장 | `S00125` | 그 팩의 125번째 낱장(봉투 한 장) |
- 박스는 `B001`, 팩은 `P299` 처럼 접두 문자로 구분합니다.
- LOT 번호는 발주 시 자동 부여되는 영문·숫자 6자리입니다.
## 품목코드 (봉투 종류)
품목코드는 봉투의 **종류·용량**을 식별하는 마스터 코드로, 바코드와는 별개입니다.
| 자리 | 의미 | 예 |
|---|---|---|
| 앞 2자리 | 봉투 구분(10 일반, 20 공공, 30 무료 …) | `10` |
| 다음 2자리 | 용량 | `15` |
| 마지막 1자리 | 재질 등 | `2` |
**예:** `10152` = 일반용 20L. 같은 품목이라도 발주·입고 건마다 바코드(LOT) 접두는 달라집니다.
## 번호알기로 확인하기
코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 번호알기(봉투번호확인)** 화면에 코드를 입력하세요.
입력 가능한 형식:
| 단위 | 입력 형식 | 예시 |
|---|---|---|
| 낱장 | `{LOT}-{입고번호}-P{팩}-S{낱장}` | `OQXCKH-000008-P299-S00125` |
| 팩 | `{LOT}-{입고번호}-P{팩}` | `OQXCKH-000008-P299` |
| 박스 | `{LOT}-{입고번호}-B{박스}` | `OQXCKH-000008-B001` |
| LOT | 영문·숫자 4~8자리 | `OQXCKH` |

29
app/Docs/manual/99_faq.md Normal file
View File

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

View File

@@ -151,17 +151,30 @@ if (! function_exists('get_site_nav_tree')) {
{ {
try { try {
$lgIdx = resolve_site_menu_lg_idx(); $lgIdx = resolve_site_menu_lg_idx();
$typeRow = model(\App\Models\MenuTypeModel::class)->getByCode('site');
if (! $typeRow) {
return [];
}
$mbLevel = (int) session()->get('mb_level'); $mbLevel = (int) session()->get('mb_level');
$menuModel = model(\App\Models\MenuModel::class); $menuModel = model(\App\Models\MenuModel::class);
$flat = $menuModel->getVisibleByLevel((int) $typeRow->mt_idx, $mbLevel, (int) $lgIdx); $typeRow = model(\App\Models\MenuTypeModel::class)->getByCode('site');
$siteMtIdx = $typeRow ? (int) $typeRow->mt_idx : 0;
if ($siteMtIdx <= 0) {
// 운영 DB 불일치 대비: menu_type 누락 시 legacy site mt_idx(4)로 시도
$siteMtIdx = 4;
}
$flat = $menuModel->getVisibleByLevel($siteMtIdx, $mbLevel, (int) $lgIdx);
// 현재 지자체에 site 메뉴가 없으면, 기본 지자체(1)의 메뉴를 한 번 복사한 뒤 다시 시도 // 현재 지자체에 site 메뉴가 없으면, 기본 지자체(1)의 메뉴를 한 번 복사한 뒤 다시 시도
if (empty($flat)) { if (empty($flat)) {
$menuModel->copyDefaultsFromLg((int) $typeRow->mt_idx, 1, (int) $lgIdx); $menuModel->copyDefaultsFromLg($siteMtIdx, 1, (int) $lgIdx);
$flat = $menuModel->getVisibleByLevel((int) $typeRow->mt_idx, $mbLevel, (int) $lgIdx); $flat = $menuModel->getVisibleByLevel($siteMtIdx, $mbLevel, (int) $lgIdx);
}
// site 타입 매핑 불일치(예: menu_type=2, menu 데이터=4) 보정
if (empty($flat) && $siteMtIdx !== 4) {
$legacyMtIdx = 4;
$flat = $menuModel->getVisibleByLevel($legacyMtIdx, $mbLevel, (int) $lgIdx);
if (empty($flat)) {
$menuModel->copyDefaultsFromLg($legacyMtIdx, 1, (int) $lgIdx);
$flat = $menuModel->getVisibleByLevel($legacyMtIdx, $mbLevel, (int) $lgIdx);
}
} }
if (empty($flat)) { if (empty($flat)) {
return []; return [];
@@ -251,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') {
@@ -266,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));
}
} }
} }
@@ -354,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/')) {
@@ -462,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')) {
/** /**
* 상단 메뉴바용: 로그인 사용자 이름·역할 표시 * 상단 메뉴바용: 로그인 사용자 이름·역할 표시
@@ -488,3 +594,374 @@ 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 ?? [];
$bestSlug = '';
$bestLen = -1;
foreach ($map as $prefix => $slug) {
$p = strtolower((string) $prefix);
if ($path === $p || str_starts_with($path . '/', $p . '/')) {
if (strlen($p) > $bestLen) {
$bestLen = strlen($p);
$bestSlug = (string) $slug;
}
}
}
return $bestSlug !== '' ? base_url('bag/manual/' . $bestSlug) : '';
}
}

View File

@@ -67,3 +67,441 @@ if (! function_exists('csv_encode_row')) {
return implode(',', $escaped) . "\r\n"; return implode(',', $escaped) . "\r\n";
} }
} }
if (! function_exists('export_excel_2003_xml')) {
/**
* Excel 2003 XML(SpreadsheetML)로 브라우저 다운로드 (.xls 확장자, 별도 라이브러리 불필요)
*
* @param string $filename 저장 파일명(확장자는 .xls로 정규화)
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
* @param string[] $headers 컬럼 헤더
* @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력)
*/
function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows): void
{
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
$safeSheet = function_exists('mb_substr')
? mb_substr($safeSheet, 0, 31, 'UTF-8')
: substr($safeSheet, 0, 31);
$esc = static function (mixed $v): string {
return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
};
$parts = [];
$parts[] = '<?xml version="1.0" encoding="UTF-8"?>';
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
$parts[] = '<Table>';
$parts[] = '<Row>';
foreach ($headers as $h) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
}
$parts[] = '</Row>';
foreach ($rows as $row) {
$parts[] = '<Row>';
foreach (array_values((array) $row) as $cell) {
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '</Table>';
$parts[] = '</Worksheet>';
$parts[] = '</Workbook>';
$output = implode('', $parts);
$response = service('response');
$response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8');
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$response->setHeader('Pragma', 'no-cache');
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
$response->setBody($output);
$response->send();
exit;
}
}
if (! function_exists('export_excel_2003_xml_workbook')) {
/**
* Excel 2003 XML — 다중 시트, 인쇄 미리보기와 유사한 헤더·줄바꿈·열 너비
*
* @param string $filename 저장 파일명
* @param list<array{name: string, headers: list<string>, rows: list<list<string>>, col_widths?: list<int>}> $sheets
*/
function export_excel_2003_xml_workbook(string $filename, array $sheets): void
{
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
$esc = static function (mixed $v): string {
return htmlspecialchars((string) ($v ?? ''), ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
};
$safeSheetName = static function (string $name) use ($esc): string {
$safe = str_replace(['/', '\\', '?', '*', '[', ']', ':'], '', $name);
$safe = function_exists('mb_substr') ? mb_substr($safe, 0, 31, 'UTF-8') : substr($safe, 0, 31);
return $esc($safe !== '' ? $safe : 'Sheet');
};
$parts = [];
$parts[] = '<?xml version="1.0" encoding="UTF-8"?>';
$parts[] = '<?mso-application progid="Excel.Sheet"?>';
$parts[] = '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">';
$parts[] = '<Styles>';
$parts[] = '<Style ss:ID="Default"><Alignment ss:Vertical="Top" ss:WrapText="1" ss:Horizontal="Left"/><Font ss:FontName="맑은 고딕" x:CharSet="129" ss:Size="9"/></Style>';
$parts[] = '<Style ss:ID="Header"><Font ss:Bold="1" ss:Size="9" ss:FontName="맑은 고딕" x:CharSet="129"/><Interior ss:Color="#F3F4F6" ss:Pattern="Solid"/><Alignment ss:Horizontal="Left" ss:Vertical="Center" ss:WrapText="1"/><Borders><Border ss:Position="Bottom" ss:LineStyle="Continuous" ss:Weight="1"/></Borders></Style>';
$parts[] = '</Styles>';
foreach ($sheets as $sheet) {
$sheetName = $safeSheetName((string) ($sheet['name'] ?? 'Sheet'));
$headers = array_values((array) ($sheet['headers'] ?? []));
$rows = (array) ($sheet['rows'] ?? []);
$colWidths = array_values((array) ($sheet['col_widths'] ?? []));
$parts[] = '<Worksheet ss:Name="' . $sheetName . '">';
$parts[] = '<WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><PageSetup><Layout x:Orientation="Landscape"/></PageSetup></WorksheetOptions>';
$parts[] = '<Table>';
$colCount = max(count($headers), 1);
for ($i = 0; $i < $colCount; $i++) {
$px = (int) ($colWidths[$i] ?? 72);
$width = max(48, min(280, $px));
$excelW = round($width / 6.5, 1);
$parts[] = '<Column ss:Index="' . ($i + 1) . '" ss:AutoFitWidth="0" ss:Width="' . $excelW . '"/>';
}
$parts[] = '<Row ss:StyleID="Header">';
foreach ($headers as $h) {
$parts[] = '<Cell ss:StyleID="Header"><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
}
$parts[] = '</Row>';
foreach ($rows as $row) {
$parts[] = '<Row>';
foreach (array_values((array) $row) as $cell) {
$parts[] = '<Cell ss:StyleID="Default"><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
}
$parts[] = '</Row>';
}
$parts[] = '</Table>';
$parts[] = '</Worksheet>';
}
$parts[] = '</Workbook>';
$output = implode('', $parts);
$response = service('response');
$response->setHeader('Content-Type', 'application/vnd.ms-excel; charset=UTF-8');
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$response->setHeader('Pragma', 'no-cache');
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
$response->setBody($output);
$response->send();
exit;
}
}
if (! function_exists('bag_flow_report_build_spreadsheet')) {
/**
* 기간별 봉투 수불 엑셀 통합문서 생성 (PhpSpreadsheet — 열 너비·병합 안정)
*
* @param list<array<string, mixed>> $reportRows
* @param list<string> $metaLines
*/
function bag_flow_report_build_spreadsheet(
string $lgName,
string $title,
array $metaLines,
array $reportRows
): \PhpOffice\PhpSpreadsheet\Spreadsheet {
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet->getDefaultStyle()->getFont()->setName('맑은 고딕')->setSize(10);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('수불현황');
$bodyFontSize = 10;
$lastCol = 'N';
$colWidths = [
'A' => 22.0,
'B' => 26.0,
'C' => 12.0,
'D' => 11.0,
'E' => 11.0,
'F' => 11.0,
'G' => 12.0,
'H' => 11.0,
'I' => 12.0,
'J' => 12.0,
'K' => 12.0,
'L' => 11.0,
'M' => 12.0,
'N' => 12.0,
];
foreach ($colWidths as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
$sheet->getColumnDimension($col)->setAutoSize(false);
}
$r = 1;
if ($lgName !== '') {
$sheet->setCellValue("A{$r}", $lgName);
$sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('666666');
$r++;
}
$sheet->setCellValue("A{$r}", $title);
$sheet->mergeCells("A{$r}:{$lastCol}{$r}");
$sheet->getStyle("A{$r}")->getFont()->setBold(true)->setSize($bodyFontSize);
$r++;
foreach ($metaLines as $line) {
$sheet->setCellValue("A{$r}", $line);
$sheet->mergeCells("A{$r}:{$lastCol}{$r}");
$sheet->getStyle("A{$r}")->getFont()->setSize(10)->getColor()->setRGB('555555');
$r++;
}
$r++;
$h1 = $r;
$h2 = $r + 1;
$sheet->setCellValue("A{$h1}", '일자');
$sheet->mergeCells("A{$h1}:A{$h2}");
$sheet->setCellValue("B{$h1}", '품목');
$sheet->mergeCells("B{$h1}:B{$h2}");
$sheet->setCellValue("C{$h1}", '전일');
$sheet->mergeCells("C{$h1}:C{$h2}");
$sheet->setCellValue("D{$h1}", '입고');
$sheet->mergeCells("D{$h1}:G{$h1}");
$sheet->setCellValue("H{$h1}", '출고');
$sheet->mergeCells("H{$h1}:M{$h1}");
$sheet->setCellValue("N{$h1}", '잔량');
$sheet->mergeCells("N{$h1}:N{$h2}");
$subHeaders = ['입고', '반품', '기타', '입계', '판매', '일반', '무료', '반품', '기타', '출계'];
foreach ($subHeaders as $i => $label) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(4 + $i);
$sheet->setCellValue("{$col}{$h2}", $label);
}
$headerStyle = [
'font' => ['bold' => true, 'size' => $bodyFontSize],
'alignment' => [
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
'wrapText' => false,
],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E9ECEF'],
],
'borders' => [
'bottom' => ['borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN],
],
];
$sheet->getStyle("A{$h1}:{$lastCol}{$h2}")->applyFromArray($headerStyle);
$dataRow = $h2 + 1;
foreach ($reportRows as $row) {
$rowType = (string) ($row['row_type'] ?? 'data');
if (! in_array($rowType, ['data', 'subtotal', 'grand'], true)) {
continue;
}
$sheet->fromArray([
(string) ($row['date'] ?? ''),
(string) ($row['item_name'] ?? ''),
(int) ($row['prev_stock'] ?? 0),
(int) ($row['recv_in'] ?? 0),
(int) ($row['recv_return'] ?? 0),
(int) ($row['recv_misc'] ?? 0),
(int) ($row['recv_total'] ?? 0),
(int) ($row['out_sale'] ?? 0),
(int) ($row['out_issue_gen'] ?? 0),
(int) ($row['out_issue_free'] ?? 0),
(int) ($row['out_return'] ?? 0),
(int) ($row['out_misc'] ?? 0),
(int) ($row['out_total'] ?? 0),
(int) ($row['balance'] ?? 0),
], null, "A{$dataRow}", true);
$sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}")
->getNumberFormat()
->setFormatCode('#,##0');
$sheet->getStyle("C{$dataRow}:{$lastCol}{$dataRow}")
->getAlignment()
->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle("A{$dataRow}:B{$dataRow}")
->getAlignment()
->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT)
->setWrapText(false);
$sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}")
->getFont()
->setSize($bodyFontSize);
if (in_array($rowType, ['subtotal', 'grand'], true)) {
$sheet->getStyle("A{$dataRow}:{$lastCol}{$dataRow}")->applyFromArray([
'font' => ['bold' => true, 'size' => $bodyFontSize],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => 'FFF8E1'],
],
]);
}
$dataRow++;
}
if ($dataRow > $h2 + 1) {
$sheet->getStyle('A' . ($h2 + 1) . ':' . $lastCol . ($dataRow - 1))
->getBorders()
->getAllBorders()
->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_HAIR);
}
$sheet->getPageSetup()->setOrientation(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::ORIENTATION_PORTRAIT);
$sheet->getPageSetup()->setPaperSize(\PhpOffice\PhpSpreadsheet\Worksheet\PageSetup::PAPERSIZE_A4);
$sheet->getPageSetup()->setFitToWidth(1);
$sheet->getPageSetup()->setFitToHeight(0);
return $spreadsheet;
}
}
if (! function_exists('export_bag_flow_report_excel')) {
/**
* 기간별 봉투 수불 (/bag/flow) — 인쇄와 동일한 헤더·2단 표 (xlsx, PhpSpreadsheet)
*
* @param list<array<string, mixed>> $reportRows
* @param list<string> $metaLines
*/
function export_bag_flow_report_excel(
string $filename,
string $lgName,
string $title,
array $metaLines,
array $reportRows
): void {
$baseName = preg_replace('/\.[^.]+$/u', '', $filename);
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow';
$baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow';
$filename = $baseName . '.xlsx';
$spreadsheet = bag_flow_report_build_spreadsheet($lgName, $title, $metaLines, $reportRows);
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
ob_start();
try {
$writer->save('php://output');
} catch (\Throwable $e) {
ob_end_clean();
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
throw $e;
}
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
$output = ob_get_clean();
if ($output === false) {
$output = '';
}
$response = service('response');
$response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$asciiName = preg_replace('/[^\x20-\x7E]+/', '_', $filename) ?? 'bag_flow.xlsx';
$response->setHeader(
'Content-Disposition',
'attachment; filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename)
);
$response->setHeader('Pragma', 'no-cache');
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
$response->setBody($output);
$response->send();
exit;
}
}
if (! function_exists('export_xlsx')) {
/**
* Office Open XML(.xlsx) 브라우저 다운로드 (PhpSpreadsheet)
*
* @param string $filename 저장 파일명(확장자는 .xlsx로 정규화)
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
* @param string[] $headers 컬럼 헤더
* @param array $rows 데이터 행(각 행은 배열)
*/
function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void
{
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
$safeSheet = function_exists('mb_substr')
? mb_substr($safeSheet, 0, 31, 'UTF-8')
: substr($safeSheet, 0, 31);
if ($safeSheet === '') {
$safeSheet = 'Sheet1';
}
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($safeSheet);
$data = [array_map(static fn ($v): string => (string) ($v ?? ''), array_values($headers))];
foreach ($rows as $row) {
$data[] = array_map(static fn ($v): string => (string) ($v ?? ''), array_values((array) $row));
}
$sheet->fromArray($data, null, 'A1', true);
$headerCount = max(1, count($headers));
$rowCount = max(1, count($data));
$lastCol = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($headerCount);
$fullRange = 'A1:' . $lastCol . $rowCount;
// 헤더/데이터 모두 좌측 정렬(요구사항)
$sheet->getStyle($fullRange)->getAlignment()->setHorizontal(
\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_LEFT
);
// 가독성을 위해 기본 열 너비를 넓게 지정
for ($i = 1; $i <= $headerCount; $i++) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i);
$sheet->getColumnDimension($col)->setWidth(22);
}
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
ob_start();
try {
$writer->save('php://output');
} catch (\Throwable $e) {
ob_end_clean();
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
throw $e;
}
$spreadsheet->disconnectWorksheets();
unset($spreadsheet);
$output = ob_get_clean();
if ($output === false) {
$output = '';
}
$response = service('response');
$response->setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$response->setHeader('Pragma', 'no-cache');
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
$response->setBody($output);
$response->send();
exit;
}
}

View File

@@ -0,0 +1,660 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 통계 분석 관리 (전년대비 / 월별·계절별 추이)
*
* 월별·계절별 추이·전년대비: bs_type = sale 판매량·판매금액만 집계 (반품·취소 제외)
*/
class BagAnalyticsReportBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
/** @var array<string, string> */
private array $bagNames = [];
/** 판매(bs_type=sale) 낱장 수량만 합산 */
private function saleQtySql(): string
{
return "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END";
}
/**
* @return array<string, array{label: string, months_label: string, months: list<int>, cross_year: bool}>
*/
public static function seasonCatalog(): array
{
return [
'spring' => ['label' => '봄', 'months_label' => '3~5월', 'months' => [3, 4, 5], 'cross_year' => false],
'summer' => ['label' => '여름', 'months_label' => '6~8월', 'months' => [6, 7, 8], 'cross_year' => false],
'autumn' => ['label' => '가을', 'months_label' => '9~11월', 'months' => [9, 10, 11], 'cross_year' => false],
'winter' => ['label' => '겨울', 'months_label' => '전년12·1~2월', 'months' => [12, 1, 2], 'cross_year' => true],
];
}
public static function normalizeSeason(string $season): string
{
$raw = trim($season);
$aliases = [
'봄' => 'spring',
'여름' => 'summer',
'가을' => 'autumn',
'겨울' => 'winter',
];
$key = $aliases[$raw] ?? strtolower($raw);
$catalog = self::seasonCatalog();
return isset($catalog[$key]) ? $key : 'spring';
}
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
/**
* @return array{
* gugunOptions: list<array{code: string, name: string}>,
* agencies: list<object>,
* lgName: string,
* gugunLabel: string
* }
*/
public function loadFilterOptions(int $lgIdx): array
{
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
$lgName = $lgRow ? trim((string) ($lgRow->lg_name ?? '')) : '';
$gugunRows = $this->db->query("
SELECT DISTINCT ds_gugun_code AS code
FROM designated_shop
WHERE ds_lg_idx = ? AND ds_gugun_code != ''
ORDER BY ds_gugun_code
", [$lgIdx])->getResultArray();
$gugunOptions = [['code' => '', 'name' => '전체']];
foreach ($gugunRows as $row) {
$code = trim((string) ($row['code'] ?? ''));
if ($code === '') {
continue;
}
$gugunOptions[] = ['code' => $code, 'name' => $this->gugunLabel($lgIdx, $code)];
}
$agencies = model(\App\Models\SalesAgencyModel::class)
->where('sa_lg_idx', $lgIdx)
->orderForDisplay()
->findAll();
return [
'gugunOptions' => $gugunOptions,
'agencies' => $agencies,
'lgName' => $lgName,
'gugunLabel' => '',
];
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* months: list<int>,
* prevYear: int,
* year: int
* }
*/
public function buildYearOverYear(
int $lgIdx,
int $year,
string $gugunCode,
int $dsIdx,
bool $queried
): array {
$prevYear = $year - 1;
$months = range(1, 12);
if (! $queried) {
return ['rows' => [], 'months' => $months, 'prevYear' => $prevYear, 'year' => $year];
}
$this->loadBagNames($lgIdx);
$agg = $this->aggregateMonthlyByBag($lgIdx, $prevYear, $year, $gugunCode, $dsIdx);
$rows = [];
$codesFromAgg = array_map(static fn ($c): string => (string) $c, array_keys($agg));
foreach ($this->bagCodesForReport($lgIdx, $codesFromAgg) as $code) {
$code = (string) $code;
$name = $this->resolveBagName($code);
$rows[] = $this->yoyBlock($code, (string) $name, '수량', $agg, $prevYear, $year, $months, false);
$rows[] = $this->yoyBlock($code, (string) $name, '금액', $agg, $prevYear, $year, $months, true);
}
return ['rows' => $rows, 'months' => $months, 'prevYear' => $prevYear, 'year' => $year];
}
/**
* @return array{rows: list<array<string, mixed>>, meta: array<string, int>}
*/
public function buildMonthlyTrend(
int $lgIdx,
string $baseYm,
string $trendBasis,
float $deviationMin,
string $gugunCode,
int $saIdx,
bool $queried
): array {
$empty = ['rows' => [], 'meta' => ['shopCount' => 0, 'monthSalesShops' => 0]];
if (! $queried || ! preg_match('/^(\d{4})-(\d{2})$/', $baseYm, $m)) {
return $empty;
}
$year = (int) $m[1];
$month = (int) $m[2];
$shops = $this->loadShops($lgIdx, $gugunCode, $saIdx);
if ($shops === []) {
return $empty;
}
$monthlyByShop = $this->monthlyNetByShop($lgIdx, $year, $month, $gugunCode, $saIdx);
$avgByShop = $this->averageNetByShop($lgIdx, $year - 1, $gugunCode, $trendBasis, $month, $saIdx);
$prevYearSameMonth = $trendBasis === 'year_avg'
? $this->monthlyNetByShop($lgIdx, $year - 1, $month, $gugunCode, $saIdx)
: [];
$rows = [];
$monthSalesShops = 0;
foreach ($shops as $shop) {
$sid = (int) ($shop['ds_idx'] ?? 0);
$monthly = (float) ($monthlyByShop[$sid] ?? 0.0);
$avg = (float) ($avgByShop[$sid] ?? 0.0);
if ($trendBasis === 'year_avg' && $avg <= 0) {
$avg = (float) ($prevYearSameMonth[$sid] ?? 0.0);
}
if ($monthly > 0) {
$monthSalesShops++;
}
$diff = $monthly - $avg;
$pct = $avg > 0 ? round(($diff / $avg) * 100, 2) : ($monthly > 0 ? 100.0 : 0.0);
// 편차 N% 이상 = |편차(%)| ≥ N (증가·감소 모두)
if ($deviationMin > 0 && abs($pct) < $deviationMin) {
continue;
}
$rows[] = [
'agency_name' => (string) ($shop['agency_name'] ?? ''),
'shop_no' => (string) ($shop['ds_shop_no'] ?? ''),
'shop_name' => (string) ($shop['ds_name'] ?? ''),
'rep_name' => (string) ($shop['ds_rep_name'] ?? ''),
'prev_avg' => (int) round($avg),
'monthly_qty' => (int) round($monthly),
'avg_diff' => (int) round($diff),
'deviation_pct'=> $pct,
'designated_at'=> (string) ($shop['ds_designated_at'] ?? ''),
];
}
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no']));
return [
'rows' => $rows,
'meta' => [
'shopCount' => count($shops),
'monthSalesShops' => $monthSalesShops,
],
];
}
/**
* @return list<array<string, mixed>>
*/
public function buildSeasonalTrend(
int $lgIdx,
int $baseYear,
string $season,
float $deviationMin,
string $gugunCode,
bool $queried
): array {
if (! $queried) {
return [];
}
$seasonKey = self::normalizeSeason($season);
$seasonDef = self::seasonCatalog()[$seasonKey];
$months = $seasonDef['months'];
$saIdx = 0;
$shops = $this->loadShops($lgIdx, $gugunCode, $saIdx);
if ($shops === []) {
return [];
}
$crossYear = (bool) ($seasonDef['cross_year'] ?? false);
$currentByShop = $this->seasonalNetByShop($lgIdx, $baseYear, $months, $gugunCode, $saIdx, $crossYear);
$prevByShop = $this->seasonalNetByShop($lgIdx, $baseYear - 1, $months, $gugunCode, $saIdx, $crossYear);
$rows = [];
foreach ($shops as $shop) {
$sid = (int) ($shop['ds_idx'] ?? 0);
$curr = (float) ($currentByShop[$sid] ?? 0.0);
$prev = (float) ($prevByShop[$sid] ?? 0.0);
$diff = $curr - $prev;
$pct = $prev > 0 ? round(($diff / $prev) * 100, 2) : ($curr > 0 ? 100.0 : 0.0);
if ($deviationMin > 0 && abs($pct) < $deviationMin) {
continue;
}
$rows[] = [
'agency_name' => (string) ($shop['agency_name'] ?? ''),
'shop_name' => (string) ($shop['ds_name'] ?? ''),
'shop_no' => (string) ($shop['ds_shop_no'] ?? ''),
'rep_name' => (string) ($shop['ds_rep_name'] ?? ''),
'prev_season_avg'=> (int) round($prev),
'base_season_avg'=> (int) round($curr),
'avg_diff' => (int) round($diff),
'deviation_pct' => $pct,
'designated_at' => (string) ($shop['ds_designated_at'] ?? ''),
];
}
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['shop_no'], (string) $b['shop_no']));
return $rows;
}
private function gugunLabel(int $lgIdx, string $code): string
{
static $cache = [];
$key = $lgIdx . ':' . $code;
if (isset($cache[$key])) {
return $cache[$key];
}
$row = $this->db->table('code_detail cd')
->select('cd.cd_name')
->join('code_kind ck', 'ck.ck_idx = cd.cd_ck_idx', 'inner')
->where('ck.ck_code', 'G')
->where('cd.cd_lg_idx', $lgIdx)
->where('cd.cd_code', $code)
->get()
->getRowArray();
$cache[$key] = trim((string) ($row['cd_name'] ?? $code));
return $cache[$key];
}
private function resolveBagName(string $code): string
{
if (isset($this->bagNames[$code])) {
return (string) $this->bagNames[$code];
}
if (ctype_digit($code) && isset($this->bagNames[(int) $code])) {
return (string) $this->bagNames[(int) $code];
}
return $code;
}
private function loadBagNames(int $lgIdx): void
{
if ($this->bagNames !== []) {
return;
}
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
if (! $kindO) {
return;
}
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
$code = trim((string) ($d->cd_code ?? ''));
if ($code !== '') {
$this->bagNames[$code] = trim((string) ($d->cd_name ?? $code));
}
}
}
/**
* @param list<string> $codesFromAgg
* @return list<string>
*/
private function bagCodesForReport(int $lgIdx, array $codesFromAgg): array
{
$this->loadBagNames($lgIdx);
$codes = array_keys($this->bagNames);
if ($codesFromAgg !== []) {
$merged = array_merge($codes, $codesFromAgg);
$codes = [];
foreach ($merged as $c) {
$codes[] = (string) $c;
}
$codes = array_values(array_unique($codes));
sort($codes, SORT_STRING);
} else {
$codes = array_map(static fn ($c): string => (string) $c, $codes);
}
return $codes;
}
/**
* @return array<string, array<int, array<int, array{qty: float, amt: float}>>>
*/
private function aggregateMonthlyByBag(
int $lgIdx,
int $fromYear,
int $toYear,
string $gugunCode,
int $dsIdx
): array {
$saleQty = $this->saleQtySql();
$sql = "
SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m,
SUM({$saleQty}) AS sale_qty,
SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS sale_amt
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ?
AND YEAR(bs.bs_sale_date) BETWEEN ? AND ?
";
$params = [$lgIdx, $fromYear, $toYear];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($dsIdx > 0) {
$sql .= ' AND bs.bs_ds_idx = ?';
$params[] = $dsIdx;
}
$sql .= ' GROUP BY bs.bs_bag_code, YEAR(bs.bs_sale_date), MONTH(bs.bs_sale_date)';
$agg = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$code = (string) ($row['bag_code'] ?? '');
$y = (int) ($row['y'] ?? 0);
$m = (int) ($row['m'] ?? 0);
if ($code === '' || $y <= 0 || $m <= 0) {
continue;
}
$agg[$code][$y][$m] = [
'qty' => (float) ($row['sale_qty'] ?? 0),
'amt' => (float) ($row['sale_amt'] ?? 0),
];
}
return $agg;
}
/**
* @param array<string, array<int, array<int, array{qty: float, amt: float}>>> $agg
* @param list<int> $months
* @return array<string, mixed>
*/
private function yoyBlock(
string $code,
string $name,
string $section,
array $agg,
int $prevYear,
int $year,
array $months,
bool $useAmount
): array {
$key = $useAmount ? 'amt' : 'qty';
$lines = [];
foreach ([$prevYear => (string) $prevYear . '년', $year => (string) $year . '년', 0 => '증감'] as $y => $label) {
$monthVals = [];
$total = 0.0;
foreach ($months as $mo) {
$v = 0.0;
if ($y === 0) {
$p = (float) ($agg[$code][$prevYear][$mo][$key] ?? 0);
$c = (float) ($agg[$code][$year][$mo][$key] ?? 0);
$v = $c - $p;
} else {
$v = (float) ($agg[$code][$y][$mo][$key] ?? 0);
}
$monthVals[$mo] = (int) round($v);
$total += $v;
}
$lines[] = ['label' => $label, 'months' => $monthVals, 'total' => (int) round($total)];
}
return [
'bag_code' => $code,
'bag_name' => $name,
'section' => $section,
'lines' => $lines,
];
}
/**
* @return list<array<string, mixed>>
*/
/**
* @return array<string, string> 구·군코드 → 대행소명
*/
private function agencyNameByGugun(int $lgIdx): array
{
$best = [];
foreach ($this->db->query("
SELECT TRIM(bo.bo_gugun_code) AS code, bo.bo_agency_idx AS sa_idx, COUNT(*) AS cnt
FROM bag_order bo
WHERE bo.bo_lg_idx = ?
AND bo.bo_status = 'normal'
AND bo.bo_agency_idx IS NOT NULL
AND TRIM(bo.bo_gugun_code) != ''
GROUP BY TRIM(bo.bo_gugun_code), bo.bo_agency_idx
", [$lgIdx])->getResultArray() as $row) {
$code = (string) ($row['code'] ?? '');
$cnt = (int) ($row['cnt'] ?? 0);
if ($code === '') {
continue;
}
if (! isset($best[$code]) || $cnt > $best[$code]['cnt']) {
$best[$code] = ['cnt' => $cnt, 'sa_idx' => (int) ($row['sa_idx'] ?? 0)];
}
}
$names = [];
foreach ($best as $code => $info) {
$sa = model(\App\Models\SalesAgencyModel::class)->find($info['sa_idx']);
$names[$code] = $sa ? trim((string) ($sa->sa_name ?? '')) : '';
}
return $names;
}
private function loadShops(int $lgIdx, string $gugunCode, int $saIdx = 0): array
{
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$agencyByGugun = $this->agencyNameByGugun($lgIdx);
$sql = '
SELECT ds.ds_idx, ds.ds_shop_no, ds.ds_name, ds.ds_rep_name, ds.ds_designated_at,
ds.ds_gugun_code';
if ($hasDsSa) {
$sql .= ', ds.ds_sa_idx';
}
$sql .= '
FROM designated_shop ds
WHERE ds.ds_lg_idx = ? AND ds.ds_state = 1
';
$params = [$lgIdx];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' ORDER BY ds.ds_shop_no ASC, ds.ds_idx ASC';
$rows = $this->db->query($sql, $params)->getResultArray();
$saNames = [];
if ($hasDsSa) {
foreach (model(\App\Models\SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->findAll() as $sa) {
$saNames[(int) ($sa->sa_idx ?? 0)] = trim((string) ($sa->sa_name ?? ''));
}
}
foreach ($rows as &$row) {
$name = '';
if ($hasDsSa) {
$saidx = (int) ($row['ds_sa_idx'] ?? 0);
$name = $saNames[$saidx] ?? '';
}
if ($name === '') {
$code = trim((string) ($row['ds_gugun_code'] ?? ''));
$name = $agencyByGugun[$code] ?? '';
}
$row['agency_name'] = $name;
}
unset($row);
return $rows;
}
/**
* @return array<int, float>
*/
private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array
{
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$saleQty = $this->saleQtySql();
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM({$saleQty}) AS sale_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ?
AND bs.bs_ds_idx IS NOT NULL
";
$params = [$lgIdx, $year, $month];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' GROUP BY bs.bs_ds_idx';
$map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['sale_qty'] ?? 0);
}
return $map;
}
/**
* @return array<int, float>
*/
private function averageNetByShop(
int $lgIdx,
int $year,
string $gugunCode,
string $trendBasis,
int $refMonth,
int $saIdx = 0
): array {
if ($trendBasis === 'month') {
return $this->monthlyNetByShop($lgIdx, $year, $refMonth, $gugunCode, $saIdx);
}
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$saleQty = $this->saleQtySql();
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM({$saleQty}) / 12 AS avg_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
AND bs.bs_ds_idx IS NOT NULL
";
$params = [$lgIdx, $year];
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' GROUP BY bs.bs_ds_idx';
$map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0);
}
return $map;
}
/**
* @param list<int> $months
* @return array<int, float>
*/
private function seasonalNetByShop(
int $lgIdx,
int $year,
array $months,
string $gugunCode,
int $saIdx = 0,
bool $crossYearWinter = false
): array {
if ($months === []) {
return [];
}
$divisor = count($months);
$saleQty = $this->saleQtySql();
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
if ($crossYearWinter) {
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM({$saleQty}) / ? AS avg_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ?
AND bs.bs_ds_idx IS NOT NULL
AND (
(YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = 12)
OR (YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) IN (1, 2))
)
";
$params = [$divisor, $lgIdx, $year - 1, $year];
} else {
$placeholders = implode(',', array_fill(0, count($months), '?'));
$sql = "
SELECT bs.bs_ds_idx AS ds_idx,
SUM({$saleQty}) / ? AS avg_qty
FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
AND MONTH(bs.bs_sale_date) IN ({$placeholders})
AND bs.bs_ds_idx IS NOT NULL
";
$params = array_merge([$divisor], [$lgIdx, $year], $months);
}
if ($gugunCode !== '') {
$sql .= ' AND ds.ds_gugun_code = ?';
$params[] = $gugunCode;
}
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' AND ds.ds_sa_idx = ?';
$params[] = $saIdx;
}
$sql .= ' GROUP BY bs.bs_ds_idx';
$map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['avg_qty'] ?? 0);
}
return $map;
}
}

View File

@@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 기간별 봉투 수불 현황 집계 (bag/flow)
*/
class BagFlowReportBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
private static function bagCodeKey(mixed $code): string
{
return (string) $code;
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* bagKindLabels: array<string, string>,
* queried: bool
* }
*/
public function build(
int $lgIdx,
string $startDate,
string $endDate,
string $aggMode,
string $bagCodeFilter,
string $bagKindFilter,
int $saIdx,
bool $queried
): array {
$bagKindLabels = $this->loadBagKindLabels();
$products = $this->loadProducts($lgIdx, $bagCodeFilter, $bagKindFilter);
if ($products === [] || ! $queried) {
return ['rows' => [], 'bagKindLabels' => $bagKindLabels, 'queried' => $queried];
}
$codes = array_keys($products);
$dayBefore = date('Y-m-d', strtotime($startDate . ' -1 day'));
$openingRaw = $this->aggregateMovements($lgIdx, $codes, $saIdx, null, $dayBefore);
$opening = $this->collapseOpeningBalances($openingRaw);
$periodMoves = $this->aggregateMovements($lgIdx, $codes, $saIdx, $startDate, $endDate);
if ($aggMode === 'daily') {
$rows = $this->buildDailyRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels);
} else {
$rows = $this->buildPeriodRows($products, $opening, $periodMoves, $startDate, $endDate, $bagKindLabels);
}
return ['rows' => $rows, 'bagKindLabels' => $bagKindLabels, 'queried' => true];
}
/**
* @return array<string, string> code => name
*/
private function loadProducts(int $lgIdx, string $bagCodeFilter, string $bagKindFilter): array
{
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
if (! $kindO) {
return [];
}
$details = model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx);
$products = [];
foreach ($details as $d) {
$code = (string) ($d->cd_code ?? '');
if ($code === '') {
continue;
}
if ($bagCodeFilter !== '' && $code !== $bagCodeFilter) {
continue;
}
if ($bagKindFilter !== '' && ! str_starts_with($code, $bagKindFilter)) {
continue;
}
$products[self::bagCodeKey($code)] = (string) ($d->cd_name ?? $code);
}
return $products;
}
/**
* @return array<string, string>
*/
private function loadBagKindLabels(): array
{
$kindE = model(\App\Models\CodeKindModel::class)->where('ck_code', 'E')->first();
if (! $kindE) {
return [];
}
$labels = [];
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null) as $d) {
$labels[(string) $d->cd_code] = (string) $d->cd_name;
}
return $labels;
}
/**
* @param list<string> $codes
* @return array<string, array<string, array<string, int>>> bag_code => date => metrics
*/
private function aggregateMovements(
int $lgIdx,
array $codes,
int $saIdx,
?string $fromDate,
?string $toDate
): array {
if ($codes === []) {
return [];
}
$buckets = [];
$ensure = static function (string $code, string $date) use (&$buckets): array {
if (! isset($buckets[$code][$date])) {
$buckets[$code][$date] = self::emptyMetrics();
}
return $buckets[$code][$date];
};
$hasMisc = $this->db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0;
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$codePlaceholders = implode(',', array_fill(0, count($codes), '?'));
// 입고(발주 입고)
$sql = "
SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS qty
FROM bag_receiving
WHERE br_lg_idx = ? AND br_bag_code IN ({$codePlaceholders})
";
$params = array_merge([$lgIdx], $codes);
if ($fromDate !== null) {
$sql .= ' AND br_receive_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND br_receive_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY br_bag_code, br_receive_date';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$m = $ensure($code, $date);
$m['recv_in'] += (int) $row->qty;
$buckets[$code][$date] = $m;
}
// 판매·반품(반품=입고)
$sql = "
SELECT bs.bs_bag_code AS bag_code, bs.bs_sale_date AS mv_date, bs.bs_type AS mv_type,
SUM(ABS(bs.bs_qty)) AS qty
FROM bag_sale bs
";
if ($saIdx > 0 && $hasDsSa) {
$sql .= ' INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx AND ds.ds_sa_idx = ?';
}
$sql .= " WHERE bs.bs_lg_idx = ? AND bs.bs_bag_code IN ({$codePlaceholders})";
$params = $saIdx > 0 && $hasDsSa ? [$saIdx, $lgIdx] : [$lgIdx];
$params = array_merge($params, $codes);
if ($fromDate !== null) {
$sql .= ' AND bs.bs_sale_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND bs.bs_sale_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY bs.bs_bag_code, bs.bs_sale_date, bs.bs_type';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$qty = (int) $row->qty;
$m = $ensure($code, $date);
$type = (string) $row->mv_type;
if ($type === 'return') {
$m['recv_return'] += $qty;
} else {
$m['out_sale'] += $qty;
}
$buckets[$code][$date] = $m;
}
// 불출
$sql = "
SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, bi2_issue_type AS issue_type,
SUM(bi2_qty) AS qty
FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$codePlaceholders})
";
$params = array_merge([$lgIdx], $codes);
if ($fromDate !== null) {
$sql .= ' AND bi2_issue_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND bi2_issue_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY bi2_bag_code, bi2_issue_date, bi2_issue_type';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$qty = (int) $row->qty;
$m = $ensure($code, $date);
$issueType = (string) $row->issue_type;
if (str_contains($issueType, '무료')) {
$m['out_issue_free'] += $qty;
} else {
$m['out_issue_gen'] += $qty;
}
$buckets[$code][$date] = $m;
}
if ($hasMisc) {
$sql = "
SELECT bmf_bag_code AS bag_code, bmf_date AS mv_date, bmf_type AS mv_type,
SUM(bmf_qty) AS qty
FROM bag_misc_flow
WHERE bmf_lg_idx = ? AND bmf_bag_code IN ({$codePlaceholders})
";
$params = array_merge([$lgIdx], $codes);
if ($fromDate !== null) {
$sql .= ' AND bmf_date >= ?';
$params[] = $fromDate;
}
if ($toDate !== null) {
$sql .= ' AND bmf_date <= ?';
$params[] = $toDate;
}
$sql .= ' GROUP BY bmf_bag_code, bmf_date, bmf_type';
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$date = (string) $row->mv_date;
$qty = (int) $row->qty;
$m = $ensure($code, $date);
if ((string) $row->mv_type === 'in') {
$m['recv_misc'] += $qty;
} else {
$m['out_misc'] += $qty;
}
$buckets[$code][$date] = $m;
}
}
$normalized = [];
foreach ($buckets as $code => $byDate) {
$key = self::bagCodeKey($code);
foreach ($byDate as $date => $m) {
$normalized[$key][$date] = self::finalizeMetrics($m);
}
}
return $normalized;
}
/**
* @param array<string, string> $products
* @param array<string, array<string, int>> $opening date key '_open'
* @param array<string, array<string, array<string, int>>> $periodMoves
* @param array<string, string> $bagKindLabels
* @return list<array<string, mixed>>
*/
private function buildPeriodRows(
array $products,
array $opening,
array $periodMoves,
string $startDate,
string $endDate,
array $bagKindLabels
): array {
$periodKey = $startDate . '~' . $endDate;
$grouped = [];
foreach ($products as $codeKey => $name) {
$code = self::bagCodeKey($codeKey);
$kind = strlen($code) >= 2 ? substr($code, 0, 2) : '';
$grouped[$kind][] = ['code' => $code, 'name' => $name];
}
ksort($grouped);
$rows = [];
$grand = self::emptyMetrics();
$grand['row_type'] = 'grand';
$grand['date'] = '';
$grand['item_name'] = '총계';
foreach ($grouped as $kind => $items) {
$sub = self::emptyMetrics();
$sub['row_type'] = 'subtotal';
$sub['date'] = '';
$sub['item_name'] = ($bagKindLabels[$kind] ?? '기타') . ' 소계';
foreach ($items as $item) {
$code = self::bagCodeKey($item['code']);
$m = self::emptyMetrics();
foreach ($periodMoves[$code] ?? [] as $dayMetrics) {
$m = self::mergeMetrics($m, $dayMetrics);
}
$m = self::finalizeMetrics($m);
$m['prev_stock'] = (int) ($opening[$code] ?? 0);
$m['balance'] = $m['prev_stock'] + $m['recv_total'] - $m['out_total'];
$m['row_type'] = 'data';
$m['date'] = $periodKey;
$m['item_name'] = $item['name'];
$m['bag_code'] = $code;
$m['bag_kind'] = $kind;
$rows[] = $m;
$sub = self::mergeMetrics($sub, $m);
}
$sub = self::finalizeMetrics($sub);
$sub['balance'] = $sub['prev_stock'] + $sub['recv_total'] - $sub['out_total'];
$rows[] = $sub;
$grand = self::mergeMetrics($grand, $sub);
}
$grand = self::finalizeMetrics($grand);
$grand['balance'] = $grand['prev_stock'] + $grand['recv_total'] - $grand['out_total'];
$rows[] = $grand;
return $rows;
}
/**
* @param array<string, array<string, array<string, int>>> $openingRaw
* @return array<string, int> bag_code => 전일(기간 전) 재고
*/
private function collapseOpeningBalances(array $openingRaw): array
{
$out = [];
foreach ($openingRaw as $code => $byDate) {
$net = self::emptyMetrics();
foreach ($byDate as $m) {
$net = self::mergeMetrics($net, $m);
}
$net = self::finalizeMetrics($net);
$out[self::bagCodeKey($code)] = $net['recv_total'] - $net['out_total'];
}
return $out;
}
/**
* @param array<string, string> $products
* @param array<string, array<string, array<string, int>>> $opening
* @param array<string, array<string, array<string, int>>> $periodMoves
* @param array<string, string> $bagKindLabels
* @return list<array<string, mixed>>
*/
private function buildDailyRows(
array $products,
array $opening,
array $periodMoves,
string $startDate,
string $endDate,
array $bagKindLabels
): array {
$dates = [];
$cursor = strtotime($startDate);
$endTs = strtotime($endDate);
while ($cursor <= $endTs) {
$dates[] = date('Y-m-d', $cursor);
$cursor = strtotime('+1 day', $cursor);
}
$rows = [];
foreach ($products as $codeKey => $name) {
$code = self::bagCodeKey($codeKey);
$kind = strlen($code) >= 2 ? substr($code, 0, 2) : '';
$running = (int) ($opening[$code] ?? 0);
foreach ($dates as $date) {
$dayM = $periodMoves[$code][$date] ?? self::emptyMetrics();
$dayM = self::finalizeMetrics($dayM);
$prev = $running;
$running = $prev + $dayM['recv_total'] - $dayM['out_total'];
$dayM['prev_stock'] = $prev;
$dayM['balance'] = $running;
$dayM['row_type'] = 'data';
$dayM['date'] = $date;
$dayM['item_name'] = $name;
$dayM['bag_code'] = $code;
$dayM['bag_kind'] = $kind;
if ($this->rowHasActivity($dayM)) {
$rows[] = $dayM;
}
}
}
return $rows;
}
/**
* @param array<string, int|float> $m
*/
private function rowHasActivity(array $m): bool
{
foreach (['recv_in', 'recv_return', 'recv_misc', 'out_sale', 'out_issue_gen', 'out_issue_free', 'out_return', 'out_misc'] as $k) {
if ((int) ($m[$k] ?? 0) !== 0) {
return true;
}
}
return (int) ($m['prev_stock'] ?? 0) !== 0;
}
/**
* @return array<string, int>
*/
private static function emptyMetrics(): array
{
return [
'prev_stock' => 0,
'recv_in' => 0,
'recv_return' => 0,
'recv_misc' => 0,
'recv_total' => 0,
'out_sale' => 0,
'out_issue_gen' => 0,
'out_issue_free' => 0,
'out_return' => 0,
'out_misc' => 0,
'out_total' => 0,
'balance' => 0,
];
}
/**
* @param array<string, int> $m
* @return array<string, int>
*/
private static function finalizeMetrics(array $m): array
{
$m['recv_total'] = (int) $m['recv_in'] + (int) $m['recv_return'] + (int) $m['recv_misc'];
$m['out_total'] = (int) $m['out_sale'] + (int) $m['out_issue_gen'] + (int) $m['out_issue_free']
+ (int) $m['out_return'] + (int) $m['out_misc'];
return $m;
}
/**
* @param array<string, int> $a
* @param array<string, int> $b
* @return array<string, int>
*/
private static function mergeMetrics(array $a, array $b): array
{
foreach (self::emptyMetrics() as $k => $_) {
$a[$k] = (int) ($a[$k] ?? 0) + (int) ($b[$k] ?? 0);
}
return $a;
}
}

View File

@@ -0,0 +1,900 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* LOT 수불 조회 (레거시 w_gd033a)
* — 바코드(팩/박스/낱장) 또는 LOT 번호로 일자·입출고처·구분 이력
*/
class BagLotFlowBuilder
{
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
/**
* @return array{
* ok: bool,
* message: string,
* barcode: string,
* unit: string,
* bag_code: string,
* bag_name: string,
* lot_no: string,
* box_code: string,
* pack_code: string,
* qty_box: int,
* qty_pack: int,
* qty_sheet: int,
* rows: list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
* }
*/
public function buildByBarcode(int $lgIdx, string $barcode, bool $queried): array
{
$empty = $this->emptyResult($barcode);
if (! $queried || trim($barcode) === '') {
return $empty;
}
$resolved = $this->resolveBarcode($lgIdx, trim($barcode));
if (! $resolved['ok']) {
return array_merge($empty, [
'message' => (string) ($resolved['message'] ?? '등록되지 않은 바코드입니다.'),
]);
}
$rows = $this->collectFlowRows($lgIdx, $resolved);
usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0));
return array_merge($empty, [
'ok' => true,
'message' => '',
'barcode' => (string) ($resolved['barcode'] ?? $barcode),
'unit' => (string) ($resolved['unit'] ?? ''),
'bag_code' => (string) ($resolved['bag_code'] ?? ''),
'bag_name' => (string) ($resolved['bag_name'] ?? ''),
'lot_no' => (string) ($resolved['lot_no'] ?? ''),
'box_code' => (string) ($resolved['box_code'] ?? ''),
'pack_code' => (string) ($resolved['pack_code'] ?? ''),
'qty_box' => (int) ($resolved['qty_box'] ?? 0),
'qty_pack' => (int) ($resolved['qty_pack'] ?? 0),
'qty_sheet' => (int) ($resolved['qty_sheet'] ?? 0),
'rows' => $rows,
]);
}
/**
* @return array<string, mixed>
*/
public function buildByLotNo(int $lgIdx, string $lotNo, bool $queried): array
{
$empty = $this->emptyResult('');
if (! $queried || trim($lotNo) === '') {
return $empty;
}
$lotNo = trim($lotNo);
if (! $this->db->tableExists('bag_receiving_pack_code')) {
return array_merge($empty, ['message' => '바코드(팩) 데이터가 없습니다.']);
}
$packRows = $this->db->table('bag_receiving_pack_code')
->select('brpc_pack_code, brpc_box_code, brpc_bag_code, brpc_bag_name, brpc_lot_no')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_lot_no', $lotNo)
->limit(500)
->get()
->getResultArray();
if ($packRows === []) {
$order = $this->db->table('bag_order')
->where('bo_lg_idx', $lgIdx)
->where('bo_lot_no', $lotNo)
->orderBy('bo_version', 'DESC')
->get()
->getRowArray();
if (! $order) {
return array_merge($empty, ['message' => '해당 LOT·바코드를 찾을 수 없습니다.', 'lot_no' => $lotNo]);
}
return $this->buildLotFromOrderOnly($lgIdx, $lotNo, $order);
}
$codes = [];
$bagCode = '';
$bagName = '';
foreach ($packRows as $p) {
$codes[] = (string) ($p['brpc_pack_code'] ?? '');
$box = (string) ($p['brpc_box_code'] ?? '');
if ($box !== '') {
$codes[] = $box;
}
if ($bagCode === '') {
$bagCode = (string) ($p['brpc_bag_code'] ?? '');
$bagName = (string) ($p['brpc_bag_name'] ?? '');
}
}
$codes = array_values(array_unique(array_filter($codes, static fn (string $c): bool => $c !== '')));
$rows = [];
foreach ($this->loadReceivingEventsForLot($lgIdx, $lotNo) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0));
if (count($rows) > 500) {
$rows = array_slice($rows, -500);
}
return array_merge($empty, [
'ok' => true,
'lot_no' => $lotNo,
'bag_code' => $bagCode,
'bag_name' => $bagName,
'barcode' => $lotNo,
'rows' => $rows,
]);
}
/**
* @return array<string, mixed>
*/
private function emptyResult(string $barcode): array
{
return [
'ok' => false,
'message' => '',
'barcode' => $barcode,
'unit' => '',
'bag_code' => '',
'bag_name' => '',
'lot_no' => '',
'box_code' => '',
'pack_code' => '',
'qty_box' => 0,
'qty_pack' => 0,
'qty_sheet' => 0,
'rows' => [],
];
}
/**
* @return array{ok: bool, message?: string, barcode?: string, unit?: string, bag_code?: string, bag_name?: string, lot_no?: string, box_code?: string, pack_code?: string, pack_ids?: list<int>, qty_box?: int, qty_pack?: int, qty_sheet?: int}
*/
private function resolveBarcode(int $lgIdx, string $barcode): array
{
if (! $this->db->tableExists('bag_receiving_pack_code')) {
return ['ok' => false, 'message' => '바코드(팩) 데이터가 없습니다.'];
}
$pack = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_pack_code', $barcode)
->get()
->getRowArray();
if ($pack) {
return $this->resolvedFromPackRow($barcode, '팩', $pack, 0, 1, (int) ($pack['brpc_sheet_qty'] ?? 0));
}
$boxRows = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $barcode)
->get()
->getResultArray();
if ($boxRows !== []) {
$first = $boxRows[0];
$sheetQty = 0;
foreach ($boxRows as $row) {
$sheetQty += (int) ($row['brpc_sheet_qty'] ?? 0);
}
return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty);
}
$exactSheet = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code', $barcode)
->limit(1)
->get()
->getRowArray();
if (is_array($exactSheet) && $exactSheet !== []) {
return $this->resolvedFromPackRow($barcode, '낱장', $exactSheet, 0, 0, 1);
}
$sheetRows = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code !=', '')
->where('brpc_sheet_end_code !=', '')
->limit(200)
->get()
->getResultArray();
foreach ($sheetRows as $row) {
$start = (string) ($row['brpc_sheet_start_code'] ?? '');
$end = (string) ($row['brpc_sheet_end_code'] ?? '');
if ($this->barcodeInRange($barcode, $start, $end)) {
return $this->resolvedFromPackRow($barcode, '낱장', $row, 0, 0, 1);
}
}
$fromScan = $this->resolveBarcodeFromScanTables($lgIdx, $barcode);
if ($fromScan !== null) {
return $fromScan;
}
return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.'];
}
/**
* 판매·반품 스캔에만 있는 낱장 코드(입고 팩 테이블 미등록) 조회
*
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}|null
*/
private function resolveBarcodeFromScanTables(int $lgIdx, string $barcode): ?array
{
$bagCode = '';
$bagName = '';
if ($this->db->tableExists('bag_sale_scan_code')) {
$sale = $this->db->table('bag_sale_scan_code')
->select('bssc_bag_code, bssc_bag_name')
->where('bssc_lg_idx', $lgIdx)
->where('bssc_code', $barcode)
->orderBy('bssc_regdate', 'DESC')
->limit(1)
->get()
->getRowArray();
if (is_array($sale) && $sale !== []) {
$bagCode = (string) ($sale['bssc_bag_code'] ?? '');
$bagName = (string) ($sale['bssc_bag_name'] ?? '');
}
}
if ($bagCode === '' && $this->db->tableExists('bag_return_scan_code')) {
$ret = $this->db->table('bag_return_scan_code')
->select('brsc_bag_code, brsc_bag_name')
->where('brsc_lg_idx', $lgIdx)
->where('brsc_code', $barcode)
->orderBy('brsc_regdate', 'DESC')
->limit(1)
->get()
->getRowArray();
if (is_array($ret) && $ret !== []) {
$bagCode = (string) ($ret['brsc_bag_code'] ?? '');
$bagName = (string) ($ret['brsc_bag_name'] ?? '');
}
}
if ($bagCode === '' && $bagName === '') {
return null;
}
$packRow = $this->findPackRowContainingSheet($lgIdx, $barcode);
return [
'ok' => true,
'barcode' => $barcode,
'unit' => '낱장',
'bag_code' => $bagCode,
'bag_name' => $bagName,
'lot_no' => (string) ($packRow['brpc_lot_no'] ?? ''),
'box_code' => (string) ($packRow['brpc_box_code'] ?? ''),
'pack_code' => (string) ($packRow['brpc_pack_code'] ?? ''),
'pack_ids' => isset($packRow['brpc_idx']) ? [(int) $packRow['brpc_idx']] : [],
'qty_box' => 0,
'qty_pack' => 0,
'qty_sheet' => 1,
];
}
/**
* @return array<string, mixed>
*/
private function findPackRowContainingSheet(int $lgIdx, string $barcode): array
{
if (! $this->db->tableExists('bag_receiving_pack_code')) {
return [];
}
$exact = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code', $barcode)
->limit(1)
->get()
->getRowArray();
if (is_array($exact) && $exact !== []) {
return $exact;
}
foreach ($this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code !=', '')
->where('brpc_sheet_end_code !=', '')
->limit(200)
->get()
->getResultArray() as $row) {
$start = (string) ($row['brpc_sheet_start_code'] ?? '');
$end = (string) ($row['brpc_sheet_end_code'] ?? '');
if ($this->barcodeInRange($barcode, $start, $end)) {
return $row;
}
}
return [];
}
/**
* @param array<string, mixed> $pack
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}
*/
private function resolvedFromPackRow(string $barcode, string $unit, array $pack, int $qtyBox, int $qtyPack, int $qtySheet): array
{
return [
'ok' => true,
'barcode' => $barcode,
'unit' => $unit,
'bag_code' => (string) ($pack['brpc_bag_code'] ?? ''),
'bag_name' => (string) ($pack['brpc_bag_name'] ?? ''),
'lot_no' => (string) ($pack['brpc_lot_no'] ?? ''),
'box_code' => (string) ($pack['brpc_box_code'] ?? ''),
'pack_code' => (string) ($pack['brpc_pack_code'] ?? ''),
'pack_ids' => [(int) ($pack['brpc_idx'] ?? 0)],
'qty_box' => $qtyBox,
'qty_pack' => $qtyPack,
'qty_sheet' => $qtySheet,
];
}
/**
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRows(int $lgIdx, array $resolved): array
{
$unit = (string) ($resolved['unit'] ?? '');
return match ($unit) {
'낱장' => $this->collectFlowRowsForSheet($lgIdx, $resolved),
'박스' => $this->collectFlowRowsForBox($lgIdx, $resolved),
default => $this->collectFlowRowsForPack($lgIdx, $resolved),
};
}
/**
* 낱장: 해당 바코드 판매·반품만 + 소속 팩 입고 1건(발주·동일 LOT 전체 이력 제외)
*
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRowsForSheet(int $lgIdx, array $resolved): array
{
$rows = [];
$barcode = trim((string) ($resolved['barcode'] ?? ''));
if ($barcode === '') {
return [];
}
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
if ($brIdx > 0) {
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
$rows[] = $ev;
}
}
foreach ($this->loadScanEventsForCodes($lgIdx, [$barcode]) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadReturnEventsForCodes($lgIdx, [$barcode]) as $ev) {
$rows[] = $ev;
}
return $rows;
}
/**
* 팩: 팩·낱장 바코드 스캔 + 입고 + LOT 발주
*
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRowsForPack(int $lgIdx, array $resolved): array
{
$rows = [];
$codes = array_values(array_unique(array_filter([
(string) ($resolved['barcode'] ?? ''),
(string) ($resolved['pack_code'] ?? ''),
], static fn (string $c): bool => $c !== '')));
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
if ($brIdx > 0) {
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
$rows[] = $ev;
}
}
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
$lotNo = (string) ($resolved['lot_no'] ?? '');
if ($lotNo !== '') {
foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) {
$rows[] = $ev;
}
}
return $rows;
}
/**
* 박스: 박스·소속 팩 코드 스캔 + 입고(박스 내 팩) + LOT 발주
*
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRowsForBox(int $lgIdx, array $resolved): array
{
$rows = [];
$boxCode = (string) ($resolved['box_code'] ?? '');
$codes = array_values(array_unique(array_filter([
(string) ($resolved['barcode'] ?? ''),
$boxCode,
(string) ($resolved['pack_code'] ?? ''),
], static fn (string $c): bool => $c !== '')));
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
$packCodes = $this->db->table('bag_receiving_pack_code')
->select('brpc_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $boxCode)
->get()
->getResultArray();
foreach ($packCodes as $p) {
$pc = (string) ($p['brpc_pack_code'] ?? '');
if ($pc !== '' && ! in_array($pc, $codes, true)) {
$codes[] = $pc;
}
}
}
$seenBr = [];
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
$brRows = $this->db->table('bag_receiving_pack_code')
->select('brpc_br_idx')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $boxCode)
->get()
->getResultArray();
foreach ($brRows as $brRow) {
$brIdx = (int) ($brRow['brpc_br_idx'] ?? 0);
if ($brIdx <= 0 || isset($seenBr[$brIdx])) {
continue;
}
$seenBr[$brIdx] = true;
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
$rows[] = $ev;
}
}
}
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
$lotNo = (string) ($resolved['lot_no'] ?? '');
if ($lotNo !== '') {
foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) {
$rows[] = $ev;
}
}
return $rows;
}
private function receivingBrIdxForPackCode(int $lgIdx, string $packCode): int
{
if ($packCode === '' || ! $this->db->tableExists('bag_receiving_pack_code')) {
return 0;
}
$p = $this->db->table('bag_receiving_pack_code')
->select('brpc_br_idx')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_pack_code', $packCode)
->get()
->getRowArray();
return (int) ($p['brpc_br_idx'] ?? 0);
}
/**
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function loadReceivingEventsByBrIdx(int $lgIdx, int $brIdx): array
{
$sql = "
SELECT r.br_receive_date, r.br_sender_name, r.br_regdate,
o.bo_order_date, c.cp_name, sa.sa_name
FROM bag_receiving r
LEFT JOIN bag_order o ON o.bo_idx = r.br_bo_idx
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
WHERE r.br_lg_idx = ? AND r.br_idx = ?
LIMIT 20
";
$rows = [];
foreach ($this->db->query($sql, [$lgIdx, $brIdx])->getResultArray() as $r) {
$rows[] = $this->makeEvent(
(string) ($r['br_receive_date'] ?? ''),
(string) ($r['br_regdate'] ?? ''),
$this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name'),
'입고'
);
}
return $rows;
}
/**
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function loadReceivingEventsForLot(int $lgIdx, string $lotNo): array
{
$sql = "
SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, r.br_bag_name,
c.cp_name, sa.sa_name
FROM bag_receiving r
INNER JOIN bag_order o ON o.bo_idx = r.br_bo_idx AND o.bo_lot_no = ?
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
WHERE r.br_lg_idx = ?
ORDER BY r.br_receive_date ASC, r.br_idx ASC
LIMIT 200
";
$rows = [];
foreach ($this->db->query($sql, [$lotNo, $lgIdx])->getResultArray() as $r) {
$label = trim((string) ($r['br_bag_name'] ?? ''));
$rows[] = $this->makeEvent(
(string) ($r['br_receive_date'] ?? ''),
(string) ($r['br_regdate'] ?? ''),
$this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name') . ($label !== '' ? ' · ' . $label : ''),
'입고'
);
}
return $rows;
}
/**
* @param list<string> $codes
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function loadScanEventsForCodes(int $lgIdx, array $codes): array
{
if ($codes === [] || ! $this->db->tableExists('bag_sale_scan_code')) {
return [];
}
$placeholders = implode(',', array_fill(0, count($codes), '?'));
$params = array_merge([$lgIdx], $codes);
$sql = "
SELECT b.bssc_regdate, b.bssc_state, b.bssc_code, d.ds_name, d.ds_shop_no
FROM bag_sale_scan_code b
LEFT JOIN designated_shop d ON d.ds_idx = b.bssc_ds_idx
WHERE b.bssc_lg_idx = ? AND b.bssc_code IN ({$placeholders})
ORDER BY b.bssc_regdate ASC
LIMIT 200
";
$rows = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $r) {
$state = strtolower((string) ($r['bssc_state'] ?? ''));
$type = $state === 'in_stock' ? '반품입고' : '출고';
$shop = trim((string) ($r['ds_name'] ?? ''));
if ($shop === '') {
$shop = trim((string) ($r['ds_shop_no'] ?? ''));
}
if ($shop === '') {
$shop = '지정판매소';
}
$rows[] = $this->makeEvent(
$this->dateOnly((string) ($r['bssc_regdate'] ?? '')),
(string) ($r['bssc_regdate'] ?? ''),
$shop,
$type
);
}
return $rows;
}
/**
* @param list<string> $codes
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function loadReturnEventsForCodes(int $lgIdx, array $codes): array
{
if ($codes === [] || ! $this->db->tableExists('bag_return_scan_code')) {
return [];
}
$placeholders = implode(',', array_fill(0, count($codes), '?'));
$params = array_merge([$lgIdx], $codes);
$sql = "
SELECT r.brsc_return_date, r.brsc_regdate, r.brsc_code, d.ds_name, d.ds_shop_no
FROM bag_return_scan_code r
LEFT JOIN designated_shop d ON d.ds_idx = r.brsc_ds_idx
WHERE r.brsc_lg_idx = ? AND r.brsc_code IN ({$placeholders})
ORDER BY r.brsc_return_date ASC
LIMIT 200
";
$rows = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $r) {
$shop = trim((string) ($r['ds_name'] ?? ''));
if ($shop === '') {
$shop = trim((string) ($r['ds_shop_no'] ?? ''));
}
if ($shop === '') {
$shop = '지정판매소';
}
$rows[] = $this->makeEvent(
(string) ($r['brsc_return_date'] ?? ''),
(string) ($r['brsc_regdate'] ?? ''),
$shop,
'반품'
);
}
return $rows;
}
/**
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function loadOrderEventsForLot(int $lgIdx, string $lotNo): array
{
$sql = "
SELECT o.bo_order_date, o.bo_regdate, c.cp_name, sa.sa_name
FROM bag_order o
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
WHERE o.bo_lg_idx = ? AND o.bo_lot_no = ? AND o.bo_status = 'normal'
ORDER BY o.bo_version DESC
LIMIT 1
";
$r = $this->db->query($sql, [$lgIdx, $lotNo])->getRowArray();
if (! $r) {
return [];
}
return [
$this->makeEvent(
(string) ($r['bo_order_date'] ?? ''),
(string) ($r['bo_regdate'] ?? ''),
$this->pickSource($r, '제작·발주', 'cp_name', 'sa_name'),
'발주'
),
];
}
/**
* @param array<string, mixed> $order
* @return array<string, mixed>
*/
private function buildLotFromOrderOnly(int $lgIdx, string $lotNo, array $order): array
{
$rows = $this->loadOrderEventsForLot($lgIdx, $lotNo);
$boIdx = (int) ($order['bo_idx'] ?? 0);
if ($boIdx > 0) {
$sql = "
SELECT r.br_receive_date, r.br_sender_name, r.br_regdate, r.br_bag_name,
c.cp_name, sa.sa_name
FROM bag_receiving r
LEFT JOIN bag_order o ON o.bo_idx = r.br_bo_idx
LEFT JOIN company c ON c.cp_idx = o.bo_company_idx
LEFT JOIN sales_agency sa ON sa.sa_idx = o.bo_agency_idx
WHERE r.br_bo_idx = ?
ORDER BY r.br_receive_date ASC
";
foreach ($this->db->query($sql, [$boIdx])->getResultArray() as $r) {
$rows[] = $this->makeEvent(
(string) ($r['br_receive_date'] ?? ''),
(string) ($r['br_regdate'] ?? ''),
$this->pickSource($r, '입고처', 'sa_name', 'cp_name', 'br_sender_name'),
'입고'
);
}
}
usort($rows, static fn (array $a, array $b): int => ($a['sort_ts'] ?? 0) <=> ($b['sort_ts'] ?? 0));
return array_merge($this->emptyResult($lotNo), [
'ok' => true,
'lot_no' => $lotNo,
'barcode' => $lotNo,
'rows' => $rows,
]);
}
/**
* @param array<string, mixed> $row
*/
private function pickSource(array $row, string $default, string ...$keys): string
{
foreach ($keys as $key) {
$v = trim((string) ($row[$key] ?? ''));
if ($v !== '') {
return $v;
}
}
return $default;
}
/**
* @return array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}
*/
private function makeEvent(string $dateYmd, string $sortDatetime, string $counterparty, string $flowType): array
{
$ts = strtotime($sortDatetime !== '' ? $sortDatetime : $dateYmd);
return [
'flow_date' => $dateYmd !== '' ? $dateYmd : ($ts ? date('Y-m-d', $ts) : ''),
'counterparty' => $counterparty,
'flow_type' => $flowType,
'sort_ts' => $ts !== false ? $ts : 0,
];
}
private function dateOnly(string $datetime): string
{
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $datetime, $m) === 1) {
return $m[1];
}
$ts = strtotime($datetime);
return $ts ? date('Y-m-d', $ts) : $datetime;
}
/**
* LOT 수불 조회 화면 테스트용 — 등록된 바코드·LOT 샘플
*
* @return list<array{kind: string, code: string, bag_name: string, lot_no: string, state: string, hint: string}>
*/
public function loadTestSamples(int $lgIdx, int $limit = 80): array
{
$samples = [];
$seen = [];
$push = static function (array &$samples, array &$seen, string $kind, string $code, string $bagName, string $lotNo, string $state, string $hint) use ($limit): void {
$code = trim($code);
if ($code === '' || isset($seen[$code]) || count($samples) >= $limit) {
return;
}
$seen[$code] = true;
$samples[] = [
'kind' => $kind,
'code' => $code,
'bag_name' => $bagName,
'lot_no' => $lotNo,
'state' => $state,
'hint' => $hint,
];
};
if ($this->db->tableExists('bag_receiving_pack_code')) {
foreach ($this->db->table('bag_receiving_pack_code')
->select('brpc_pack_code, brpc_box_code, brpc_sheet_start_code, brpc_bag_name, brpc_lot_no, brpc_state')
->where('brpc_lg_idx', $lgIdx)
->orderBy('brpc_idx', 'DESC')
->limit(40)
->get()
->getResultArray() as $row) {
$state = $this->packStateLabel((string) ($row['brpc_state'] ?? ''));
$bagName = (string) ($row['brpc_bag_name'] ?? '');
$lotNo = (string) ($row['brpc_lot_no'] ?? '');
$push($samples, $seen, '팩', (string) ($row['brpc_pack_code'] ?? ''), $bagName, $lotNo, $state, '입고 팩 코드');
}
$boxRows = $this->db->query("
SELECT brpc_box_code,
MAX(brpc_bag_name) AS brpc_bag_name,
MAX(brpc_lot_no) AS brpc_lot_no,
MAX(brpc_state) AS brpc_state
FROM bag_receiving_pack_code
WHERE brpc_lg_idx = ? AND brpc_box_code != ''
GROUP BY brpc_box_code
ORDER BY MAX(brpc_idx) DESC
LIMIT 15
", [$lgIdx])->getResultArray();
foreach ($boxRows as $row) {
$push($samples, $seen, '박스', (string) ($row['brpc_box_code'] ?? ''), (string) ($row['brpc_bag_name'] ?? ''), (string) ($row['brpc_lot_no'] ?? ''), $this->packStateLabel((string) ($row['brpc_state'] ?? '')), '박스 단위 조회');
}
$sheetRows = $this->db->table('bag_receiving_pack_code')
->select('brpc_sheet_start_code, brpc_bag_name, brpc_lot_no, brpc_state')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code !=', '')
->orderBy('brpc_idx', 'DESC')
->limit(15)
->get()
->getResultArray();
foreach ($sheetRows as $row) {
$push($samples, $seen, '낱장', (string) ($row['brpc_sheet_start_code'] ?? ''), (string) ($row['brpc_bag_name'] ?? ''), (string) ($row['brpc_lot_no'] ?? ''), $this->packStateLabel((string) ($row['brpc_state'] ?? '')), '낱장 시작 코드');
}
$lotRows = $this->db->query("
SELECT DISTINCT brpc_lot_no
FROM bag_receiving_pack_code
WHERE brpc_lg_idx = ? AND brpc_lot_no != ''
ORDER BY brpc_lot_no DESC
LIMIT 10
", [$lgIdx])->getResultArray();
foreach ($lotRows as $row) {
$lot = (string) ($row['brpc_lot_no'] ?? '');
$push($samples, $seen, 'LOT', $lot, '(LOT 전체)', $lot, '—', 'lot_no 파라미터·입력 동일');
}
}
if ($this->db->tableExists('bag_sale_scan_code')) {
foreach ($this->db->table('bag_sale_scan_code b')
->select('b.bssc_code, b.bssc_bag_name, b.bssc_unit, b.bssc_state, b.bssc_regdate')
->where('b.bssc_lg_idx', $lgIdx)
->orderBy('b.bssc_regdate', 'DESC')
->limit(20)
->get()
->getResultArray() as $row) {
$state = strtolower((string) ($row['bssc_state'] ?? '')) === 'sold' ? '판매' : '반품재고';
$push($samples, $seen, '스캔', (string) ($row['bssc_code'] ?? ''), (string) ($row['bssc_bag_name'] ?? ''), '', $state, '판매·반품 스캔 이력');
}
}
return $samples;
}
private function packStateLabel(string $state): string
{
return match (strtolower($state)) {
'in_stock' => '재고',
'sold' => '판매',
default => $state !== '' ? $state : '—',
};
}
private function barcodeInRange(string $code, string $start, string $end): bool
{
if ($start === '' || $end === '') {
return false;
}
$extract = static function (string $v): array {
if (preg_match('/^(.*?)(\d+)$/', $v, $m) === 1) {
return [(string) $m[1], (int) $m[2], strlen((string) $m[2])];
}
return ['', -1, 0];
};
[$cp, $cn, $cl] = $extract($code);
[$sp, $sn, $sl] = $extract($start);
[$ep, $en, $el] = $extract($end);
if ($cn >= 0 && $sn >= 0 && $en >= 0 && $cp === $sp && $sp === $ep && $cl === $sl && $sl === $el) {
return $cn >= $sn && $cn <= $en;
}
return strcmp($code, $start) >= 0 && strcmp($code, $end) <= 0;
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 봉투번호확인(번호알기) — 코드 입력 → 바코드·인쇄숫자·인식번호 분해.
* LOT-입고PK-팩/박스-낱장 형식 및 DB 등록 바코드 지원.
*/
class BagNumberLookup
{
/**
* @return array{
* ok: bool,
* message: string,
* input: string,
* barcode_text: string,
* print_text: string,
* recognition_text: string,
* unit: string,
* bag_code: string,
* bag_name: string
* }
*/
public function resolve(string $raw, ?int $lgIdx = null): array
{
$input = trim($raw);
if ($input === '') {
return $this->fail('코드를 입력해 주세요.', $input);
}
$normalized = strtoupper(preg_replace('/\s+/', '', $input) ?? $input);
$parsed = $this->parseStructuredCode($normalized);
if ($parsed === null && $lgIdx !== null) {
$parsed = $this->resolveFromDatabase($lgIdx, $normalized);
}
if ($parsed === null) {
return $this->fail('인식할 수 없는 코드입니다. 봉투 바코드(LOT-입고번호-팩/박스-낱장) 형식을 확인해 주세요.', $input);
}
return [
'ok' => true,
'message' => '',
'input' => $input,
'barcode_text' => $this->formatRow($parsed['barcode'], 4),
'print_text' => $this->formatRow($parsed['print'], 3),
'recognition_text' => $this->formatRow($parsed['recognition'], 2),
'unit' => (string) ($parsed['unit'] ?? ''),
'bag_code' => (string) ($parsed['bag_code'] ?? ''),
'bag_name' => (string) ($parsed['bag_name'] ?? ''),
];
}
/**
* @param list<string> $parts
*/
private function formatRow(array $parts, int $slots): string
{
$cells = array_pad($parts, $slots, '-');
foreach ($cells as $i => $cell) {
if ($cell === '' || $cell === null) {
$cells[$i] = '-';
}
}
return implode(' ', $cells);
}
/**
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
*/
private function parseStructuredCode(string $code): ?array
{
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})-S(\d+)$/i', $code, $m) === 1) {
$sheet = str_pad((string) $m[4], 5, '0', STR_PAD_LEFT);
return [
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], 'S' . $sheet],
'print' => [(string) (int) $m[2], (string) (int) $m[3], (string) (int) $sheet],
'recognition' => [(string) $m[2], 'P' . $m[3]],
'unit' => '낱장',
];
}
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})$/i', $code, $m) === 1) {
return [
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], '-'],
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
'recognition' => [(string) $m[2], 'P' . $m[3]],
'unit' => '팩',
];
}
if (preg_match('/^([A-Z0-9]+)-(\d{6})-B(\d{3})$/i', $code, $m) === 1) {
return [
'barcode' => [(string) $m[1], (string) $m[2], 'B' . $m[3], '-'],
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
'recognition' => [(string) $m[2], 'B' . $m[3]],
'unit' => '박스',
];
}
if (preg_match('/^([A-Z0-9]{4,8})$/i', $code) === 1) {
return [
'barcode' => [$code, '-', '-', '-'],
'print' => ['-', '-', '-'],
'recognition' => [$code, '-'],
'unit' => 'LOT',
];
}
return null;
}
/**
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
*/
private function resolveFromDatabase(int $lgIdx, string $code): ?array
{
$db = \Config\Database::connect();
if (! $db->tableExists('bag_receiving_pack_code')) {
return null;
}
$row = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_pack_code', $code)
->get()
->getRowArray();
if (is_array($row) && $row !== []) {
return $this->parsedFromPackRow($code, $row, '팩');
}
$boxRows = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $code)
->limit(1)
->get()
->getRowArray();
if (is_array($boxRows) && $boxRows !== []) {
return $this->parsedFromPackRow($code, $boxRows, '박스');
}
$sheetRow = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code <=', $code)
->where('brpc_sheet_end_code >=', $code)
->limit(1)
->get()
->getRowArray();
if (is_array($sheetRow) && $sheetRow !== []) {
$parsed = $this->parseStructuredCode($code);
if ($parsed !== null) {
$parsed['bag_code'] = (string) ($sheetRow['brpc_bag_code'] ?? '');
$parsed['bag_name'] = (string) ($sheetRow['brpc_bag_name'] ?? '');
return $parsed;
}
}
$exactSheet = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->groupStart()
->where('brpc_sheet_start_code', $code)
->orWhere('brpc_sheet_end_code', $code)
->groupEnd()
->limit(1)
->get()
->getRowArray();
if (is_array($exactSheet) && $exactSheet !== []) {
$parsed = $this->parseStructuredCode($code);
if ($parsed !== null) {
$parsed['bag_code'] = (string) ($exactSheet['brpc_bag_code'] ?? '');
$parsed['bag_name'] = (string) ($exactSheet['brpc_bag_name'] ?? '');
return $parsed;
}
}
return null;
}
/**
* @param array<string, mixed> $row
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code:string,bag_name:string}
*/
private function parsedFromPackRow(string $code, array $row, string $unit): array
{
$parsed = $this->parseStructuredCode($code);
if ($parsed === null) {
$parsed = [
'barcode' => [$code, '-', '-', '-'],
'print' => ['-', '-', '-'],
'recognition' => ['-', '-'],
'unit' => $unit,
];
}
$parsed['unit'] = $unit;
$parsed['bag_code'] = (string) ($row['brpc_bag_code'] ?? '');
$parsed['bag_name'] = (string) ($row['brpc_bag_name'] ?? '');
return $parsed;
}
/**
* @return array{ok:bool,message:string,input:string,barcode_text:string,print_text:string,recognition_text:string,unit:string,bag_code:string,bag_name:string}
*/
private function fail(string $message, string $input): array
{
return [
'ok' => false,
'message' => $message,
'input' => $input,
'barcode_text' => $this->formatRow([], 4),
'print_text' => $this->formatRow([], 3),
'recognition_text' => $this->formatRow([], 2),
'unit' => '',
'bag_code' => '',
'bag_name' => '',
];
}
}

View File

@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 쓰레기봉투 수급 계획 (레거시 w_gm820r 유추)
*
* - 바코드 봉투: bag_receiving_pack_code 에 등록된 품목
* - 기존 봉투: 그 외 (수기·비바코드 재고)
* - 소진일수 ≈ (총재고 / 월판매량) × 30
* - 발주예정일 = 기준일 + 소진일수 적정재고보유일수(제작기일)
* - 긴급(발주예정일 ≤ 기준일): 발주수량 ≈ max(0, 월판매량×18 총재고)
*/
class BagSupplyPlanBuilder
{
private const AVG_SALES_MONTHS = 12;
/** 긴급 발주 시 목표 보유 개월 수 (레거시 화면 값 유추) */
private const URGENT_REPLENISH_MONTHS = 18;
private \CodeIgniter\Database\BaseConnection $db;
public function __construct(?\CodeIgniter\Database\BaseConnection $db = null)
{
$this->db = $db ?? \Config\Database::connect();
}
/**
* @return array{
* rows: list<array<string, mixed>>,
* barcodeCodes: array<string, bool>,
* queried: bool
* }
*/
public function build(
int $lgIdx,
string $refDate,
int $leadDays,
string $stockScope,
string $salesScope,
bool $queried
): array {
$barcodeCodes = $this->loadBarcodeCodes($lgIdx);
$products = $this->loadProducts($lgIdx);
if ($products === [] || ! $queried) {
return ['rows' => [], 'barcodeCodes' => $barcodeCodes, 'queried' => $queried];
}
$inventory = $this->loadInventoryMap($lgIdx);
$pendingIn = $this->loadPendingInbound($lgIdx);
$lastOrders = $this->loadLastOrders($lgIdx);
$monthlySales = $this->loadMonthlyAverageSales($lgIdx, $refDate, $salesScope, $barcodeCodes);
$movementsSince = $this->loadMovementsSinceOrders($lgIdx, $lastOrders, $refDate);
$rows = [];
foreach ($products as $code => $name) {
$isBarcode = isset($barcodeCodes[$code]);
$rawStock = (int) ($inventory[$code] ?? 0);
$currentStock = $this->scopedStock($rawStock, $stockScope, $isBarcode);
$pendingQty = $this->scopedPending((int) ($pendingIn[$code] ?? 0), $stockScope, $isBarcode);
$totalStock = $currentStock + $pendingQty;
$monthlyFloat = (float) ($monthlySales[$code] ?? 0.0);
$monthlyAvg = (int) round($monthlyFloat);
$depletionDays = $this->calcDepletionDays($totalStock, $monthlyFloat);
$scheduleDate = $this->calcScheduleDate($refDate, $depletionDays, $leadDays);
$orderQty = $this->calcOrderQty($refDate, $scheduleDate, $depletionDays, $leadDays, $monthlyAvg, $totalStock);
$last = $lastOrders[$code] ?? null;
$orderDate = $last ? (string) ($last['order_date'] ?? '') : '';
$lastQty = $last ? (int) ($last['qty_sheet'] ?? 0) : 0;
$stockAtOrder = 0;
if ($orderDate !== '' && $lastQty > 0) {
$mv = $movementsSince[$code] ?? ['sale' => 0, 'recv' => 0, 'issue' => 0];
$stockAtOrder = max(0, $rawStock + $mv['sale'] - $mv['recv'] - $mv['issue']);
}
$rows[] = [
'bag_code' => $code,
'bag_name' => $name,
'is_barcode' => $isBarcode,
'last_order_date' => $orderDate,
'last_order_qty' => $lastQty,
'stock_at_order' => $stockAtOrder,
'current_stock' => $currentStock,
'pending_inbound' => $pendingQty,
'total_stock' => $totalStock,
'monthly_avg_sales' => $monthlyAvg,
'depletion_days' => $depletionDays,
'schedule_date' => $scheduleDate,
'schedule_overdue' => $scheduleDate !== '' && $scheduleDate <= $refDate && $depletionDays < 99999,
'order_qty' => $orderQty,
];
}
usort($rows, static fn (array $a, array $b): int => strcmp((string) $a['bag_code'], (string) $b['bag_code']));
return ['rows' => $rows, 'barcodeCodes' => $barcodeCodes, 'queried' => true];
}
/**
* @return array<string, bool>
*/
private function loadBarcodeCodes(int $lgIdx): array
{
if (! $this->db->tableExists('bag_receiving_pack_code')) {
return [];
}
$set = [];
foreach ($this->db->table('bag_receiving_pack_code')
->select('brpc_bag_code')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->where('brpc_bag_code !=', '')
->get()
->getResultArray() as $row) {
$code = trim((string) ($row['brpc_bag_code'] ?? ''));
if ($code !== '') {
$set[$code] = true;
}
}
return $set;
}
/**
* @return array<string, string> code => name
*/
private function loadProducts(int $lgIdx): array
{
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
$products = [];
if ($kindO) {
foreach (model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx) as $d) {
$code = trim((string) ($d->cd_code ?? ''));
if ($code !== '') {
$products[$code] = trim((string) ($d->cd_name ?? $code));
}
}
}
foreach ($this->db->table('bag_inventory')
->select('bi_bag_code, bi_bag_name')
->where('bi_lg_idx', $lgIdx)
->get()
->getResultArray() as $row) {
$code = trim((string) ($row['bi_bag_code'] ?? ''));
if ($code === '') {
continue;
}
if (! isset($products[$code])) {
$products[$code] = trim((string) ($row['bi_bag_name'] ?? $code));
}
}
return $products;
}
/**
* @return array<string, int>
*/
private function loadInventoryMap(int $lgIdx): array
{
$map = [];
foreach (model(\App\Models\BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->findAll() as $inv) {
$code = trim((string) ($inv->bi_bag_code ?? ''));
if ($code !== '') {
$map[$code] = (int) ($inv->bi_qty ?? 0);
}
}
return $map;
}
/**
* @return array<string, int>
*/
private function loadPendingInbound(int $lgIdx): array
{
$map = [];
$sql = "
SELECT boi.boi_bag_code AS bag_code,
SUM(GREATEST(0, CAST(boi.boi_qty_sheet AS SIGNED) - IFNULL(r.recv_qty, 0))) AS pending_qty
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
LEFT JOIN (
SELECT br_bo_idx, br_bag_code, SUM(br_qty_sheet) AS recv_qty
FROM bag_receiving
GROUP BY br_bo_idx, br_bag_code
) r ON r.br_bo_idx = bo.bo_idx AND r.br_bag_code = boi.boi_bag_code
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
GROUP BY boi.boi_bag_code
";
foreach ($this->db->query($sql, [$lgIdx])->getResult() as $row) {
$qty = (int) ($row->pending_qty ?? 0);
if ($qty > 0) {
$map[(string) $row->bag_code] = $qty;
}
}
return $map;
}
/**
* @return array<string, array{order_date: string, qty_sheet: int, bag_name: string}>
*/
private function loadLastOrders(int $lgIdx): array
{
$map = [];
$supportsWindow = $this->db->DBDriver === 'MySQLi';
if ($supportsWindow) {
$sql = "
SELECT bag_code, order_date, qty_sheet, bag_name
FROM (
SELECT boi.boi_bag_code AS bag_code, bo.bo_order_date AS order_date,
boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name,
ROW_NUMBER() OVER (
PARTITION BY boi.boi_bag_code
ORDER BY bo.bo_order_date DESC, bo.bo_idx DESC
) AS rn
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
) t
WHERE t.rn = 1
";
foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) {
$code = trim((string) ($row['bag_code'] ?? ''));
if ($code === '') {
continue;
}
$map[$code] = [
'order_date' => (string) ($row['order_date'] ?? ''),
'qty_sheet' => (int) ($row['qty_sheet'] ?? 0),
'bag_name' => (string) ($row['bag_name'] ?? ''),
];
}
return $map;
}
$sql = "
SELECT boi.boi_bag_code AS bag_code, MAX(bo.bo_order_date) AS order_date
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
GROUP BY boi.boi_bag_code
";
foreach ($this->db->query($sql, [$lgIdx])->getResultArray() as $row) {
$code = trim((string) ($row['bag_code'] ?? ''));
$orderDate = (string) ($row['order_date'] ?? '');
if ($code === '' || $orderDate === '') {
continue;
}
$item = $this->db->query("
SELECT boi.boi_qty_sheet AS qty_sheet, boi.boi_bag_name AS bag_name
FROM bag_order_item boi
INNER JOIN bag_order bo ON bo.bo_idx = boi.boi_bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
AND boi.boi_bag_code = ? AND bo.bo_order_date = ?
ORDER BY bo.bo_idx DESC
LIMIT 1
", [$lgIdx, $code, $orderDate])->getRowArray();
if ($item) {
$map[$code] = [
'order_date' => $orderDate,
'qty_sheet' => (int) ($item['qty_sheet'] ?? 0),
'bag_name' => (string) ($item['bag_name'] ?? ''),
];
}
}
return $map;
}
/**
* @param array<string, array{order_date: string, qty_sheet: int, bag_name: string}> $lastOrders
* @return array<string, array{sale: int, recv: int, issue: int}>
*/
private function loadMovementsSinceOrders(int $lgIdx, array $lastOrders, string $refDate): array
{
$byCode = [];
$minDate = $refDate;
foreach ($lastOrders as $code => $info) {
$d = (string) ($info['order_date'] ?? '');
if ($d === '') {
continue;
}
$byCode[$code] = $d;
if ($d < $minDate) {
$minDate = $d;
}
}
if ($byCode === []) {
return [];
}
$codes = array_keys($byCode);
$placeholders = implode(',', array_fill(0, count($codes), '?'));
$params = array_merge([$lgIdx], $codes, [$minDate, $refDate]);
$out = [];
foreach ($codes as $code) {
$out[$code] = ['sale' => 0, 'recv' => 0, 'issue' => 0];
}
$sql = "
SELECT bs_bag_code AS bag_code, bs_sale_date AS mv_date,
SUM(CASE WHEN bs_type IN ('sale','cancel') THEN ABS(bs_qty) ELSE 0 END) AS sale_qty
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_bag_code IN ({$placeholders})
AND bs_sale_date >= ? AND bs_sale_date <= ?
GROUP BY bs_bag_code, bs_sale_date
";
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$orderDate = $byCode[$code] ?? '';
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
continue;
}
$out[$code]['sale'] += (int) $row->sale_qty;
}
$sql = "
SELECT br_bag_code AS bag_code, br_receive_date AS mv_date, SUM(br_qty_sheet) AS recv_qty
FROM bag_receiving
WHERE br_lg_idx = ? AND br_bag_code IN ({$placeholders})
AND br_receive_date >= ? AND br_receive_date <= ?
GROUP BY br_bag_code, br_receive_date
";
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$orderDate = $byCode[$code] ?? '';
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
continue;
}
$out[$code]['recv'] += (int) $row->recv_qty;
}
$sql = "
SELECT bi2_bag_code AS bag_code, bi2_issue_date AS mv_date, SUM(bi2_qty) AS issue_qty
FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_bag_code IN ({$placeholders})
AND bi2_issue_date >= ? AND bi2_issue_date <= ?
GROUP BY bi2_bag_code, bi2_issue_date
";
foreach ($this->db->query($sql, $params)->getResult() as $row) {
$code = (string) $row->bag_code;
$orderDate = $byCode[$code] ?? '';
if ($orderDate === '' || (string) $row->mv_date < $orderDate) {
continue;
}
$out[$code]['issue'] += (int) $row->issue_qty;
}
return $out;
}
/**
* @param array<string, bool> $barcodeCodes
* @return array<string, float>
*/
private function loadMonthlyAverageSales(
int $lgIdx,
string $refDate,
string $salesScope,
array $barcodeCodes
): array {
$fromDate = date('Y-m-d', strtotime($refDate . ' -' . self::AVG_SALES_MONTHS . ' months'));
$legacyNet = [];
$barcodeNet = [];
foreach ($this->db->query("
SELECT bs_bag_code AS bag_code,
SUM(CASE WHEN bs_type = 'sale' THEN ABS(bs_qty)
WHEN bs_type IN ('return','cancel') THEN -ABS(bs_qty)
ELSE 0 END) AS net_qty
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_sale_date > ? AND bs_sale_date <= ?
GROUP BY bs_bag_code
", [$lgIdx, $fromDate, $refDate])->getResult() as $row) {
$code = (string) $row->bag_code;
$legacyNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS;
}
if ($this->db->tableExists('bag_sale_scan_code')) {
foreach ($this->db->query("
SELECT bssc_bag_code AS bag_code, SUM(bssc_qty) AS net_qty
FROM bag_sale_scan_code
WHERE bssc_lg_idx = ? AND bssc_state = 'sold'
AND DATE(bssc_regdate) > ? AND DATE(bssc_regdate) <= ?
GROUP BY bssc_bag_code
", [$lgIdx, $fromDate, $refDate])->getResult() as $row) {
$code = (string) $row->bag_code;
$barcodeNet[$code] = (float) ($row->net_qty ?? 0) / self::AVG_SALES_MONTHS;
}
}
$merged = [];
$allCodes = array_unique(array_merge(array_keys($legacyNet), array_keys($barcodeNet)));
foreach ($allCodes as $code) {
$isBarcode = isset($barcodeCodes[$code]);
$legacy = $legacyNet[$code] ?? 0.0;
$scan = $barcodeNet[$code] ?? 0.0;
$merged[$code] = match ($salesScope) {
'legacy' => $isBarcode ? 0.0 : $legacy,
'barcode' => $isBarcode ? ($scan > 0 ? $scan : $legacy) : 0.0,
default => $isBarcode && $scan > 0 ? $scan : $legacy,
};
}
return $merged;
}
private function scopedStock(int $qty, string $scope, bool $isBarcode): int
{
return match ($scope) {
'legacy' => $isBarcode ? 0 : $qty,
'barcode' => $isBarcode ? $qty : 0,
default => $qty,
};
}
private function scopedPending(int $qty, string $scope, bool $isBarcode): int
{
return $this->scopedStock($qty, $scope, $isBarcode);
}
private function calcDepletionDays(int $totalStock, float $monthlyAvg): int
{
if ($monthlyAvg <= 0.0) {
return 0;
}
return (int) round(($totalStock / $monthlyAvg) * 30);
}
private function calcScheduleDate(string $refDate, int $depletionDays, int $leadDays): string
{
if ($depletionDays <= 0) {
return '';
}
if ($depletionDays >= 99999) {
return '';
}
$offset = $depletionDays - $leadDays;
if ($offset < 0) {
return $refDate;
}
$base = \DateTimeImmutable::createFromFormat('Y-m-d', $refDate);
if ($base === false) {
return '';
}
try {
$scheduled = $base->modify('+' . $offset . ' days');
} catch (\Exception) {
return '';
}
$year = (int) $scheduled->format('Y');
$refYear = (int) $base->format('Y');
if ($year < $refYear - 1 || $year > $refYear + 120) {
return '';
}
return $scheduled->format('Y-m-d');
}
private function calcOrderQty(
string $refDate,
string $scheduleDate,
int $depletionDays,
int $leadDays,
int $monthlyAvg,
int $totalStock
): int {
if ($monthlyAvg <= 0) {
return 0;
}
$urgent = $scheduleDate !== '' && $scheduleDate <= $refDate;
$lowStock = $depletionDays > 0 && $depletionDays <= $leadDays && $scheduleDate !== '';
if (! $urgent && ! $lowStock) {
return 0;
}
$target = (int) round($monthlyAvg * ($urgent ? self::URGENT_REPLENISH_MONTHS : max(2, (int) ceil($leadDays / 30.0))));
return max(0, $target - $totalStock);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Libraries\Blockchain;
use App\Models\BlockchainLedgerModel;
class SqlLedger
{
private BlockchainLedgerModel $ledgerModel;
public function __construct()
{
$this->ledgerModel = model(BlockchainLedgerModel::class);
}
/**
* @param array<string,mixed> $payload
* @return array{index:int,hash:string,previous_hash:string}
*/
public function appendBlock(string $txType, array $payload, ?string $entityUuid, int $entityVersion, ?int $actorIdx, ?int $lgIdx): array
{
$latest = $this->ledgerModel->orderBy('bl_idx', 'DESC')->first();
// 원장이 비어 있으면 $latest 가 null — $latest->bl_hash 는 PHP 8에서 치명 오류(? 는 ?? 와 달리 속성 접근 자체가 먼저 평가됨)
$previousHash = ($latest === null || ! isset($latest->bl_hash) || (string) $latest->bl_hash === '')
? str_repeat('0', 64)
: (string) $latest->bl_hash;
$now = date('Y-m-d H:i:s');
$normalizedPayload = $this->normalizeArray($payload);
$payloadJson = json_encode($normalizedPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
$hashInput = implode('|', [
$now,
$txType,
$entityUuid ?? '',
(string) $entityVersion,
$payloadJson,
$previousHash,
'0',
]);
$currentHash = hash('sha256', $hashInput);
$this->ledgerModel->insert([
'bl_created_at' => $now,
'bl_tx_type' => $txType,
'bl_entity_uuid' => $entityUuid,
'bl_entity_version' => $entityVersion,
'bl_payload' => $payloadJson,
'bl_previous_hash' => $previousHash,
'bl_hash' => $currentHash,
'bl_nonce' => 0,
'bl_actor_idx' => $actorIdx,
'bl_lg_idx' => $lgIdx,
]);
return [
'index' => (int) $this->ledgerModel->getInsertID(),
'hash' => $currentHash,
'previous_hash' => $previousHash,
];
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
private function normalizeArray(array $data): array
{
ksort($data);
foreach ($data as $key => $value) {
if (is_array($value)) {
if ($this->isAssoc($value)) {
/** @var array<string,mixed> $assoc */
$assoc = $value;
$data[$key] = $this->normalizeArray($assoc);
} else {
$normalizedList = [];
foreach ($value as $item) {
if (is_array($item) && $this->isAssoc($item)) {
/** @var array<string,mixed> $assoc */
$assoc = $item;
$normalizedList[] = $this->normalizeArray($assoc);
} else {
$normalizedList[] = $item;
}
}
$data[$key] = $normalizedList;
}
}
}
return $data;
}
/**
* @param array<mixed> $array
*/
private function isAssoc(array $array): bool
{
if ($array === []) {
return false;
}
return array_keys($array) !== range(0, count($array) - 1);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use App\Models\CodeDetailModel;
use App\Models\CodeKindModel;
use Config\Roles;
/**
* 공공 포털형 기본 코드관리 UI 시안 전용 데이터 (/bag/code-kinds 와 별도 URL, 동일 집계)
*/
class GovPortalCodeKindsPage
{
/**
* @return array<string, mixed>
*/
public function buildPageData(?int $lgIdx, int $level, ?int $adminLgIdx, int $selectedCkIdx, array $filters): array
{
$kindModel = model(CodeKindModel::class);
$detailModel = model(CodeDetailModel::class);
$kinds = [];
$countMap = [];
$selectedKind = null;
$detailList = [];
$rowCanEdit = [];
$qCode = trim((string) ($filters['q_code'] ?? ''));
$qName = trim((string) ($filters['q_name'] ?? ''));
$qState = (string) ($filters['q_state'] ?? '');
try {
$builder = $kindModel->orderBy('ck_code', 'ASC');
if ($qCode !== '') {
$builder->like('ck_code', $qCode);
}
if ($qName !== '') {
$builder->like('ck_name', $qName);
}
if ($qState === '1' || $qState === '0') {
$builder->where('ck_state', (int) $qState);
}
$kinds = $builder->findAll();
foreach ($kinds as $row) {
$countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx)
->filterByTenantScope($lgIdx)
->countAllResults();
}
} catch (\Throwable $e) {
log_message('error', '[GovPortalCodeKinds] {type} {message}', [
'type' => $e::class,
'message' => $e->getMessage(),
]);
}
$canManageKinds = Roles::canManageCodeKindMaster($level);
$canManageDetails = Roles::canManageCodeMaster($level);
if ($kinds !== []) {
foreach ($kinds as $row) {
if ((int) $row->ck_idx === $selectedCkIdx) {
$selectedKind = $row;
break;
}
}
if ($selectedKind === null) {
$selectedKind = $kinds[0];
}
}
if ($selectedKind !== null) {
$detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx)
->filterByTenantScope($lgIdx)
->orderBy('cd_sort', 'ASC')
->orderBy('cd_code', 'ASC')
->orderBy('cd_idx', 'ASC')
->findAll();
foreach ($detailList as $row) {
$rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLgIdx);
}
}
return [
'codeKinds' => $kinds,
'countMap' => $countMap,
'canManageKinds' => $canManageKinds,
'canManageDetails' => $canManageDetails,
'selectedKind' => $selectedKind,
'detailList' => $detailList,
'rowCanEdit' => $rowCanEdit,
'totalCount' => count($kinds),
'filters' => [
'q_code' => $qCode,
'q_name' => $qName,
'q_state' => $qState,
],
];
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use Config\Manual as ManualConfig;
use League\CommonMark\Exception\CommonMarkException;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* 사용자 매뉴얼(설명서) 렌더러.
*
* - 목차(manifest)는 Config\Manual 에서 가져온다.
* - slug → 파일 매핑은 화이트리스트(manifest)로만 결정한다(사용자 입력으로 파일명 조합 금지).
* - 마크다운은 GFM(표·코드블록)로 변환하며, md 내 raw HTML 은 이스케이프한다.
*/
class ManualRenderer
{
private ManualConfig $config;
private GithubFlavoredMarkdownConverter $converter;
public function __construct(?ManualConfig $config = null)
{
$this->config = $config ?? config(ManualConfig::class);
$this->converter = new GithubFlavoredMarkdownConverter([
// 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다.
'html_input' => 'escape',
'allow_unsafe_links' => false,
'max_nesting_level' => 50,
]);
}
/**
* 목차(slug → title/file). 배열 순서가 노출 순서.
*
* @return array<string, array{title: string, file: string}>
*/
public function pages(): array
{
return $this->config->pages;
}
/** 목차의 첫 번째 slug (기본 진입 페이지). */
public function firstSlug(): string
{
return (string) (array_key_first($this->config->pages) ?? '');
}
/**
* slug 메타 조회. 화이트리스트에 없으면 null.
*
* @return array{title: string, file: string}|null
*/
public function find(string $slug): ?array
{
return $this->config->pages[$slug] ?? null;
}
/**
* 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''.
*/
public function plainText(string $slug): string
{
$html = $this->render($slug);
if ($html === null || $html === '') {
return '';
}
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $text));
}
/**
* 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순).
*
* @return list<array{slug:string,title:string,snippet:string,hits:int}>
*/
public function search(string $q): array
{
$q = trim($q);
if ($q === '' || mb_strlen($q) < 1) {
return [];
}
$needle = mb_strtolower($q);
$out = [];
foreach ($this->pages() as $slug => $page) {
$text = $this->plainText((string) $slug);
if ($text === '') {
continue;
}
$hay = mb_strtolower($text);
$pos = mb_strpos($hay, $needle);
if ($pos === false) {
continue;
}
$hits = mb_substr_count($hay, $needle);
$start = max(0, $pos - 30);
$snippet = mb_substr($text, $start, 100);
if ($start > 0) {
$snippet = '…' . $snippet;
}
$out[] = [
'slug' => (string) $slug,
'title' => (string) $page['title'],
'snippet' => trim($snippet),
'hits' => $hits,
];
}
usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']);
return $out;
}
/**
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
*/
public function render(string $slug): ?string
{
$page = $this->find($slug);
if ($page === null) {
return null;
}
$path = $this->resolvePath($page['file']);
if ($path === null) {
return null;
}
$markdown = @file_get_contents($path);
if ($markdown === false) {
return null;
}
try {
return (string) $this->converter->convert($markdown);
} catch (CommonMarkException) {
return null;
}
}
/**
* 파일명을 디렉터리 경계 안으로 안전하게 해석한다.
* manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다.
*/
private function resolvePath(string $file): ?string
{
$baseReal = realpath(rtrim($this->config->dir, '/\\'));
if ($baseReal === false) {
return null;
}
$candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file);
if ($candidate === false || ! is_file($candidate)) {
return null;
}
$prefix = $baseReal . DIRECTORY_SEPARATOR;
if (! str_starts_with($candidate, $prefix)) {
return null;
}
return $candidate;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BagIssueItemCodeModel extends Model
{
protected $table = 'bag_issue_item_code';
protected $primaryKey = 'bic_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'bic_lg_idx',
'bic_bi2_idx',
'bic_bag_code',
'bic_issue_code',
'bic_qty',
'bic_cancel_qty',
'bic_state',
'bic_regdate',
];
}

View File

@@ -10,9 +10,26 @@ class BagOrderModel extends Model
protected $primaryKey = 'bo_idx'; protected $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

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

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BlockchainLedgerModel extends Model
{
protected $table = 'blockchain_ledger';
protected $primaryKey = 'bl_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'bl_created_at',
'bl_tx_type',
'bl_entity_uuid',
'bl_entity_version',
'bl_payload',
'bl_previous_hash',
'bl_hash',
'bl_nonce',
'bl_actor_idx',
'bl_lg_idx',
];
}

View File

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

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

View File

@@ -189,4 +189,102 @@ class MenuModel extends Model
} }
} }
/**
* 특정 메뉴 타입(mt_idx)을 source 지자체 기준으로 모든 지자체에 재배포.
* 기존 대상 지자체의 해당 타입 메뉴는 삭제 후 source 구조로 재생성한다.
*/
public function syncTypeToAllLgs(int $mtIdx, int $sourceLg): void
{
if ($mtIdx <= 0 || $sourceLg <= 0) {
return;
}
$source = $this->where('mt_idx', $mtIdx)
->where('lg_idx', $sourceLg)
->orderBy('mm_dep', 'ASC')
->orderBy('mm_num', 'ASC')
->findAll();
if (empty($source)) {
return;
}
$lgRows = $this->db->table('local_government')
->select('lg_idx')
->orderBy('lg_idx', 'ASC')
->get()
->getResultArray();
foreach ($lgRows as $lgRow) {
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
if ($destLg <= 0 || $destLg === $sourceLg) {
continue;
}
$this->db->transStart();
$this->where('mt_idx', $mtIdx)
->where('lg_idx', $destLg)
->delete();
$idMap = [];
foreach ($source as $row) {
$oldId = (int) ($row->mm_idx ?? 0);
$oldP = (int) ($row->mm_pidx ?? 0);
$newPidx = 0;
if ($oldP > 0 && isset($idMap[$oldP])) {
$newPidx = (int) $idMap[$oldP];
}
$this->insert([
'mt_idx' => $mtIdx,
'lg_idx' => $destLg,
'mm_name' => (string) ($row->mm_name ?? ''),
'mm_link' => (string) ($row->mm_link ?? ''),
'mm_pidx' => $newPidx,
'mm_dep' => (int) ($row->mm_dep ?? 0),
'mm_num' => (int) ($row->mm_num ?? 0),
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
'mm_level' => (string) ($row->mm_level ?? ''),
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
]);
$idMap[$oldId] = (int) $this->getInsertID();
}
$this->db->transComplete();
}
}
/**
* 재고 관리 하위 메뉴는 "재고 현황", "실사 선별 조회"만 유지.
*/
public function pruneInventoryManagementMenus(int $mtIdx, int $lgIdx): void
{
if ($mtIdx <= 0 || $lgIdx <= 0) {
return;
}
$parentRows = $this->where('mt_idx', $mtIdx)
->where('lg_idx', $lgIdx)
->where('mm_pidx', 0)
->groupStart()
->where('mm_name', '재고 관리')
->orWhere('mm_name', '재고관리')
->groupEnd()
->findAll();
if ($parentRows === []) {
return;
}
$parentIds = array_values(array_filter(array_map(
static fn ($row): int => (int) ($row->mm_idx ?? 0),
$parentRows
)));
if ($parentIds === []) {
return;
}
$this->where('mt_idx', $mtIdx)
->where('lg_idx', $lgIdx)
->whereIn('mm_pidx', $parentIds)
->whereNotIn('mm_link', ['bag/inventory', 'bag/inventory/inspection-select'])
->delete();
}
} }

View File

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

@@ -12,7 +12,7 @@ class ShopOrderModel extends Model
protected $useTimestamps = false; protected $useTimestamps = false;
protected $allowedFields = [ protected $allowedFields = [
'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date', 'so_lg_idx', 'so_ds_idx', 'so_ds_name', 'so_order_date', 'so_delivery_date',
'so_payment_type', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount', 'so_payment_type', 'so_channel', 'so_paid', 'so_received', 'so_total_qty', 'so_total_amount',
'so_status', 'so_orderer_idx', 'so_regdate', 'so_status', 'so_orderer_idx', 'so_regdate',
]; ];
} }

View File

@@ -31,7 +31,6 @@
<th>봉투코드</th> <th>봉투코드</th>
<th>봉투명</th> <th>봉투명</th>
<th>수량</th> <th>수량</th>
<th class="w-20">상태</th>
<th class="w-24">작업</th> <th class="w-24">작업</th>
</tr> </tr>
</thead> </thead>
@@ -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 bg-white p-2">
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label> <div class="flex flex-wrap items-center gap-4 text-sm">
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bo_order_date" type="date" value="<?= esc(old('bo_order_date', date('Y-m-d'))) ?>" required/> <div class="flex items-center gap-2">
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
<input id="bo_order_month_ui" name="bo_order_month_ui" type="month" value="<?= esc($defaultOrderMonth) ?>" class="border border-gray-300 rounded px-2 py-1" />
</div>
<div class="flex items-center gap-2">
<label for="bo_order_date" class="font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1" />
</div>
<p class="text-blue-600 font-bold">※ 발주수량은 박스단위로 입력해 주세요. (발주일은 미래일도 선택 가능)</p>
</div>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label> <section class="xl:col-span-5 border border-gray-300 bg-white">
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>"/> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<span class="text-sm text-gray-500">%</span> <div class="overflow-auto max-h-[410px]">
</div> <table class="w-full data-table text-sm">
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">제작업체</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_company_idx">
<option value="">선택</option>
<?php foreach ($companies as $cp): ?>
<option value="<?= esc($cp->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $cp->cp_idx ? 'selected' : '' ?>>
<?= esc($cp->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">입고처</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_agency_idx">
<option value="">선택</option>
<?php foreach ($agencies as $ag): ?>
<option value="<?= esc($ag->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $ag->sa_idx ? 'selected' : '' ?>>
[<?= esc($ag->sa_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">발주일</th>
<th>봉투</th> <th>제작업체</th>
<th class="w-32">박스수</th> <th>입고처</th>
<th class="w-16">상태</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php for ($i = 0; $i < 3; $i++): ?> <?php foreach (($recentOrders ?? []) as $history): ?>
<tr> <tr>
<td class="text-center"><?= $i + 1 ?></td> <td class="text-center"><?= esc((string) $history->bo_order_date) ?></td>
<td> <td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]"> <td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<option value="">선택</option> <td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty_box[]" type="number" min="0" value="0"/>
</td>
</tr> </tr>
<?php endfor; ?> <?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<section class="xl:col-span-7 border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 Form</div>
<div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex items-center gap-2 text-sm">
<label for="bo_fee_rate" class="w-20 font-bold text-gray-700">수수료</label>
<input id="bo_fee_rate" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>" class="border border-gray-300 rounded px-2 py-1 w-24 text-right" />
<span>%</span>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($associations ?? []) as $association): ?>
<option value="<?= esc((string) $association->cp_idx) ?>" <?= (int) old('bo_association_idx') === (int) $association->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $association->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_company_idx" class="w-20 font-bold text-gray-700">제작업체</label>
<select id="bo_company_idx" name="bo_company_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= esc((string) $company->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $company->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_agency_idx" class="w-20 font-bold text-gray-700">입고처</label>
<select id="bo_agency_idx" name="bo_agency_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($agencies ?? []) as $agency): ?>
<option value="<?= esc((string) $agency->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $agency->sa_idx ? 'selected' : '' ?>>
[<?= esc((string) ($agency->sa_kind ?? '')) ?>] <?= esc((string) ($agency->sa_code ?? '')) ?> — <?= esc((string) $agency->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="border border-gray-300 overflow-auto">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button> <table class="w-full data-table text-sm order-input-table" id="order-item-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-16">선택</th>
<th>품명</th>
<th class="w-28">수량(BOX)</th>
<th class="w-24">단가</th>
<th class="w-24">환산수량</th>
<th class="w-28">금액</th>
</tr>
</thead>
<tbody id="selected-order-items-body"></tbody>
<tfoot>
<tr>
<th colspan="3" class="text-center">계</th>
<th class="text-right pr-2" id="sum-box-qty">0</th>
<th></th>
<th class="text-right pr-2" id="sum-sheet-qty">0</th>
<th class="text-right pr-2" id="sum-amount">0</th>
</tr>
</tfoot>
</table>
</div>
<div class="flex gap-2 pt-1">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">발주</button>
<a href="<?= mgmt_url('bag-orders') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a> <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 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div>
<p class="text-xs text-gray-600 px-2 py-1">아래 목록에서 봉투를 선택하면 발주 품목에 추가됩니다. (개수 제한 없음)</p>
<div class="overflow-auto">
<table class="w-full data-table text-sm order-reference-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-20">선택</th>
<th>봉투 종류</th>
<th class="w-24">발주단가</th>
<th class="w-24">Box당 팩</th>
<th class="w-24">팩당 낱장</th>
<th class="w-28">1박스 총 낱장</th>
</tr>
</thead>
<tbody>
<?php foreach (($bagReferenceRows ?? []) as $idx => $row): ?>
<tr data-reference-row data-code="<?= esc((string) $row['code']) ?>" class="cursor-pointer">
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center">
<button type="button" class="js-toggle-bag border border-gray-300 rounded px-2 py-0.5 text-xs hover:bg-gray-100" data-code="<?= esc((string) $row['code']) ?>">선택</button>
</td>
<td class="text-left pl-2"><?= esc((string) $row['name']) ?></td>
<td class="text-right pr-2"><?= number_format((float) $row['orderPrice'], 2) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['boxPerPack']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['packPerSheet']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['totalPerBox']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($bagReferenceRows)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 봉투 기준 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</form>
<style>
.order-input-table tbody tr,
.order-reference-table tbody tr {
height: 34px;
}
.order-input-table tbody td,
.order-reference-table tbody td {
padding-top: 4px;
padding-bottom: 4px;
}
</style>
<script>
(() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const initialSelectedItems = <?= json_encode($initialSelectedItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const selectedBody = document.getElementById('selected-order-items-body');
const referenceRows = Array.from(document.querySelectorAll('[data-reference-row]'));
const sumBoxQtyEl = document.getElementById('sum-box-qty');
const sumSheetQtyEl = document.getElementById('sum-sheet-qty');
const sumAmountEl = document.getElementById('sum-amount');
const monthInput = document.getElementById('bo_order_month_ui');
const orderDateInput = document.getElementById('bo_order_date');
const orderForm = document.querySelector('form[action*="bag-orders/store"]');
const selectedItems = new Map();
let activeCode = null;
const formatNumber = (value) => new Intl.NumberFormat('ko-KR').format(Number.isFinite(value) ? value : 0);
const escapeHtml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const syncMonthFromDate = () => {
if (!orderDateInput || !monthInput || !orderDateInput.value) return;
monthInput.value = orderDateInput.value.substring(0, 7);
};
const syncDateFromMonth = () => {
if (!orderDateInput || !monthInput || !monthInput.value) return;
const parts = monthInput.value.split('-');
if (parts.length !== 2) return;
const year = Number(parts[0]);
const month = Number(parts[1]);
if (!Number.isFinite(year) || !Number.isFinite(month)) return;
const currentDay = orderDateInput.value ? Number(orderDateInput.value.split('-')[2]) : 1;
const lastDay = new Date(year, month, 0).getDate();
const day = String(Math.min(Math.max(currentDay, 1), lastDay)).padStart(2, '0');
orderDateInput.value = `${String(year)}-${String(month).padStart(2, '0')}-${day}`;
};
const updateReferenceSelectionUi = () => {
referenceRows.forEach((row) => {
const code = row.dataset.code || '';
const button = row.querySelector('.js-toggle-bag');
const isSelected = selectedItems.has(code);
row.classList.toggle('bg-blue-50', isSelected);
if (button) {
button.textContent = isSelected ? '선택됨' : '선택';
button.classList.toggle('bg-blue-600', isSelected);
button.classList.toggle('text-white', isSelected);
button.classList.toggle('border-blue-600', isSelected);
}
});
};
const updateTotals = () => {
let sumBoxQty = 0;
let sumSheetQty = 0;
let sumAmount = 0;
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
const code = row.dataset.code || '';
const qtyInput = row.querySelector('.item-qty-box');
const qtyBox = Math.max(0, parseInt(qtyInput?.value || '0', 10));
const meta = bagMeta[code] || { orderPrice: 0, totalPerBox: 1 };
const unitPrice = Number(meta.orderPrice || 0);
const totalPerBox = Math.max(1, Number(meta.totalPerBox || 1));
const qtySheet = qtyBox * totalPerBox;
const amount = qtySheet * unitPrice;
const unitPriceEl = row.querySelector('.item-unit-price');
const qtySheetEl = row.querySelector('.item-qty-sheet');
const sheetHelpEl = row.querySelector('.item-sheet-help');
const amountEl = row.querySelector('.item-amount');
if (unitPriceEl) unitPriceEl.textContent = formatNumber(unitPrice);
if (qtySheetEl) qtySheetEl.textContent = formatNumber(qtySheet);
if (sheetHelpEl) sheetHelpEl.textContent = `낱장 ${formatNumber(qtySheet)}장`;
if (amountEl) amountEl.textContent = formatNumber(amount);
selectedItems.set(code, { qtyBox });
sumBoxQty += qtyBox;
sumSheetQty += qtySheet;
sumAmount += amount;
});
if (sumBoxQtyEl) sumBoxQtyEl.textContent = formatNumber(sumBoxQty);
if (sumSheetQtyEl) sumSheetQtyEl.textContent = formatNumber(sumSheetQty);
if (sumAmountEl) sumAmountEl.textContent = formatNumber(sumAmount);
};
const setActiveRow = (code) => {
activeCode = code || null;
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
row.classList.toggle('bg-amber-50', row.dataset.code === activeCode);
});
};
const renderSelectedRows = () => {
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
if (codes.length === 0) {
selectedBody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">아래 "발주 등록 종류"에서 봉투를 선택해 주세요.</td></tr>';
setActiveRow(null);
updateTotals();
updateReferenceSelectionUi();
return;
}
selectedBody.innerHTML = codes.map((code, idx) => {
const meta = bagMeta[code];
const qtyBox = Math.max(0, parseInt(String(selectedItems.get(code)?.qtyBox ?? 0), 10));
const name = meta?.name || code;
return `
<tr data-item-row data-code="${escapeHtml(code)}" class="cursor-pointer">
<td class="text-center">${idx + 1}</td>
<td class="text-center">
<button type="button" class="js-remove-selected text-xs text-red-600 hover:underline" data-code="${escapeHtml(code)}">해제</button>
</td>
<td class="text-left pl-2">
${escapeHtml(name)}
<input type="hidden" name="item_bag_code[]" value="${escapeHtml(code)}" />
</td>
<td>
<input name="item_qty_box[]" type="number" min="0" step="1" value="${qtyBox}" class="item-qty-box border border-gray-300 rounded px-2 py-1 text-sm w-full text-right leading-tight" />
<p class="text-[11px] text-gray-500 mt-1 item-sheet-help">낱장 0장</p>
</td>
<td class="text-right pr-2 item-unit-price">0</td>
<td class="text-right pr-2 item-qty-sheet">0</td>
<td class="text-right pr-2 item-amount">0</td>
</tr>
`;
}).join('');
if (!activeCode || !selectedItems.has(activeCode)) {
activeCode = codes[0];
}
setActiveRow(activeCode);
updateTotals();
updateReferenceSelectionUi();
};
const toggleSelection = (code) => {
if (!code || !bagMeta[code]) return;
if (selectedItems.has(code)) {
selectedItems.delete(code);
if (activeCode === code) activeCode = null;
} else {
selectedItems.set(code, { qtyBox: 0 });
activeCode = code;
}
renderSelectedRows();
};
initialSelectedItems.forEach((item) => {
if (!item || !item.code || !bagMeta[item.code]) return;
selectedItems.set(item.code, { qtyBox: Math.max(0, parseInt(String(item.qtyBox ?? 0), 10)) });
activeCode = item.code;
});
selectedBody.addEventListener('click', (event) => {
const removeButton = event.target.closest('.js-remove-selected');
if (removeButton) {
toggleSelection(removeButton.dataset.code || '');
return;
}
const row = event.target.closest('tr[data-item-row]');
if (!row) return;
const code = row.dataset.code || '';
setActiveRow(code);
const qtyInput = row.querySelector('.item-qty-box');
if (qtyInput) qtyInput.focus();
});
selectedBody.addEventListener('input', (event) => {
const qtyInput = event.target.closest('.item-qty-box');
if (!qtyInput) return;
const row = qtyInput.closest('tr[data-item-row]');
if (!row) return;
const code = row.dataset.code || '';
selectedItems.set(code, { qtyBox: Math.max(0, parseInt(qtyInput.value || '0', 10)) });
updateTotals();
});
referenceRows.forEach((row) => {
row.addEventListener('click', (event) => {
const button = event.target.closest('.js-toggle-bag');
if (button) {
toggleSelection(button.dataset.code || '');
return;
}
if (event.target.closest('td')) {
toggleSelection(row.dataset.code || '');
}
});
});
if (monthInput) monthInput.addEventListener('change', () => { syncDateFromMonth(); updateTotals(); });
if (orderDateInput) orderDateInput.addEventListener('change', syncMonthFromDate);
if (orderForm) {
orderForm.addEventListener('submit', (event) => {
const hasValidItem = Array.from(selectedBody.querySelectorAll('tr[data-item-row]')).some((row) => {
const qtyInput = row.querySelector('.item-qty-box');
return Math.max(0, parseInt(qtyInput?.value || '0', 10)) > 0;
});
if (!hasValidItem) {
event.preventDefault();
alert('봉투를 선택하고 수량을 1 이상 입력해 주세요.');
}
});
}
syncMonthFromDate();
renderSelectedRows();
})();
</script>

View File

@@ -1,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 overflow-auto mt-2">
<table class="bag-order-print-table w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-32">발주일자</th>
<th>LOT번호</th> <th class="min-w-[10rem]">제작 업체</th>
<th>발주일</th> <th class="min-w-[12rem]">품 명</th>
<th>제작업체</th> <th class="w-28">발주 수량</th>
<th>입고처</th> <th class="w-28">입고 수량</th>
<th>품목수</th> <th class="w-28">미입고수량</th>
<th>총수량</th> <th class="w-32">발주 금액</th>
<th>총금액</th> <th class="min-w-[9rem]">입고처</th>
<th class="w-20">상태</th> <th class="min-w-[8rem]">비 고</th>
<th class="w-44">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php $printedGroup = []; ?>
<tr> <?php foreach (($rows ?? []) as $row): ?>
<td class="text-center"><?= esc($row->bo_idx) ?></td> <?php if (! empty($row['is_subtotal'])): ?>
<td class="text-center font-mono"><?= esc($row->bo_lot_no) ?></td> <tr class="bg-gray-50 font-semibold">
<td class="text-center"><?= esc($row->bo_order_date) ?></td> <td colspan="3" class="text-center"><?= esc((string) ($row['label'] ?? '소계')) ?></td>
<td class="text-left pl-2"><?= esc($companyMap[$row->bo_company_idx] ?? '') ?></td> <td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<td class="text-left pl-2"><?= esc($agencyMap[$row->bo_agency_idx] ?? '') ?></td> <td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['count'] ?? 0)) ?></td> <td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['qty'] ?? 0)) ?></td> <td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['amount'] ?? 0)) ?></td> <td></td>
<td class="text-center"> <td></td>
</tr>
<?php continue; ?>
<?php endif; ?>
<?php <?php
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; $boIdx = (int) ($row['bo_idx'] ?? 0);
echo esc($statusMap[$row->bo_status] ?? $row->bo_status); $showGroup = ! isset($printedGroup[$boIdx]);
$rowspan = (int) (($groupRows[$boIdx] ?? 1));
if ($showGroup) {
$printedGroup[$boIdx] = true;
}
?> ?>
</td> <tr>
<td class="text-center"> <?php if ($showGroup): ?>
<a href="<?= 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

@@ -9,12 +9,91 @@
<span class="text-sm font-bold text-gray-700">봉투 단가 관리</span> <span class="text-sm font-bold text-gray-700">봉투 단가 관리</span>
<div class="flex items-center gap-2 no-print"> <div class="flex items-center gap-2 no-print">
<button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= base_url('bag/prices') ?>" class="text-blue-600 hover:underline text-sm">단가 조회·검색</a>
<a href="<?= mgmt_url('bag-prices/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">단가 등록</a> <a href="<?= mgmt_url('bag-prices/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">단가 등록</a>
</div> </div>
</div> </div>
</section> </section>
<p class="text-xs text-gray-500 mt-2 no-print">목록·등록·수정·삭제는 이 화면에서, <strong>기간·봉투별 조회·인쇄</strong>는 <a href="<?= base_url('bag/prices') ?>" class="text-blue-600 hover:underline">봉투 단가(조회)</a>에서 이용하세요.</p> <section class="no-print border border-gray-200 rounded-lg bg-white p-3 mt-2">
<form method="get" action="<?= mgmt_url('bag-prices') ?>" class="flex flex-wrap items-end gap-3" autocomplete="off">
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">봉투구분</label>
<select name="bag_kind_e" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[9rem]">
<option value="">전체</option>
<?php foreach ($bag_kind_options ?? [] as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= (string) ($cd->cd_code ?? '') === (string) ($bag_kind_e ?? '') ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">봉투코드</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[11rem]">
<option value="">전체</option>
<?php foreach ($bag_codes ?? [] as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= (string) ($cd->cd_code ?? '') === (string) ($bag_code ?? '') ? 'selected' : '' ?>>
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">조회 기간 (적용기간 겹침)</label>
<?php
$sp = $startParts ?? ['y' => '', 'm' => '', 'd' => ''];
$ep = $endParts ?? ['y' => '', 'm' => '', 'd' => ''];
$ymin = (int) ($dateYearMin ?? ((int) date('Y') - 12));
$ymax = (int) ($dateYearMax ?? ((int) date('Y') + 2));
?>
<div class="flex flex-wrap items-center gap-1">
<span class="text-xs text-gray-500 mr-0.5">시작</span>
<select name="start_y" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[4.5rem]">
<option value="">연도</option>
<?php for ($yy = $ymin; $yy <= $ymax; $yy++): ?>
<option value="<?= $yy ?>" <?= (string) ($sp['y'] ?? '') === (string) $yy ? 'selected' : '' ?>><?= $yy ?></option>
<?php endfor; ?>
</select>
<select name="start_m" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">월</option>
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
<option value="<?= $mi ?>" <?= isset($sp['m']) && (int) $sp['m'] === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
<?php endfor; ?>
</select>
<select name="start_d" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">일</option>
<?php for ($di = 1; $di <= 31; $di++): ?>
<option value="<?= $di ?>" <?= isset($sp['d']) && (int) $sp['d'] === $di ? 'selected' : '' ?>><?= $di ?>일</option>
<?php endfor; ?>
</select>
<span class="text-sm text-gray-500 mx-0.5">~</span>
<span class="text-xs text-gray-500 mr-0.5">종료</span>
<select name="end_y" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[4.5rem]">
<option value="">연도</option>
<?php for ($yy = $ymin; $yy <= $ymax; $yy++): ?>
<option value="<?= $yy ?>" <?= (string) ($ep['y'] ?? '') === (string) $yy ? 'selected' : '' ?>><?= $yy ?></option>
<?php endfor; ?>
</select>
<select name="end_m" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">월</option>
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
<option value="<?= $mi ?>" <?= isset($ep['m']) && (int) $ep['m'] === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
<?php endfor; ?>
</select>
<select name="end_d" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">일</option>
<?php for ($di = 1; $di <= 31; $di++): ?>
<option value="<?= $di ?>" <?= isset($ep['d']) && (int) $ep['d'] === $di ? 'selected' : '' ?>><?= $di ?>일</option>
<?php endfor; ?>
</select>
</div>
</div>
<div class="flex items-center gap-2 pb-0.5">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('bag-prices') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
<button type="button" onclick="window.print()" class="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
</div>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
@@ -32,9 +111,17 @@
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->bp_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center font-mono"><?= esc($row->bp_bag_code) ?></td> <td class="text-center font-mono"><?= esc($row->bp_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bp_bag_name) ?></td> <td class="text-left pl-2"><?= esc($row->bp_bag_name) ?></td>
<td><?= number_format((float) $row->bp_order_price) ?></td> <td><?= number_format((float) $row->bp_order_price) ?></td>

View File

@@ -8,6 +8,19 @@
</div> </div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('companies') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">업체유형</label>
<select name="cp_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-44">
<option value="">전 체</option>
<?php foreach (($typeOptions ?? []) as $type): ?>
<option value="<?= esc($type) ?>" <?= (string) ($cpType ?? '') === (string) $type ? 'selected' : '' ?>><?= esc($type) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('companies') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
@@ -24,9 +37,17 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->cp_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center"><?= esc($row->cp_type) ?></td> <td class="text-center"><?= esc($row->cp_type) ?></td>
<td class="text-left pl-2"><?= esc($row->cp_name) ?></td> <td class="text-left pl-2"><?= esc($row->cp_name) ?></td>
<td class="text-center"><?= esc($row->cp_biz_no) ?></td> <td class="text-center"><?= esc($row->cp_biz_no) ?></td>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
<?php
$readOnly = ! empty($readOnly);
$listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
?>
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2">
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<?php endif; ?>
<?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
<?php endif; ?>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url($listBasePath) ?>" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 mr-1">지정판매소 검색</span>
<label class="text-sm text-gray-600">상호명</label>
<input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명" class="border border-gray-300 rounded px-2 py-1 text-sm w-36"/>
<label class="text-sm text-gray-600">구군코드</label>
<select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<?php foreach (($gugunCodes ?? []) as $gc): ?>
<option value="<?= esc($gc->ds_gugun_code) ?>" <?= ($dsGugunCode ?? '') === $gc->ds_gugun_code ? 'selected' : '' ?>><?= esc($gc->ds_gugun_code) ?></option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">상태</label>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<option value="1" <?= ($dsState ?? '') === '1' ? 'selected' : '' ?>>정상</option>
<option value="2" <?= ($dsState ?? '') === '2' ? 'selected' : '' ?>>폐업</option>
<option value="3" <?= ($dsState ?? '') === '3' ? 'selected' : '' ?>>직권해지</option>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-16">번호</th>
<th>지자체</th>
<th>판매소번호</th>
<th>상호명</th>
<th>대표자</th>
<th>사업자번호</th>
<th>가상계좌</th>
<th>상태</th>
<th>등록일</th>
<?php if (! $readOnly): ?>
<th class="w-28">작업</th>
<?php endif; ?>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($list as $row): ?>
<tr>
<td class="text-center"><?= esc($row->ds_idx) ?></td>
<td class="text-left pl-2"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_shop_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_rep_name) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_biz_no) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_va_number) ?></td>
<td class="text-center"><?= (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_regdate ?? '') ?></td>
<?php if (! $readOnly): ?>
<td class="text-center">
<a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a>
<form action="<?= mgmt_url('designated-shops/delete/' . (int) $row->ds_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -8,12 +8,12 @@
<div id="kakao-map" class="w-full border border-gray-300 mt-2" style="height:600px;"></div> <div id="kakao-map" class="w-full border border-gray-300 mt-2" style="height:600px;"></div>
<div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div> <div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=KAKAO_APP_KEY&libraries=services"></script> <script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=<?= esc($kakaoJavascriptKey ?? '', 'attr') ?>&libraries=services"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var mapContainer = document.getElementById('kakao-map'); var mapContainer = document.getElementById('kakao-map');
if (typeof kakao === 'undefined' || typeof kakao.maps === 'undefined') { if (typeof kakao === 'undefined' || typeof kakao.maps === 'undefined') {
mapContainer.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400">카카오맵 API 키를 설정해 주세요.</div>'; mapContainer.innerHTML = '<div class="flex items-center justify-center h-full text-gray-500 text-sm px-4 text-center">카카오맵을 불러올 수 없습니다. Kakao Developers → 제품 설정에서 「Kakao Map」을 켜고, 플랫폼(Web)에 이 사이트 URL을 등록했는지 확인하세요.</div>';
return; return;
} }

View File

@@ -1,80 +1,387 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> $ry = (int) ($reportYear ?? (int) date('Y'));
$exportUrl = mgmt_url('designated-shops/status/export') . '?' . http_build_query([
'year' => $ry,
]);
$fixedGugunLabel = trim((string) ($fixedGugunLabel ?? ''));
$regionColLabel = '군·구';
$sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
?>
<style>
.ds-status-x-scroll {
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
max-width: 100%;
}
@media print {
.ds-status-x-scroll { overflow: visible !important; border: none; }
}
.ds-status-x-scroll .ds-status-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
}
.ds-status-x-scroll .ds-status-table th,
.ds-status-x-scroll .ds-status-table td {
white-space: nowrap;
padding: 6px 10px;
font-size: 12px;
}
.ds-status-x-scroll .ds-status-table thead th {
background: #e9ecef;
border: 1px solid #ccc;
}
.ds-status-x-scroll .ds-status-table tbody td {
border: 1px solid #ccc;
}
.ds-status-x-scroll th.sticky-num,
.ds-status-x-scroll td.sticky-num {
position: sticky;
left: 0;
z-index: 3;
min-width: 3rem;
max-width: 3rem;
width: 3rem;
box-sizing: border-box;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
}
.ds-status-x-scroll td.sticky-num {
background: #fff;
text-align: center;
}
.ds-status-x-scroll tr.sum-row td.sticky-num {
background: #f3f4f6;
}
.ds-status-x-scroll th.sticky-region,
.ds-status-x-scroll td.sticky-region {
position: sticky;
left: 3rem;
z-index: 2;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
max-width: 16rem;
text-align: left;
}
.ds-status-x-scroll td.sticky-region {
background: #fff;
overflow: hidden;
text-overflow: ellipsis;
}
.ds-status-x-scroll tr.sum-row td.sticky-region {
background: #f3f4f6;
}
.ds-help {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.ds-help-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #64748b;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
cursor: help;
user-select: none;
}
.ds-floating-tip {
position: fixed;
left: 0;
top: 0;
display: none;
max-width: min(22rem, calc(100vw - 16px));
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 9999;
pointer-events: none;
}
</style>
<?= view('components/print_header', ['printTitle' => '지정판매소 신규·취소 현황 (' . $ry . '년)']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 현황 (신규/취소)</span> <span class="text-sm font-bold text-gray-700">지정판매소 현황 (신규/취소)</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <a href="<?= esc($exportUrl) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a> <a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
</div> </div>
</div> </div>
</section> </section>
<!-- 전체 현황 요약 --> <section class="p-2 bg-white border-b border-gray-200 no-print">
<div class="flex gap-4 mt-2 mb-2"> <form method="get" action="<?= mgmt_url('designated-shops/status') ?>" class="flex flex-wrap items-end gap-3">
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">활성 판매소</div>
<div class="text-2xl font-bold text-green-600"><?= number_format($totalActive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">비활성/취소 판매소</div>
<div class="text-2xl font-bold text-red-600"><?= number_format($totalInactive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-700"><?= number_format($totalActive + $totalInactive) ?></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<!-- 연도별 신규등록 -->
<div> <div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 신규등록 건수</h3> <label class="block text-xs text-gray-600 mb-0.5">연도</label>
<div class="border border-gray-300 overflow-auto"> <select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[6rem]">
<table class="w-full data-table"> <?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel !== '' ? $fixedGugunLabel : '현재 지자체 기준') ?>
</div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 군·구는 현재 로그인 사용자의 지자체 기준으로 고정 표시됩니다.
</p>
</section>
<!-- 인쇄 시에도 보이는 본표 -->
<div class="mx-2 mt-2 mb-2 ds-status-x-scroll">
<table class="ds-status-table data-table">
<thead> <thead>
<tr> <tr>
<th>연도</th> <th class="sticky-num text-center w-12">순번</th>
<th>신규등록 건수</th> <th class="sticky-region"><?= esc($regionColLabel) ?></th>
<th class="text-left">
<span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span>
</th>
<th class="text-right">
<span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말)
</th>
<th class="text-right">
<span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말)
</th>
<th class="text-right">
<span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span>
<br/><span class="font-normal text-xs">(현행−종전)</span>
</th>
<th class="text-right">
<span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span>
<br/><span class="font-normal text-xs">(<?= $ry ?>년)</span>
</th>
<th class="text-right">
<span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span>
<br/><span class="font-normal text-xs">(%)</span>
</th>
<th class="text-right">
<span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span>
<br/><span class="font-normal text-xs">증감률(%)</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($newByYear as $row): ?> <?php $rowNo = 0; ?>
<?php foreach (($districtRows ?? []) as $row): ?>
<?php
$rowNo++;
$curr = (int) $row->curr_end;
$prev = (int) $row->prev_end;
$pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0;
$yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null;
?>
<tr> <tr>
<td class="text-center"><?= esc($row->yr) ?></td> <td class="sticky-num"><?= $rowNo ?></td>
<td><?= number_format((int) $row->cnt) ?></td> <td class="sticky-region" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
<td class="text-left text-xs"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
<td><?= number_format($prev) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format($curr) ?></td>
<td><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
<td><?= $pctShare ?></td>
<td><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="font-bold bg-gray-50 sum-row">
<td class="sticky-num">—</td>
<td class="sticky-region"><?= esc($districtTotal->region_label) ?></td>
<td class="text-left">—</td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
<td><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
<td>100</td>
<td>
<?php
$tPrev = (int) $districtTotal->prev_end;
$tCurr = (int) $districtTotal->curr_end;
echo $tPrev > 0 ? round((($tCurr - $tPrev) / $tPrev) * 100, 1) : '—';
?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php $zoneRows = $zoneSummaryRows ?? []; ?>
<section class="mx-2 mb-3 no-print">
<div class="text-xs font-semibold text-gray-700 mb-1">동별 현행 요약 (<?= esc($fixedGugunLabel ?? '군·구') ?>)</div>
<?php if (! empty($zoneRows)): ?>
<div class="flex flex-wrap gap-1 mb-2">
<?php foreach ($zoneRows as $z): ?>
<span class="inline-flex items-center px-2 py-0.5 text-xs rounded border border-gray-300 bg-gray-50 text-gray-700">
<?= esc((string) $z->zone_label) ?> <?= number_format((int) $z->curr_end) ?>
</span>
<?php endforeach; ?>
</div>
<div class="border border-gray-300 bg-white overflow-auto max-h-56">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="text-left">동</th>
<th class="text-right">종전</th>
<th class="text-right">지정</th>
<th class="text-right">취소</th>
<th class="text-right">현행</th>
<th class="text-right">증감</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($zoneRows as $z): ?>
<tr>
<td class="text-left"><?= esc((string) $z->zone_label) ?></td>
<td><?= number_format((int) $z->prev_end) ?></td>
<td><?= number_format((int) $z->designated_y) ?></td>
<td><?= number_format((int) $z->cancelled_y) ?></td>
<td><?= number_format((int) $z->curr_end) ?></td>
<td><?= number_format((int) $z->delta_curr_prev) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p class="text-xs text-gray-500">동별 집계 데이터가 없습니다.</p>
<?php endif; ?>
</section>
<div id="ds-floating-tip" class="ds-floating-tip no-print" aria-hidden="true"></div>
<script>
(function () {
var tipEl = document.getElementById('ds-floating-tip');
if (!tipEl) return;
var badges = Array.prototype.slice.call(document.querySelectorAll('.ds-help-badge'));
if (!badges.length) return;
function placeTip(target) {
var text = String(target.getAttribute('data-tip') || '').trim();
if (!text) return;
tipEl.textContent = text;
tipEl.style.display = 'block';
tipEl.setAttribute('aria-hidden', 'false');
var rect = target.getBoundingClientRect();
var tipRect = tipEl.getBoundingClientRect();
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var gap = 8;
var left = rect.left + (rect.width / 2) - (tipRect.width / 2);
var top = rect.bottom + gap;
if (left < gap) left = gap;
if (left + tipRect.width > vw - gap) left = vw - gap - tipRect.width;
if (top + tipRect.height > vh - gap) top = rect.top - gap - tipRect.height;
if (top < gap) top = gap;
tipEl.style.left = Math.round(left) + 'px';
tipEl.style.top = Math.round(top) + 'px';
}
function hideTip() {
tipEl.style.display = 'none';
tipEl.setAttribute('aria-hidden', 'true');
tipEl.textContent = '';
}
badges.forEach(function (badge) {
badge.addEventListener('mouseenter', function () { placeTip(badge); });
badge.addEventListener('focus', function () { placeTip(badge); });
badge.addEventListener('mouseleave', hideTip);
badge.addEventListener('blur', hideTip);
});
window.addEventListener('scroll', hideTip, true);
window.addEventListener('resize', hideTip);
})();
</script>
<details class="mx-2 mb-4 no-print text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary>
<div class="flex gap-4 mt-2">
<div class="border border-gray-300 p-2 flex-1">
<div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div>
<div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach (($newByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?> <?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr> <tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<!-- 연도별 취소/비활성 -->
<div> <div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 취소/비활성 건수</h3> <h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3>
<div class="border border-gray-300 overflow-auto"> <div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table"> <table class="w-full data-table text-xs">
<thead> <thead><tr><th>연도</th><th>건수</th></tr></thead>
<tr>
<th>연도</th>
<th>취소/비활성 건수</th>
</tr>
</thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($cancelByYear as $row): ?> <?php foreach (($cancelByYear ?? []) as $row): ?>
<tr> <tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<td class="text-center"><?= esc($row->yr) ?>년</td>
<td><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($cancelByYear)): ?> <?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr> <tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</details>

View File

@@ -1,5 +1,5 @@
<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 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('free-recipients/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('free-recipients/store') ?>" method="POST" class="space-y-4">
@@ -9,29 +9,19 @@
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_type_code" required> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_type_code" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($typeCodes as $cd): ?> <?php foreach (($recipientTypeOptions ?? []) as $typeCode => $typeName): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('fr_type_code') === $cd->cd_code ? 'selected' : '' ?>> <option value="<?= esc((string) $typeCode) ?>" <?= old('fr_type_code') === (string) $typeCode ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?> <?= esc((string) $typeName) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">대상자명 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_name" type="text" value="<?= esc(old('fr_name')) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_name" type="text" value="<?= esc(old('fr_name')) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">연락처</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_phone" type="text" value="<?= esc(old('fr_phone')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="fr_addr" type="text" value="<?= esc(old('fr_addr')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">동코드</label> <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="fr_dong_code"> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_dong_code">
@@ -52,6 +42,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종료일</label> <label class="block text-sm font-bold text-gray-700 w-28">종료일</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="fr_end_date" type="date" value="<?= esc(old('fr_end_date')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="fr_end_date" type="date" value="<?= esc(old('fr_end_date')) ?>"/>
<span class="text-xs text-gray-500">미입력 시 계속 유효</span>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">

View File

@@ -1,5 +1,5 @@
<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 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('free-recipients/update/' . (int) $item->fr_idx) ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('free-recipients/update/' . (int) $item->fr_idx) ?>" method="POST" class="space-y-4">
@@ -9,29 +9,19 @@
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_type_code" required> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_type_code" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($typeCodes as $cd): ?> <?php foreach (($recipientTypeOptions ?? []) as $typeCode => $typeName): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('fr_type_code', $item->fr_type_code) === $cd->cd_code ? 'selected' : '' ?>> <option value="<?= esc((string) $typeCode) ?>" <?= old('fr_type_code', $item->fr_type_code) === (string) $typeCode ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?> <?= esc((string) $typeName) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">대상자명 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_name" type="text" value="<?= esc(old('fr_name', $item->fr_name)) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_name" type="text" value="<?= esc(old('fr_name', $item->fr_name)) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">연락처</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_phone" type="text" value="<?= esc(old('fr_phone', $item->fr_phone)) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="fr_addr" type="text" value="<?= esc(old('fr_addr', $item->fr_addr)) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">동코드</label> <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="fr_dong_code"> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="fr_dong_code">
@@ -52,6 +42,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종료일</label> <label class="block text-sm font-bold text-gray-700 w-28">종료일</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="fr_end_date" type="date" value="<?= esc(old('fr_end_date', $item->fr_end_date)) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="fr_end_date" type="date" value="<?= esc(old('fr_end_date', $item->fr_end_date)) ?>"/>
<span class="text-xs text-gray-500">미입력 시 계속 유효</span>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">

View File

@@ -13,28 +13,36 @@
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16">번호</th>
<th>구분</th> <th class="w-28">동코드</th>
<th>대상자명</th> <th class="w-40">구분</th>
<th>연락처</th> <th>명칭</th>
<th>주소</th> <th class="w-28">종료일자</th>
<th>동코드</th> <th class="w-48">비고</th>
<th>비고</th>
<th>종료일</th>
<th class="w-20">상태</th> <th class="w-20">상태</th>
<th class="w-36">작업</th> <th class="w-36">작업</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php
$total = (int) ($totalCount ?? count($list));
$page = max(1, (int) ($currentPage ?? 1));
$size = max(1, (int) ($perPage ?? max(1, count($list))));
$rowNo = $total - (($page - 1) * $size);
?>
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<?php
$typeCode = (string) ($row->fr_type_code ?? '');
$typeName = (string) (($recipientTypeOptions[$typeCode] ?? '') ?: $typeCode);
$dongCode = (string) ($row->fr_dong_code ?? '');
$dongLabel = $dongCode !== '' ? (string) (($dongNameMap[$dongCode] ?? $dongCode) . ' (' . $dongCode . ')') : '-';
?>
<tr> <tr>
<td class="text-center"><?= esc($row->fr_idx) ?></td> <td class="text-center"><?= esc((string) $rowNo) ?></td>
<td class="text-center"><?= esc($row->fr_type_code) ?></td> <td class="text-center"><?= esc($dongLabel) ?></td>
<td class="text-center"><?= esc($typeName) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_name) ?></td> <td class="text-left pl-2"><?= esc($row->fr_name) ?></td>
<td class="text-center"><?= esc($row->fr_phone) ?></td> <td class="text-center"><?= esc($row->fr_end_date ?: '9999.99.99') ?></td>
<td class="text-left pl-2"><?= esc($row->fr_addr) ?></td>
<td class="text-center"><?= esc($row->fr_dong_code) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_note) ?></td> <td class="text-left pl-2"><?= esc($row->fr_note) ?></td>
<td class="text-center"><?= esc($row->fr_end_date) ?></td>
<td class="text-center"><?= (int) $row->fr_state === 1 ? '사용' : '미사용' ?></td> <td class="text-center"><?= (int) $row->fr_state === 1 ? '사용' : '미사용' ?></td>
<td class="text-center"> <td class="text-center">
<a href="<?= mgmt_url('free-recipients/edit/' . (int) $row->fr_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a> <a href="<?= mgmt_url('free-recipients/edit/' . (int) $row->fr_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
@@ -44,10 +52,11 @@
</form> </form>
</td> </td>
</tr> </tr>
<?php $rowNo--; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr> <tr>
<td colspan="10" class="text-center text-gray-500 py-4 text-sm space-y-1"> <td colspan="8" class="text-center text-gray-500 py-4 text-sm space-y-1">
<p>등록된 데이터가 없습니다.</p> <p>등록된 데이터가 없습니다.</p>
<p class="text-gray-400">다른 지자체를 선택 중이면 해당 지자체 기준으로만 조회됩니다. Super Admin 은 상단에서 작업 지자체를 바꿔 보세요.</p> <p class="text-gray-400">다른 지자체를 선택 중이면 해당 지자체 기준으로만 조회됩니다. Super Admin 은 상단에서 작업 지자체를 바꿔 보세요.</p>
</td> </td>

View File

@@ -1,179 +1,164 @@
<?php <?php
declare(strict_types=1);
/**
* 관리자 공통 레이아웃 — gov-portal 디자인 적용판.
* 헤더 + 관리자 대메뉴(클릭) + 좌측 사이드바(소메뉴) + 본문($content).
*
* @var string $title
* @var string $content
*/
helper('admin'); helper('admin');
$uriObj = service('request')->getUri();
$n = $uriObj->getTotalSegments();
$uri = $n >= 2 ? $uriObj->getSegment(2) : '';
$seg3 = $n >= 3 ? $uriObj->getSegment(3) : '';
$mbLevel = (int) session()->get('mb_level'); $mbLevel = (int) session()->get('mb_level');
$isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel); $isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel);
$mbName = (string) (session()->get('mb_name') ?? '담당자');
$levelName = config(\Config\Roles::class)->getLevelName($mbLevel);
$effectiveLgIdx = admin_effective_lg_idx(); $effectiveLgIdx = admin_effective_lg_idx();
$effectiveLgName = null; $effectiveLgName = '';
if ($effectiveLgIdx) { if ($effectiveLgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx); $lgRow = model(\App\Models\LocalGovernmentModel::class)->find($effectiveLgIdx);
$effectiveLgName = $lgRow ? $lgRow->lg_name : null; $effectiveLgName = $lgRow ? (string) $lgRow->lg_name : '';
} }
$userNav = session_user_nav_display();
$currentPath = current_nav_request_path();
$adminNavTree = get_admin_nav_tree();
/** DB 링크(mm_link)만 사용. 짧게 적은 항목(menus 등)은 실제 URI(admin/menus)와 맞춰 후보 비교 */ $adminTree = function_exists('get_admin_nav_tree') ? get_admin_nav_tree() : [];
$adminNavItemIsCurrent = static function (?string $mmLink) use ($currentPath): bool { $gov = gov_portal_nav_context(false, $adminTree);
return menu_link_matches_request($mmLink, $currentPath, []);
};
/** 메뉴가 DB에서 안 쓰일 때만(폴백 상단바) 세그먼트 기반 활성 */ // 관리자 메뉴가 비어 있으면(지자체 미선택 등) 핵심 항목 폴백 노출
$isActive = static function (string $path) use ($uri, $seg3) { $navItems = $gov['navItems'];
if ($path === 'admin' || $path === '') return $uri === ''; if ($navItems === []) {
if ($path === 'users') return $uri === 'users'; $mk = static fn (string $name, string $path): array => [
if ($path === 'login-history') return $uri === 'access' && $seg3 === 'login-history'; 'idx' => 0, 'name' => $name, 'href' => $path, 'url' => base_url($path),
if ($path === 'approvals') return $uri === 'access' && $seg3 === 'approvals'; ];
if ($path === 'roles') return $uri === 'roles'; $navItems = [
if ($path === 'menus') return $uri === 'menus'; ['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
if ($path === 'local-governments') return $uri === 'local-governments'; ['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
if ($path === 'select-local-government') return $uri === 'select-local-government'; $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'),
if ($path === 'designated-shops') return $uri === 'designated-shops'; ]],
return false; ['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
}; $mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
]],
];
if ($isSuperAdmin) {
$navItems[] = ['idx' => 0, 'name' => '지자체', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
$mk('지자체 전환', 'admin/select-local-government'), $mk('지자체 관리', 'admin/local-governments'),
]];
}
}
$navJson = json_encode($navItems, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS);
$navPartial = [
'govNavItems' => $navItems,
'govNavJson' => $navJson,
'govActiveParentIdx' => $gov['activeParentIdx'],
'govCurrentPath' => $gov['currentPath'],
'govDashboardAliases' => $gov['dashboardAliases'],
'govActiveChildHref' => $gov['currentPath'],
];
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko" class="gov-portal-html">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - 종량제 시스템</title> <title><?= esc($title ?? '관리자') ?> - GBLS</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
extend: { extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] }, fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: { colors: {
'system-header': '#ffffff', 'system-header': '#ffffff', 'title-bar': '#1a2b4b', 'control-panel': '#f8f9fa',
'title-bar': '#2c3e50', 'btn-search': '#243a5e', 'btn-excel-border': '#28a745', 'btn-excel-text': '#28a745',
'control-panel': '#f8f9fa', 'btn-print-border': '#ced4da', 'btn-exit': '#d9534f',
'btn-search': '#1c4e80',
'btn-excel-border': '#28a745',
'btn-excel-text': '#28a745',
'btn-print-border': '#ced4da',
'btn-exit': '#d9534f',
}, },
fontSize: { 'xxs': '0.65rem' } fontSize: { 'xxs': '0.65rem' }
} }
} }
} }
</script> </script>
<style data-purpose="table-layout"> <style>
.data-table { width: 100%; border-collapse: collapse; font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif; } <?php include __DIR__ . '/../home/_dashboard_gov_portal_brand_css.php'; ?>
<?php include __DIR__ . '/../home/_dashboard_gov_portal_topnav_css.php'; ?>
<?php include __DIR__ . '/../home/_dashboard_gov_portal_chrome_css.php'; ?>
.data-table { width: 100%; border-collapse: collapse; font-family: 'Pretendard', 'Malgun Gothic', 'Noto Sans KR', sans-serif; }
.data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; } .data-table th, .data-table td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; font-size: 13px; }
.data-table th { background-color: #e9ecef; text-align: center; vertical-align: middle; font-weight: bold; color: #333; } .data-table th { background-color: #e9ecef; text-align: center; vertical-align: middle; font-weight: bold; color: #333; }
.data-table tbody tr:nth-child(even) td { background-color: #f9f9f9; } .data-table tbody tr:nth-child(even) td { background-color: #f9f9f9; }
.data-table tbody tr:nth-child(even) { background-color: #f9f9f9; }
.data-table tbody tr:hover td { background-color: #e6f7ff !important; } .data-table tbody tr:hover td { background-color: #e6f7ff !important; }
.main-content-area { height: calc(100vh - 170px); overflow: auto; }
body { overflow: hidden; }
@media print { @media print {
header, footer, .no-print, nav { display: none !important; } .portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
.main-content-area { height: auto !important; overflow: visible !important; } body.gov-portal-shell { background: #fff; display: block; }
body { overflow: visible !important; } .gov-portal-shell .main.work-main { overflow: visible !important; padding: 0 !important; }
.bg-title-bar { display: none !important; }
.bg-control-panel { break-inside: avoid; }
.print-header { display: block !important; } .print-header { display: block !important; }
} }
</style> </style>
</head> </head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased select-none"> <body class="gov-portal-shell select-none">
<header class="relative bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0 z-50"> <header class="portal-header">
<div class="flex items-center gap-2 shrink-0"> <div class="portal-header-inner">
<?= view('components/header_brand', ['href' => base_url('admin')]) ?> <?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('admin')]) ?>
</div> <?= view('home/_dashboard_gov_portal_topnav_click', $navPartial) ?>
<nav class="hidden md:flex gap-5 text-sm font-medium text-gray-600"> <div class="portal-header-utils" style="display:flex;align-items:center;gap:.5rem;">
<?php if (! empty($adminNavTree)): ?> <span class="user-line">
<?php foreach ($adminNavTree as $navItem): ?> <?php if ($effectiveLgName !== ''): ?><strong><?= esc($effectiveLgName) ?></strong> · <?php endif; ?>
<?php <?= esc($levelName) ?> · <?= esc($mbName) ?>님
$hasChildren = ! empty($navItem->children);
$parentLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath);
$parentIsCurrent = $adminNavItemIsCurrent($navItem->mm_link ?? null);
if (! $parentIsCurrent && $hasChildren) {
foreach ($navItem->children as $ch) {
if ($adminNavItemIsCurrent($ch->mm_link ?? null)) {
$parentIsCurrent = true;
break;
}
}
}
?>
<div class="relative group">
<a class="<?= $parentIsCurrent ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>"
href="<?= $parentLink !== '' ? base_url($parentLink) : '#' ?>">
<?= esc($navItem->mm_name) ?>
</a>
<?php if ($hasChildren): ?>
<?php /* 사이트 메뉴와 동일: 호버 끊김 방지 pt-1, 키보드 포커스, z-index */ ?>
<div class="absolute left-0 top-full z-50 hidden pt-1 min-w-[12rem] group-hover:block group-focus-within:block">
<div class="bg-white border border-gray-200 rounded shadow-lg py-1">
<?php foreach ($navItem->children as $child): ?>
<?php
$childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
$childIsCurrent = $adminNavItemIsCurrent($child->mm_link ?? null);
?>
<?php if ($childLink !== ''): ?>
<a href="<?= base_url($childLink) ?>"
class="block px-3 py-1.5 text-sm hover:bg-blue-50 whitespace-nowrap <?= $childIsCurrent ? 'text-blue-700 font-semibold bg-blue-50' : 'text-gray-700' ?>">
<?= esc($child->mm_name) ?>
</a>
<?php else: ?>
<span class="block px-3 py-1.5 text-sm text-gray-400 cursor-default whitespace-nowrap" title="메뉴 관리에서 링크를 설정해 주세요">
<?= esc($child->mm_name) ?>
</span> </span>
<?php endif; ?> <a href="<?= base_url('/') ?>" title="사이트로"
<?php endforeach; ?> style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.3);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-solid fa-house"></i> 사이트
</a>
<a href="<?= base_url('logout') ?>" title="로그아웃"
style="display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:6px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.22);color:#fff;text-decoration:none;font-size:.75rem;font-weight:600;white-space:nowrap;">
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
</a>
</div> </div>
</div> </div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php else: ?>
<a class="<?= $isActive('') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin') ?>">대시보드</a>
<a class="<?= $isActive('users') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/users') ?>">회원 관리</a>
<a class="<?= $isActive('login-history') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/access/login-history') ?>">로그인 이력</a>
<a class="<?= $isActive('approvals') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/access/approvals') ?>">승인 대기</a>
<a class="<?= $isActive('roles') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/roles') ?>">역할</a>
<a class="<?= $isActive('menus') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/menus') ?>">메뉴</a>
<?php if ($isSuperAdmin): ?>
<a class="<?= $isActive('select-local-government') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/select-local-government') ?>">지자체 전환</a>
<a class="<?= $isActive('local-governments') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('admin/local-governments') ?>">지자체</a>
<?php endif; ?>
<a class="<?= $isActive('designated-shops') ? 'text-blue-700 font-bold border-b-2 border-blue-700 pb-3 -mb-3' : 'hover:text-blue-600' ?>" href="<?= base_url('bag/designated-shops') ?>">지정판매소</a>
<?php endif; ?>
</nav>
<?= view('components/header_user_tools', [
'userNav' => $userNav,
'effectiveLgName' => $effectiveLgName,
'showSiteLink' => true,
'showAdminLink' => false,
]) ?>
</header> </header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
<?= esc($title ?? '관리자') ?> <div class="layout">
</div> <?= view('home/_dashboard_gov_portal_sidebar', $navPartial) ?>
<main class="main work-main main-content-area">
<?php if (! empty($title)): ?>
<h1 class="work-titlebar"><i class="fa-solid fa-gear tb-ico"></i><?= esc($title) ?></h1>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?> <?php if (session()->getFlashdata('success')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div> <div class="work-flash ok"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (session()->getFlashdata('error')): ?> <?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div> <div class="work-flash err"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?> <?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert"> <div class="work-flash err"><?php foreach (session()->getFlashdata('errors') as $err): ?><div><?= esc($err) ?></div><?php endforeach; ?></div>
<?php foreach (session()->getFlashdata('errors') as $err): ?><p><?= esc($err) ?></p><?php endforeach; ?>
</div>
<?php endif; ?> <?php endif; ?>
<main class="main-content-area flex-grow bg-white p-4"> <div class="work-surface">
<?= $content ?> <?= $content ?>
</div>
</main> </main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 flex items-center justify-between shrink-0"> </div>
<span>종량제 시스템 관리자</span>
<span><?= date('Y.m.d (D) g:i:sA') ?></span> <footer class="portal-footer">
<span>GBLS 관리자</span>
<span><?= date('Y.m.d (D) H:i') ?></span>
</footer> </footer>
<?= view('home/_dashboard_gov_portal_nav_script_base', $navPartial) ?>
<script>
(function () {
// bfcache 복원 시 열린 채 남은 모달/팝업으로 회색 레이어가 클릭을 막는 문제 방지
function closeStuckOverlays() {
document.querySelectorAll('.fixed.inset-0[id$="-modal"], .fixed.inset-0[id$="-popup"]').forEach(function (el) {
el.classList.add('hidden'); el.setAttribute('aria-hidden', 'true');
});
document.body.style.overflow = '';
}
window.addEventListener('pageshow', function (e) { if (e.persisted) closeStuckOverlays(); });
window.addEventListener('pagehide', closeStuckOverlays);
})();
</script>
</body> </body>
</html> </html>

View File

@@ -11,28 +11,18 @@
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">소속</label> <label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_dept_code"> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($deptCodes as $cd): ?> <?php foreach (($categories ?? []) as $key => $label): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_dept_code') === $cd->cd_code ? 'selected' : '' ?>> <option value="<?= esc($key) ?>" <?= old('mg_category') === $key ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?> <?= esc($label) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <input type="hidden" name="mg_position_code" value="<?= esc(old('mg_position_code', '')) ?>"/>
<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="mg_position_code">
<option value="">선택</option>
<?php foreach ($positionCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_position_code') === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">전화</label> <label class="block text-sm font-bold text-gray-700 w-28">전화</label>

View File

@@ -11,28 +11,18 @@
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">소속</label> <label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_dept_code"> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($deptCodes as $cd): ?> <?php foreach (($categories ?? []) as $key => $label): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_dept_code', $item->mg_dept_code) === $cd->cd_code ? 'selected' : '' ?>> <option value="<?= esc($key) ?>" <?= old('mg_category', (string) ($item->mg_dept_code ?? '')) === $key ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?> <?= esc($label) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <input type="hidden" name="mg_position_code" value="<?= esc(old('mg_position_code', $item->mg_position_code)) ?>"/>
<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="mg_position_code">
<option value="">선택</option>
<?php foreach ($positionCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_position_code', $item->mg_position_code) === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">전화</label> <label class="block text-sm font-bold text-gray-700 w-28">전화</label>

View File

@@ -8,14 +8,26 @@
</div> </div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('managers') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">카테고리</label>
<select name="category" class="border border-gray-300 rounded px-2 py-1 text-sm w-44">
<option value="">전 체</option>
<?php foreach (($categories ?? []) as $key => $label): ?>
<option value="<?= esc($key) ?>" <?= ($category ?? '') === $key ? 'selected' : '' ?>><?= esc($label) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('managers') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16">번호</th>
<th>담당자명</th> <th>담당자명</th>
<th>소속</th> <th>카테고리</th>
<th>직위</th>
<th>전화</th> <th>전화</th>
<th>휴대전화</th> <th>휴대전화</th>
<th>이메일</th> <th>이메일</th>
@@ -28,8 +40,13 @@
<tr> <tr>
<td class="text-center"><?= esc($row->mg_idx) ?></td> <td class="text-center"><?= esc($row->mg_idx) ?></td>
<td class="text-center"><?= esc($row->mg_name) ?></td> <td class="text-center"><?= esc($row->mg_name) ?></td>
<td class="text-center"><?= esc($row->mg_dept_code) ?></td> <td class="text-center">
<td class="text-center"><?= esc($row->mg_position_code) ?></td> <?php
$cat = (string) ($row->mg_dept_code ?? '');
$catLabel = $categories[$cat] ?? $cat;
echo esc($catLabel);
?>
</td>
<td class="text-center"><?= esc($row->mg_tel) ?></td> <td class="text-center"><?= esc($row->mg_tel) ?></td>
<td class="text-center"><?= esc($row->mg_phone) ?></td> <td class="text-center"><?= esc($row->mg_phone) ?></td>
<td class="text-center"><?= esc($row->mg_email) ?></td> <td class="text-center"><?= esc($row->mg_email) ?></td>
@@ -44,7 +61,7 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr> <tr><td colspan="8" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

@@ -4,13 +4,17 @@ $list = $list ?? [];
$mtIdx = (int) ($mtIdx ?? 0); $mtIdx = (int) ($mtIdx ?? 0);
$mtCode = (string) ($mtCode ?? ''); $mtCode = (string) ($mtCode ?? '');
$levelNames = $levelNames ?? []; $levelNames = $levelNames ?? [];
$debugMode = (bool) ($debug_mode ?? false);
$debugInfo = is_array($debug_info ?? null) ? $debug_info : [];
helper('admin'); helper('admin');
$adminMenusNavPath = current_nav_request_path(); $adminMenusNavPath = current_nav_request_path();
// 사이트 메뉴(mt_code=site)는 업무 URL이 /bag/* — 관리 화면(admin/menus) 맥락으로 해석하면 admin/ 링크가 잘못 선택됨
$menuListResolvePath = ($mtCode === 'site') ? 'bag/dashboard' : $adminMenusNavPath;
/** /**
* 메뉴 관리 목록용: 저장된 mm_link → 실제 href (외부 http(s) 또는 base_url). * 메뉴 관리 목록용: 저장된 mm_link → 실제 href (외부 http(s) 또는 base_url).
*/ */
$adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNavPath): string { $adminMenuListResolveHref = static function (string $rawLink) use ($menuListResolvePath): string {
$rawLink = trim($rawLink); $rawLink = trim($rawLink);
if ($rawLink === '') { if ($rawLink === '') {
return ''; return '';
@@ -18,7 +22,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNa
if (preg_match('#^https?://#i', $rawLink)) { if (preg_match('#^https?://#i', $rawLink)) {
return $rawLink; return $rawLink;
} }
$pathSeg = menu_link_preferred_href_path($rawLink, $adminMenusNavPath); $pathSeg = menu_link_preferred_href_path($rawLink, $menuListResolvePath);
if ($pathSeg === '') { if ($pathSeg === '') {
$pathSeg = normalize_menu_link_for_url($rawLink); $pathSeg = normalize_menu_link_for_url($rawLink);
} }
@@ -44,6 +48,19 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNa
</div> </div>
</section> </section>
<?php if ($debugMode): ?>
<section class="mt-2 border border-amber-300 bg-amber-50 text-amber-900 rounded p-2 text-xs">
<strong>[DEBUG]</strong>
lg_idx=<?= esc((string) ($debugInfo['lg_idx'] ?? '')) ?>,
requested_mt_idx=<?= esc((string) ($debugInfo['requested_mt_idx'] ?? '')) ?>,
resolved_mt_idx=<?= esc((string) ($debugInfo['resolved_mt_idx'] ?? '')) ?>,
effective_mt_idx=<?= esc((string) ($debugInfo['effective_mt_idx'] ?? '')) ?>,
resolved_mt_code=<?= esc((string) ($debugInfo['resolved_mt_code'] ?? '')) ?>,
list_count=<?= esc((string) ($debugInfo['list_count'] ?? '')) ?>,
fallback_applied=<?= esc((string) ($debugInfo['fallback_applied'] ?? 'N')) ?>
</section>
<?php endif; ?>
<div class="flex gap-4 mt-2 flex-wrap"> <div class="flex gap-4 mt-2 flex-wrap">
<div class="border border-gray-300 bg-white rounded p-4 flex-1 min-w-0" style="min-width: 320px;"> <div class="border border-gray-300 bg-white rounded p-4 flex-1 min-w-0" style="min-width: 320px;">
<h3 class="text-sm font-bold text-gray-700 mb-2">메뉴 목록</h3> <h3 class="text-sm font-bold text-gray-700 mb-2">메뉴 목록</h3>
@@ -140,6 +157,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNa
<?php endif; ?> <?php endif; ?>
<form action="<?= base_url('admin/menus/delete/' . (int) $row->mm_idx) ?>" method="post" class="inline ml-1" onsubmit="return confirm('이 메뉴를 삭제하시겠습니까?');"> <form action="<?= base_url('admin/menus/delete/' . (int) $row->mm_idx) ?>" method="post" class="inline ml-1" onsubmit="return confirm('이 메뉴를 삭제하시겠습니까?');">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button> <button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form> </form>
</td> </td>
@@ -300,6 +318,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($adminMenusNa
formTitle.textContent = '메뉴 수정'; formTitle.textContent = '메뉴 수정';
btnSubmit.textContent = '수정'; btnSubmit.textContent = '수정';
btnCancel.style.display = 'inline-block'; btnCancel.style.display = 'inline-block';
// 수정 폼이 보이도록 스크롤 최상단으로 이동
window.scrollTo({ top: 0, behavior: 'smooth' });
const sc = document.querySelector('.main-content-area');
if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
}); });
}); });

View File

@@ -25,7 +25,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">적용시작일 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-32">적용시작일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="pu_start_date" type="date" value="<?= esc(old('pu_start_date', $item->pu_start_date)) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="pu_start_date" type="date" value="<?= esc(old('pu_start_date', date('Y-m-d'))) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">

View File

@@ -6,11 +6,20 @@
</div> </div>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<?php
$fieldLabelMap = [
'pu_box_per_pack' => '박스당 팩 수',
'pu_pack_per_sheet' => '팩당 낱장 수',
'pu_start_date' => '적용시작일',
'pu_end_date' => '적용종료일',
'pu_state' => '상태',
];
?>
<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">번호</th>
<th>변경 필드</th> <th>변경 내용</th>
<th>이전 값</th> <th>이전 값</th>
<th>변경 값</th> <th>변경 값</th>
<th>변경일시</th> <th>변경일시</th>
@@ -20,7 +29,7 @@
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->puh_idx) ?></td> <td class="text-center"><?= esc($row->puh_idx) ?></td>
<td class="text-left pl-2"><?= esc($row->puh_field) ?></td> <td class="text-left pl-2"><?= esc($fieldLabelMap[(string) $row->puh_field] ?? $row->puh_field) ?></td>
<td><?= esc($row->puh_old_value) ?></td> <td><?= esc($row->puh_old_value) ?></td>
<td><?= esc($row->puh_new_value) ?></td> <td><?= esc($row->puh_new_value) ?></td>
<td class="text-center"><?= esc($row->puh_changed_at) ?></td> <td class="text-center"><?= esc($row->puh_changed_at) ?></td>

View File

@@ -35,9 +35,17 @@
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->pu_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center font-mono"><?= esc($row->pu_bag_code) ?></td> <td class="text-center font-mono"><?= esc($row->pu_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->pu_bag_name) ?></td> <td class="text-left pl-2"><?= esc($row->pu_bag_name) ?></td>
<td><?= number_format((int) $row->pu_box_per_pack) ?></td> <td><?= number_format((int) $row->pu_box_per_pack) ?></td>

View File

@@ -44,9 +44,17 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->sa_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-left pl-2"><?= esc($row->sa_kind ?? '') ?></td> <td class="text-left pl-2"><?= esc($row->sa_kind ?? '') ?></td>
<td class="text-center"><?= esc($row->sa_code ?? '') ?></td> <td class="text-center"><?= esc($row->sa_code ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->sa_name) ?></td> <td class="text-left pl-2"><?= esc($row->sa_name) ?></td>

View File

@@ -1,106 +1,140 @@
<?= view('components/print_header', ['printTitle' => '일계표']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> declare(strict_types=1);
/** @var list<array<string,mixed>> $tableRows */
/** @var string $date */
/** @var string $monthStart */
/** @var int $saIdx */
/** @var string $catFilter */
/** @var list<object> $agencies */
/** @var array<string,string> $catLabels */
/** @var bool $hasBsFee */
/** @var string $lgName */
/** @var string $agencyLabel */
/** @var string $catLabelFilter */
/** @var list<string> $printExtraLines */
$exportParams = array_filter([
'date' => $date ?? '',
'sa_idx' => (int) ($saIdx ?? 0),
'cat' => (string) ($catFilter ?? ''),
'export' => '1',
], static fn ($v): bool => $v !== '' && $v !== null);
$excelUrl = mgmt_url('reports/daily-summary?' . http_build_query($exportParams));
?>
<?= view('components/print_header', [
'printTitle' => '일계표',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">일계표</span> <span class="text-sm font-bold text-gray-700">일계표</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/daily-summary') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-3 bg-white border-b border-gray-200 no-print">
<label class="text-sm text-gray-600">조회일</label> <form method="GET" action="<?= mgmt_url('reports/daily-summary') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">조회일자</label>
<input type="date" name="date" value="<?= esc($date ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="date" name="date" value="<?= esc($date ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> </div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구분</label>
<select name="cat" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="" <?= ($catFilter ?? '') === '' ? 'selected' : '' ?>>전체</option>
<?php foreach (($catLabels ?? []) as $ck => $lab): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= ($catFilter ?? '') === $ck ? 'selected' : '' ?>><?= esc($lab) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form> </form>
</section> </section>
<div class="flex gap-4 mt-2"> <section class="p-3 bg-white">
<!-- 당일 --> <style>
<div class="flex-1 border border-gray-300 overflow-auto"> @media print {
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5"> .daily-summary-screen-title { display: none !important; }
<span class="text-sm font-bold text-gray-700">당일 (<?= esc($date ?? '') ?>)</span> }
</div> </style>
<table class="w-full data-table"> <div class="mb-2 text-center daily-summary-screen-title no-print">
<thead> <h1 class="text-lg font-bold m-0">일계표</h1>
<tr> <p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · 조회일: ' . ($date ?? '') . ' · 대행소: ' . ($agencyLabel ?? '') . ' · 구분: ' . ($catLabelFilter ?? ''))) ?></p>
<th>봉투코드</th> <p class="text-xs text-gray-500 m-0">누계(월): <?= esc(($monthStart ?? '') . ' ~ ' . ($date ?? '')) ?> · (단위: 매 / 원)</p>
<th>봉투명</th>
<th>판매수량</th>
<th>판매금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$dailySaleQtyTotal = 0;
$dailySaleAmountTotal = 0;
?>
<?php foreach ($daily as $row): ?>
<?php
$dailySaleQtyTotal += (int) $row->sale_qty;
$dailySaleAmountTotal += (int) $row->sale_amount;
?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($daily)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot class="bg-gray-50 font-bold text-right">
<tr>
<td colspan="2" class="text-center">합계</td>
<td><?= number_format($dailySaleQtyTotal) ?></td>
<td><?= number_format($dailySaleAmountTotal) ?></td>
</tr>
</tfoot>
</table>
</div> </div>
<!-- 당월 누계 --> <div class="border border-gray-300 overflow-auto">
<div class="flex-1 border border-gray-300 overflow-auto"> <table class="w-full data-table text-sm" id="daily-summary-table">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">당월 누계 (<?= esc($monthStart ?? '') ?> ~ <?= esc($date ?? '') ?>)</span>
</div>
<table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>봉투코드</th> <th rowspan="2" class="align-middle">구분</th>
<th>봉투</th> <th rowspan="2" class="align-middle">봉투종류</th>
<th>판매수량</th> <th colspan="4" class="text-center border-l border-gray-300">일계</th>
<th>판매금액</th> <th colspan="4" class="text-center border-l border-gray-300">누계(월)</th>
</tr>
<tr>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($tableRows ?? [] as $r): ?>
<?php <?php
$monthlySaleQtyTotal = 0; $kind = (string) ($r['kind'] ?? 'data');
$monthlySaleAmountTotal = 0; $trClass = $kind === 'subtotal' ? 'bg-gray-50 font-semibold' : ($kind === 'grand' ? 'bg-amber-50 font-bold' : '');
$fmtFee = static function (float $v) use ($hasBsFee): string {
if (! $hasBsFee) {
return '—';
}
return $v != 0.0 ? number_format((int) round($v)) : '';
};
?> ?>
<?php foreach ($monthly as $row): ?> <tr class="<?= esc($trClass, 'attr') ?>">
<?php <?php if ($kind === 'grand'): ?>
$monthlySaleQtyTotal += (int) $row->sale_qty; <td colspan="2" class="text-center"><?= esc((string) ($r['bag_name'] ?? '합 계')) ?></td>
$monthlySaleAmountTotal += (int) $row->sale_amount; <?php else: ?>
?> <td class="text-left pl-2"><?= esc((string) ($r['cat_label'] ?? '')) ?></td>
<tr> <td class="text-left pl-2"><?= esc((string) ($r['bag_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td> <?php endif; ?>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td> <td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($r['d_qty'] ?? 0)) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td> <td class="tabular-nums"><?= number_format((int) round((float) ($r['d_amt'] ?? 0))) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td> <td class="tabular-nums"><?= $fmtFee((float) ($r['d_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['d_levy'] ?? 0))) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($r['m_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['m_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($r['m_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($r['m_levy'] ?? 0))) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($monthly)): ?> <?php if (($tableRows ?? []) === []): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr> <tr>
<td colspan="10" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
<tfoot class="bg-gray-50 font-bold text-right">
<tr>
<td colspan="2" class="text-center">합계</td>
<td><?= number_format($monthlySaleQtyTotal) ?></td>
<td><?= number_format($monthlySaleAmountTotal) ?></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </section>

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
/** @var string $startDate */
/** @var string $endDate */
/** @var string $writeDate */
/** @var bool $searched */
/** @var list<string> $headers */
/** @var list<list<string>> $displayRows */
/** @var int $totalCount */
/** @var float $totalSupplyAmount */
/** @var float $totalTaxAmount */
/** @var int $missingBizCount */
/** @var string $lgName */
/** @var list<string> $printExtraLines */
/** @var list<array{label: string, sheet_name: string, cols: list<int>}> $hometaxPrintPages */
/** @var list<int> $hometaxColMinPx */
helper('admin');
$baseParams = [
'start_date' => $startDate ?? '',
'end_date' => $endDate ?? '',
'write_date' => $writeDate ?? '',
];
$searchParams = array_merge($baseParams, ['search' => '1']);
$exportParams = array_merge($searchParams, ['export' => '1']);
$searchUrl = mgmt_url('reports/hometax-export?' . http_build_query($searchParams));
$excelUrl = mgmt_url('reports/hometax-export?' . http_build_query($exportParams));
$totalGrand = (float) ($totalSupplyAmount ?? 0) + (float) ($totalTaxAmount ?? 0);
$colCount = max(1, count($headers ?? []));
/** 홈택스 28열 — 주소·상호·이메일 등 텍스트 열을 넓게 (합계 100%) */
$hometaxColWidths = [
'4.5%', '4%', '4%', '6%', '3%', '6.5%', '3.5%', '11%', '3%', '3%', '5.5%',
'6%', '3%', '6.5%', '3.5%', '11%', '3%', '3%', '5.5%',
'4%', '4%', '3%', '5.5%', '3%', '3%', '3.5%', '3.5%', '3.5%',
];
$hometaxColMinPx = $hometaxColMinPx ?? [];
$hometaxPrintPages = $hometaxPrintPages ?? [];
$hometaxWrapColIdx = [7, 15, 5, 6, 13, 14, 10, 18, 22, 27];
$hometaxNumColIdx = [19, 20, 24, 25, 26, 27];
$hometaxNormalizeColWidths = static function (array $colIndices) use ($hometaxColWidths): array {
$sum = 0.0;
foreach ($colIndices as $ci) {
$sum += (float) str_replace('%', '', (string) ($hometaxColWidths[$ci] ?? '3'));
}
$normalized = [];
foreach ($colIndices as $ci) {
$pct = (float) str_replace('%', '', (string) ($hometaxColWidths[$ci] ?? '3'));
$normalized[$ci] = ($sum > 0 ? round($pct / $sum * 100, 2) : round(100 / max(1, count($colIndices)), 2)) . '%';
}
return $normalized;
};
$hometaxCellClass = static function (int $ci) use ($hometaxWrapColIdx, $hometaxNumColIdx): string {
$class = 'text-left px-1 py-1';
if (in_array($ci, $hometaxWrapColIdx, true)) {
$class .= ' ht-wrap';
}
if (in_array($ci, $hometaxNumColIdx, true)) {
$class .= ' ht-num';
}
return $class;
};
/**
* @param list<int> $colIndices
*/
$hometaxRenderTable = static function (
array $colIndices,
string $tableExtraClass,
string $tableId,
bool $forPrint
) use (
$headers,
$displayRows,
$searched,
$colCount,
$hometaxColWidths,
$hometaxColMinPx,
$hometaxCellClass,
$hometaxNormalizeColWidths
): void {
$sliceCount = count($colIndices);
$widthsForSet = $hometaxNormalizeColWidths($colIndices);
?>
<table
class="w-full data-table text-xs <?= esc($tableExtraClass, 'attr') ?>"
id="<?= esc($tableId, 'attr') ?>"
<?= $forPrint ? 'data-hometax-print="1"' : '' ?>
>
<colgroup>
<?php foreach ($colIndices as $ci):
$wPct = $widthsForSet[$ci] ?? (string) round(100 / max(1, $sliceCount), 2) . '%';
$wPx = (int) ($hometaxColMinPx[$ci] ?? 56);
?>
<col style="width: <?= esc($wPct, 'attr') ?>;<?= $forPrint ? '' : ' min-width: ' . $wPx . 'px' ?>"/>
<?php endforeach; ?>
</colgroup>
<thead>
<tr>
<?php foreach ($colIndices as $ci): ?>
<th class="<?= esc($hometaxCellClass($ci), 'attr') ?>"><?= esc((string) ($headers[$ci] ?? '')) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody class="text-right">
<?php if (! ($searched ?? true)): ?>
<tr>
<td colspan="<?= (int) $sliceCount ?>" class="text-center text-gray-500 py-8">조회를 건너뛴 상태입니다. <strong>조회</strong>를 눌러 주세요.</td>
</tr>
<?php elseif (($displayRows ?? []) === []): ?>
<tr>
<td colspan="<?= (int) $sliceCount ?>" class="text-center text-gray-500 py-8">조회된 판매 내역이 없습니다.</td>
</tr>
<?php else: ?>
<?php foreach ($displayRows as $row): ?>
<tr>
<?php foreach ($colIndices as $ci):
$tdClass = 'tabular-nums text-left px-1 py-0.5 border-t border-gray-100 ' . $hometaxCellClass($ci);
?>
<td class="<?= esc(trim($tdClass), 'attr') ?>"><?= esc((string) ($row[$ci] ?? '')) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php
};
?>
<?= view('components/print_header', [
'printTitle' => '홈택스 처리',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-3 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<h1 class="text-base font-bold text-gray-800">홈택스 처리</h1>
<div class="flex flex-wrap gap-2 items-center">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
<form method="get" action="<?= esc(mgmt_url('reports/hometax-export'), 'attr') ?>" id="hometax-process-form" class="flex flex-wrap items-end gap-3 text-sm">
<input type="hidden" name="search" value="1"/>
<div>
<label class="block text-gray-600 mb-0.5">판매일자</label>
<div class="flex items-center gap-1">
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<span class="text-gray-500">~</span>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
</div>
<div>
<label class="block text-gray-600 mb-0.5">작성일자</label>
<input type="date" name="write_date" value="<?= esc($writeDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
<div class="pt-5">
<button type="submit" class="border border-blue-600 bg-blue-50 text-blue-800 px-4 py-1 rounded-sm text-sm font-medium hover:bg-blue-100 transition">조회</button>
</div>
</form>
</section>
<section class="p-3 bg-white border-b border-gray-200 hometax-report-section">
<style>
.hometax-print-only { display: none; }
@media print {
@page {
size: A4 landscape;
margin: 4mm 5mm;
}
.hometax-report-section {
padding: 0 !important;
border: none !important;
}
.hometax-screen-only {
display: none !important;
}
.hometax-print-only {
display: block !important;
}
.hometax-print-page {
page-break-after: always;
break-after: page;
}
.hometax-print-page:last-child {
page-break-after: auto;
break-after: auto;
}
.hometax-print-page-label {
font-size: 8pt;
font-weight: 700;
margin: 0 0 4px;
}
.hometax-print-scroll {
overflow: visible !important;
border: 1px solid #333 !important;
}
.hometax-print-table {
font-size: 8pt !important;
width: 100% !important;
table-layout: fixed !important;
}
.hometax-print-table th,
.hometax-print-table td {
padding: 3px 5px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.3;
vertical-align: top;
}
.hometax-print-table th {
font-size: 7.5pt !important;
font-weight: 600;
}
.hometax-print-table .ht-num {
white-space: nowrap !important;
word-break: normal !important;
}
.hometax-print-table thead {
display: table-header-group;
}
.hometax-print-table tr {
page-break-inside: avoid;
break-inside: avoid;
}
}
@media screen {
#hometax-result-table {
width: max(100%, 4200px);
min-width: 4200px;
table-layout: fixed;
}
#hometax-result-table th,
#hometax-result-table td {
white-space: nowrap;
padding: 4px 6px !important;
}
#hometax-result-table .ht-wrap {
white-space: normal;
word-break: break-word;
max-width: 220px;
}
}
</style>
<div class="text-sm font-semibold text-gray-700 mb-2 no-print">조회결과</div>
<div class="hometax-screen-only hometax-scroll-wrap overflow-x-auto border border-gray-300" style="max-width: 100%;">
<?php
$hometaxRenderTable(
range(0, max(0, $colCount - 1)),
'',
'hometax-result-table',
false
);
?>
</div>
<div class="hometax-print-only" aria-hidden="true">
<?php foreach ($hometaxPrintPages as $ppi => $page):
$pageCols = $page['cols'];
if ($pageCols === []) {
continue;
}
?>
<section class="hometax-print-page">
<p class="hometax-print-page-label"><?= esc((string) ($page['label'] ?? '')) ?></p>
<div class="hometax-print-scroll">
<?php
$hometaxRenderTable(
$pageCols,
'hometax-print-table',
'hometax-print-table-' . (int) $ppi,
true
);
?>
</div>
</section>
<?php endforeach; ?>
</div>
<div class="mt-4 flex flex-wrap gap-6 text-sm border-t border-gray-200 pt-3 no-print">
<div><span class="text-gray-600">총 건수</span> <strong class="tabular-nums"><?= (int) ($totalCount ?? 0) ?></strong> 건</div>
<div><span class="text-gray-600">총 금액</span> <strong class="tabular-nums"><?= esc(number_format((int) round($totalGrand))) ?></strong> 원 <span class="text-gray-400 text-xs">(공급가액+세액)</span></div>
<div><span class="text-gray-600">사업자등록번호 없음</span> <strong class="tabular-nums text-amber-800"><?= (int) ($missingBizCount ?? 0) ?></strong> 건</div>
</div>
<div class="mt-2 text-xs text-gray-500 no-print print:hidden">
인쇄·엑셀저장은 동일하게 2쪽 열 구성입니다(1쪽: 공급자·공급받는자, 2쪽: 금액·품목). 요약·결재란은 인쇄용 헤더에 포함됩니다.
</div>
</section>
<style media="print">
.no-print { display: none !important; }
</style>
<script>
(function () {
let savedTitle = document.title;
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
window.addEventListener('beforeprint', function () {
savedTitle = document.title;
document.title = '홈택스처리_' + stamp();
});
window.addEventListener('afterprint', function () {
document.title = savedTitle;
});
})();
</script>

View File

@@ -1,99 +1,218 @@
<?= view('components/print_header', ['printTitle' => 'LOT 수불 조회']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> $barcode = (string) ($barcode ?? '');
$lotNo = (string) ($lotNo ?? '');
$result = is_array($result ?? null) ? $result : [];
$queried = (bool) ($queried ?? false);
$ok = (bool) ($result['ok'] ?? false);
$message = (string) ($result['message'] ?? '');
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
$unit = (string) ($result['unit'] ?? '');
$bagName = (string) ($result['bag_name'] ?? '');
$bagCode = (string) ($result['bag_code'] ?? '');
$lotLabel = (string) ($result['lot_no'] ?? $lotNo);
$qtyBox = (int) ($result['qty_box'] ?? 0);
$qtyPack = (int) ($result['qty_pack'] ?? 0);
$qtySheet = (int) ($result['qty_sheet'] ?? 0);
$testSamples = is_array($testSamples ?? null) ? $testSamples : [];
$printExtra = [];
if ($queried && $barcode !== '') {
$printExtra[] = '봉투번호(바코드): ' . $barcode;
}
if ($bagName !== '' || $bagCode !== '') {
$printExtra[] = '품목: ' . trim($bagName . ($bagCode !== '' ? ' (' . $bagCode . ')' : ''));
}
?>
<?= view('components/print_header', [
'printTitle' => 'LOT 수불 조회',
'printExtraLines' => $printExtra,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">LOT 수불 조회</span> <span class="text-sm font-bold text-gray-700">LOT 수불 조회</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/lot-flow') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<label class="text-sm text-gray-600">LOT 번호</label> <form method="get" action="<?= mgmt_url('reports/lot-flow') ?>" class="flex flex-wrap items-end gap-x-4 gap-y-2">
<input type="text" name="lot_no" value="<?= esc($lotNo ?? '') ?>" placeholder="LOT-YYYYMMDD-XXXXXX" class="border border-gray-300 rounded px-2 py-1 text-sm w-64"/> <input type="hidden" name="search" value="1"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> <div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">봉투번호</label>
<input type="text" name="barcode" id="lot-flow-barcode-input" value="<?= esc($barcode) ?>"
placeholder="바코드·팩·박스·낱장 코드 입력"
class="border border-gray-300 rounded px-2 py-1 w-80 font-mono text-sm" autocomplete="off"/>
<span class="text-gray-500 text-xs">(바코드 스캔 = 번호 직접 입력)</span>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
<p class="text-xs text-gray-500 mt-1">
낱장 번호 조회 시 <strong>해당 장(바코드)의 판매·반품</strong>만 표시합니다. 팩·박스·LOT 조회는 해당 단위 이력입니다.
</p>
</section> </section>
<?php if ($lotNo !== '' && $order): ?> <?php if ($queried && ! $ok && $message !== ''): ?>
<!-- 발주 정보 --> <div class="m-2 p-3 border border-amber-300 bg-amber-50 text-sm text-amber-900 no-print">
<div class="border border-gray-300 p-3 mt-2 bg-gray-50"> <?= esc($message) ?>
<h3 class="text-sm font-bold text-gray-700 mb-2">발주 정보</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<div><span class="text-gray-500">LOT번호:</span> <span class="font-mono"><?= esc($order->bo_lot_no) ?></span></div>
<div><span class="text-gray-500">발주일:</span> <?= esc($order->bo_order_date) ?></div>
<div><span class="text-gray-500">상태:</span>
<?php $statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; ?>
<?= esc($statusMap[$order->bo_status] ?? $order->bo_status) ?>
</div>
<div><span class="text-gray-500">등록일:</span> <?= esc($order->bo_regdate) ?></div>
</div>
</div> </div>
<?php endif; ?>
<!-- 발주 품목 --> <?php if (! $queried): ?>
<h3 class="text-sm font-bold text-gray-700 mt-3 mb-1">발주 품목</h3> <div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
<div class="border border-gray-300 overflow-auto"> 봉투번호(바코드)를 입력한 뒤 <strong>조회</strong>를 눌러 주세요.
<table class="w-full data-table"> </div>
<?php endif; ?>
<section class="mx-2 mb-2 border border-amber-400 bg-amber-50/80 rounded-sm no-print" id="lot-flow-test-samples">
<div class="px-3 py-2 border-b border-amber-300 bg-amber-100/80">
<strong class="text-amber-950 text-sm">[개발용 임시] 등록·조회 가능 봉투번호 샘플</strong>
<span class="text-xs text-amber-800 ml-2">행 클릭 → 봉투번호 입력 후 조회 · 현재 지자체 DB 기준</span>
</div>
<div class="overflow-auto max-h-48">
<table class="w-full text-xs data-table">
<thead> <thead>
<tr> <tr>
<th>봉투코드</th> <th class="w-14 text-center">구분</th>
<th>봉투</th> <th>봉투번호(입력값)</th>
<th>발주수량(박스)</th> <th class="w-28">품목</th>
<th>발주수량(매)</th> <th class="w-24">LOT</th>
<th>단가</th> <th class="w-14 text-center">상태</th>
<th>금액</th> <th class="w-32">비고</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php foreach ($items as $item): ?> <?php if ($testSamples === []): ?>
<tr> <tr>
<td class="text-center font-mono"><?= esc($item->boi_bag_code) ?></td> <td colspan="6" class="text-center text-gray-500 py-4">
<td class="text-left pl-2"><?= esc($item->boi_bag_name) ?></td> <code>bag_receiving_pack_code</code> 데이터가 없습니다. 입고 처리 후 표시됩니다.
<td><?= number_format((int) $item->boi_qty_box) ?></td> </td>
<td><?= number_format((int) $item->boi_qty_sheet) ?></td> </tr>
<td><?= number_format((int) $item->boi_unit_price) ?></td> <?php endif; ?>
<td><?= number_format((int) $item->boi_amount) ?></td> <?php foreach ($testSamples as $sample): ?>
<tr class="lot-flow-sample-row cursor-pointer hover:bg-amber-100"
data-code="<?= esc((string) ($sample['code'] ?? ''), 'attr') ?>"
title="클릭하여 조회">
<td class="text-center"><?= esc((string) ($sample['kind'] ?? '')) ?></td>
<td class="font-mono text-left pl-2 break-all"><?= esc((string) ($sample['code'] ?? '')) ?></td>
<td class="text-left pl-1 truncate max-w-[7rem]" title="<?= esc((string) ($sample['bag_name'] ?? ''), 'attr') ?>">
<?= esc((string) ($sample['bag_name'] ?? '')) ?>
</td>
<td class="font-mono text-left pl-1 truncate max-w-[6rem]"><?= esc((string) ($sample['lot_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($sample['state'] ?? '')) ?></td>
<td class="text-gray-600 pl-1"><?= esc((string) ($sample['hint'] ?? '')) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($items)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">품목이 없습니다.</td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<!-- 입고 내역 --> <div class="lot-flow-layout m-2 flex flex-col lg:flex-row gap-3 min-h-[320px]">
<h3 class="text-sm font-bold text-gray-700 mt-3 mb-1">입고 내역</h3> <!-- 좌: 품목·단위 요약 (레거시 BOX/PACK/낱장) -->
<div class="border border-gray-300 overflow-auto"> <div class="lot-flow-summary border border-gray-300 bg-gray-50 p-3 lg:w-64 shrink-0">
<table class="w-full data-table"> <h3 class="text-sm font-bold text-gray-700 mb-2">봉투 정보</h3>
<thead> <?php if ($ok): ?>
<tr> <dl class="text-sm space-y-1.5">
<th>입고일</th> <div><dt class="text-gray-500 inline">품목</dt>
<th>봉투코드</th> <dd class="font-medium"><?= esc($bagName !== '' ? $bagName : '-') ?></dd></div>
<th>봉투명</th> <?php if ($bagCode !== ''): ?>
<th>입고수량(박스)</th> <div><dt class="text-gray-500 inline">코드</dt>
<th>입고수량(매)</th> <dd class="font-mono text-xs"><?= esc($bagCode) ?></dd></div>
<th>납품자</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($receivings as $recv): ?>
<tr>
<td class="text-center"><?= esc($recv->br_receive_date) ?></td>
<td class="text-center font-mono"><?= esc($recv->br_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($recv->br_bag_name) ?></td>
<td><?= number_format((int) $recv->br_qty_box) ?></td>
<td><?= number_format((int) $recv->br_qty_sheet) ?></td>
<td class="text-left pl-2"><?= esc($recv->br_sender_name ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($receivings)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">입고 내역이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> <?php if ($lotLabel !== ''): ?>
</table> <div><dt class="text-gray-500 inline">LOT</dt>
<dd class="font-mono text-xs break-all"><?= esc($lotLabel) ?></dd></div>
<?php endif; ?>
<div><dt class="text-gray-500 inline">봉투번호</dt>
<dd class="font-mono text-xs break-all"><?= esc($barcode !== '' ? $barcode : '-') ?></dd></div>
<?php if ($unit !== ''): ?>
<div><dt class="text-gray-500 inline">조회단위</dt>
<dd><?= esc($unit) ?></dd></div>
<?php endif; ?>
</dl>
<div class="grid grid-cols-3 gap-2 mt-4 text-center text-xs">
<div class="border border-gray-300 bg-white rounded p-2">
<div class="text-gray-500 font-bold">BOX</div>
<div class="text-lg font-semibold tabular-nums"><?= $qtyBox > 0 ? number_format($qtyBox) : '—' ?></div>
</div> </div>
<div class="border border-gray-300 bg-white rounded p-2">
<?php elseif ($lotNo !== '' && !$order): ?> <div class="text-gray-500 font-bold">PACK</div>
<div class="border border-gray-300 p-4 mt-2 text-center text-gray-400">해당 LOT 번호의 발주를 찾을 수 없습니다.</div> <div class="text-lg font-semibold tabular-nums"><?= $qtyPack > 0 ? number_format($qtyPack) : '—' ?></div>
</div>
<div class="border border-gray-300 bg-white rounded p-2">
<div class="text-gray-500 font-bold">낱장</div>
<div class="text-lg font-semibold tabular-nums"><?= $qtySheet > 0 ? number_format($qtySheet) : '—' ?></div>
</div>
</div>
<?php elseif ($queried): ?>
<p class="text-sm text-gray-500">조회 결과 없음</p>
<?php else: ?> <?php else: ?>
<div class="border border-gray-300 p-4 mt-2 text-center text-gray-400">LOT 번호를 입력하고 조회해 주세요.</div> <p class="text-sm text-gray-400">조회 후 표시</p>
<?php endif; ?> <?php endif; ?>
</div>
<!-- 우: LOT 수불 현황 -->
<div class="lot-flow-table-wrap flex-1 border border-gray-300 flex flex-col min-w-0">
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">LOT 수불 현황</span>
</div>
<div class="overflow-auto flex-1">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-28">일자</th>
<th>입출고처</th>
<th class="w-24 text-center">구분</th>
</tr>
</thead>
<tbody>
<?php if ($queried && $ok && $rows === []): ?>
<tr>
<td colspan="3" class="text-center text-gray-500 py-8">수불 이력이 없습니다.</td>
</tr>
<?php endif; ?>
<?php if (! $queried): ?>
<tr>
<td colspan="3" class="text-center text-gray-400 py-8">봉투번호 입력 후 조회</td>
</tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<tr>
<td class="text-center"><?= esc((string) ($row['flow_date'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['counterparty'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['flow_type'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
const input = document.getElementById('lot-flow-barcode-input');
const form = input?.closest('form');
document.querySelectorAll('.lot-flow-sample-row').forEach((row) => {
row.addEventListener('click', () => {
const code = row.getAttribute('data-code') || '';
if (!input || !form || code === '') return;
input.value = code;
form.submit();
});
});
})();
</script>
<style>
@media print {
.no-print { display: none !important; }
#lot-flow-test-samples { display: none !important; }
.lot-flow-layout { margin: 0; flex-direction: row; }
}
</style>

View File

@@ -1,84 +1,369 @@
<?= view('components/print_header', ['printTitle' => '기타 입출고']) ?> <?= view('components/print_header', ['printTitle' => '기타 입출고']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <?php
$filters = is_array($filters ?? null) ? $filters : [];
$flowYear = (string) ($filters['flow_y'] ?? '');
$flowMonthNum = (string) ($filters['flow_m'] ?? '');
$dateYearMin = (int) ($dateYearMin ?? ((int) date('Y') - 12));
$dateYearMax = (int) ($dateYearMax ?? ((int) date('Y') + 2));
$bagCodeFilter = (string) ($filters['bag_code'] ?? '');
$bagKind = (string) ($filters['bag_kind'] ?? '');
$bagCancelOnly = ! empty($filters['bag_cancel']);
$selKey = (string) ($filters['sel_key'] ?? '');
$selectedGroup = is_array($selectedGroup ?? null) ? $selectedGroup : null;
$detailLines = is_array($detailLines ?? null) ? $detailLines : [];
$groupList = is_array($groupList ?? null) ? $groupList : [];
$packagingMap = is_array($packagingMap ?? null) ? $packagingMap : [];
$bagKindOptions = is_array($bagKindOptions ?? null) ? $bagKindOptions : [];
$bagCodes = is_array($bagCodes ?? null) ? $bagCodes : [];
$miscFlowListUrl = static function (array $extra = []) use ($filters): string {
$qs = array_merge($filters, $extra);
unset($qs['sel_key']);
if (isset($extra['sel_key'])) {
$qs['sel_key'] = $extra['sel_key'];
}
return mgmt_url('reports/misc-flow') . ($qs !== [] ? '?' . http_build_query($qs) : '');
};
$detailTotalQty = 0;
foreach ($detailLines as $line) {
$detailTotalQty += (int) ($line->bmf_qty ?? 0);
}
$registerDate = $selectedGroup ? (string) ($selectedGroup['date'] ?? date('Y-m-d')) : date('Y-m-d');
$registerType = $selectedGroup ? (string) ($selectedGroup['type'] ?? 'in') : 'in';
$registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '';
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기타 입출고</span> <span class="text-sm font-bold text-gray-700">기타 입출고</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= esc(work_area_home_url()) ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div> </div>
</section> </section>
<?php if (! ($tableExists ?? false)): ?> <?php if (! ($tableExists ?? false)): ?>
<div class="border border-orange-300 bg-orange-50 p-3 mt-2 text-sm text-orange-700"> <div class="border border-orange-300 bg-orange-50 p-3 m-2 text-sm text-orange-700 no-print">
bag_misc_flow 테이블이 생성되지 않았습니다. <code>writable/database/bag_misc_flow_tables.sql</code>을 실행해 주세요. bag_misc_flow 테이블이 생성되지 않았습니다. <code>writable/database/bag_misc_flow_tables.sql</code>을 실행해 주세요.
</div> </div>
<?php elseif (($totalRowsForLg ?? 0) === 0): ?>
<div class="border border-blue-200 bg-blue-50 p-3 m-2 text-sm text-blue-900 no-print">
선택한 지자체에 등록된 <strong>기타 입출고</strong> 데이터가 없습니다. 아래 <strong>품목 등록</strong>으로 첫 건을 넣으면 좌측 리스트에 표시됩니다.
</div>
<?php elseif (($groupList ?? []) === [] && ($fetchedRowCount ?? 0) === 0 && (($flowYear ?? '') !== '' || ($flowMonthNum ?? '') !== '' || ($bagCodeFilter ?? '') !== '' || ($bagKind ?? '') !== '' || ! empty($filters['bag_cancel']))): ?>
<div class="border border-amber-200 bg-amber-50 p-3 m-2 text-sm text-amber-900 no-print">
조회 조건(수불 년월·봉투코드·구분 등)에 맞는 내역이 없습니다. <strong>수불 년월을 「전체」</strong>로 두거나 조건을 넓혀 다시 조회해 주세요.
</div>
<?php endif; ?> <?php endif; ?>
<!-- 등록 폼 --> <?php if (session()->getFlashdata('success')): ?>
<div class="mx-2 mt-2 border border-green-300 bg-green-50 text-green-800 text-sm px-3 py-2 no-print"><?= esc((string) session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-2 mt-2 border border-red-300 bg-red-50 text-red-800 text-sm px-3 py-2 no-print"><?= esc((string) session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<!-- 조회 조건 (레거시: 수불 년월, 봉투코드, 봉투 취소, 구분, 조회) -->
<section class="p-2 bg-white border-b border-gray-200 no-print"> <section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="POST" action="<?= mgmt_url('reports/misc-flow') ?>" class="flex flex-wrap items-center gap-2"> <form method="get" action="<?= mgmt_url('reports/misc-flow') ?>" class="flex flex-wrap items-end gap-2 text-sm">
<?= csrf_field() ?> <span class="font-bold text-gray-700 whitespace-nowrap">수불 년월</span>
<label class="text-sm text-gray-600">구분</label> <select name="flow_y" class="border border-gray-300 rounded px-2 py-1 min-w-[5.5rem]" aria-label="수불 년도">
<select name="bmf_type" class="border border-gray-300 rounded px-2 py-1 text-sm" required> <option value="">전체</option>
<option value="in">입고</option> <?php for ($yy = $dateYearMax; $yy >= $dateYearMin; $yy--): ?>
<option value="out">출고</option> <option value="<?= $yy ?>" <?= $flowYear === (string) $yy ? 'selected' : '' ?>><?= $yy ?>년</option>
<?php endfor; ?>
</select> </select>
<label class="text-sm text-gray-600">봉투</label> <select name="flow_m" class="border border-gray-300 rounded px-2 py-1 min-w-[4.5rem]" aria-label="수불 월" <?= $flowYear === '' ? 'disabled' : '' ?>>
<select name="bmf_bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm" required> <option value=""><?= $flowYear === '' ? '—' : '전체' ?></option>
<option value="">선택</option> <?php for ($mi = 1; $mi <= 12; $mi++): ?>
<?php foreach ($bagCodes as $bc): ?> <option value="<?= $mi ?>" <?= $flowMonthNum !== '' && (int) $flowMonthNum === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
<option value="<?= esc($bc->cd_code) ?>"><?= esc($bc->cd_code . ' - ' . $bc->cd_name) ?></option> <?php endfor; ?>
</select>
<label class="font-bold text-gray-700 whitespace-nowrap">봉투코드</label>
<input type="text" name="bag_code" value="<?= esc($bagCodeFilter) ?>" placeholder="코드 일부"
class="border border-gray-300 rounded px-2 py-1 w-28 font-mono"/>
<label class="inline-flex items-center gap-1 text-gray-700 whitespace-nowrap">
<input type="checkbox" name="bag_cancel" value="1" <?= $bagCancelOnly ? 'checked' : '' ?>/>
봉투 취소
</label>
<span class="text-xs text-gray-500 hidden sm:inline" title="출고 건만 조회">(출고만)</span>
<label class="font-bold text-gray-700 whitespace-nowrap">구분</label>
<select name="bag_kind" class="border border-gray-300 rounded px-2 py-1 min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($bagKindOptions as $opt): ?>
<option value="<?= esc((string) $opt->cd_code) ?>" <?= $bagKind === (string) $opt->cd_code ? 'selected' : '' ?>>
<?= esc((string) $opt->cd_name) ?>
</option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<label class="text-sm text-gray-600">수량</label>
<input type="number" name="bmf_qty" min="1" class="border border-gray-300 rounded px-2 py-1 text-sm w-24" required/> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
<label class="text-sm text-gray-600">일자</label> <a href="<?= mgmt_url('reports/misc-flow') ?>" class="text-gray-500 hover:text-gray-800 px-2 py-1">초기화</a>
<input type="date" name="bmf_date" value="<?= date('Y-m-d') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm" required/>
<label class="text-sm text-gray-600">사유</label>
<input type="text" name="bmf_reason" placeholder="입출고 사유" class="border border-gray-300 rounded px-2 py-1 text-sm w-48" required/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">등록</button>
</form> </form>
</section> </section>
<!-- 조회 필터 --> <div class="grid grid-cols-1 xl:grid-cols-4 gap-2 p-2">
<section class="p-2 bg-white border-b border-gray-200 no-print"> <!-- 입출고 리스트 -->
<form method="GET" action="<?= mgmt_url('reports/misc-flow') ?>" class="flex flex-wrap items-center gap-2"> <section class="border border-gray-300 bg-white xl:col-span-1">
<label class="text-sm text-gray-600">시작일</label> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 리스트</div>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <div class="overflow-auto max-h-[520px]">
<label class="text-sm text-gray-600">~</label> <table class="w-full data-table text-sm">
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>번호</th> <th class="w-24">수불일자</th>
<th>구분</th> <th class="w-16">수량</th>
<th>일자</th> <th class="w-14">구분</th>
<th>봉투코드</th> <th>메모</th>
<th>봉투명</th>
<th>수량</th>
<th>사유</th>
<th>등록일</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php foreach ($result as $row): ?> <?php if ($groupList !== []): ?>
<tr> <?php foreach ($groupList as $grp): ?>
<td class="text-center"><?= (int) $row->bmf_idx ?></td> <?php
<td class="text-center"><?= $row->bmf_type === 'in' ? '<span class="text-blue-600">입고</span>' : '<span class="text-red-600">출고</span>' ?></td> $key = (string) ($grp['key'] ?? '');
<td class="text-center"><?= esc($row->bmf_date) ?></td> $isSelected = $key !== '' && $key === $selKey;
<td class="text-center font-mono"><?= esc($row->bmf_bag_code) ?></td> $listUrl = $miscFlowListUrl(['sel_key' => $key]);
<td class="text-left pl-2"><?= esc($row->bmf_bag_name) ?></td> ?>
<td><?= number_format((int) $row->bmf_qty) ?></td> <tr
<td class="text-left pl-2"><?= esc($row->bmf_reason) ?></td> class="<?= $isSelected ? 'bg-blue-100 font-semibold' : '' ?> cursor-pointer hover:bg-blue-50"
<td class="text-center"><?= esc($row->bmf_regdate) ?></td> onclick="window.location.href='<?= esc($listUrl, 'attr') ?>'"
>
<td class="text-center <?= $isSelected ? 'border-l-4 border-blue-600' : '' ?>"><?= esc((string) ($grp['date'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($grp['totalQty'] ?? 0)) ?></td>
<td class="text-center">
<?php if ((string) ($grp['type'] ?? '') === 'in'): ?>
<span class="text-blue-700">입고</span>
<?php else: ?>
<span class="text-red-700">출고</span>
<?php endif; ?>
</td>
<td class="text-left pl-2 truncate max-w-[8rem]" title="<?= esc((string) ($grp['reason'] ?? '')) ?>"><?= esc((string) ($grp['reason'] ?? '')) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($result)): ?> <?php else: ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr> <tr><td colspan="4" class="text-center text-gray-400 py-6">
<?php if (($totalRowsForLg ?? 0) === 0): ?>
등록된 기타 입출고가 없습니다.
<?php elseif (($fetchedRowCount ?? 0) === 0): ?>
선택한 기간·조건에 해당하는 내역이 없습니다.
<?php else: ?>
조회 결과가 없습니다.
<?php endif; ?>
</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<!-- 우측: 입출고 정보 + 봉투 코드 + 등록 -->
<div class="xl:col-span-3 space-y-2">
<form method="post" action="<?= mgmt_url('reports/misc-flow/delete') ?>" id="misc-flow-delete-form" class="no-print">
<?= csrf_field() ?>
<input type="hidden" name="flow_y" value="<?= esc($flowYear) ?>"/>
<input type="hidden" name="flow_m" value="<?= esc($flowMonthNum) ?>"/>
<input type="hidden" name="bag_code" value="<?= esc($bagCodeFilter) ?>"/>
<input type="hidden" name="bag_kind" value="<?= esc($bagKind) ?>"/>
<?php if ($bagCancelOnly): ?><input type="hidden" name="bag_cancel" value="1"/><?php endif; ?>
<input type="hidden" name="sel_key" value="<?= esc($selKey) ?>"/>
<div class="flex flex-wrap gap-2 mb-1">
<button type="submit" class="bg-red-600 text-white px-4 py-1 rounded-sm text-sm disabled:opacity-40"
<?= $selKey === '' ? 'disabled' : '' ?>
onclick="return confirm('선택한 입출고 건을 삭제할까요? 재고가 복원됩니다.');">삭제</button>
<?php
$cancelQs = $filters;
unset($cancelQs['sel_key']);
$cancelUrl = mgmt_url('reports/misc-flow') . ($cancelQs !== [] ? '?' . http_build_query($cancelQs) : '');
?>
<a href="<?= esc($cancelUrl) ?>" class="border border-gray-400 text-gray-700 px-4 py-1 rounded-sm text-sm hover:bg-gray-50 inline-block">취소</a>
</div>
</form>
<!-- 입출고 일자 (상세) -->
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 일자</div>
<div class="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<?php if ($selectedGroup): ?>
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-gray-700 whitespace-nowrap">수불 일자</span>
<span class="border border-gray-200 bg-gray-50 px-2 py-1 rounded"><?= esc((string) ($selectedGroup['date'] ?? '')) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-gray-700 whitespace-nowrap">선택</span>
<span class="border border-gray-200 bg-gray-50 px-2 py-1 rounded"><?= esc((string) ($selectedGroup['typeLabel'] ?? '')) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-gray-700 whitespace-nowrap">분류</span>
<span class="border border-gray-200 bg-gray-50 px-2 py-1 rounded min-w-[6rem]">
<?= esc($selectedBagKindLabel ?? '') !== '' ? esc($selectedBagKindLabel) : '—' ?>
</span>
</div>
<div class="md:col-span-2">
<span class="font-bold text-gray-700 block mb-1">비고</span>
<div class="border border-gray-200 bg-gray-50 rounded px-2 py-2 min-h-[4rem] whitespace-pre-wrap"><?= esc((string) ($selectedGroup['reason'] ?? '')) ?></div>
</div>
<?php else: ?>
<p class="text-gray-400 col-span-2 py-2">좌측 입출고 리스트에서 건을 선택하거나, 아래에서 신규 등록해 주세요.</p>
<?php endif; ?>
</div>
</section>
<!-- 입출고 봉투 코드 -->
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 봉투 코드</div>
<div class="overflow-auto max-h-[280px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10">No</th>
<th>봉투 코드</th>
<th>봉투 종류</th>
<th class="w-20">수량</th>
<th class="w-14">단위</th>
</tr>
</thead>
<tbody class="text-right">
<?php if ($detailLines !== []): ?>
<?php foreach ($detailLines as $idx => $line): ?>
<?php
$code = (string) ($line->bmf_bag_code ?? '');
$pu = $packagingMap[$code] ?? null;
$unitLabel = '매';
if ($pu && (int) ($pu->pu_pack_per_sheet ?? 0) > 0) {
$unitLabel = '매';
}
?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center font-mono"><?= esc($code) ?></td>
<td class="text-left pl-2"><?= esc((string) ($line->bmf_bag_name ?? '')) ?></td>
<td class="pr-2"><?= number_format((int) ($line->bmf_qty ?? 0)) ?></td>
<td class="text-center"><?= esc($unitLabel) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-6">봉투 코드 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-50 font-semibold">
<td colspan="3" class="text-center">합계</td>
<td class="text-right pr-2"><?= number_format($detailTotalQty) ?></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) -->
<?php if ($tableExists ?? false): ?>
<section class="border border-gray-300 bg-white no-print">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">품목 등록</div>
<form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm">
<?= csrf_field() ?>
<input type="hidden" name="flow_y" value="<?= esc($flowYear) ?>"/>
<input type="hidden" name="flow_m" value="<?= esc($flowMonthNum) ?>"/>
<input type="hidden" name="bag_code" value="<?= esc($bagCodeFilter) ?>"/>
<input type="hidden" name="bag_kind" value="<?= esc($bagKind) ?>"/>
<?php if ($bagCancelOnly): ?><input type="hidden" name="bag_cancel" value="1"/><?php endif; ?>
<input type="hidden" name="sel_key" value="<?= esc($selKey) ?>"/>
<label class="font-bold text-gray-700">수불 일자</label>
<input type="date" name="bmf_date" value="<?= esc($registerDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<label class="font-bold text-gray-700">선택</label>
<select name="bmf_type" class="border border-gray-300 rounded px-2 py-1 min-w-[7.5rem] w-32">
<option value="in" <?= $registerType === 'in' ? 'selected' : '' ?>>입고</option>
<option value="out" <?= $registerType === 'out' ? 'selected' : '' ?>>출고</option>
</select>
<label class="font-bold text-gray-700">분류</label>
<select name="bmf_bag_kind" id="bmf-bag-kind" class="border border-gray-300 rounded px-2 py-1 min-w-[7rem]">
<option value="">전체</option>
<?php foreach ($bagKindOptions as $opt): ?>
<option value="<?= esc((string) $opt->cd_code) ?>" <?= ($selectedBagKind ?? '') === (string) $opt->cd_code ? 'selected' : '' ?>>
<?= esc((string) $opt->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">비고</label>
<input type="text" name="bmf_reason" value="<?= esc($registerReason) ?>" placeholder="입출고 메모" maxlength="200"
class="border border-gray-300 rounded px-2 py-1 w-40 max-w-[10rem] shrink-0" required/>
<label class="font-bold text-gray-700">봉투 코드</label>
<select name="bmf_bag_code" id="bmf-bag-code" class="border border-gray-300 rounded px-2 py-1 min-w-[11rem]" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $bc): ?>
<?php $code = (string) $bc->cd_code; ?>
<option value="<?= esc($code) ?>" data-kind-prefix="<?= esc(substr($code, 0, 2)) ?>">
<?= esc($code . ' - ' . $bc->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700">수량</label>
<input type="number" name="bmf_qty" min="1" class="border border-gray-300 rounded px-2 py-1 w-24" required/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">등록</button>
</form>
<p class="px-2 pb-2 text-xs text-gray-500">동일 수불일자·입출고·비고로 등록한 품목은 좌측 리스트에서 한 건으로 묶여 표시됩니다.</p>
</section>
<?php endif; ?>
</div>
</div>
<script>
(function () {
const kindSelect = document.getElementById('bmf-bag-kind');
const bagSelect = document.getElementById('bmf-bag-code');
if (!kindSelect || !bagSelect) return;
const allOptions = Array.from(bagSelect.querySelectorAll('option[data-kind-prefix]'));
function filterBagCodes() {
const prefix = kindSelect.value;
const current = bagSelect.value;
allOptions.forEach(function (opt) {
const show = prefix === '' || opt.getAttribute('data-kind-prefix') === prefix;
opt.hidden = !show;
opt.disabled = !show;
});
const selected = bagSelect.querySelector('option[value="' + CSS.escape(current) + '"]');
if (selected && !selected.hidden) {
bagSelect.value = current;
} else {
bagSelect.value = '';
}
}
kindSelect.addEventListener('change', filterBagCodes);
filterBagCodes();
const flowYearSelect = document.querySelector('select[name="flow_y"]');
const flowMonthSelect = document.querySelector('form[method="get"] select[name="flow_m"]');
if (flowYearSelect && flowMonthSelect) {
const syncFlowMonthSelect = function () {
const hasYear = flowYearSelect.value !== '';
flowMonthSelect.disabled = !hasYear;
if (!hasYear) {
flowMonthSelect.value = '';
}
const firstOpt = flowMonthSelect.querySelector('option[value=""]');
if (firstOpt) {
firstOpt.textContent = hasYear ? '전체' : '—';
}
};
flowYearSelect.addEventListener('change', syncFlowMonthSelect);
syncFlowMonthSelect();
}
})();
</script>

View File

@@ -1,73 +1,184 @@
<?= view('components/print_header', ['printTitle' => '기간별 판매현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> declare(strict_types=1);
/** @var list<array<string,mixed>> $lines */
/** @var string $startDate */
/** @var string $endDate */
/** @var int $saIdx */
/** @var string $catFilter */
/** @var string $mode */
/** @var list<object> $agencies */
/** @var array<string,string> $catLabels */
/** @var bool $hasBsFee */
/** @var string $lgName */
/** @var string $agencyLabel */
/** @var string $catLabelFilter */
/** @var list<string> $printExtraLines */
$byDaily = ($mode ?? 'daily') === 'daily';
$exportParams = array_filter([
'start_date' => $startDate ?? '',
'end_date' => $endDate ?? '',
'sa_idx' => (int) ($saIdx ?? 0),
'cat' => (string) ($catFilter ?? ''),
'mode' => $byDaily ? '' : 'period',
'export' => '1',
], static fn ($v): bool => $v !== '' && $v !== null);
$excelUrl = mgmt_url('reports/period-sales?' . http_build_query($exportParams));
$fmtFee = static function (float $v) use ($hasBsFee): string {
if (! $hasBsFee) {
return '—';
}
return number_format((int) round($v));
};
$rowClass = static function (string $kind): string {
return match ($kind) {
'day_sub_all' => 'bg-gray-100 font-semibold',
'day_sub_bag' => 'bg-sky-50 font-semibold text-sky-900',
'day_sub_fs' => 'bg-violet-50 font-semibold text-violet-900',
'foot_all' => 'bg-red-50 font-bold text-red-700',
'foot_bag' => 'bg-blue-50 font-bold text-blue-700',
'foot_fs' => 'bg-purple-50 font-bold text-purple-800',
default => '',
};
};
?>
<?= view('components/print_header', [
'printTitle' => '기간별 판매현황',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기간별 판매현황</span> <span class="text-sm font-bold text-gray-700">기간별 판매현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/period-sales') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-3 bg-white border-b border-gray-200 no-print">
<label class="text-sm text-gray-600">시작일</label> <form method="GET" action="<?= mgmt_url('reports/period-sales') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label> </div>
<div>
<label class="block text-gray-600 mb-0.5">종료일</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> </div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구분</label>
<select name="cat" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="" <?= ($catFilter ?? '') === '' ? 'selected' : '' ?>>전체</option>
<?php foreach (($catLabels ?? []) as $ck => $lab): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= ($catFilter ?? '') === $ck ? 'selected' : '' ?>><?= esc($lab) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">집계 방식</label>
<select name="mode" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[9rem]">
<option value="daily" <?= $byDaily ? 'selected' : '' ?>>일자별</option>
<option value="period" <?= ! $byDaily ? 'selected' : '' ?>>기간별</option>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <section class="p-3 bg-white">
<table class="w-full data-table"> <style>
@media print {
.period-sales-screen-title { display: none !important; }
}
</style>
<div class="mb-2 text-center period-sales-screen-title no-print">
<h1 class="text-lg font-bold m-0">기간별 판매현황<?= $byDaily ? ' [일집계]' : ' [기간집계]' ?></h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · ' . ($startDate ?? '') . ' ~ ' . ($endDate ?? '') . ' · 대행소: ' . ($agencyLabel ?? '') . ' · 구분: ' . ($catLabelFilter ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0">집계: <?= $byDaily ? '일자별' : '기간별' ?> · (단위: 매 / 원)</p>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm" id="period-sales-table">
<thead> <thead>
<tr> <tr>
<th>봉투코드</th> <?php if ($byDaily): ?>
<th>봉투명</th> <th rowspan="2" class="align-middle whitespace-nowrap">일자</th>
<th>판매수량</th> <?php endif; ?>
<th>판매금액</th> <th rowspan="2" class="align-middle text-left pl-2">품목</th>
<th>반품수량</th> <th colspan="4" class="text-center border-l border-gray-300">판매</th>
<th>반품금액</th> <th colspan="2" class="text-center border-l border-gray-300">반품</th>
<th>합계수량</th> <th colspan="4" class="text-center border-l border-gray-300">계</th>
<th>합계금액</th> </tr>
<tr>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">금액</th>
<th class="text-right border-l border-gray-300">수량</th>
<th class="text-right">판매금액</th>
<th class="text-right">수수료</th>
<th class="text-right">징수액</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php <?php
$grandSaleQty = 0; $emptyColspan = $byDaily ? 12 : 11;
$grandSaleAmount = 0;
$grandReturnQty = 0;
$grandReturnAmount = 0;
?> ?>
<?php foreach ($result as $row): ?> <?php foreach ($lines ?? [] as $ln): ?>
<?php <?php
$grandSaleQty += (int) $row->sale_qty; $kind = (string) ($ln['kind'] ?? 'data');
$grandSaleAmount += (int) $row->sale_amount; $trCls = $rowClass($kind);
$grandReturnQty += (int) $row->return_qty; $isData = $kind === 'data';
$grandReturnAmount += (int) $row->return_amount; $rs = (int) ($ln['ymd_rowspan'] ?? 0);
?> ?>
<tr> <tr class="<?= esc($trCls, 'attr') ?>">
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td> <?php if ($byDaily): ?>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td> <?php if ($isData && $rs > 0): ?>
<td><?= number_format((int) $row->sale_qty) ?></td> <td rowspan="<?= $rs ?>" class="text-center align-top whitespace-nowrap tabular-nums pt-1"><?= esc((string) ($ln['ymd'] ?? '')) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td> <?php elseif (str_starts_with($kind, 'foot_')): ?>
<td><?= number_format((int) $row->return_qty) ?></td> <td class="bg-inherit"></td>
<td><?= number_format((int) $row->return_amount) ?></td> <?php endif; ?>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td> <?php endif; ?>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td> <td class="text-left pl-2"><?= esc((string) ($ln['name'] ?? '')) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($ln['s_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['s_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($ln['s_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['s_levy'] ?? 0))) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($ln['r_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['r_amt'] ?? 0))) ?></td>
<td class="border-l border-gray-200 tabular-nums"><?= number_format((int) ($ln['t_qty'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['t_amt'] ?? 0))) ?></td>
<td class="tabular-nums"><?= $fmtFee((float) ($ln['t_fee'] ?? 0)) ?></td>
<td class="tabular-nums"><?= number_format((int) round((float) ($ln['t_levy'] ?? 0))) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($result)): ?> <?php if (($lines ?? []) === []): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr> <tr>
<td colspan="<?= (int) $emptyColspan ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
<tfoot class="bg-gray-50 font-bold text-right">
<tr>
<td colspan="2" class="text-center">합계</td>
<td><?= number_format($grandSaleQty) ?></td>
<td><?= number_format($grandSaleAmount) ?></td>
<td><?= number_format($grandReturnQty) ?></td>
<td><?= number_format($grandReturnAmount) ?></td>
<td><?= number_format($grandSaleQty - $grandReturnQty) ?></td>
<td><?= number_format($grandSaleAmount - $grandReturnAmount) ?></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</section>

View File

@@ -1,59 +1,178 @@
<?= view('components/print_header', ['printTitle' => '반품/파기 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> $startDate = (string) ($startDate ?? date('Y-m-01'));
$endDate = (string) ($endDate ?? date('Y-m-d'));
$ioType = (string) ($ioType ?? 'out');
$result = is_array($result ?? null) ? $result : (array) ($result ?? []);
$queried = (bool) ($queried ?? false);
$exportParams = $queried ? array_filter([
'search' => '1',
'start_date' => $startDate,
'end_date' => $endDate,
'io_type' => $ioType,
], static fn ($v) => $v !== null && $v !== '') : [];
$excelUrl = $exportParams !== []
? mgmt_url('reports/returns/export') . '?' . http_build_query($exportParams)
: '';
$fmtKrDate = static function (string $ymd): string {
$ts = strtotime($ymd);
return $ts ? date('Y년 m월 d일', $ts) : $ymd;
};
$ioLabel = $ioType === 'in' ? '입고' : '출고';
$periodLabel = $fmtKrDate($startDate) . ' ~ ' . $fmtKrDate($endDate);
$printExtraLines = [
'조회기간: ' . $periodLabel,
'입출고 구분: ' . $ioLabel,
'(단위: 매)',
];
$typeLabel = static function (string $bsType): string {
return match ($bsType) {
'return' => '반품',
'dispose' => '파기',
'cancel' => '파기',
default => $bsType,
};
};
$kindLabel = static function (object $row): string {
$name = trim((string) ($row->bs_bag_name ?? ''));
if ($name !== '') {
return $name;
}
$code = trim((string) ($row->bs_bag_code ?? ''));
return $code !== '' ? $code : '-';
};
$totalQty = 0;
foreach ($result as $row) {
$totalQty += (int) ($row->qty ?? 0);
}
$tipPage = "지정판매소 반품·물류 입고분 파기 내역을 기간·입출고 구분으로 조회합니다.\n"
. "· 출고: 지정판매소 반품 등록 화면에서 처리된 반품\n"
. "· 입고: 물류 창고 입고분 파기 처리 내역\n"
. "조회 후 표·엑셀·인쇄에 반영됩니다.";
?>
<?= view('components/print_header', [
'printTitle' => '반품 / 파기 현황',
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">반품/파기 현황</span> <span class="text-sm font-bold text-gray-700 inline-flex items-center gap-1">
<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> 반품/파기 현황
<?= view('components/field_tooltip', ['text' => $tipPage, 'placement' => 'below']) ?>
</span>
<div class="flex flex-wrap items-center gap-2">
<?php if ($excelUrl !== ''): ?>
<a href="<?= esc($excelUrl, 'attr') ?>" target="_blank" rel="noopener noreferrer"
class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a>
<?php else: ?>
<span class="bg-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm cursor-not-allowed" title="조회 후 이용">엑셀저장</span>
<?php endif; ?>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/returns') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-2 bg-white border-b border-gray-200 no-print">
<label class="text-sm text-gray-600">시작일</label> <form method="get" action="<?= mgmt_url('reports/returns') ?>" class="flex flex-wrap items-end gap-x-4 gap-y-2 text-sm">
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="hidden" name="search" value="1"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <div class="flex flex-wrap items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> <label class="font-bold text-gray-700 whitespace-nowrap">조회기간</label>
<input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="font-bold text-gray-700 whitespace-nowrap">입출고 구분</span>
<label class="inline-flex items-center gap-1">
<input type="radio" name="io_type" value="in" <?= $ioType === 'in' ? 'checked' : '' ?>/>
입고
</label>
<label class="inline-flex items-center gap-1">
<input type="radio" name="io_type" value="out" <?= $ioType === 'out' ? 'checked' : '' ?>/>
출고
</label>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <?php if (! $queried): ?>
<div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
조회기간과 입출고 구분을 선택한 뒤 <strong>조회</strong> 버튼을 눌러 주세요.
</div>
<?php endif; ?>
<div class="border border-gray-300 overflow-auto m-2 print:m-0">
<table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th>일자</th> <th class="w-28">일자</th>
<th>판매소</th> <th>반품처</th>
<th>봉투코드</th> <th>종류</th>
<th>봉투명</th> <th class="w-24 text-right">수량</th>
<th>구분</th> <th class="w-20 text-center">구분</th>
<th>수량</th>
<th>금액</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php <?php if ($queried && $result === []): ?>
$totalQty = 0; $totalAmt = 0;
$typeMap = ['return' => '반품', 'cancel' => '취소/파기'];
foreach ($result as $row):
$totalQty += (int) $row->qty;
$totalAmt += (int) $row->amount;
?>
<tr> <tr>
<td class="text-center"><?= esc($row->bs_sale_date) ?></td> <td colspan="5" class="text-center text-gray-500 py-8">해당 자료가 없습니다.</td>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td> </tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td> <?php endif; ?>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td> <?php foreach ($result as $row): ?>
<td class="text-center"><?= esc($typeMap[$row->bs_type] ?? $row->bs_type) ?></td> <tr>
<td><?= number_format((int) $row->qty) ?></td> <td class="text-center"><?= esc((string) $row->bs_sale_date) ?></td>
<td><?= number_format((int) $row->amount) ?></td> <td class="text-left pl-2"><?= esc((string) ($row->bs_ds_name ?? '')) ?></td>
<td class="text-left pl-2"><?= esc($kindLabel($row)) ?></td>
<td class="text-right pr-2 tabular-nums"><?= number_format((int) ($row->qty ?? 0)) ?></td>
<td class="text-center"><?= esc($typeLabel((string) ($row->bs_type ?? ''))) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($result)): ?> <?php if ($queried && $result !== []): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<tr class="font-bold bg-gray-100"> <tr class="font-bold bg-gray-100">
<td colspan="5" class="text-center">합계</td> <td colspan="3" class="text-center">합계</td>
<td><?= number_format($totalQty) ?></td> <td class="text-right pr-2 tabular-nums"><?= number_format($totalQty) ?></td>
<td><?= number_format($totalAmt) ?></td> <td></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<style>
.field-tip { position: relative; display: inline-flex; vertical-align: middle; }
.field-tip-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 14px; height: 14px; font-size: 10px; font-weight: 700; line-height: 1;
color: #6b7280; background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 50%;
cursor: help; user-select: none;
}
.field-tip-btn:hover, .field-tip-btn:focus { color: #1d4ed8; border-color: #93c5fd; background: #eff6ff; outline: none; }
.field-tip-panel {
position: absolute; z-index: 60; left: 50%; transform: translateX(-50%);
bottom: calc(100% + 6px); width: max-content; max-width: 280px;
padding: 0.35rem 0.5rem; border-radius: 4px;
background: #1f2937; color: #f9fafb; font-size: 11px; font-weight: 500; line-height: 1.35;
text-align: left; white-space: pre-line; box-shadow: 0 2px 8px rgba(0,0,0,.15);
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .12s, visibility .12s;
}
.field-tip--below .field-tip-panel { bottom: auto; top: calc(100% + 6px); }
.field-tip:hover .field-tip-panel,
.field-tip:focus-within .field-tip-panel { opacity: 1; visibility: visible; }
@media print {
.no-print { display: none !important; }
}
</style>

View File

@@ -1,97 +1,258 @@
<?= view('components/print_header', ['printTitle' => '판매 대장']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> declare(strict_types=1);
/** @var list<object> $shops */
/** @var list<object> $agencies */
/** @var list<array<string,mixed>> $ledgerRows */
/** @var int $saleLineCount */
/** @var string $startDate */
/** @var string $endDate */
/** @var string $mode */
/** @var int $dsIdx */
/** @var int $saIdx */
/** @var list<string> $cats */
/** @var string $lgName */
/** @var string $filterAgencyLabel */
/** @var list<string> $printSubtitleLines */
$printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장';
$printDate = date('Y-m-d');
$printExtraLines = $printSubtitleLines ?? [];
$catKeys = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste'];
$catLabels = [
'general' => '일반용',
'food' => '음식물',
'sticker' => '스티커',
'reuse' => '재사용',
'apt' => '공동주택용',
'public_use' => '공공용',
'container' => '용기',
'waste' => '폐기물',
];
$exportParams = [
'start_date' => $startDate,
'end_date' => $endDate,
'mode' => $mode,
'ds_idx' => $dsIdx,
'sa_idx' => $saIdx ?? 0,
'export' => '1',
];
if ($cats !== []) {
$exportParams['cat'] = $cats;
}
$excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
?>
<?= view('components/print_header', [
'printTitle' => $printTitle,
'printDate' => $printDate,
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">판매 대장</span> <span class="text-sm font-bold text-gray-700">지정 판매소 판매 대장</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/sales-ledger') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-3 bg-white border-b border-gray-200 no-print">
<label class="text-sm text-gray-600">시작일</label> <form method="get" action="<?= mgmt_url('reports/sales-ledger') ?>" id="sales-ledger-form" class="space-y-3 text-sm">
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <div class="flex flex-wrap items-end gap-3">
<label class="text-sm text-gray-600">~</label> <div>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <label class="block text-gray-600 mb-0.5">조회일자</label>
<label class="text-sm text-gray-600">조회방식</label> <div class="flex items-center gap-1">
<select name="mode" class="border border-gray-300 rounded px-2 py-1 text-sm"> <input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<option value="daily" <?= ($mode ?? '') === 'daily' ? 'selected' : '' ?>>일자별</option> <span>~</span>
<option value="period" <?= ($mode ?? '') === 'period' ? 'selected' : '' ?>>기간별</option> <input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
</div>
</div>
<div>
<label class="block text-gray-600 mb-0.5">지정판매소</label>
<select name="ds_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[14rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($shops as $s): ?>
<?php $sid = (int) ($s->ds_idx ?? 0); ?>
<option value="<?= esc((string) $sid) ?>" <?= $dsIdx === $sid ? 'selected' : '' ?>>
<?= esc(trim((string) ($s->ds_shop_no ?? '') . ' ' . (string) ($s->ds_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select> </select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> </div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<span class="block text-gray-600 mb-0.5">집계 방식</span>
<div class="flex gap-3">
<label class="inline-flex items-center gap-1"><input type="radio" name="mode" value="daily" <?= $mode === 'daily' ? 'checked' : '' ?>/> 일자별</label>
<label class="inline-flex items-center gap-1"><input type="radio" name="mode" value="period" <?= $mode === 'period' ? 'checked' : '' ?>/> 기간별</label>
</div>
</div>
</div>
<fieldset class="border border-gray-200 rounded p-2">
<legend class="text-xs text-gray-600 px-1">품목</legend>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label class="inline-flex items-center gap-1">
<input type="checkbox" name="cat[]" value="all" id="cat-all" <?= $cats === [] ? 'checked' : '' ?>/>
전체
</label>
<?php foreach ($catKeys as $ck): ?>
<label class="inline-flex items-center gap-1">
<input type="checkbox" name="cat[]" value="<?= esc($ck, 'attr') ?>" class="cat-item" <?= in_array($ck, $cats, true) ? 'checked' : '' ?>/>
<?= esc($catLabels[$ck] ?? $ck) ?>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</div>
</form> </form>
</section> </section>
<?php if (($mode ?? 'daily') === 'daily'): ?> <section class="p-3 bg-white sales-ledger-report-section">
<div class="border border-gray-300 overflow-auto mt-2"> <style>
<table class="w-full data-table"> @media print {
<thead> /* 일계표 등 다른 리포트와 동일: 브라우저 기본 세로 A4 (landscape 지정 안 함) */
<tr> .sales-ledger-screen-title { display: none !important; }
<th>판매일</th> .sales-ledger-report-section { padding: 0 !important; }
<th>판매소</th> .sales-ledger-scroll-wrap {
<th>봉투코드</th> overflow: visible !important;
<th>봉투명</th> border: 1px solid #333 !important;
<th>구분</th> }
<th>수량</th> #sales-ledger-table {
<th>금액</th> font-size: 7.5pt !important;
</tr> width: 100% !important;
</thead> table-layout: fixed !important;
<tbody class="text-right"> }
<?php foreach ($result as $row): ?> #sales-ledger-table th,
<tr> #sales-ledger-table td {
<td class="text-center"><?= esc($row->bs_sale_date) ?></td> min-width: 0 !important;
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td> padding: 3px 4px !important;
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td> white-space: normal !important;
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td> word-break: break-word;
<td class="text-center"> overflow-wrap: anywhere;
<?php line-height: 1.35;
$typeMap = ['sale' => '판매', 'return' => '반품']; vertical-align: middle;
echo esc($typeMap[$row->bs_type] ?? $row->bs_type); }
?> #sales-ledger-table th {
</td> font-size: 7pt !important;
<td><?= number_format((int) $row->total_qty) ?></td> padding-top: 4px !important;
<td><?= number_format((int) $row->total_amount) ?></td> padding-bottom: 4px !important;
</tr> }
<?php endforeach; ?> /* 세로 A4 폭에 맞춘 열 비율 (긴 칸은 줄바꿈) */
<?php if (empty($result)): ?> #sales-ledger-table .sl-col-date { width: 9%; }
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr> #sales-ledger-table .sl-col-designation { width: 9%; }
<?php endif; ?> #sales-ledger-table .sl-col-shop { width: 10%; }
</tbody> #sales-ledger-table .sl-col-rep { width: 7%; }
</table> #sales-ledger-table .sl-col-addr { width: 18%; }
#sales-ledger-table .sl-col-product { width: 12%; }
#sales-ledger-table .sl-col-num { width: 7%; }
#sales-ledger-table.sl-period .sl-col-addr { width: 22%; }
#sales-ledger-table.sl-period .sl-col-product { width: 14%; }
}
@media screen {
#sales-ledger-table th,
#sales-ledger-table td {
padding: 4px 8px;
line-height: 1.45;
font-size: 13px;
vertical-align: middle;
}
#sales-ledger-table .sl-col-date,
#sales-ledger-table .sl-col-num { white-space: nowrap; }
#sales-ledger-table .sl-col-addr,
#sales-ledger-table .sl-col-shop,
#sales-ledger-table .sl-col-product {
white-space: normal;
word-break: break-word;
}
}
</style>
<div class="mb-2 text-center sales-ledger-screen-title no-print">
<h1 class="text-lg font-bold m-0"><?= esc($printTitle) ?></h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' / 지정판매소: ' . ($filterShopLabel ?? '') . ' / 대행소: ' . ($filterAgencyLabel ?? '전체'))) ?></p>
<p class="text-xs text-gray-500 m-0">(단위: 매 / 원) · <?= esc($startDate) ?> ~ <?= esc($endDate) ?></p>
</div> </div>
<?php else: ?> <div class="sales-ledger-scroll-wrap border border-gray-300 overflow-auto">
<div class="border border-gray-300 overflow-auto mt-2"> <table class="w-full data-table text-sm <?= ($mode ?? 'daily') === 'period' ? 'sl-period' : '' ?>" id="sales-ledger-table">
<table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th>판매소</th> <?php if (($mode ?? 'daily') === 'daily'): ?>
<th>봉투코드</th> <th class="sl-col-date">일자</th>
<th>봉투명</th> <?php endif; ?>
<th>판매수량</th> <th class="sl-col-designation">지정번호</th>
<th>판매금액</th> <th class="sl-col-shop text-left">판매소명</th>
<th>반품수량</th> <th class="sl-col-rep">대표자</th>
<th>반품금액</th> <th class="sl-col-addr text-left">소재지</th>
<th>계(수량)</th> <th class="sl-col-product text-left">품명</th>
<th>계(금액)</th> <th class="text-right sl-col-num">판매량</th>
<th class="text-right sl-col-num">판매금액</th>
<th class="text-right sl-col-num">수수료</th>
<th class="text-right sl-col-num">총액</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php foreach ($result as $row): ?> <?php foreach ($ledgerRows as $r): ?>
<tr> <?php
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td> $kind = (string) ($r['kind'] ?? 'data');
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td> $trClass = $kind === 'subtotal' ? 'bg-gray-50 font-semibold' : ($kind === 'grand' ? 'bg-amber-50 font-bold' : '');
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td> ?>
<td><?= number_format((int) $row->sale_qty) ?></td> <tr class="<?= esc($trClass, 'attr') ?>">
<td><?= number_format((int) $row->sale_amount) ?></td> <?php if (($mode ?? 'daily') === 'daily'): ?>
<td><?= number_format((int) $row->return_qty) ?></td> <td class="text-center sl-col-date"><?= esc((string) ($r['sale_date'] ?? '')) ?></td>
<td><?= number_format((int) $row->return_amount) ?></td> <?php endif; ?>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td> <td class="text-center sl-col-designation"><?= esc((string) ($r['designation_no'] ?? '')) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td> <td class="text-left pl-1 sl-col-shop"><?= esc((string) ($r['shop_name'] ?? '')) ?></td>
<td class="text-center sl-col-rep"><?= esc((string) ($r['rep_name'] ?? '')) ?></td>
<td class="text-left pl-1 sl-col-addr"><?= esc((string) ($r['address'] ?? '')) ?></td>
<td class="text-left pl-1 sl-col-product"><?= esc((string) ($r['product_name'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['qty'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['amount'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['fee'] ?? '')) ?></td>
<td class="text-right tabular-nums sl-col-num"><?= esc((string) ($r['total'] ?? '')) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($result)): ?> <?php if ($ledgerRows === []): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr> <tr><td colspan="<?= ($mode ?? 'daily') === 'daily' ? '10' : '9' ?>" class="text-center text-gray-400 py-6">조회된 판매 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<?php endif; ?> <p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p>
</section>
<script>
(function () {
const form = document.getElementById('sales-ledger-form');
const catAll = document.getElementById('cat-all');
const items = () => Array.from(document.querySelectorAll('.cat-item'));
if (!form || !catAll) return;
catAll.addEventListener('change', () => {
if (catAll.checked) items().forEach((el) => { el.checked = false; });
});
items().forEach((el) => {
el.addEventListener('change', () => {
if (el.checked) catAll.checked = false;
if (!items().some((x) => x.checked)) catAll.checked = true;
});
});
form.addEventListener('submit', () => {
if (catAll.checked) items().forEach((el) => { el.checked = false; });
});
})();
</script>

View File

@@ -1,64 +1,227 @@
<?= view('components/print_header', ['printTitle' => '지정판매소별 판매현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> declare(strict_types=1);
/** @var string $startDate */
/** @var string $endDate */
/** @var string $zoneCode */
/** @var string $bagCode */
/** @var string $catFilter */
/** @var string $metric */
/** @var list<string> $zoneOptions */
/** @var array<string, string> $bagOptions */
/** @var array<string, string> $catLabels */
/** @var list<array<string, mixed>> $reportRows */
/** @var list<float> $grandMonths */
/** @var float $grandTotal */
/** @var string $lgName */
/** @var string $zoneLabel */
/** @var string $bagLabel */
/** @var string $catLabelFilter */
/** @var string $metricLabel */
/** @var list<string> $printExtraLines */
$isAmt = ($metric ?? 'qty') === 'amt';
$fmtVal = static function (float $v) use ($isAmt): string {
return number_format((int) round($v));
};
$exportParams = array_merge([
'start_date' => $startDate ?? '',
'end_date' => $endDate ?? '',
'metric' => ($metric ?? 'qty') === 'amt' ? 'amt' : 'qty',
'export' => '1',
], array_filter([
'zone_code' => (string) ($zoneCode ?? ''),
'bag_code' => (string) ($bagCode ?? ''),
'cat' => (string) ($catFilter ?? ''),
], static fn ($v): bool => $v !== '' && $v !== null));
$excelUrl = mgmt_url('reports/shop-sales?' . http_build_query($exportParams));
$colCount = 16;
?>
<?= view('components/print_header', [
'printTitle' => '지정 판매소별 판매현황',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정 판매소별 판매현황</span> <span class="text-sm font-bold text-gray-700">지정 판매소별 판매현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div> </div>
</div>
<p class="text-xs text-gray-500 mt-2">
<?php if ($isAmt): ?>
금액은 조회기간 내 판매(sale) 건의 판매금액을 거래 월별로 합산합니다(반품·취소는 제외).
<?php else: ?>
수량은 반품·판매취소를 연초부터 판매와 품목별 선입선출로 맞추고, 반품취소는 원복합니다. 조회에 포함되지 않은 달의 수치는 조회 구간의 첫 달에 합쳐 집계됩니다.
<?php endif; ?>
</p>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/shop-sales') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-3 bg-white border-b border-gray-200 no-print">
<label class="text-sm text-gray-600">시작일</label> <form method="GET" action="<?= mgmt_url('reports/shop-sales') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-gray-600 mb-0.5">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label> </div>
<div>
<label class="block text-gray-600 mb-0.5">종료일</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> </div>
<div>
<label class="block text-gray-600 mb-0.5">읍면동</label>
<select name="zone_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem] max-w-[16rem]">
<option value="">전체</option>
<?php foreach ($zoneOptions ?? [] as $z): ?>
<option value="<?= esc($z, 'attr') ?>" <?= ($zoneCode ?? '') === $z ? 'selected' : '' ?>><?= esc($z) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">봉투 종류</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem] max-w-[20rem]">
<option value="">전체</option>
<?php foreach (($bagOptions ?? []) as $bc => $bn): ?>
<option value="<?= esc((string) $bc, 'attr') ?>" <?= ($bagCode ?? '') === (string) $bc ? 'selected' : '' ?>>
<?= esc(trim((string) $bc . (($bn ?? '') !== '' ? ' · ' . $bn : ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">구분</label>
<select name="cat" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[9rem]">
<option value="" <?= ($catFilter ?? '') === '' ? 'selected' : '' ?>>전체</option>
<?php foreach (($catLabels ?? []) as $ck => $lab): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= ($catFilter ?? '') === $ck ? 'selected' : '' ?>><?= esc($lab) ?></option>
<?php endforeach; ?>
</select>
</div>
<fieldset class="border border-gray-200 rounded px-2 py-1">
<legend class="text-xs text-gray-600 px-1">집계 대상</legend>
<label class="inline-flex items-center gap-1 mr-3 cursor-pointer">
<input type="radio" name="metric" value="qty" <?= ! $isAmt ? 'checked' : '' ?>/>
<span>수량</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="metric" value="amt" <?= $isAmt ? 'checked' : '' ?>/>
<span>금액</span>
</label>
</fieldset>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <section class="p-3 bg-white shop-sales-report-section">
<style>
@media print {
@page {
size: A4 landscape;
margin: 5mm 6mm;
}
.shop-sales-report-section { padding: 0 !important; }
.shop-sales-scroll-wrap {
overflow: visible !important;
border: 1px solid #333 !important;
}
#shop-sales-table {
font-size: 7pt !important;
width: 100% !important;
table-layout: fixed !important;
}
#shop-sales-table th,
#shop-sales-table td {
min-width: 0 !important;
padding: 1px 2px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.12;
vertical-align: top;
}
#shop-sales-table th { font-size: 6.5pt !important; }
}
@media screen {
#shop-sales-table td.num-cell { white-space: nowrap; }
}
</style>
<div class="mb-2 text-center no-print">
<h1 class="text-lg font-bold m-0">지정 판매소별 판매현황</h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · ' . ($startDate ?? '') . ' ~ ' . ($endDate ?? '') . ' · 읍면동: ' . ($zoneLabel ?? '') . ' · 집계: ' . ($metricLabel ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0"><?= $isAmt ? '(단위: 원)' : '(단위: 매)' ?></p>
</div>
<div class="shop-sales-scroll-wrap border border-gray-300 overflow-x-auto">
<table class="w-full data-table text-xs" id="shop-sales-table">
<colgroup>
<col style="width: 14%;"/>
<col style="width: 7%;"/>
<col style="width: 16%;"/>
<col style="width: 6%;"/>
<?php for ($i = 0; $i < 12; $i++): ?>
<col style="width: <?= esc((string) round(57 / 12, 2), 'attr') ?>%;"/>
<?php endfor; ?>
</colgroup>
<thead> <thead>
<tr> <tr>
<th>판매소</th> <th class="text-left pl-2">지정판매소</th>
<th>판매수량</th> <th>대표자명</th>
<th>판매금액</th> <th class="text-left pl-1">주소</th>
<th>반품수량</th> <th>합계</th>
<th>반품금액</th> <?php for ($m = 1; $m <= 12; $m++): ?>
<th>순판매수량</th> <th><?= $m ?>월</th>
<th>순판매금액</th> <?php endfor; ?>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php <?php foreach ($reportRows ?? [] as $rw): ?>
$totSaleQty = 0; $totSaleAmt = 0; $totRetQty = 0; $totRetAmt = 0;
foreach ($result as $row):
$totSaleQty += (int) $row->sale_qty;
$totSaleAmt += (int) $row->sale_amount;
$totRetQty += (int) $row->return_qty;
$totRetAmt += (int) $row->return_amount;
?>
<tr> <tr>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td> <td class="text-left pl-2 font-medium"><?= esc((string) ($rw['name'] ?? '')) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td> <td class="text-center"><?= esc((string) ($rw['rep'] ?? '')) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td> <td class="text-left pl-1"><?= esc((string) ($rw['address'] ?? '')) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td> <td class="num-cell tabular-nums"><?= $fmtVal((float) ($rw['total'] ?? 0)) ?></td>
<td><?= number_format((int) $row->return_amount) ?></td> <?php foreach (($rw['months'] ?? []) as $mv): ?>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td> <td class="num-cell tabular-nums"><?= $fmtVal((float) $mv) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td> <?php endforeach; ?>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($result)): ?> <?php if (($reportRows ?? []) === []): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr> <tr>
<td colspan="<?= (int) $colCount ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
</tr>
<?php else: ?> <?php else: ?>
<tr class="font-bold bg-gray-100"> <tr class="bg-gray-100 font-bold">
<td class="text-center">합계</td> <td colspan="3" class="text-center">전체 합계</td>
<td><?= number_format($totSaleQty) ?></td> <td class="num-cell tabular-nums"><?= $fmtVal((float) ($grandTotal ?? 0)) ?></td>
<td><?= number_format($totSaleAmt) ?></td> <?php foreach (($grandMonths ?? []) as $gm): ?>
<td><?= number_format($totRetQty) ?></td> <td class="num-cell tabular-nums"><?= $fmtVal((float) $gm) ?></td>
<td><?= number_format($totRetAmt) ?></td> <?php endforeach; ?>
<td><?= number_format($totSaleQty - $totRetQty) ?></td>
<td><?= number_format($totSaleAmt - $totRetAmt) ?></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<script>
(function () {
const metric = <?= json_encode($isAmt ? 'amt' : 'qty', JSON_THROW_ON_ERROR) ?>;
const start = <?= json_encode((string) ($startDate ?? ''), JSON_THROW_ON_ERROR) ?>;
const end = <?= json_encode((string) ($endDate ?? ''), JSON_THROW_ON_ERROR) ?>;
let savedTitle = document.title;
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
window.addEventListener('beforeprint', function () {
savedTitle = document.title;
document.title = '지정판매소별판매현황_' + metric + '_' + start + '_' + end + '_' + stamp();
});
window.addEventListener('afterprint', function () {
document.title = savedTitle;
});
})();
</script>

View File

@@ -1,134 +1,278 @@
<?= view('components/print_header', ['printTitle' => '봉투 수불 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> $refDate = (string) ($refDate ?? date('Y-m-d'));
$leadDays = (int) ($leadDays ?? 40);
$stockScope = (string) ($stockScope ?? 'all');
$salesScope = (string) ($salesScope ?? 'all');
$rows = is_array($rows ?? null) ? $rows : [];
$queried = (bool) ($queried ?? false);
$stockLabel = (string) ($stockLabel ?? 'ALL');
$salesLabel = (string) ($salesLabel ?? 'ALL');
$fmtKrRef = static function (string $ymd): string {
$ts = strtotime($ymd);
return $ts ? date('Y.m.d', $ts) . ' 현재' : $ymd;
};
/** 툴팁: 의미 + 계산(간단) */
$tipPage = "봉투 품목별로 재고가 며칠 버티는지, 언제·얼마나 발주할지 보는 수급·발주 계획표입니다.";
$tipLead = "의미: 발주 후 입고까지 걸리는 제작기일(일). 재고 소진 전에 발주하려는 여유.\n계산: 발주예정일 = 기준일 + 소진일수 보유일수";
$tipStock = "의미: 표에 넣을 현재고·총재고 범위.\n기존=바코드 미등록(수기), 바코드=등록 품목.";
$tipSales = "의미: 소진일수에 쓸 판매 속도 범위.\n최근 12개월 순판매(또는 바코드 판매) 월평균.";
$tipTotal = "의미: 지금·곧 쓸 수 있는 재고 합계.\n계산: 현재고 + 입고예정량";
$tipMonth = "의미: 요즘 한 달 판매 규모(평균).\n최근 12개월 월평균 판매량.";
$tipDepl = "의미: 이 판매 속도면 재고가 며칠 남는지.\n계산: (총재고 ÷ 월판매량) × 30";
$tipSched = "의미: 발주를 넣기 좋은 날(제작기일 반영).\n계산: 기준일 + 소진일수 보유일수. 기한 지남=빨간색·긴급";
$tipOrder = "의미: 그 시점에 맞춰 제안하는 추가 발주 장수.\n촉박하거나 발주예정일이 지난 품목만 표시.";
$printExtraLines = [
$fmtKrRef($refDate),
'적정재고 보유일수(제작기일): ' . $leadDays . '일',
'현재고: ' . $stockLabel . ' · 월평균판매량: ' . $salesLabel,
'※ 제작기일 ' . $leadDays . '일 기준으로 발주예정일 산정 (레거시 화면 유추)',
];
?>
<?= view('components/print_header', [
'printTitle' => '쓰레기봉투 수급 계획',
'printExtraLines' => $printExtraLines,
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">봉투 수불 현황</span> <span class="text-sm font-bold text-gray-700 inline-flex items-center gap-1">
<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> 쓰레기봉투 수급 계획
<?= view('components/field_tooltip', ['text' => $tipPage, 'placement' => 'below']) ?>
</span>
<div class="flex flex-wrap items-center gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/supply-demand') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-2 bg-white border-b border-gray-200 no-print text-sm">
<label class="text-sm text-gray-600">시작일</label> <form method="get" action="<?= mgmt_url('reports/supply-demand') ?>" class="flex flex-wrap items-end gap-x-4 gap-y-3">
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <input type="hidden" name="search" value="1"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <div class="flex flex-wrap items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> <label class="font-bold text-gray-700 whitespace-nowrap">기준일</label>
<input type="date" name="ref_date" value="<?= esc($refDate) ?>" class="border border-gray-300 rounded px-2 py-1" required/>
<span class="text-gray-600"><?= esc($fmtKrRef($refDate)) ?></span>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap inline-flex items-center gap-0.5">
적정재고 보유일수
<?= view('components/field_tooltip', ['text' => $tipLead]) ?>
</label>
<input type="number" name="lead_days" value="<?= (int) $leadDays ?>" min="1" max="365"
class="border border-gray-300 rounded px-2 py-1 w-20 text-right"/>
</div>
<fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0">
<legend class="font-bold text-gray-700 whitespace-nowrap mr-1 inline-flex items-center gap-0.5">
현재고 선택 옵션
<?= view('components/field_tooltip', ['text' => $tipStock]) ?>
</legend>
<?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?>
<label class="inline-flex items-center gap-1">
<input type="radio" name="stock_scope" value="<?= esc($val) ?>" <?= $stockScope === $val ? 'checked' : '' ?>/>
<?= esc($lab) ?>
</label>
<?php endforeach; ?>
</fieldset>
<fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0">
<legend class="font-bold text-gray-700 whitespace-nowrap mr-1 inline-flex items-center gap-0.5">
월 평균판매량 선택 옵션
<?= view('components/field_tooltip', ['text' => $tipSales]) ?>
</legend>
<?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?>
<label class="inline-flex items-center gap-1">
<input type="radio" name="sales_scope" value="<?= esc($val) ?>" <?= $salesScope === $val ? 'checked' : '' ?>/>
<?= esc($lab) ?>
</label>
<?php endforeach; ?>
</fieldset>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
</section> </section>
<div class="grid grid-cols-2 gap-4 mt-2"> <?php if (! $queried): ?>
<!-- 현재 재고 --> <div class="m-2 p-3 border border-blue-200 bg-blue-50 text-sm text-blue-900 no-print">
<div class="border border-gray-300 rounded overflow-auto"> 기준일·옵션을 선택한 뒤 <strong>조회</strong>를 눌러 주세요.
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5">
<span class="text-sm font-bold text-gray-700">현재 재고</span>
</div> </div>
<table class="w-full data-table"> <?php endif; ?>
<div class="supply-plan-print-sheet">
<div class="supply-plan-print m-2 border border-gray-300 overflow-auto print:m-0">
<table class="w-full data-table text-sm supply-plan-table">
<thead> <thead>
<tr class="bg-gray-100">
<th colspan="4" class="text-center border-b border-gray-300 sp-group-h">최근 발주 내역</th>
<th colspan="5" class="text-center border-b border-gray-300 border-l sp-group-h">현재고 및 예상 판매일수</th>
<th colspan="2" class="text-center border-b border-gray-300 border-l sp-group-h">추가발주 예정내역</th>
</tr>
<tr> <tr>
<th>봉투코드</th> <th class="sp-col-date">발주일자</th>
<th>봉투</th> <th class="sp-col-name">봉투종류</th>
<th>재고수량</th> <th class="sp-col-num text-right">발주량</th>
<th class="sp-col-num text-right">발주시재고</th>
<th class="sp-col-num text-right border-l">현재고</th>
<th class="sp-col-num text-right">입고예정량</th>
<th class="sp-col-num text-right">
<span class="inline-flex items-center justify-end gap-0.5 w-full">총재고<?= view('components/field_tooltip', ['text' => $tipTotal, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-num text-right">
<span class="inline-flex items-center justify-end gap-0.5 w-full">월판매량<?= view('components/field_tooltip', ['text' => $tipMonth, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-num text-right">
<span class="inline-flex items-center justify-end gap-0.5 w-full">소진일수(일)<?= view('components/field_tooltip', ['text' => $tipDepl, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-date text-center border-l">
<span class="inline-flex items-center justify-center gap-0.5">발주예정일<?= view('components/field_tooltip', ['text' => $tipSched, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-num text-right">
<span class="inline-flex items-center justify-end gap-0.5 w-full">발주수량<?= view('components/field_tooltip', ['text' => $tipOrder, 'placement' => 'below']) ?></span>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php foreach ($inventory as $row): ?> <?php if ($queried && $rows === []): ?>
<tr> <tr>
<td class="text-center font-mono"><?= esc($row->bi_bag_code) ?></td> <td colspan="11" class="text-center text-gray-500 py-8">표시할 품목이 없습니다.</td>
<td class="text-left pl-2"><?= esc($row->bi_bag_name) ?></td> </tr>
<td class="font-bold"><?= number_format((int) $row->bi_qty) ?></td> <?php endif; ?>
<?php foreach ($rows as $row): ?>
<?php
$depl = (int) ($row['depletion_days'] ?? 0);
$deplDisplay = $depl <= 0 ? '—' : number_format($depl);
$sched = (string) ($row['schedule_date'] ?? '');
$schedOver = (bool) ($row['schedule_overdue'] ?? false);
$schedDisplay = '—';
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $sched, $m)) {
$y = (int) $m[1];
if ($y >= 1990 && $y <= 2200) {
$schedDisplay = $m[1] . '.' . $m[2] . '.' . $m[3];
}
}
?>
<tr>
<td class="sp-col-date text-center"><?= ($row['last_order_date'] ?? '') !== '' ? esc(str_replace('-', '.', (string) $row['last_order_date'])) : '—' ?></td>
<td class="sp-col-name text-left"><?= esc((string) ($row['bag_name'] ?? $row['bag_code'] ?? '')) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= (int) ($row['last_order_qty'] ?? 0) > 0 ? number_format((int) $row['last_order_qty']) : '—' ?></td>
<td class="sp-col-num text-right tabular-nums"><?= (int) ($row['stock_at_order'] ?? 0) > 0 ? number_format((int) $row['stock_at_order']) : '—' ?></td>
<td class="sp-col-num text-right tabular-nums border-l"><?= number_format((int) ($row['current_stock'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= number_format((int) ($row['pending_inbound'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums font-semibold"><?= number_format((int) ($row['total_stock'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= number_format((int) ($row['monthly_avg_sales'] ?? 0)) ?></td>
<td class="sp-col-num text-right tabular-nums"><?= esc($deplDisplay) ?></td>
<td class="sp-col-date text-center border-l <?= $schedOver ? 'text-red-600 font-bold' : '' ?>"><?= esc($schedDisplay) ?></td>
<td class="sp-col-num text-right tabular-nums <?= (int) ($row['order_qty'] ?? 0) > 0 ? 'text-red-600 font-bold' : '' ?>">
<?= number_format((int) ($row['order_qty'] ?? 0)) ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($inventory)): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- 기간 입고 --> <style>
<div class="border border-gray-300 rounded overflow-auto"> .field-tip { position: relative; display: inline-flex; vertical-align: middle; }
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5"> .field-tip-btn {
<span class="text-sm font-bold text-gray-700">기간 입고</span> display: inline-flex; align-items: center; justify-content: center;
</div> width: 14px; height: 14px; font-size: 10px; font-weight: 700; line-height: 1;
<table class="w-full data-table"> color: #6b7280; background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 50%;
<thead> cursor: help; user-select: none;
<tr> }
<th>봉투코드</th> .field-tip-btn:hover, .field-tip-btn:focus { color: #1d4ed8; border-color: #93c5fd; background: #eff6ff; outline: none; }
<th>봉투명</th> .field-tip-panel {
<th>입고수량</th> position: absolute; z-index: 60; left: 50%; transform: translateX(-50%);
</tr> bottom: calc(100% + 6px); width: max-content; max-width: 280px;
</thead> padding: 0.35rem 0.5rem; border-radius: 4px;
<tbody class="text-right"> background: #1f2937; color: #f9fafb; font-size: 11px; font-weight: 500; line-height: 1.35;
<?php foreach ($receiving as $row): ?> text-align: left; white-space: pre-line; box-shadow: 0 2px 8px rgba(0,0,0,.15);
<tr> opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .12s, visibility .12s;
<td class="text-center font-mono"><?= esc($row->br_bag_code) ?></td> }
<td class="text-left pl-2"><?= esc($row->br_bag_name) ?></td> .field-tip--below .field-tip-panel { bottom: auto; top: calc(100% + 6px); }
<td><?= number_format((int) $row->recv_qty) ?></td> .field-tip:hover .field-tip-panel,
</tr> .field-tip:focus-within .field-tip-panel { opacity: 1; visibility: visible; }
<?php endforeach; ?>
<?php if (empty($receiving)): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 기간 판매 --> .supply-plan-table thead th { font-size: 0.75rem; line-height: 1.2; padding: 0.35rem 0.25rem; overflow: visible; }
<div class="border border-gray-300 rounded overflow-auto"> .supply-plan-table thead th .field-tip-panel { max-width: 260px; }
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5"> .supply-plan-table tbody td { padding: 0.25rem 0.35rem; }
<span class="text-sm font-bold text-gray-700">기간 판매</span>
</div>
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>판매수량</th>
<th>반품수량</th>
<th>순판매</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($sales as $row): ?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($sales)): ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 기간 불출 --> @media screen {
<div class="border border-gray-300 rounded overflow-auto"> .supply-plan-print { overflow-x: auto; }
<div class="bg-gray-100 border-b border-gray-300 px-3 py-1.5"> .supply-plan-table { min-width: 960px; }
<span class="text-sm font-bold text-gray-700">기간 불출</span> }
</div>
<table class="w-full data-table"> @media print {
<thead> @page {
<tr> size: A4 portrait;
<th>봉투코드</th> margin: 10mm 8mm;
<th>봉투명</th> }
<th>불출수량</th>
</tr> .no-print { display: none !important; }
</thead>
<tbody class="text-right"> .supply-plan-print-sheet {
<?php foreach ($issues as $row): ?> width: 100% !important;
<tr> max-width: 100% !important;
<td class="text-center font-mono"><?= esc($row->bi2_bag_code) ?></td> box-sizing: border-box;
<td class="text-left pl-2"><?= esc($row->bi2_bag_name) ?></td> }
<td><?= number_format((int) $row->issue_qty) ?></td>
</tr> .supply-plan-print {
<?php endforeach; ?> border: none !important;
<?php if (empty($issues)): ?> margin: 0 !important;
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr> padding: 0 !important;
<?php endif; ?> overflow: hidden !important;
</tbody> width: 100% !important;
</table> max-width: 100% !important;
</div> }
</div>
.supply-plan-table.data-table {
min-width: 0 !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
font-size: 6px !important;
}
.supply-plan-table.data-table th,
.supply-plan-table.data-table td {
white-space: normal !important;
word-break: keep-all;
overflow-wrap: anywhere;
padding: 1px 1px !important;
line-height: 1.1;
vertical-align: middle;
}
.supply-plan-table .sp-group-h {
font-size: 5px !important;
padding: 1px !important;
}
/* 세로 A4: 날짜 2×4.5% + 품목 11% + 수치 8×10% = 100% */
.supply-plan-table .sp-col-date {
width: 4.5%;
font-size: 5px !important;
text-align: center;
}
.supply-plan-table .sp-col-name {
width: 11%;
text-align: left !important;
font-size: 5px !important;
line-height: 1.2;
}
.supply-plan-table .sp-col-num {
width: 10%;
font-size: 5px !important;
text-align: right !important;
}
}
</style>

View File

@@ -1,62 +1,232 @@
<?= view('components/print_header', ['printTitle' => '년 판매 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> declare(strict_types=1);
/** @var int $year */
/** @var string $gugunCode */
/** @var int $saIdx */
/** @var list<object> $agencies */
/** @var list<array{code: string, name: string}> $gugunOptions */
/** @var list<array{id: string, label: string}> $colSpec */
/** @var list<array{name: string, lines: list<array<string,mixed>>}> $itemBlocks */
/** @var array{name: string, lines: list<array<string,mixed>>} $footerBlock */
/** @var bool $hasBsFee */
/** @var string $lgName */
/** @var string $gugunLabel */
/** @var string $agencyLabel */
/** @var list<string> $printExtraLines */
/** @var bool $hasYearlyData */
$yMax = (int) date('Y') + 1;
$yMin = 2020;
$exportParams = array_merge([
'year' => (string) ($year ?? date('Y')),
'export' => '1',
], array_filter([
'gugun_code' => (string) ($gugunCode ?? ''),
'sa_idx' => (int) ($saIdx ?? 0) > 0 ? (string) (int) ($saIdx ?? 0) : '',
], static fn ($v): bool => $v !== '' && $v !== null && $v !== 0));
$excelUrl = mgmt_url('reports/yearly-sales?' . http_build_query($exportParams));
$colCount = 2 + count($colSpec ?? []);
$nMetricCols = max(1, count($colSpec ?? []));
$metricColPct = round(86 / $nMetricCols, 4);
$fmtMeasureCell = static function (array $cell, string $measureKey, bool $hasBsFee): string {
if ($measureKey === 'fee' && ! $hasBsFee) {
return '—';
}
if ($measureKey === 'qty') {
return number_format((int) ($cell['qty'] ?? 0));
}
return number_format((int) round((float) ($cell[$measureKey] ?? 0)));
};
?>
<?= view('components/print_header', [
'printTitle' => ((int) ($year ?? date('Y'))) . '년 판매 현황',
'printExtraLines' => $printExtraLines ?? [],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">년 판매 현황 (월별)</span> <span class="text-sm font-bold text-gray-700">년 판매 현황</span>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <div class="flex flex-wrap gap-2">
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($excelUrl, 'attr') ?>" class="inline-flex items-center border border-green-600 text-green-700 px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('reports/yearly-sales') ?>" class="flex flex-wrap items-center gap-2"> <section class="p-3 bg-white border-b border-gray-200 no-print">
<label class="text-sm text-gray-600">연도</label> <form method="GET" action="<?= mgmt_url('reports/yearly-sales') ?>" class="flex flex-wrap items-end gap-3 text-sm">
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm"> <div>
<?php for ($y = (int) date('Y'); $y >= 2020; $y--): ?> <label class="block text-gray-600 mb-0.5">조회 년도</label>
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[7rem]">
<?php for ($y = $yMax; $y >= $yMin; $y--): ?>
<option value="<?= $y ?>" <?= (int) ($year ?? date('Y')) === $y ? 'selected' : '' ?>><?= $y ?>년</option> <option value="<?= $y ?>" <?= (int) ($year ?? date('Y')) === $y ? 'selected' : '' ?>><?= $y ?>년</option>
<?php endfor; ?> <?php endfor; ?>
</select> </select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> </div>
<div>
<label class="block text-gray-600 mb-0.5">구·군</label>
<select name="gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem] max-w-[18rem]">
<option value="">전체</option>
<?php foreach ($gugunOptions ?? [] as $g): ?>
<?php $gc = (string) ($g['code'] ?? ''); ?>
<option value="<?= esc($gc, 'attr') ?>" <?= ($gugunCode ?? '') === $gc ? 'selected' : '' ?>><?= esc((string) ($g['name'] ?? $gc)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-gray-600 mb-0.5">대행소</label>
<select name="sa_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[12rem] max-w-[20rem]">
<option value="0">전체</option>
<?php foreach ($agencies ?? [] as $agency): ?>
<?php $aid = (int) ($agency->sa_idx ?? 0); ?>
<option value="<?= esc((string) $aid) ?>" <?= (int) ($saIdx ?? 0) === $aid ? 'selected' : '' ?>>
<?= esc(trim((string) ($agency->sa_name ?? ''))) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <section class="p-3 bg-white yearly-sales-report-section">
<style>
/* 화면: 가로 스크롤. 인쇄: 가로 용지 + 작은 글자 + 셀 줄바꿈으로 한 페이지에 맞춤 */
@media print {
@page {
size: A4 landscape;
margin: 5mm 6mm;
}
.yearly-sales-report-section {
padding: 0 !important;
}
.yearly-sales-scroll-wrap {
overflow: visible !important;
border: 1px solid #333 !important;
max-width: none !important;
}
#yearly-sales-table {
font-size: 7pt !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
}
#yearly-sales-table th,
#yearly-sales-table td {
min-width: 0 !important;
max-width: none !important;
padding: 1px 2px !important;
white-space: normal !important;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.15;
vertical-align: top;
}
#yearly-sales-table th {
font-size: 6.5pt !important;
font-weight: 700;
}
}
@media screen {
#yearly-sales-table td.tabular-nums {
white-space: nowrap;
}
}
</style>
<div class="mb-2 text-center no-print">
<h1 class="text-lg font-bold m-0"><?= (int) ($year ?? date('Y')) ?>년 판매 현황</h1>
<p class="text-sm text-gray-700 m-1"><?= esc(trim(($lgName ?? '') . ' · 구·군: ' . ($gugunLabel ?? '') . ' · 대행소: ' . ($agencyLabel ?? ''))) ?></p>
<p class="text-xs text-gray-500 m-0">(단위: 매 / 원)</p>
</div>
<div class="yearly-sales-scroll-wrap border border-gray-300 overflow-x-auto">
<table class="w-full data-table text-xs sm:text-sm" id="yearly-sales-table">
<colgroup>
<col style="width: 9%;"/>
<col style="width: 5%;"/>
<?php foreach (($colSpec ?? []) as $_): ?>
<col style="width: <?= esc((string) $metricColPct, 'attr') ?>%;"/>
<?php endforeach; ?>
</colgroup>
<thead> <thead>
<tr> <tr>
<th>봉투코드</th> <th class="align-middle min-w-0 sm:min-w-[7rem] max-w-[10rem] sm:max-w-[12rem] text-left pl-2">품목</th>
<th>봉투명</th> <th class="align-middle min-w-0 sm:min-w-[4.5rem]">구분</th>
<th>1월</th><th>2월</th><th>3월</th><th>4월</th><th>5월</th><th>6월</th> <?php foreach ($colSpec ?? [] as $col): ?>
<th>7월</th><th>8월</th><th>9월</th><th>10월</th><th>11월</th><th>12월</th> <th class="align-middle text-center min-w-0 sm:min-w-[4.5rem] border-l border-gray-200"><?= esc((string) ($col['label'] ?? '')) ?></th>
<th class="bg-gray-100">합계</th> <?php endforeach; ?>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php <?php if (! ($hasYearlyData ?? false)): ?>
$grandTotal = array_fill(1, 13, 0); // 1~12 + 13=total
foreach ($result as $row):
?>
<tr> <tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td> <td colspan="<?= (int) $colCount ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td> </tr>
<?php for ($m = 1; $m <= 12; $m++): <?php else: ?>
$key = 'm' . sprintf('%02d', $m); <?php foreach ($itemBlocks ?? [] as $block): ?>
$val = (int) $row->$key; <?php $lines = $block['lines'] ?? []; ?>
$grandTotal[$m] += $val; <?php foreach ($lines as $liIdx => $li): ?>
<tr class="odd:bg-white even:bg-gray-50/80">
<?php if ($liIdx === 0): ?>
<td rowspan="4" class="text-left align-top pl-2 pt-1 font-medium border-r border-gray-200"><?= esc((string) ($block['name'] ?? '')) ?></td>
<?php endif; ?>
<td class="text-left pl-2 border-r border-gray-100"><?= esc((string) ($li['measure'] ?? '')) ?></td>
<?php
$cells = (array) ($li['cells'] ?? []);
$mk = (string) ($li['measureKey'] ?? '');
?> ?>
<td><?= $val > 0 ? number_format($val) : '-' ?></td> <?php foreach ($colSpec ?? [] as $col): ?>
<?php endfor; ?> <?php $cid = (string) ($col['id'] ?? ''); ?>
<?php $grandTotal[13] += (int) $row->total; ?> <?php $cell = (array) ($cells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]); ?>
<td class="font-bold bg-gray-50"><?= number_format((int) $row->total) ?></td> <td class="border-l border-gray-100 tabular-nums"><?= $fmtMeasureCell($cell, $mk, (bool) ($hasBsFee ?? false)) ?></td>
<?php endforeach; ?>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($result)): ?> <?php endforeach; ?>
<tr><td colspan="15" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?> <?php $fLines = $footerBlock['lines'] ?? []; ?>
<tr class="font-bold bg-gray-100"> <?php foreach ($fLines as $fIdx => $li): ?>
<td colspan="2" class="text-center">합계</td> <tr class="bg-amber-50 font-semibold border-t-2 border-amber-200">
<?php for ($m = 1; $m <= 12; $m++): ?> <?php if ($fIdx === 0): ?>
<td><?= $grandTotal[$m] > 0 ? number_format($grandTotal[$m]) : '-' ?></td> <td rowspan="4" class="text-center align-middle text-amber-900 border-r border-amber-200"><?= esc((string) ($footerBlock['name'] ?? '전체 합계')) ?></td>
<?php endfor; ?> <?php endif; ?>
<td class="bg-gray-200"><?= number_format($grandTotal[13]) ?></td> <td class="text-left pl-2 border-r border-amber-100"><?= esc((string) ($li['measure'] ?? '')) ?></td>
<?php
$cells = (array) ($li['cells'] ?? []);
$mk = (string) ($li['measureKey'] ?? '');
?>
<?php foreach ($colSpec ?? [] as $col): ?>
<?php $cid = (string) ($col['id'] ?? ''); ?>
<?php $cell = (array) ($cells[$cid] ?? ['qty' => 0, 'amt' => 0.0, 'fee' => 0.0, 'levy' => 0.0]); ?>
<td class="border-l border-amber-100 tabular-nums"><?= $fmtMeasureCell($cell, $mk, (bool) ($hasBsFee ?? false)) ?></td>
<?php endforeach; ?>
</tr> </tr>
<?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<script>
(function () {
const year = <?= json_encode((int) ($year ?? (int) date('Y')), JSON_THROW_ON_ERROR) ?>;
let savedTitle = document.title;
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
window.addEventListener('beforeprint', function () {
savedTitle = document.title;
document.title = '년판매현황_' + year + '_' + stamp();
});
window.addEventListener('afterprint', function () {
document.title = savedTitle;
});
})();
</script>

View File

@@ -1,74 +1,301 @@
<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"> <div class="border border-gray-300 p-4 mt-2 bg-white">
<form action="<?= mgmt_url('shop-orders/store') ?>" method="POST" class="space-y-4"> <form action="<?= mgmt_url('shop-orders/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">판매소 검색 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="so_ds_idx" required> <input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" type="text" list="shop-search-list" placeholder="코드/사업자번호/대표자명/상호/전화/주소"/>
<datalist id="shop-search-list">
<?php foreach ($shops as $shop): ?>
<option value="<?= esc(trim(($shop->ds_shop_no ?? '') . ' ' . ($shop->ds_name ?? '') . ' ' . ($shop->ds_rep_name ?? '') . ' ' . ($shop->ds_biz_no ?? '') . ' ' . ($shop->ds_tel ?? '') . ' ' . ($shop->ds_addr ?? ''))) ?>"></option>
<?php endforeach; ?>
</datalist>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 선택 <span class="text-red-500">*</span></label>
<select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="so_ds_idx" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($shops as $shop): ?> <?php foreach ($shops as $shop): ?>
<option value="<?= esc($shop->ds_idx) ?>" <?= (int) old('so_ds_idx') === (int) $shop->ds_idx ? 'selected' : '' ?>> <option
<?= esc($shop->ds_name) ?> value="<?= esc($shop->ds_idx) ?>"
data-shop-no="<?= esc((string) ($shop->ds_shop_no ?? '')) ?>"
data-name="<?= esc((string) ($shop->ds_name ?? '')) ?>"
data-rep-name="<?= esc((string) ($shop->ds_rep_name ?? '')) ?>"
data-rep-phone="<?= esc((string) ($shop->ds_rep_phone ?? '')) ?>"
data-tel="<?= esc((string) ($shop->ds_tel ?? '')) ?>"
data-address="<?= esc(trim((string) ($shop->ds_addr ?? '') . ' ' . (string) ($shop->ds_addr_detail ?? ''))) ?>"
data-va-bank="<?= esc((string) ($shop->ds_va_bank ?? '')) ?>"
data-va-account="<?= esc((string) ($shop->ds_va_account ?? '')) ?>"
<?= (int) old('so_ds_idx') === (int) $shop->ds_idx ? 'selected' : '' ?>
>
<?= esc(($shop->ds_shop_no ? '[' . $shop->ds_shop_no . '] ' : '') . $shop->ds_name) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
</div>
<div class="border border-gray-300 p-2 bg-gray-50">
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
<table class="w-full text-sm">
<tr><th class="text-left w-28 py-1">판매소 코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">상호</th><td id="shop-info-name" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">대표자명</th><td id="shop-info-rep" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">연락처</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr>
<tr><th class="text-left py-1">가상계좌</th><td id="shop-info-va" class="py-1 text-gray-700">-</td></tr>
</table>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">접수일</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44 bg-gray-100" type="date" value="<?= esc(date('Y-m-d')) ?>" readonly/>
<span class="text-xs text-gray-500">배달일 기본값은 접수일 다음날입니다.</span>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">배달일 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">배달일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_delivery_date" type="date" value="<?= esc(old('so_delivery_date', date('Y-m-d', strtotime('+1 day')))) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_delivery_date" type="date" value="<?= esc(old('so_delivery_date', date('Y-m-d', strtotime('+1 day')))) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">결제방법 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-28">결제방법 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_payment_type" required> <select id="payment-type" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_payment_type" required>
<option value="">선택</option> <option value="">선택</option>
<option value="이체" <?= old('so_payment_type') === '이체' ? 'selected' : '' ?>>이체</option> <option value="이체" <?= old('so_payment_type') === '이체' ? 'selected' : '' ?>>이체</option>
<option value="가상계좌" <?= old('so_payment_type') === '가상계좌' ? 'selected' : '' ?>>가상계좌</option> <option value="가상계좌" <?= old('so_payment_type') === '가상계좌' ? 'selected' : '' ?>>가상계좌</option>
</select> </select>
<span id="payment-guide" class="text-xs text-gray-500"></span>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">주문 품목</label> <div class="flex items-center justify-between mb-2">
<label class="block text-sm font-bold text-gray-700">전화 주문 접수표</label>
<button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button>
</div>
<div class="border border-gray-300 overflow-auto"> <div class="border border-gray-300 overflow-auto">
<table class="w-full data-table"> <table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="w-16">순번</th> <th class="w-14">순번</th>
<th>봉투</th> <th class="w-48">품목</th>
<th class="w-32">수량</th> <th class="w-36">1박스(낱장/판매가)</th>
<th class="w-36">1팩(낱장/판매가)</th>
<th class="w-24">단가</th>
<th class="w-28">주문수량</th>
<th class="w-28">금액</th>
<th class="w-32">포장(박스/팩/낱장)</th>
<th class="w-20">행삭제</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="order-rows">
<?php for ($i = 0; $i < 3; $i++): ?> <?php for ($i = 0; $i < 3; $i++): ?>
<tr> <tr class="order-row">
<td class="text-center"><?= $i + 1 ?></td> <td class="text-center row-no"><?= $i + 1 ?></td>
<td> <td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]"> <select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?> <?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>"> <?php $code = (string) $cd->cd_code; $price = $priceMap[$code] ?? null; $unit = $unitMap[$code] ?? null; ?>
<option value="<?= esc($code) ?>" data-unit-price="<?= esc((string) (int) ($price->bp_consumer ?? 0)) ?>" data-box-sheets="<?= esc((string) (int) ($unit->pu_total_per_box ?? 0)) ?>" data-box-packs="<?= esc((string) (int) ($unit->pu_box_per_pack ?? 0)) ?>" data-pack-sheets="<?= esc((string) (int) ($unit->pu_pack_per_sheet ?? 0)) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?> <?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</td> </td>
<td> <td class="text-right px-2 box-info-cell">0 / 0</td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty[]" type="number" min="0" value="0"/> <td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-amount-input" type="number" min="0" step="1" value="0"/></td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td> </td>
</tr> </tr>
<?php endfor; ?> <?php endfor; ?>
</tbody> </tbody>
<tfoot>
<tr class="font-semibold bg-gray-50">
<td colspan="5" class="text-right px-2 py-1">합계</td>
<td class="text-right px-2 py-1" id="sum-qty">0</td>
<td class="text-right px-2 py-1" id="sum-amount">0</td>
<td class="text-right px-2 py-1" id="sum-pack">박스=0, 팩=0, 낱장=0</td>
<td></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button> <button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
<a href="<?= mgmt_url('shop-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('shop-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> </form>
</div> </div>
<template id="order-row-template">
<tr class="order-row">
<td class="text-center row-no">1</td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<?php $code = (string) $cd->cd_code; $price = $priceMap[$code] ?? null; $unit = $unitMap[$code] ?? null; ?>
<option value="<?= esc($code) ?>" data-unit-price="<?= esc((string) (int) ($price->bp_consumer ?? 0)) ?>" data-box-sheets="<?= esc((string) (int) ($unit->pu_total_per_box ?? 0)) ?>" data-box-packs="<?= esc((string) (int) ($unit->pu_box_per_pack ?? 0)) ?>" data-pack-sheets="<?= esc((string) (int) ($unit->pu_pack_per_sheet ?? 0)) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td class="text-right px-2 box-info-cell">0 / 0</td>
<td class="text-right px-2 pack-info-cell">0 / 0</td>
<td class="text-right px-2 unit-price-cell">0</td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-amount-input" type="number" min="0" step="1" value="0"/></td>
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
<td class="text-center px-2">
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
</td>
</tr>
</template>
<script>
(function () {
const shopSearch = document.getElementById('shop-search');
const shopSelect = document.getElementById('shop-select');
const paymentType = document.getElementById('payment-type');
const paymentGuide = document.getElementById('payment-guide');
const addRowButton = document.getElementById('add-order-row');
const orderRows = document.getElementById('order-rows');
const rowTemplate = document.getElementById('order-row-template');
const form = shopSelect.closest('form');
function nf(n) { return new Intl.NumberFormat('ko-KR').format(n || 0); }
function updateShopInfo() {
const opt = shopSelect.options[shopSelect.selectedIndex];
const bank = opt?.dataset?.vaBank || '';
const account = opt?.dataset?.vaAccount || '';
const va = bank || account ? [bank, account].filter(Boolean).join(' ') : '-';
document.getElementById('shop-info-code').textContent = opt?.dataset?.shopNo || '-';
document.getElementById('shop-info-name').textContent = opt?.dataset?.name || '-';
document.getElementById('shop-info-rep').textContent = opt?.dataset?.repName || '-';
document.getElementById('shop-info-tel').textContent = opt?.dataset?.tel || opt?.dataset?.repPhone || '-';
document.getElementById('shop-info-addr').textContent = opt?.dataset?.address || '-';
document.getElementById('shop-info-va').textContent = va;
paymentGuide.textContent = paymentType.value === '가상계좌' ? ('가상계좌 안내: ' + va) : '';
}
function matchShopByKeyword(keyword) {
const q = (keyword || '').trim().toLowerCase();
if (!q) { return; }
for (let i = 0; i < shopSelect.options.length; i++) {
const opt = shopSelect.options[i];
const merged = [opt.dataset.shopNo || '', opt.dataset.name || '', opt.dataset.repName || '', opt.dataset.tel || '', opt.dataset.address || '', opt.text || ''].join(' ').toLowerCase();
if (merged.includes(q)) { shopSelect.selectedIndex = i; updateShopInfo(); return; }
}
}
function calcRow(row, source) {
const select = row.querySelector('.bag-code-select');
const qtyInput = row.querySelector('.item-qty-input');
const amountInput = row.querySelector('.item-amount-input');
const selected = select.options[select.selectedIndex];
let qty = parseInt(qtyInput.value || '0', 10) || 0;
const unitPrice = parseInt(selected?.dataset?.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(selected?.dataset?.boxSheets || '0', 10) || 0;
const boxPacks = parseInt(selected?.dataset?.boxPacks || '0', 10) || 0;
const packSheets = parseInt(selected?.dataset?.packSheets || '0', 10) || 0;
const rawAmount = parseInt(amountInput?.value || '0', 10) || 0;
if (source === 'amount' && unitPrice > 0) {
qty = Math.max(0, Math.round(rawAmount / unitPrice));
qtyInput.value = String(qty);
}
let box = 0, pack = 0, sheet = qty;
if (boxSheets > 0) {
box = Math.floor(qty / boxSheets);
const remain = qty % boxSheets;
if (packSheets > 0) { pack = Math.floor(remain / packSheets); sheet = remain % packSheets; } else { sheet = remain; }
} else if (packSheets > 0) {
pack = Math.floor(qty / packSheets);
sheet = qty % packSheets;
}
const amount = unitPrice * qty;
const boxPrice = boxSheets * unitPrice;
const packPrice = packSheets * unitPrice;
row.querySelector('.box-info-cell').textContent = nf(boxSheets) + ' / ' + nf(boxPrice);
row.querySelector('.pack-info-cell').textContent = nf(packSheets) + ' / ' + nf(packPrice);
row.querySelector('.unit-price-cell').textContent = nf(unitPrice);
if (amountInput && source !== 'amount') {
amountInput.value = String(amount);
}
const innerPackCount = box * boxPacks;
const innerSheetCount = box * boxSheets;
row.querySelector('.pack-result-cell').textContent = '박스=' + nf(box) + '(내부 팩=' + nf(innerPackCount) + ', 내부 낱장=' + nf(innerSheetCount) + '), 잔여 팩=' + nf(pack) + ', 잔여 낱장=' + nf(sheet);
return { qty, amount, box, pack, sheet };
}
function recalcAllRows(sourceRow, sourceType) {
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
document.querySelectorAll('.order-row').forEach((row, index) => {
const noCell = row.querySelector('.row-no');
if (noCell) {
noCell.textContent = String(index + 1);
}
const source = row === sourceRow ? sourceType : 'qty';
const r = calcRow(row, source);
sumQty += r.qty; sumAmount += r.amount; sumBox += r.box; sumPack += r.pack; sumSheet += r.sheet;
});
document.getElementById('sum-qty').textContent = nf(sumQty);
document.getElementById('sum-amount').textContent = nf(sumAmount);
document.getElementById('sum-pack').textContent = '박스=' + nf(sumBox) + ', 팩=' + nf(sumPack) + ', 낱장=' + nf(sumSheet);
}
shopSearch?.addEventListener('change', (e) => matchShopByKeyword(e.target.value));
shopSearch?.addEventListener('blur', (e) => matchShopByKeyword(e.target.value));
shopSelect?.addEventListener('change', updateShopInfo);
paymentType?.addEventListener('change', updateShopInfo);
orderRows?.addEventListener('change', function (e) {
const row = e.target.closest('.order-row');
if (e.target.closest('.bag-code-select') || e.target.closest('.item-qty-input')) {
recalcAllRows(row, 'qty');
} else if (e.target.closest('.item-amount-input')) {
recalcAllRows(row, 'amount');
}
});
orderRows?.addEventListener('input', function (e) {
const row = e.target.closest('.order-row');
if (e.target.closest('.item-qty-input')) {
recalcAllRows(row, 'qty');
}
});
orderRows?.addEventListener('click', function (e) {
const removeButton = e.target.closest('.remove-order-row');
if (!removeButton) {
return;
}
const row = removeButton.closest('.order-row');
if (!row) {
return;
}
if (orderRows.querySelectorAll('.order-row').length <= 1) {
alert('최소 1개 행은 유지해야 합니다.');
return;
}
row.remove();
recalcAllRows(null, 'qty');
});
addRowButton?.addEventListener('click', function () {
if (!rowTemplate || !orderRows) {
return;
}
const fragment = rowTemplate.content.cloneNode(true);
orderRows.appendChild(fragment);
recalcAllRows(null, 'qty');
});
form?.addEventListener('submit', function (e) {
let hasItem = false;
document.querySelectorAll('.order-row').forEach((row) => {
const code = row.querySelector('.bag-code-select').value;
const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0;
if (code && qty > 0) { hasItem = true; }
});
if (!hasItem) { e.preventDefault(); alert('주문 품목과 수량을 1개 이상 입력해 주세요.'); }
});
updateShopInfo();
recalcAllRows(null, 'qty');
})();
</script>

View File

@@ -26,6 +26,7 @@
<th>판매소</th> <th>판매소</th>
<th>접수일</th> <th>접수일</th>
<th>배달일</th> <th>배달일</th>
<th>접수채널</th>
<th>결제</th> <th>결제</th>
<th>입금</th> <th>입금</th>
<th>수령</th> <th>수령</th>
@@ -42,6 +43,12 @@
<td class="text-left pl-2"><?= esc($row->so_ds_name) ?></td> <td class="text-left pl-2"><?= esc($row->so_ds_name) ?></td>
<td class="text-center"><?= esc($row->so_order_date) ?></td> <td class="text-center"><?= esc($row->so_order_date) ?></td>
<td class="text-center"><?= esc($row->so_delivery_date) ?></td> <td class="text-center"><?= esc($row->so_delivery_date) ?></td>
<td class="text-center">
<?php
$channelMap = ['phone' => '전화', 'web' => '웹', 'app' => '앱', 'counter' => '창구'];
echo esc($channelMap[$row->so_channel ?? ''] ?? ($row->so_channel ?? '전화'));
?>
</td>
<td class="text-center"><?= esc($row->so_payment_type) ?></td> <td class="text-center"><?= esc($row->so_payment_type) ?></td>
<td class="text-center"> <td class="text-center">
<?php <?php
@@ -72,7 +79,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="12" class="text-center text-gray-400 py-4">등록된 주문이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

77
app/Views/auth/_shell.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* 인증 페이지 공통 셸 — gov-portal 디자인.
* 사용: 자식 뷰 상단에서 $this->extend('auth/_shell'),
* 섹션 'heading'(카드 제목)·'content'(본문) 정의.
* 선택 변수: $subtitle(카드 헤더 소제목), $cardMax(예: 'max-w-lg', 기본 'max-w-md')
*/
$cardMax = $cardMax ?? 'max-w-md';
$subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($pageTitle ?? 'GBLS') ?></title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Pretendard', '"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'navy': '#1a2b4b', 'title-bar': '#1a2b4b', 'portal-bg': '#f0f4f8',
'btn-search': '#243a5e', 'btn-exit': '#d9534f',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: -0.01em; }</style>
</head>
<script>
// iframe(워크스페이스 탭) 안에서 세션 만료로 로그인이 열리면 상위 창 전체를 로그인으로 전환
if (window.top !== window.self) { try { window.top.location.href = <?= json_encode(base_url('login'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; } catch (e) {} }
</script>
<body class="bg-portal-bg text-gray-700 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
</svg>
<span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
<span class="text-[0.56rem] font-medium text-white/65 tracking-tight whitespace-nowrap">Garbage Bag Logistics System</span>
</span>
</a>
</header>
<main class="flex-grow p-6 flex items-center justify-center">
<section class="w-full <?= esc($cardMax) ?> bg-white border border-[#dde4ec] rounded-2xl shadow-[0_2px_12px_rgba(26,43,75,0.08)] overflow-hidden">
<div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-6 py-5">
<p class="text-xs text-white/70"><?= esc($subtitle) ?></p>
<h1 class="text-lg font-bold mt-0.5"><?= $this->renderSection('heading') ?></h1>
</div>
<div class="p-6">
<?php if (session()->getFlashdata('error')): ?>
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('success')): ?>
<div class="mb-4 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?= $this->renderSection('content') ?>
</div>
</section>
</main>
<footer class="bg-[#eef2f7] border-t border-[#dde4ec] px-4 py-1.5 text-xs text-gray-500 shrink-0 text-center">종량제 물류시스템</footer>
</body>
</html>

View File

@@ -1,71 +1,21 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>로그인<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>로그인 - 종량제 시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
'btn-exit': '#d9534f',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600" title="종량제 시스템">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-blue-900 translate-y-[1px]" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
</svg>
<span class="whitespace-nowrap">종량제 시스템</span>
</a>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
로그인
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-green-50 text-green-700 text-sm" role="alert"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-md bg-white border border-gray-300 rounded shadow-sm p-6">
<form action="<?= base_url('login') ?>" method="POST" class="space-y-4"> <form action="<?= base_url('login') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="login_id">아이디</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="login_id">아이디</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="login_id" name="login_id" type="text" placeholder="아이디" value="<?= esc(old('login_id')) ?>" autocomplete="username" autofocus/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="login_id" name="login_id" type="text" placeholder="아이디" value="<?= esc(old('login_id')) ?>" autocomplete="username" autofocus/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="password">비밀번호</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="password">비밀번호</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="password" name="password" type="password" placeholder="비밀번호" autocomplete="current-password"/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="password" name="password" type="password" placeholder="비밀번호" autocomplete="current-password"/>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">로그인</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition border border-transparent">로그인</button>
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">회원가입</a> <a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -1,59 +1,18 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>2차 인증 (TOTP)<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>2 인증 - 종량제 시스템</title> <p class="text-sm text-gray-600 mb-4">계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 대해 인증 앱의 6자리 코드를 입력해 주세요.</p>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<?= view('components/header_brand') ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
2차 인증 (TOTP)
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-md bg-white border border-gray-300 rounded shadow-sm p-6 space-y-4">
<p class="text-sm text-gray-600">계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 대해 인증 앱의 6자리 코드를 입력해 주세요.</p>
<form action="<?= base_url('login/two-factor') ?>" method="POST" class="space-y-4"> <form action="<?= base_url('login/two-factor') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">인증 코드</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">인증 코드</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm tracking-widest focus:ring-blue-500 focus:border-blue-500" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm tracking-widest focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">확인</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition border border-transparent">확인</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">처음으로</a> <a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">처음으로</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -1,75 +1,38 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>회원가입<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>회원가입 - 종량제 시스템</title> <?php $inputCls = 'block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]'; ?>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
'btn-exit': '#d9534f',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<?= view('components/header_brand') ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
회원가입
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 overflow-auto">
<section class="w-full max-w-md mx-auto bg-white border border-gray-300 rounded shadow-sm p-6">
<form action="<?= base_url('register') ?>" method="POST" class="space-y-4"> <form action="<?= base_url('register') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_id">아이디 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_id">아이디 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_id" name="mb_id" type="text" value="<?= esc(old('mb_id')) ?>" autocomplete="username" autofocus/> <input class="<?= $inputCls ?>" id="mb_id" name="mb_id" type="text" value="<?= esc(old('mb_id')) ?>" autocomplete="username" autofocus/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/> <input class="<?= $inputCls ?>" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_passwd_confirm" name="mb_passwd_confirm" type="password" autocomplete="new-password"/> <input class="<?= $inputCls ?>" id="mb_passwd_confirm" name="mb_passwd_confirm" type="password" autocomplete="new-password"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_name">이름 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_name">이름 <span class="text-red-500">*</span></label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_name" name="mb_name" type="text" value="<?= esc(old('mb_name')) ?>" autocomplete="name"/> <input class="<?= $inputCls ?>" id="mb_name" name="mb_name" type="text" value="<?= esc(old('mb_name')) ?>" autocomplete="name"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_email">이메일</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_email">이메일</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_email" name="mb_email" type="email" value="<?= esc(old('mb_email')) ?>"/> <input class="<?= $inputCls ?>" id="mb_email" name="mb_email" type="email" value="<?= esc(old('mb_email')) ?>"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_phone">연락처</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_phone">연락처</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_phone" name="mb_phone" type="tel" value="<?= esc(old('mb_phone')) ?>"/> <input class="<?= $inputCls ?>" id="mb_phone" name="mb_phone" type="tel" value="<?= esc(old('mb_phone')) ?>"/>
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_lg_idx">지자체</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_lg_idx">지자체</label>
<select class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_lg_idx" name="mb_lg_idx"> <select class="<?= $inputCls ?>" id="mb_lg_idx" name="mb_lg_idx">
<option value="">선택 안 함</option> <option value="">선택 안 함</option>
<?php if (! empty($localGovernments)): ?> <?php if (! empty($localGovernments)): ?>
<?php foreach ($localGovernments as $lg): ?> <?php foreach ($localGovernments as $lg): ?>
@@ -80,7 +43,7 @@ tailwind.config = {
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_level">사용자 역할 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 mb-1" for="mb_level">사용자 역할 <span class="text-red-500">*</span></label>
<select class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_level" name="mb_level"> <select class="<?= $inputCls ?>" id="mb_level" name="mb_level">
<?php foreach (config('Roles')->levelNames as $level => $name): ?> <?php foreach (config('Roles')->levelNames as $level => $name): ?>
<?php if (\Config\Roles::isSuperAdminEquivalent((int) $level)) continue; ?> <?php if (\Config\Roles::isSuperAdminEquivalent((int) $level)) continue; ?>
<option value="<?= $level ?>" <?= old('mb_level', config('Roles')->defaultLevelForSelfRegister) == $level ? 'selected' : '' ?>><?= esc($name) ?></option> <option value="<?= $level ?>" <?= old('mb_level', config('Roles')->defaultLevelForSelfRegister) == $level ? 'selected' : '' ?>><?= esc($name) ?></option>
@@ -89,12 +52,8 @@ tailwind.config = {
<p class="text-xs text-gray-500 mt-1">가입 후 관리자 승인 완료 시 로그인할 수 있습니다.</p> <p class="text-xs text-gray-500 mt-1">가입 후 관리자 승인 완료 시 로그인할 수 있습니다.</p>
</div> </div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition">가입하기</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition">가입하기</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">로그인</a> <a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">로그인</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

View File

@@ -1,69 +1,29 @@
<!DOCTYPE html> <?= $this->extend('auth/_shell') ?>
<html lang="ko">
<head> <?= $this->section('heading') ?>2차 인증 앱 등록<?= $this->endSection() ?>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <?= $this->section('content') ?>
<title>2 인증 등록 - 종량제 시스템</title> <p class="text-sm text-gray-600 mb-4">관리자 계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 Google Authenticator, Microsoft Authenticator 등으로 아래 시크릿 또는 QR을 등록한 뒤, 표시되는 6자리 코드를 입력해 주세요.</p>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center px-4 shrink-0">
<?= view('components/header_brand') ?>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
2차 인증 앱 등록
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-lg bg-white border border-gray-300 rounded shadow-sm p-6 space-y-4">
<p class="text-sm text-gray-600">관리자 계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 Google Authenticator, Microsoft Authenticator 등으로 아래 시크릿 또는 QR을 등록한 뒤, 표시되는 6자리 코드를 입력해 주세요.</p>
<?php if (! empty($qrDataUri)): ?> <?php if (! empty($qrDataUri)): ?>
<div class="flex justify-center"> <div class="flex justify-center mb-4">
<img src="<?= esc($qrDataUri, 'attr') ?>" alt="TOTP QR 코드" class="border border-gray-200 rounded max-w-[200px] h-auto"/> <img src="<?= esc($qrDataUri, 'attr') ?>" alt="TOTP QR 코드" class="border border-gray-200 rounded-lg max-w-[200px] h-auto"/>
</div> </div>
<?php else: ?> <?php else: ?>
<p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">QR 이미지를 불러올 수 없습니다. 아래 시크릿을 앱에 직접 입력해 주세요.</p> <p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2 mb-4">QR 이미지를 불러올 수 없습니다. 아래 시크릿을 앱에 직접 입력해 주세요.</p>
<?php endif; ?> <?php endif; ?>
<div> <div class="mb-4">
<span class="block text-xs font-semibold text-gray-500 mb-1">수동 입력용 시크릿</span> <span class="block text-xs font-semibold text-gray-500 mb-1">수동 입력용 시크릿</span>
<code class="block text-sm bg-gray-100 border border-gray-200 rounded px-3 py-2 break-all select-all"><?= esc($secret) ?></code> <code class="block text-sm bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 break-all select-all"><?= esc($secret) ?></code>
</div> </div>
<form action="<?= base_url('login/totp-setup') ?>" method="POST" class="space-y-4 pt-2 border-t border-gray-200"> <form action="<?= base_url('login/totp-setup') ?>" method="POST" class="space-y-4 pt-2 border-t border-gray-200">
<?= csrf_field() ?> <?= csrf_field() ?>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">확인용 인증 코드</label> <label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">확인용 인증 코드</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm tracking-widest focus:ring-blue-500 focus:border-blue-500" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/> <input class="block w-full border border-gray-300 rounded-lg px-3 py-2 text-sm tracking-widest focus:ring-2 focus:ring-[#007bff]/40 focus:border-[#007bff]" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">등록 완료</button> <button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-lg text-sm font-semibold shadow hover:brightness-110 transition border border-transparent">등록 완료</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">취소</a> <a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">취소</a>
</div> </div>
</form> </form>
</section> <?= $this->endSection() ?>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">종량제 시스템</footer>
</body>
</html>

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