Compare commits
107 Commits
bf7ecfd609
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74608abce4 | |||
| c5e1bf1aa9 | |||
| 0b11e06467 | |||
|
|
aa96f49525 | ||
| 121a30141f | |||
|
|
deb6b64953 | ||
|
|
ca14804c9f | ||
|
|
ad79ed93a4 | ||
|
|
8fc1e2a027 | ||
|
|
af10610ee9 | ||
|
|
e3d455fdcb | ||
|
|
a1e73cc252 | ||
|
|
59932eb8ce | ||
|
|
5677a48d8c | ||
|
|
be8070da5f | ||
|
|
d73f80d432 | ||
|
|
f66055f3da | ||
|
|
32989e1f0e | ||
|
|
dfe6279780 | ||
|
|
730eaf4af5 | ||
|
|
fc44275ca6 | ||
|
|
2027c224dd | ||
|
|
67e40e5ab0 | ||
|
|
6a4e4d864c | ||
|
|
657282e080 | ||
|
|
a5ac30fd48 | ||
|
|
dda2d6845d | ||
|
|
afee8b9dcf | ||
|
|
1cb0be6244 | ||
|
|
e94ee05267 | ||
| e9e15bf0c4 | |||
|
|
6df27f68d6 | ||
|
|
3b691596cc | ||
|
|
78bb6b6378 | ||
|
|
50308dd340 | ||
|
|
2ab3534278 | ||
|
|
f00e869368 | ||
|
|
f0e41c58aa | ||
|
|
6f89cbaa58 | ||
|
|
e8062431e4 | ||
|
|
2a3382084d | ||
|
|
179c89a514 | ||
|
|
d5b7a3c060 | ||
|
|
854af0f236 | ||
|
|
f6e127dfb8 | ||
|
|
9ce6a897a8 | ||
|
|
ef5d4e4a45 | ||
|
|
14b1450eca | ||
|
|
bfe9ca6825 | ||
|
|
2cd21b25dd | ||
|
|
fb282770d7 | ||
|
|
7fd66f681a | ||
|
|
d44df83545 | ||
|
|
d293cdd7a1 | ||
|
|
227cb79d16 | ||
|
|
6e9f5c189e | ||
|
|
1a33498b9d | ||
|
|
1e636ecbcc | ||
|
|
8f121484d5 | ||
|
|
933b00b6e6 | ||
|
|
756679538c | ||
| 7cae32b54c | |||
| e4c6a05e35 | |||
| 351e2e73ca | |||
| baa560e261 | |||
| 43a8c47ad1 | |||
| d60017ca30 | |||
| b868603280 | |||
| 651a80018a | |||
| 848958da5f | |||
| 2d1dc8f9ba | |||
| 47ecc60685 | |||
| 394145c9a5 | |||
| 4ee2d50a52 | |||
| 1df962b957 | |||
| b2f58a7957 | |||
| b57a898ba8 | |||
| e2bbb4af97 | |||
| 83f99a13bd | |||
| 6ebb692000 | |||
| da9b0445f7 | |||
| 78d340b359 | |||
| 24edadf7d7 | |||
| 2f1e090a07 | |||
| ef1ae8bc17 | |||
| 6341d70f36 | |||
| f8546431e9 | |||
| 0a59899d4a | |||
| abf13b7a2a | |||
| bed13dc633 | |||
| c029440396 | |||
| 1e1d390050 | |||
| 27c825c927 | |||
| 6d9427dd5a | |||
| 293643d639 | |||
| fddbbf17cd | |||
| 4440c8abd8 | |||
| e26ba7ef3a | |||
| a5bc2198eb | |||
| 16451963ff | |||
| d5893aa6ed | |||
| e65e55e37b | |||
| 77a9293d05 | |||
| 939ba4b887 | |||
| 0326878386 | |||
| 246cf46eaf | |||
| 02a5058512 |
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(echo:*)",
|
||||
"Bash(cat:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
42
.cursorrules
Normal file
@@ -0,0 +1,42 @@
|
||||
# Cursor AI 전역 설정
|
||||
|
||||
## 작업 완료 시 Git 커밋 메시지 자동 생성 규칙
|
||||
|
||||
작업이 완료되면 항상 다음 형식으로 git 커밋 명령어를 제안하세요:
|
||||
|
||||
### 커밋 메시지 형식
|
||||
```
|
||||
g "작업 요약: 변경된 파일 및 주요 내용"
|
||||
```
|
||||
|
||||
### 커밋 메시지 작성 가이드
|
||||
1. **간결하고 명확한 요약**: 한 줄로 작업 내용을 요약
|
||||
2. **변경된 파일 포함**: 주요 수정 파일명을 언급
|
||||
3. **한국어 사용**: 프로젝트가 한국어로 진행되므로 커밋 메시지도 한국어로 작성
|
||||
4. **구체적인 변경사항**: "수정", "추가", "삭제" 등 동작을 명시
|
||||
|
||||
### 예시
|
||||
- `g "히어로 섹션에 Vimeo 영상 추가 (etc/shutter.php)"`
|
||||
- `g "품질인정제도 가이드 페이지에 영상 설명 섹션 추가"`
|
||||
- `g "자동방화셔터 가이드: 영상 임베드 및 반응형 디자인 적용"`
|
||||
|
||||
### 작업 완료 시 필수 동작
|
||||
작업이 완료되면 반드시 터미널 명령어를 다음 형식으로 제안하세요:
|
||||
|
||||
```powershell
|
||||
g "작업 요약 내용"
|
||||
```
|
||||
|
||||
이 명령어는 `git add .`, `git commit -m "메시지"`, `git push`를 자동으로 실행합니다.
|
||||
- `git push`는 실패 시 최대 2번까지 자동으로 재시도합니다.
|
||||
|
||||
### 주의사항
|
||||
- 사용자가 명시적으로 요청하지 않는 한 실제로 git 명령어를 실행하지 마세요
|
||||
- 단지 제안만 하세요
|
||||
- 커밋 메시지는 작업 내용을 정확히 반영해야 합니다
|
||||
|
||||
## 문서 작성 규칙
|
||||
1. **한국어 사용**: `walkthrough.md`, `implementation_plan.md`, `task.md`를 포함한 모든 `.md` (Markdown) 파일 및 Antigravity 아티팩트는 항상 한국어를 사용하세요.
|
||||
2. **명확한 구조**: 문서의 가독성을 위해 헤더, 목록, 코드 블록 등을 적절히 사용하여 구조화하세요.
|
||||
3. **직관적인 용어**: 전문 용어가 필요한 경우를 제외하고는 사용자가 이해하기 쉬운 한국어 용어를 우선적으로 사용하세요.
|
||||
|
||||
1
.gitignore
vendored
@@ -24,6 +24,7 @@ public/mix-manifest.json
|
||||
public/build/
|
||||
|
||||
/storage/pail/
|
||||
/research/
|
||||
public/js/*.map
|
||||
public/css/*.map
|
||||
|
||||
|
||||
207
DB_SYNC_GUIDE.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 데이터베이스 동기화 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
이 가이드는 서버와 로컬 개발 환경 간 데이터베이스를 동기화하는 방법을 설명합니다.
|
||||
|
||||
- 서버 내 계정 pro에서 가져오는 방법 : scp pro@114.203.209.83:/home/pro/chandj_backup.sql .\chandj_backup.sql
|
||||
- 서버에 백업을 받은 상태로 추정한 후 처리한다.
|
||||
|
||||
- **로컬 → 서버**: `mysql_sync.sh` 사용 (테이블 단위 동기화)
|
||||
- **서버 → 로컬**: 이 문서의 방법 사용 (전체 DB 다운로드)
|
||||
|
||||
---
|
||||
|
||||
## 서버에서 로컬로 DB 다운로드
|
||||
|
||||
### 방법 1: 서버에서 덤프 생성 후 SFTP로 다운로드 (권장)
|
||||
|
||||
#### 1단계: 서버에서 덤프 파일 생성
|
||||
|
||||
서버에 SSH 접속 후:
|
||||
|
||||
```bash
|
||||
# 서버의 .env 파일 확인
|
||||
cat /var/www/sales/.env
|
||||
# 또는
|
||||
cat /home/webservice/sales/.env
|
||||
|
||||
# .env에서 확인한 DB 사용자로 덤프 생성
|
||||
# 예시 1: root 사용자 (비밀번호 입력 필요)
|
||||
mysqldump -u root -p chandj > /tmp/chandj_backup.sql
|
||||
|
||||
# 예시 2: codebridge 사용자
|
||||
mysqldump -u codebridge -p chandj > /tmp/chandj_backup.sql
|
||||
|
||||
# 예시 3: 127.0.0.1 사용 (소켓 문제 해결)
|
||||
mysqldump -h 127.0.0.1 -u root -p chandj > /tmp/chandj_backup.sql
|
||||
|
||||
# 예시 4: 비밀번호를 환경 변수로 사용 (.env에 비밀번호가 있을 경우)
|
||||
MYSQL_PWD='[비밀번호]' mysqldump -u root chandj > /tmp/chandj_backup.sql
|
||||
```
|
||||
|
||||
#### 2단계: VSCode SFTP로 다운로드
|
||||
|
||||
1. VSCode에서 `Ctrl+Shift+P` → `SFTP: Download` 실행
|
||||
2. 경로 입력: `/tmp/chandj_backup.sql`
|
||||
3. 다운로드 위치 선택
|
||||
|
||||
또는 PowerShell에서:
|
||||
|
||||
```powershell
|
||||
scp pro@114.203.209.83:/tmp/chandj_backup.sql .\chandj_backup.sql
|
||||
```
|
||||
|
||||
#### 3단계: 로컬 Docker DB에 적용
|
||||
|
||||
```bash
|
||||
# Docker 컨테이너에 SQL 파일 복사
|
||||
docker cp chandj_backup.sql sam-mysql-1:/tmp/
|
||||
|
||||
# Docker 컨테이너에서 SQL 파일 실행
|
||||
docker exec -i sam-mysql-1 mysql -uroot -proot chandj < /tmp/chandj_backup.sql
|
||||
|
||||
# 또는 직접 파이프 사용
|
||||
cat chandj_backup.sql | docker exec -i sam-mysql-1 mysql -uroot -proot chandj
|
||||
```
|
||||
|
||||
### 방법 2: PHP 스크립트 사용
|
||||
|
||||
#### 1단계: `dump_db.php` 업로드
|
||||
|
||||
VSCode SFTP로 `dump_db.php`를 서버에 업로드:
|
||||
- 업로드 위치: `/var/www/sales/` 또는 `/home/webservice/sales/`
|
||||
|
||||
#### 2단계: 서버에서 스크립트 실행
|
||||
|
||||
```bash
|
||||
cd /var/www/sales
|
||||
php dump_db.php
|
||||
```
|
||||
|
||||
스크립트가 `.env` 파일을 읽어서 자동으로 덤프를 생성합니다.
|
||||
|
||||
#### 3단계: 덤프 파일 다운로드 및 적용
|
||||
|
||||
방법 1의 2-3단계와 동일합니다.
|
||||
|
||||
### 방법 3: PowerShell 스크립트 사용 (Windows)
|
||||
|
||||
#### 1단계: `download_db.ps1` 실행
|
||||
|
||||
```powershell
|
||||
.\download_db.ps1
|
||||
```
|
||||
|
||||
**주의**: 비밀번호 입력이 필요한 경우 수동으로 서버에서 실행해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 로컬에서 서버로 DB 업로드
|
||||
|
||||
### `mysql_sync.sh` 사용
|
||||
|
||||
```bash
|
||||
# 단일 테이블 동기화
|
||||
./mysql_sync.sh table_name
|
||||
|
||||
# 여러 테이블 동기화
|
||||
./mysql_sync.sh table1,table2,table3
|
||||
```
|
||||
|
||||
**동작 방식**:
|
||||
1. 로컬 Docker에서 테이블 덤프 생성
|
||||
2. 서버로 SQL 파일 전송 (SCP)
|
||||
3. 서버 DB에 적용
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 문제 1: "Access denied for user 'root'@'dev-codebridgex'"
|
||||
|
||||
**원인**: MySQL root 사용자가 해당 호스트에서 접근 권한이 없거나, MySQL 설정 파일의 호스트 바인딩 문제
|
||||
|
||||
**해결**:
|
||||
1. 서버의 `.env` 파일에서 실제 DB 사용자 확인
|
||||
2. 해당 사용자로 mysqldump 실행
|
||||
3. 또는 `127.0.0.1` 사용: `mysqldump -h 127.0.0.1 -u root -p chandj`
|
||||
4. **근본 해결**: `my.cnf` 파일에서 `bind-address = localhost` 설정 (문제 4 참조)
|
||||
|
||||
### 문제 2: "No such file or directory" (PHP 연결 시)
|
||||
|
||||
**원인**: MySQL 소켓 파일 경로 문제
|
||||
|
||||
**해결**:
|
||||
- `mysqldump`는 PHP 연결과 무관하게 작동하므로 직접 실행
|
||||
- 또는 `-h 127.0.0.1` 옵션 사용
|
||||
|
||||
### 문제 3: 비밀번호 입력 프롬프트
|
||||
|
||||
**해결**:
|
||||
- `.env` 파일에서 `DB_PASS` 확인
|
||||
- 환경 변수 사용: `MYSQL_PWD='비밀번호' mysqldump -u root chandj`
|
||||
|
||||
### 문제 4: "Access denied" 오류가 계속 발생하는 경우
|
||||
|
||||
**원인**: MySQL 설정 파일(`my.cnf`)에서 호스트 바인딩 문제
|
||||
|
||||
**해결**: 서버의 `my.cnf` 파일에서 `bind-address`를 `localhost`로 설정
|
||||
|
||||
```bash
|
||||
# my.cnf 파일 위치 확인 (일반적으로 다음 중 하나)
|
||||
# /etc/mysql/my.cnf
|
||||
# /etc/my.cnf
|
||||
# ~/.my.cnf
|
||||
|
||||
# my.cnf 파일 편집
|
||||
sudo nano /etc/mysql/my.cnf
|
||||
# 또는
|
||||
sudo nano /etc/my.cnf
|
||||
|
||||
# 다음 설정 추가 또는 수정
|
||||
[mysqld]
|
||||
bind-address = 127.0.0.1
|
||||
# 또는
|
||||
bind-address = localhost
|
||||
|
||||
# MySQL 서비스 재시작
|
||||
sudo systemctl restart mysql
|
||||
# 또는
|
||||
sudo service mysql restart
|
||||
```
|
||||
|
||||
**참고**: `bind-address = 127.0.0.1` 또는 `bind-address = localhost`로 설정하면 로컬 호스트에서만 연결을 허용합니다. 이 설정 후 `mysqldump -u root -p chandj` 명령이 정상 작동합니다.
|
||||
|
||||
---
|
||||
|
||||
## 서버 정보
|
||||
|
||||
- **서버 주소**: `114.203.209.83`
|
||||
- **SSH 사용자**: `pro`
|
||||
- **DB 이름**: `chandj`
|
||||
- **서버 DB 경로**: `/var/www/sales/.env` 또는 `/home/webservice/sales/.env`
|
||||
|
||||
---
|
||||
|
||||
## 빠른 참조
|
||||
|
||||
### 서버에서 덤프 생성
|
||||
```bash
|
||||
mysqldump -h 127.0.0.1 -u root -p chandj > /tmp/chandj_backup.sql
|
||||
```
|
||||
|
||||
### 로컬로 다운로드 (PowerShell)
|
||||
```powershell
|
||||
scp pro@114.203.209.83:/chandj_backup.sql .\chandj_backup.sql
|
||||
```
|
||||
|
||||
### 로컬 Docker에 적용
|
||||
```bash
|
||||
cat chandj_backup.sql | docker exec -i sam-mysql-1 mysql -uroot -proot chandj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025-01-06
|
||||
|
||||
197
ENV_SETUP_GUIDE.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# .env 파일 설정 가이드 (로컬/서버 환경 분리)
|
||||
|
||||
## 현재 구조
|
||||
|
||||
프로젝트는 `.env` 파일을 사용하여 데이터베이스 연결 정보를 관리합니다.
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
sales/
|
||||
├── .env # 환경별 설정 파일 (Git에 포함되지 않음)
|
||||
├── session.php # .env 파일 로드
|
||||
└── lib/mydb.php # DB 연결 (session.php에서 로드된 .env 사용)
|
||||
```
|
||||
|
||||
### 코드 동작 방식
|
||||
|
||||
1. **session.php**: 프로젝트 루트의 `.env` 파일을 읽어 `$_ENV` 배열에 저장
|
||||
2. **lib/mydb.php**: `$_ENV` 배열에서 DB 연결 정보를 읽어옴 (기본값 제공)
|
||||
|
||||
## 환경별 설정
|
||||
|
||||
### 로컬 개발 환경 (Docker)
|
||||
|
||||
**파일 위치**: `C:\Users\aweso\sam\sales\.env`
|
||||
|
||||
```env
|
||||
DB_HOST=sam-mysql-1
|
||||
DB_NAME=chandj
|
||||
DB_USER=root
|
||||
DB_PASS=root
|
||||
```
|
||||
|
||||
또는 Docker Compose 서비스 이름 사용:
|
||||
|
||||
```env
|
||||
DB_HOST=mysql
|
||||
DB_NAME=chandj
|
||||
DB_USER=root
|
||||
DB_PASS=root
|
||||
```
|
||||
|
||||
**중요 사항**:
|
||||
- Docker 네트워크에서 MySQL 컨테이너는 서비스 이름(`mysql`) 또는 컨테이너 이름(`sam-mysql-1`)으로 접근
|
||||
- `localhost`를 사용하면 Docker 컨테이너 내부의 localhost를 가리켜 연결 실패
|
||||
|
||||
### 서버 환경 (실서버)
|
||||
|
||||
**파일 위치**: `/home/webservice/sales/.env`
|
||||
|
||||
```env
|
||||
DB_HOST=localhost
|
||||
DB_NAME=chandj
|
||||
DB_USER=codebridge
|
||||
DB_PASS=실제비밀번호
|
||||
APP_URL=https://sales.codebridge-x.com/
|
||||
DOCUMENT_ROOT=/home/webservice/sales
|
||||
```
|
||||
|
||||
**중요 사항**:
|
||||
- 서버에서는 MySQL이 같은 서버에 설치되어 있으므로 `localhost` 사용
|
||||
- 보안상 `root` 계정 대신 전용 계정(`codebridge`) 사용
|
||||
|
||||
## 기본값 (Fallback)
|
||||
|
||||
`.env` 파일이 없거나 값이 설정되지 않은 경우 `lib/mydb.php`의 기본값이 사용됩니다:
|
||||
|
||||
```php
|
||||
$db_host = $_ENV['DB_HOST'] ?? 'localhost'; // 기본값: localhost
|
||||
$db_name = $_ENV['DB_NAME'] ?? 'chandj';
|
||||
$db_user = $_ENV['DB_USER'] ?? 'root';
|
||||
$db_pass = $_ENV['DB_PASS'] ?? 'root';
|
||||
```
|
||||
|
||||
**주의**: 기본값이 `localhost`인데, Docker 환경에서는 `.env` 파일이 필수입니다!
|
||||
|
||||
## 현재 구조의 장단점
|
||||
|
||||
### ✅ 장점
|
||||
|
||||
1. **환경 분리**: 로컬과 서버에서 다른 설정 파일 사용 가능
|
||||
2. **보안**: `.env` 파일은 Git에 포함되지 않음 (`.gitignore`에 추가됨)
|
||||
3. **유연성**: 환경별로 다른 DB 계정, 호스트, 비밀번호 사용 가능
|
||||
4. **기본값 제공**: `.env` 파일이 없어도 기본값으로 동작 (단, Docker 환경에서는 문제 발생)
|
||||
|
||||
### ⚠️ 주의사항
|
||||
|
||||
1. **Docker 환경**: `.env` 파일이 반드시 필요 (기본값 `localhost`는 Docker에서 작동하지 않음)
|
||||
2. **기본값 불일치**: 기본값이 `localhost`인데 Docker 환경 기본값은 `mysql`이어야 함
|
||||
3. **배포 시**: 서버에 `.env` 파일을 수동으로 생성해야 함
|
||||
|
||||
## 권장 설정
|
||||
|
||||
### 로컬 개발 환경
|
||||
|
||||
`.env` 파일 생성 (필수):
|
||||
|
||||
```env
|
||||
DB_HOST=mysql
|
||||
DB_NAME=chandj
|
||||
DB_USER=root
|
||||
DB_PASS=root
|
||||
```
|
||||
|
||||
### 서버 환경
|
||||
|
||||
`.env` 파일 생성 (필수):
|
||||
|
||||
```env
|
||||
DB_HOST=localhost
|
||||
DB_NAME=chandj
|
||||
DB_USER=codebridge
|
||||
DB_PASS=실제비밀번호
|
||||
APP_URL=https://sales.codebridge-x.com/
|
||||
DOCUMENT_ROOT=/home/webservice/sales
|
||||
```
|
||||
|
||||
## 확인 방법
|
||||
|
||||
### 로컬에서 .env 파일 확인
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
cd C:\Users\aweso\sam\sales
|
||||
Get-Content .env
|
||||
|
||||
# 또는 파일이 있는지 확인
|
||||
Test-Path .env
|
||||
```
|
||||
|
||||
### 서버에서 .env 파일 확인
|
||||
|
||||
```bash
|
||||
cd /home/webservice/sales
|
||||
cat .env
|
||||
|
||||
# 또는 파일이 있는지 확인
|
||||
test -f .env && echo "존재함" || echo "없음"
|
||||
```
|
||||
|
||||
### PHP에서 현재 설정 확인
|
||||
|
||||
임시 파일 생성 (`check_env.php`):
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'session.php';
|
||||
echo "DB_HOST: " . ($_ENV['DB_HOST'] ?? '기본값 사용') . "\n";
|
||||
echo "DB_NAME: " . ($_ENV['DB_NAME'] ?? '기본값 사용') . "\n";
|
||||
echo "DB_USER: " . ($_ENV['DB_USER'] ?? '기본값 사용') . "\n";
|
||||
?>
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 문제: Docker 환경에서 DB 연결 실패
|
||||
|
||||
**원인**: `.env` 파일이 없거나 `DB_HOST`가 `localhost`로 설정됨
|
||||
|
||||
**해결**:
|
||||
```env
|
||||
DB_HOST=mysql
|
||||
```
|
||||
|
||||
또는 Docker Compose에서 정의한 서비스 이름 사용
|
||||
|
||||
### 문제: 서버에서 DB 연결 실패
|
||||
|
||||
**원인**: `.env` 파일이 없거나 잘못된 설정
|
||||
|
||||
**해결**:
|
||||
1. `.env` 파일 생성
|
||||
2. `DB_HOST=localhost` 확인
|
||||
3. 올바른 DB 사용자 계정 및 비밀번호 설정
|
||||
|
||||
### 문제: Git에 .env 파일이 포함됨
|
||||
|
||||
**해결**: `.gitignore`에 `.env` 추가 확인
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
```
|
||||
|
||||
## 개선 제안
|
||||
|
||||
현재 구조는 올바르게 작동하지만, 다음을 고려할 수 있습니다:
|
||||
|
||||
1. **기본값 개선**: Docker 환경 감지하여 기본값을 `mysql`로 변경
|
||||
2. **.env.example 파일**: 환경 변수 템플릿 제공
|
||||
3. **환경 변수 검증**: 필수 환경 변수가 없을 때 명확한 에러 메시지
|
||||
|
||||
---
|
||||
|
||||
**결론**: 현재 구조는 로컬과 서버 환경 분리에 적합합니다. 각 환경에 맞는 `.env` 파일을 생성하여 사용하면 됩니다.
|
||||
|
||||
81
GIT_AUTO_GUIDE.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Git 자동화 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
`g` 명령어를 사용하여 Git 작업(`git add .`, `git commit`, `git push`)을 한 번에 자동화할 수 있습니다.
|
||||
|
||||
## 사용 방법
|
||||
|
||||
```powershell
|
||||
g "커밋 메시지"
|
||||
```
|
||||
|
||||
### 예시
|
||||
|
||||
```powershell
|
||||
g "sales 로컬파일 로그인 수정"
|
||||
```
|
||||
|
||||
이 명령어는 다음을 자동으로 실행합니다:
|
||||
1. `git add .` - 모든 변경사항 스테이징
|
||||
2. `git commit -m "sales 로컬파일 로그인 수정"` - 커밋 생성
|
||||
3. `git push` - 원격 저장소에 푸시
|
||||
|
||||
## 설정 완료 확인
|
||||
|
||||
새로운 PowerShell 창을 열거나 다음 명령어로 함수를 로드하세요:
|
||||
|
||||
```powershell
|
||||
. $PROFILE
|
||||
```
|
||||
|
||||
함수가 제대로 로드되었는지 확인:
|
||||
|
||||
```powershell
|
||||
Get-Command g
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 함수가 인식되지 않는 경우
|
||||
|
||||
1. **프로필 파일 다시 로드**:
|
||||
```powershell
|
||||
. $PROFILE
|
||||
```
|
||||
|
||||
2. **새 PowerShell 창 열기**:
|
||||
- 현재 창을 닫고 새 PowerShell 창을 엽니다.
|
||||
|
||||
3. **프로필 파일 위치 확인**:
|
||||
```powershell
|
||||
$PROFILE
|
||||
```
|
||||
|
||||
4. **프로필 파일 내용 확인**:
|
||||
```powershell
|
||||
Get-Content $PROFILE
|
||||
```
|
||||
|
||||
### 실행 정책 오류가 발생하는 경우
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
## 추가 기능
|
||||
|
||||
현재 함수는 다음을 포함합니다:
|
||||
- ✅ 각 단계별 진행 상황 표시
|
||||
- ✅ 에러 발생 시 중단 및 메시지 표시
|
||||
- ✅ 색상으로 구분된 출력
|
||||
|
||||
## 파일 위치
|
||||
|
||||
- **프로필 파일**: `$PROFILE` (일반적으로 `C:\Users\사용자명\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1`)
|
||||
- **스크립트 파일**: `git-auto.ps1` (프로젝트 루트)
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2025-01-06
|
||||
|
||||
46
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
DEPLOY_USER = 'hskwon'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
slackSend channel: '#deploy_mng', color: '#439FE0',
|
||||
message: "🚀 *sales* 빌드 시작 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
// ── main → 운영서버 Production 배포 ──
|
||||
stage('Deploy Production') {
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='storage' \
|
||||
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/sales/
|
||||
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'cd /home/webservice/sales && echo "sales deployed"'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
slackSend channel: '#deploy_mng', color: 'good',
|
||||
message: "✅ *sales* 배포 성공 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
failure {
|
||||
slackSend channel: '#deploy_mng', color: 'danger',
|
||||
message: "❌ *sales* 배포 실패 (`${env.BRANCH_NAME}`)\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
1152
Requestforcorrection/index.php
Normal file
117
Requestforcorrection/ref/경정청구.md
Normal file
1
apikey/barobill_api_key.txt
Normal file
@@ -0,0 +1 @@
|
||||
2DD6C76C-04DB-44F7-B6E9-3FC0B2211826
|
||||
1
apikey/barobill_api_key.txt.example
Normal file
@@ -0,0 +1 @@
|
||||
2DD6C76C-04DB-44F7-B6E9-3FC0B2211826
|
||||
39
apikey/barobill_cert_id.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
===========================================
|
||||
바로빌 인증서 ID (선택사항)
|
||||
===========================================
|
||||
|
||||
⚠️ 주의: 이것은 로그인 이메일이 아닙니다!
|
||||
|
||||
이 파일에는 바로빌 개발자센터에서 등록한
|
||||
공동인증서 또는 금융인증서의 ID를 입력하세요.
|
||||
|
||||
===========================================
|
||||
설정 방법:
|
||||
===========================================
|
||||
|
||||
1. 바로빌 개발자센터(https://dev.barobill.co.kr) 로그인
|
||||
- 로그인 이메일: admin@codebridge-x.com (이것 아님!)
|
||||
|
||||
2. 개발자센터 메뉴에서 "인증서 관리" 또는 "인증서 등록" 메뉴 찾기
|
||||
|
||||
3. 공동인증서 또는 금융인증서 등록
|
||||
- 인증서 파일(.pfx 또는 .p12) 업로드
|
||||
- 인증서 비밀번호 입력
|
||||
|
||||
4. 등록 후 발급받은 "인증서 ID" 복사
|
||||
- 예: "CERT-2024-ABC123-XYZ789"
|
||||
- 또는 숫자/문자 조합의 고유 ID
|
||||
|
||||
5. 아래에 인증서 ID 입력:
|
||||
|
||||
[여기에 인증서 ID 입력]
|
||||
|
||||
===========================================
|
||||
참고:
|
||||
===========================================
|
||||
|
||||
- 운영 환경: 세금계산서 발행 시 필수입니다
|
||||
- 테스트 환경: 인증서 ID 없이도 테스트 가능할 수 있습니다
|
||||
(barobill_test_mode.txt에 "true"가 설정되어 있으면 선택사항)
|
||||
- 인증서가 없으면 실제 세금계산서를 발행할 수 없지만,
|
||||
테스트 API에서는 가상의 인증서를 사용할 수 있습니다
|
||||
3
apikey/barobill_cert_id.txt.example
Normal file
@@ -0,0 +1,3 @@
|
||||
여기에 바로빌 인증서 ID를 입력하세요 (선택사항)
|
||||
예: cert-id-here
|
||||
|
||||
37
apikey/barobill_cert_key.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
===========================================
|
||||
바로빌 CERTKEY (인증서 키) - 필수
|
||||
===========================================
|
||||
|
||||
⚠️ 중요: 이것은 로그인 이메일이 아닙니다!
|
||||
|
||||
이 파일에는 바로빌 개발자센터에서 등록한
|
||||
공동인증서 또는 금융인증서의 CERTKEY를 입력하세요.
|
||||
|
||||
===========================================
|
||||
설정 방법:
|
||||
===========================================
|
||||
|
||||
1. 바로빌 개발자센터(https://dev.barobill.co.kr) 로그인
|
||||
|
||||
2. 개발자센터 메뉴에서 "인증서 관리" 또는 "인증서 등록" 메뉴 찾기
|
||||
|
||||
3. 공동인증서 또는 금융인증서 등록
|
||||
- 인증서 파일(.pfx 또는 .p12) 업로드
|
||||
- 인증서 비밀번호 입력
|
||||
|
||||
4. 등록 후 발급받은 "CERTKEY" 복사
|
||||
- 예: "CERT-2024-ABC123-XYZ789"
|
||||
- 또는 숫자/문자 조합의 고유 키
|
||||
|
||||
5. 아래에 CERTKEY 입력:
|
||||
|
||||
[여기에 CERTKEY 입력]
|
||||
|
||||
===========================================
|
||||
참고:
|
||||
===========================================
|
||||
|
||||
- 세금계산서 발행에 필수입니다
|
||||
- CERTKEY가 없으면 실제 API를 호출할 수 없습니다
|
||||
- 테스트 환경에서도 CERTKEY가 필요할 수 있습니다
|
||||
|
||||
1
apikey/barobill_corp_num.txt
Normal file
@@ -0,0 +1 @@
|
||||
6648603713
|
||||
1
apikey/barobill_test_mode.txt
Normal file
@@ -0,0 +1 @@
|
||||
true
|
||||
1
apikey/barobill_test_mode.txt.example
Normal file
@@ -0,0 +1 @@
|
||||
true
|
||||
1
apikey/barobill_user_id.txt
Normal file
@@ -0,0 +1 @@
|
||||
cbx0913
|
||||
1
apikey/claude_api.txt
Normal file
@@ -0,0 +1 @@
|
||||
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
|
||||
2
apikey/gcs_config.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
bucket_name=codebridge-speech-audio-files
|
||||
|
||||
1
apikey/gemini_api_key.txt
Normal file
@@ -0,0 +1 @@
|
||||
AIzaSyAS3bAzmXlhhZHgO3buFiTGzavXZ6ubYq8
|
||||
1
apikey/google_api.txt
Normal file
@@ -0,0 +1 @@
|
||||
f7d58533aa1dba0db19d799d85f22686684521d2
|
||||
13
apikey/google_service_account.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "codebridge-chatbot",
|
||||
"private_key_id": "bc6e2c8d65ac9567824b1c3bae245f2249c52aed",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDddbnvIMgXEul\nQKi2hY+LS1zJLHDLlVzlhJ3WU8XJP38z2BeINSSwoUXnLLiCrhideDBoY7p6sD//\nFG6/3dNarsj+UwBDUPcbD8aoxjRUM637BNprb0dOexfLifnKxdDYrsGf1ZwaGvk1\nVtyzvZ0FKsmvMmZJhTwPQD1Qng0ewtRs63U2kV/K/2CMB/Vgw2vh2KzbaJtOncBB\nZ++ZnRAlQntnmvQON2hP+Q/N43oHCOXz6TshphN2EoEgncKkuQxkSHFAPBKtAR0B\nf4sfQ6jFBGJaYcLCm1vIioirw6zNam5CvkbJnuhG6DfSiczynOKH71Y5XvldKAn9\nQY9LDQWnAgMBAAECggEAP9hbBUEHV+et8eGn+k6wL6DDQxcYFPfJ61KhN+QPRAhQ\npX+dWCl7vZJAQh3SeyUg9zbOIRsKS48MqGZlMpjjs8hJ8QDtog9tV9KUYoazixmC\nZ+8S5Wro0NLWV+7OwBRTfqO+rVXZ8pEC/BBOcQuroYdzb5232aYCkzy5in7F7FjH\nMCAxUDJhCM4yv05zM/8SV1Ypn6/f1+ZhIpkzCxFlA/Gx85l7pwV6LCaZjYgDiNAY\nDbVEccshrdKXBVKhkyuuGToDLsiRCdish5zdyA8COPr4tflnXvkvQk0MwjZTEygC\nJ8Ceh7oTY44A/rzSqo2kXaCCByGVnFAXJ3z1JoRbCQKBgQDsANCgHZHSzyvgGlyk\nFST7lgS36r1zNEwyGMIZC/edglXqXGeDnelTiSii3MQcq3fqXvXgVr6Pk/vLHf11\nz8zPVg/lJeYhrz6EF2loMN8rhp0yjzYRMT7AZ8kITtCV5HEgmnU+/o4mNwJfN6qK\n26HTSjjmujAjejDL7my+8naSHQKBgQDUBZop2CZdrEALVp3DIw07ZeFEbnG8j5U9\nvJveaz9wZWZnMlxJgc+dk6I1bZHqlIGU3vJ95xJ6JjnoRhPSi1yceaUFEU9UkQLY\n0XuQwsgaFRJLqHcTwZi1Z//IGagtPkAYXDsJDoRr2CEz2lzwd5RV0XCmDoZN9xV6\n+wnOpGlrkwKBgAE3l9vbiy79JorHWAb4nPI3OdsA+O0pLeNsQUQDzckgLPVCeL4z\nCEsIAA+m99P2Bm5NAxOfHuh6qOfJRc9fvPyswvQ5l9BAqR/hRwfkiKIe1Zy3JF4+\nVMaFQoIqdeTwAq1aXpRul6kWy4pWLSj+LP17+oMmHq1wKeRDXIg3k+j5AoGAQGfD\nQNrMLMBaZBdXrSNErbpxB5yVKDZlm29j2diyWK40wTxnFF0+eBuUtq4mGSArjNF8\n0AoVbs2V4Z0IAHkdFNtO6Y8sjf/O4ZYg9wR0TJgCCsGOCo5QmSqSZHKGx9eVGNFL\njaC/URNCYsH+YX2xrbAFjCv1WFGqUMVZYVBIRckCgYEA3yPZlawHMbHtFXFPL+by\nANKqtgX5mKO7E90kqTTumhOpkuywe3okR30wfs/kzX24cL/+brXs7naYyPDYRZz5\nxAMPGa5D4BvVchz1EBQh4lygA6gRlX2Yy/B3grpyZyCaSANX8sReML3wzn+AQze4\nfVaQ2YB6//fwv/5s95n1FAM=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "vertex-ai-client@codebridge-chatbot.iam.gserviceaccount.com",
|
||||
"client_id": "114574435593304532521",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/vertex-ai-client%40codebridge-chatbot.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
1
apikey/google_vertex_api.txt
Normal file
@@ -0,0 +1 @@
|
||||
AIzaSyAS3bAzmXlhhZHgO3buFiTGzavXZ6ubYq8
|
||||
1
apikey/notion.txt
Normal file
@@ -0,0 +1 @@
|
||||
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
|
||||
1
apikey/opendart.txt
Normal file
@@ -0,0 +1 @@
|
||||
ad5002bf2c7d2f93eac3c6f9ff1d4b63bf3027bb
|
||||
138
barobill/eaccount/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 법인카드 사용내역 조회 모듈
|
||||
|
||||
바로빌 API를 이용한 법인카드 사용내역 조회 모듈입니다.
|
||||
|
||||
## 📋 기능
|
||||
|
||||
- 등록된 카드 목록 조회
|
||||
- 기간별/일별/월별 카드 사용내역 조회
|
||||
- 사용금액 통계 (총 사용금액, 사용건수, 취소건수)
|
||||
- 페이지네이션 지원
|
||||
|
||||
## 🔧 설정
|
||||
|
||||
### 1. API 키 설정 (기존 etax 모듈과 공유)
|
||||
|
||||
다음 파일들이 필요합니다 (`/apikey/` 폴더):
|
||||
|
||||
| 파일명 | 설명 | 예시 |
|
||||
|--------|------|------|
|
||||
| `barobill_cert_key.txt` | 바로빌 CERTKEY (인증서 키) | `ABC123...` |
|
||||
| `barobill_corp_num.txt` | 사업자번호 (하이픈 제외) | `6648603713` |
|
||||
| `barobill_test_mode.txt` | 테스트 모드 (선택) | `test` 또는 `true` |
|
||||
|
||||
### 2. 바로빌 카드 등록
|
||||
|
||||
카드 사용내역을 조회하려면 **바로빌 웹사이트**에서 카드를 먼저 등록해야 합니다.
|
||||
|
||||
1. [바로빌](https://www.barobill.co.kr) 로그인
|
||||
2. 카드조회 서비스 신청
|
||||
3. 카드 등록 (카드사 웹 ID/비밀번호 필요)
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
ecard/
|
||||
├── index.php # 메인 UI (React 기반)
|
||||
├── api/
|
||||
│ ├── barobill_card_config.php # 바로빌 카드 API 설정
|
||||
│ ├── cards.php # 등록된 카드 목록 API
|
||||
│ └── usage.php # 카드 사용내역 조회 API
|
||||
└── README.md # 이 문서
|
||||
```
|
||||
|
||||
## 🔌 API 엔드포인트
|
||||
|
||||
### 카드 목록 조회
|
||||
```
|
||||
GET /ecard/api/cards.php
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"cards": [
|
||||
{
|
||||
"cardNum": "1234-****-****-5678",
|
||||
"cardCompany": "02",
|
||||
"cardCompanyName": "KB국민",
|
||||
"alias": "법인카드1",
|
||||
"status": "1",
|
||||
"statusName": "정상"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 사용내역 조회
|
||||
```
|
||||
GET /ecard/api/usage.php?type=period&startDate=20241101&endDate=20241130
|
||||
```
|
||||
|
||||
**파라미터:**
|
||||
| 파라미터 | 설명 | 기본값 |
|
||||
|---------|------|--------|
|
||||
| `type` | 조회 타입 (period/daily/monthly) | `period` |
|
||||
| `cardNum` | 카드번호 (빈값=전체) | - |
|
||||
| `startDate` | 시작일 (YYYYMMDD) - period용 | 30일 전 |
|
||||
| `endDate` | 종료일 (YYYYMMDD) - period용 | 오늘 |
|
||||
| `baseDate` | 기준일 (YYYYMMDD) - daily용 | 오늘 |
|
||||
| `baseMonth` | 기준월 (YYYYMM) - monthly용 | 이번달 |
|
||||
| `page` | 페이지 번호 | `1` |
|
||||
| `limit` | 페이지당 건수 | `50` |
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"logs": [
|
||||
{
|
||||
"cardNum": "1234-****-****-5678",
|
||||
"approvalNum": "12345678",
|
||||
"approvalDate": "2024-11-15",
|
||||
"approvalTime": "14:30:25",
|
||||
"merchantName": "스타벅스 강남점",
|
||||
"amount": 5000,
|
||||
"totalAmountFormatted": "5,000",
|
||||
"approvalTypeName": "승인",
|
||||
"installmentName": "일시불"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 1,
|
||||
"countPerPage": 50,
|
||||
"maxPageNum": 1,
|
||||
"totalCount": 15
|
||||
},
|
||||
"summary": {
|
||||
"totalAmount": 150000,
|
||||
"count": 15,
|
||||
"approvalCount": 14,
|
||||
"cancelCount": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 UI 기능
|
||||
|
||||
- **카드 선택**: 특정 카드 또는 전체 카드 조회
|
||||
- **기간 설정**: 날짜 범위 직접 선택 또는 빠른 선택 (오늘, 7일, 30일, 3개월, 6개월)
|
||||
- **통계 대시보드**: 총 사용금액, 사용건수, 취소건수 표시
|
||||
- **사용내역 테이블**: 승인일시, 가맹점명, 금액, 할부, 승인/취소 구분
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. 바로빌 카드조회 서비스는 **유료 서비스**입니다.
|
||||
2. 카드 등록 시 **카드사 웹 ID/비밀번호**가 필요합니다.
|
||||
3. 카드사에서 데이터를 수집하므로 **실시간 조회가 아닐 수 있습니다** (보통 1일 1회 수집).
|
||||
4. 테스트 환경에서는 실제 데이터가 아닌 테스트 데이터가 조회됩니다.
|
||||
|
||||
## 🔗 참고 문서
|
||||
|
||||
- [바로빌 카드조회 API 레퍼런스](https://dev.barobill.co.kr/docs/references/카드조회-API)
|
||||
- [바로빌 개발자센터](https://dev.barobill.co.kr)
|
||||
|
||||
340
barobill/eaccount/api/account_status.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
/**
|
||||
* 계좌 등록 상태 조회 API
|
||||
* 로컬 DB의 company_accounts 테이블과 바로빌 API에서 계좌 정보를 가져옵니다.
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
try {
|
||||
// 1. 로컬 DB의 company_accounts 테이블에서 계좌 정보 가져오기
|
||||
$localAccounts = [];
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
|
||||
if ($selectedTenantId) {
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if ($pdo) {
|
||||
$sql = "SELECT id, company_id, bank_code, account_num, account_pwd
|
||||
FROM {$DB}.company_accounts
|
||||
WHERE company_id = ?
|
||||
ORDER BY id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$selectedTenantId]);
|
||||
$localAccounts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('로컬 계좌 정보 로드 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 바로빌 API에서 계좌 정보 가져오기 (시도)
|
||||
$barobillAccounts = [];
|
||||
$barobillError = null;
|
||||
$barobillErrorCode = null;
|
||||
$debugInfo = null;
|
||||
|
||||
$result = callBarobillAccountSOAP('GetBankAccountEx', [
|
||||
'AvailOnly' => 0 // 전체 계좌 조회 (0: 전체, 1: 사용가능, 2: 해지)
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
$data = $result['data'];
|
||||
$accountList = [];
|
||||
|
||||
// 디버그: 응답 구조 확인
|
||||
$debugInfo = [
|
||||
'data_type' => gettype($data),
|
||||
'data_keys' => is_object($data) ? array_keys(get_object_vars($data)) : [],
|
||||
'has_BankAccountEx' => isset($data->BankAccountEx),
|
||||
'has_BankAccount' => isset($data->BankAccount),
|
||||
'BankAccountEx_type' => isset($data->BankAccountEx) ? gettype($data->BankAccountEx) : 'N/A',
|
||||
'BankAccount_type' => isset($data->BankAccount) ? gettype($data->BankAccount) : 'N/A',
|
||||
'BankAccountEx_is_array' => isset($data->BankAccountEx) ? is_array($data->BankAccountEx) : false,
|
||||
'BankAccount_is_array' => isset($data->BankAccount) ? is_array($data->BankAccount) : false
|
||||
];
|
||||
|
||||
// 실제 SOAP 응답 구조 확인:
|
||||
// GetBankAccountExResult -> BankAccount (단일 객체 또는 배열)
|
||||
// 또는 BankAccountEx (배열) - 다른 API 버전일 수 있음
|
||||
|
||||
// 우선순위 1: BankAccount 확인 (실제 응답 구조 - SOAP XML에서 확인됨)
|
||||
if (isset($data->BankAccount)) {
|
||||
if (is_array($data->BankAccount)) {
|
||||
$accountList = $data->BankAccount;
|
||||
} else if (is_object($data->BankAccount)) {
|
||||
// 단일 객체인 경우 배열로 변환
|
||||
$accountList = [$data->BankAccount];
|
||||
}
|
||||
}
|
||||
// 우선순위 2: BankAccountEx 배열 확인 (다른 API 버전)
|
||||
else if (isset($data->BankAccountEx)) {
|
||||
// 단일 객체가 에러 코드인 경우 (예: -10002, -25001)
|
||||
if (is_numeric($data->BankAccountEx) && $data->BankAccountEx < 0) {
|
||||
$errorCode = $data->BankAccountEx;
|
||||
$barobillError = '바로빌 API 오류: ' . $errorCode;
|
||||
$barobillErrorCode = $errorCode;
|
||||
|
||||
// 상세 에러 메시지 매핑
|
||||
if ($errorCode == -10002) {
|
||||
$barobillError = '인증 실패 (-10002). CERTKEY 또는 사업자번호를 확인해주세요.';
|
||||
} else if ($errorCode == -25001) {
|
||||
$barobillError = '등록된 계좌가 없습니다 (-25001). 바로빌 사이트에서 계좌를 등록해주세요.';
|
||||
} else if ($errorCode == -50214) {
|
||||
$barobillError = '은행 로그인 실패 (-50214). 바로빌 사이트에서 계좌 비밀번호/인증서를 점검해주세요.';
|
||||
}
|
||||
}
|
||||
// 배열 또는 객체인 경우
|
||||
else if (is_array($data->BankAccountEx)) {
|
||||
$accountList = $data->BankAccountEx;
|
||||
} else if (is_object($data->BankAccountEx)) {
|
||||
// 단일 객체인 경우 배열로 변환
|
||||
$accountList = [$data->BankAccountEx];
|
||||
}
|
||||
}
|
||||
// 방법 3: 직접 BankAccount 속성 확인 (다른 구조일 수 있음)
|
||||
else {
|
||||
// 객체의 모든 속성을 확인하여 BankAccount 관련 속성 찾기
|
||||
if (is_object($data)) {
|
||||
$vars = get_object_vars($data);
|
||||
foreach ($vars as $key => $value) {
|
||||
// BankAccount로 시작하는 속성 찾기
|
||||
if (stripos($key, 'BankAccount') !== false) {
|
||||
if (is_array($value)) {
|
||||
$accountList = $value;
|
||||
break;
|
||||
} else if (is_object($value)) {
|
||||
$accountList = [$value];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 계좌 정보 파싱
|
||||
foreach ($accountList as $acc) {
|
||||
// 객체가 아닌 경우 스킵
|
||||
if (!is_object($acc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 에러 코드 체크 (개별 계좌 레벨)
|
||||
if (isset($acc->BankAccountNum)) {
|
||||
// BankAccountNum이 음수인 경우 에러 코드
|
||||
if (is_numeric($acc->BankAccountNum) && $acc->BankAccountNum < 0) {
|
||||
$errorCode = $acc->BankAccountNum;
|
||||
if (!$barobillError) {
|
||||
$barobillError = '바로빌 API 오류: ' . $errorCode;
|
||||
$barobillErrorCode = $errorCode;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// BankAccountNum이 비어있는 경우도 스킵
|
||||
if (empty($acc->BankAccountNum)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// BankAccountNum이 없는 경우도 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// BankName으로 BankCode 추론 (응답에 BankCode가 없는 경우)
|
||||
$bankCode = $acc->BankCode ?? '';
|
||||
if (empty($bankCode) && isset($acc->BankName)) {
|
||||
// BankName으로 BankCode 찾기
|
||||
$bankName = $acc->BankName;
|
||||
$bankCodeMap = [
|
||||
'기업은행' => '003',
|
||||
'IBK기업은행' => '003',
|
||||
'KB국민은행' => '004',
|
||||
'국민은행' => '004',
|
||||
'우리은행' => '020',
|
||||
'신한은행' => '088',
|
||||
'하나은행' => '081',
|
||||
'NH농협은행' => '011',
|
||||
'농협은행' => '011'
|
||||
];
|
||||
$bankCode = $bankCodeMap[$bankName] ?? '';
|
||||
}
|
||||
|
||||
// UseState 처리: 없으면 기본값 1 (사용중)으로 설정
|
||||
// UseState: 1=사용중, 0=중지, 2=해지
|
||||
$useState = isset($acc->UseState) ? intval($acc->UseState) : 1; // 기본값: 사용중
|
||||
|
||||
$barobillAccounts[] = [
|
||||
'bankAccountNum' => $acc->BankAccountNum ?? '',
|
||||
'bankCode' => $bankCode,
|
||||
'bankName' => getBankName($bankCode) ?: ($acc->BankName ?? ''),
|
||||
'accountName' => $acc->AccountName ?? '',
|
||||
'accountType' => $acc->AccountType ?? '',
|
||||
'currency' => $acc->Currency ?? 'KRW',
|
||||
'issueDate' => $acc->IssueDate ?? '',
|
||||
'balance' => $acc->Balance ?? 0,
|
||||
'status' => $useState,
|
||||
'statusText' => $useState == 1 ? '사용중' : ($useState == 0 ? '중지' : ($useState == 2 ? '해지' : '알 수 없음')),
|
||||
'source' => 'barobill_api' // 바로빌 API에서 가져온 정보
|
||||
];
|
||||
}
|
||||
|
||||
// 디버그 정보 추가
|
||||
$debugInfo['account_count'] = count($barobillAccounts);
|
||||
$debugInfo['account_list'] = array_map(function($acc) {
|
||||
return [
|
||||
'bankAccountNum' => $acc['bankAccountNum'],
|
||||
'bankCode' => $acc['bankCode'],
|
||||
'accountName' => $acc['accountName']
|
||||
];
|
||||
}, $barobillAccounts);
|
||||
|
||||
} else {
|
||||
$barobillError = $result['error'];
|
||||
$barobillErrorCode = $result['error_code'] ?? null;
|
||||
}
|
||||
|
||||
// 3. 로컬 DB 계좌 정보를 바로빌 계좌 정보와 매칭하여 통합
|
||||
$allAccounts = [];
|
||||
|
||||
// 먼저 바로빌 API 계좌 정보를 기준으로 추가 (실제 사용 가능한 계좌)
|
||||
foreach ($barobillAccounts as $barobillAcc) {
|
||||
$allAccounts[] = $barobillAcc;
|
||||
}
|
||||
|
||||
// 로컬 DB 계좌 정보를 바로빌 API 계좌와 매칭
|
||||
foreach ($localAccounts as $localAcc) {
|
||||
$matched = false;
|
||||
// 바로빌 API에 같은 계좌번호가 있는지 확인
|
||||
foreach ($allAccounts as &$existingAcc) {
|
||||
// 계좌번호 매칭 (하이픈 제거 후 비교)
|
||||
$localAccountNum = str_replace('-', '', $localAcc['account_num']);
|
||||
$barobillAccountNum = str_replace('-', '', $existingAcc['bankAccountNum']);
|
||||
|
||||
if ($localAccountNum === $barobillAccountNum) {
|
||||
// 바로빌 API 계좌 정보에 로컬 DB 정보 병합
|
||||
$existingAcc['id'] = $localAcc['id'];
|
||||
$existingAcc['hasPassword'] = !empty($localAcc['account_pwd']);
|
||||
$existingAcc['source'] = 'both'; // 양쪽 모두에 있음 (실제 사용 가능)
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 바로빌 API에 없는 로컬 계좌는 경고와 함께 추가
|
||||
if (!$matched) {
|
||||
$isApiError = !empty($barobillError);
|
||||
$statusText = $isApiError ? '상태 확인 불가' : '바로빌 미등록';
|
||||
$sourceText = $isApiError ? 'barobill_api_error' : 'local_db_only';
|
||||
|
||||
$allAccounts[] = [
|
||||
'id' => $localAcc['id'],
|
||||
'bankAccountNum' => $localAcc['account_num'],
|
||||
'bankCode' => $localAcc['bank_code'],
|
||||
'bankName' => getBankName($localAcc['bank_code']),
|
||||
'accountName' => '', // 로컬 DB에는 별칭 정보 없음
|
||||
'accountType' => '',
|
||||
'currency' => 'KRW',
|
||||
'issueDate' => '',
|
||||
'balance' => 0,
|
||||
'status' => '',
|
||||
'statusText' => $statusText,
|
||||
'source' => $sourceText, // 상태 확인 필요
|
||||
'hasPassword' => !empty($localAcc['account_pwd']),
|
||||
'warning' => true, // 경고 표시용
|
||||
'api_error' => $isApiError // 프론트엔드에서 구분하기 위함
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 사용 가능한 계좌 수 계산 (바로빌 API에서 확인된 계좌)
|
||||
$availableAccounts = array_filter($allAccounts, function($acc) {
|
||||
return $acc['source'] === 'barobill_api' || $acc['source'] === 'both';
|
||||
});
|
||||
$availableCount = count($availableAccounts);
|
||||
|
||||
// 경고가 필요한 계좌 수 (로컬에만 있는 계좌)
|
||||
$warningAccounts = array_filter($allAccounts, function($acc) {
|
||||
return isset($acc['warning']) && $acc['warning'] === true;
|
||||
});
|
||||
$warningCount = count($warningAccounts);
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'accounts' => $allAccounts,
|
||||
'count' => count($allAccounts),
|
||||
'available_count' => $availableCount, // 바로빌 API에서 확인된 사용 가능한 계좌 수
|
||||
'warning_count' => $warningCount, // 로컬에만 있는 계좌 수
|
||||
'local_count' => count($localAccounts),
|
||||
'barobill_count' => count($barobillAccounts),
|
||||
'message' => $availableCount > 0
|
||||
? '사용 가능한 계좌가 ' . $availableCount . '개 있습니다.' . ($warningCount > 0 ? ' (바로빌 미등록 계좌 ' . $warningCount . '개)' : '')
|
||||
: '사용 가능한 계좌가 없습니다.' . ($warningCount > 0 ? ' (로컬에만 등록된 계좌 ' . $warningCount . '개는 바로빌 API에 등록이 필요합니다)' : '')
|
||||
];
|
||||
|
||||
// 바로빌 API 오류 정보 추가
|
||||
if ($barobillError) {
|
||||
$response['barobill_error'] = $barobillError;
|
||||
$response['barobill_error_code'] = $barobillErrorCode;
|
||||
}
|
||||
|
||||
// 디버그 정보 추가
|
||||
if (isset($result['debug'])) {
|
||||
$response['debug'] = $result['debug'];
|
||||
}
|
||||
|
||||
// API 응답 구조 디버그 정보 추가
|
||||
if (isset($debugInfo)) {
|
||||
$response['api_debug'] = $debugInfo;
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage(),
|
||||
'accounts' => [],
|
||||
'count' => 0
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 코드 -> 은행명 변환
|
||||
*/
|
||||
function getBankName($code) {
|
||||
$banks = [
|
||||
'002' => 'KDB산업은행',
|
||||
'003' => 'IBK기업은행',
|
||||
'004' => 'KB국민은행',
|
||||
'007' => '수협은행',
|
||||
'011' => 'NH농협은행',
|
||||
'012' => '지역농축협',
|
||||
'020' => '우리은행',
|
||||
'023' => 'SC제일은행',
|
||||
'027' => '한국씨티은행',
|
||||
'031' => '대구은행',
|
||||
'032' => '부산은행',
|
||||
'034' => '광주은행',
|
||||
'035' => '제주은행',
|
||||
'037' => '전북은행',
|
||||
'039' => '경남은행',
|
||||
'045' => '새마을금고',
|
||||
'048' => '신협',
|
||||
'050' => '저축은행',
|
||||
'064' => '산림조합',
|
||||
'071' => '우체국',
|
||||
'081' => '하나은행',
|
||||
'088' => '신한은행',
|
||||
'089' => 'K뱅크',
|
||||
'090' => '카카오뱅크',
|
||||
'092' => '토스뱅크'
|
||||
];
|
||||
return $banks[$code] ?? $code;
|
||||
}
|
||||
?>
|
||||
|
||||
245
barobill/eaccount/api/accounts.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
/**
|
||||
* 등록된 계좌 목록 조회 API (GetBankAccountEx)
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
|
||||
try {
|
||||
// 0: 전체, 1: 사용가능, 2: 해지
|
||||
$availOnly = isset($_GET['availOnly']) ? intval($_GET['availOnly']) : 0;
|
||||
|
||||
// GetBankAccountEx 호출
|
||||
$result = callBarobillAccountSOAP('GetBankAccountEx', [
|
||||
'AvailOnly' => $availOnly
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
$accounts = [];
|
||||
$data = $result['data'];
|
||||
|
||||
// 에러 코드 체크 (전체 응답 레벨)
|
||||
if (isset($data->BankAccountEx)) {
|
||||
// 단일 객체가 에러 코드인 경우
|
||||
if (is_numeric($data->BankAccountEx) && $data->BankAccountEx < 0) {
|
||||
$errorCode = $data->BankAccountEx;
|
||||
$errorMsg = '계좌 목록 조회 실패: ' . $errorCode;
|
||||
|
||||
// 상세 에러 메시지 매핑
|
||||
if ($errorCode == -50214) {
|
||||
$errorMsg = '은행 로그인 실패 (-50214). 바로빌 사이트에서 계좌 비밀번호/인증서를 점검해주세요.';
|
||||
} else if ($errorCode == -24005) {
|
||||
$errorMsg = '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.';
|
||||
} else if ($errorCode == -25001) {
|
||||
$errorMsg = '등록된 계좌가 없습니다 (-25001). 바로빌 사이트에서 계좌를 등록해주세요.';
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
'error_code' => $errorCode
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 실제 SOAP 응답 구조 확인:
|
||||
// GetBankAccountExResult -> BankAccount (단일 객체 또는 배열)
|
||||
// 또는 BankAccountEx (배열) - 다른 API 버전일 수 있음
|
||||
|
||||
$accountList = [];
|
||||
|
||||
// 우선순위 1: BankAccount 확인 (실제 응답 구조)
|
||||
if (isset($data->BankAccount)) {
|
||||
if (is_array($data->BankAccount)) {
|
||||
$accountList = $data->BankAccount;
|
||||
} else if (is_object($data->BankAccount)) {
|
||||
// 단일 객체인 경우 배열로 변환
|
||||
$accountList = [$data->BankAccount];
|
||||
}
|
||||
}
|
||||
// 우선순위 2: BankAccountEx 배열 확인 (다른 API 버전)
|
||||
else if (isset($data->BankAccountEx)) {
|
||||
if (is_array($data->BankAccountEx)) {
|
||||
$accountList = $data->BankAccountEx;
|
||||
} else if (is_object($data->BankAccountEx)) {
|
||||
// 단일 객체인 경우 배열로 변환
|
||||
$accountList = [$data->BankAccountEx];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($accountList as $acc) {
|
||||
// 객체가 아닌 경우 스킵
|
||||
if (!is_object($acc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 에러 코드 체크 (개별 계좌 레벨)
|
||||
if (isset($acc->BankAccountNum)) {
|
||||
// BankAccountNum이 음수인 경우 에러 코드
|
||||
if (is_numeric($acc->BankAccountNum) && $acc->BankAccountNum < 0) {
|
||||
continue;
|
||||
}
|
||||
// BankAccountNum이 비어있는 경우도 스킵
|
||||
if (empty($acc->BankAccountNum)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// BankAccountNum이 없는 경우도 스킵
|
||||
continue;
|
||||
}
|
||||
|
||||
// BankName으로 BankCode 추론 (응답에 BankCode가 없는 경우)
|
||||
$bankCode = $acc->BankCode ?? '';
|
||||
if (empty($bankCode) && isset($acc->BankName)) {
|
||||
// BankName으로 BankCode 찾기
|
||||
$bankName = $acc->BankName;
|
||||
$bankCodeMap = [
|
||||
'기업은행' => '003',
|
||||
'IBK기업은행' => '003',
|
||||
'KB국민은행' => '004',
|
||||
'국민은행' => '004',
|
||||
'우리은행' => '020',
|
||||
'신한은행' => '088',
|
||||
'하나은행' => '081',
|
||||
'NH농협은행' => '011',
|
||||
'농협은행' => '011'
|
||||
];
|
||||
$bankCode = $bankCodeMap[$bankName] ?? '';
|
||||
}
|
||||
|
||||
// UseState 처리: 없으면 기본값 1 (사용중)으로 설정
|
||||
$useState = isset($acc->UseState) ? intval($acc->UseState) : 1;
|
||||
|
||||
$accounts[] = [
|
||||
'bankAccountNum' => $acc->BankAccountNum ?? '',
|
||||
'bankCode' => $bankCode,
|
||||
'bankName' => getBankName($bankCode) ?: ($acc->BankName ?? ''),
|
||||
'accountName' => $acc->AccountName ?? '', // 계좌 별칭/이름
|
||||
'accountType' => $acc->AccountType ?? '', // 1:입출금, 2:예적금
|
||||
'currency' => $acc->Currency ?? 'KRW',
|
||||
'issueDate' => $acc->IssueDate ?? '',
|
||||
'balance' => $acc->Balance ?? 0,
|
||||
'status' => $useState // 1:사용, 0:중지, 2:해지
|
||||
];
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'accounts' => $accounts,
|
||||
'count' => count($accounts)
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
if (isset($result['debug'])) {
|
||||
$response['debug'] = $result['debug'];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// API 호출 실패 시 (예: SoapClient 미설치, 통신 등) 로컬 DB에서 조회
|
||||
error_log('바로빌 API 호출 실패, 로컬 DB 조회 시도: ' . $result['error']);
|
||||
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$accounts = [];
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
|
||||
if ($selectedTenantId) {
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if ($pdo) {
|
||||
// 로컬 DB에서 계좌 정보 조회
|
||||
$sql = "SELECT id, company_id, bank_code, account_num, account_pwd
|
||||
FROM {$DB}.company_accounts
|
||||
WHERE company_id = ?
|
||||
ORDER BY id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$selectedTenantId]);
|
||||
$localAccounts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($localAccounts as $acc) {
|
||||
// 은행명 변환
|
||||
$bankName = getBankName($acc['bank_code']);
|
||||
|
||||
$accounts[] = [
|
||||
'bankAccountNum' => $acc['account_num'],
|
||||
'bankCode' => $acc['bank_code'],
|
||||
'bankName' => $bankName,
|
||||
'accountName' => $bankName . ' ' . $acc['account_num'],
|
||||
'accountType' => '', // 로컬 정보 없음
|
||||
'currency' => 'KRW',
|
||||
'issueDate' => '',
|
||||
'balance' => 0, // 잔액 정보 없음
|
||||
'status' => 1, // 기본값: 사용중
|
||||
'source' => 'local_db_fallback',
|
||||
'error_message' => 'API 연동 실패로 로컬 데이터 표시'
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Exception $dbEx) {
|
||||
error_log('로컬 DB 조회 실패: ' . $dbEx->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 데이터가 있으면 성공으로 masquerade
|
||||
if (!empty($accounts)) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'accounts' => $accounts,
|
||||
'count' => count($accounts),
|
||||
'message' => '바로빌 API 연동에 실패하여 로컬 저장된 계좌 목록을 표시합니다.',
|
||||
'api_error' => $result['error']
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// 로컬 데이터도 없으면 에러 리턴
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 코드 -> 은행명 변환
|
||||
*/
|
||||
function getBankName($code) {
|
||||
$banks = [
|
||||
'002' => 'KDB산업은행',
|
||||
'003' => 'IBK기업은행',
|
||||
'004' => 'KB국민은행',
|
||||
'007' => '수협은행',
|
||||
'011' => 'NH농협은행',
|
||||
'012' => '지역농축협',
|
||||
'020' => '우리은행',
|
||||
'023' => 'SC제일은행',
|
||||
'027' => '한국씨티은행',
|
||||
'031' => '대구은행',
|
||||
'032' => '부산은행',
|
||||
'034' => '광주은행',
|
||||
'035' => '제주은행',
|
||||
'037' => '전북은행',
|
||||
'039' => '경남은행',
|
||||
'045' => '새마을금고',
|
||||
'048' => '신협',
|
||||
'050' => '저축은행',
|
||||
'064' => '산림조합',
|
||||
'071' => '우체국',
|
||||
'081' => '하나은행',
|
||||
'088' => '신한은행',
|
||||
'089' => 'K뱅크',
|
||||
'090' => '카카오뱅크',
|
||||
'092' => '토스뱅크'
|
||||
];
|
||||
return $banks[$code] ?? $code;
|
||||
}
|
||||
?>
|
||||
366
barobill/eaccount/api/barobill_account_config.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 계좌 API 설정 파일
|
||||
*
|
||||
* ⚠️ 중요: 바로빌은 SOAP 웹서비스를 사용합니다 (REST API가 아님)
|
||||
*
|
||||
* 계좌 입출금내역 조회를 위해서는 바로빌 웹사이트(https://www.barobill.co.kr)에서
|
||||
* 계좌를 먼저 등록해야 합니다.
|
||||
*
|
||||
* 설정 파일:
|
||||
* 1. apikey/barobill_cert_key.txt - CERTKEY (인증서 키)
|
||||
* 2. apikey/barobill_corp_num.txt - 사업자번호
|
||||
* 3. apikey/barobill_test_mode.txt - 테스트 모드 설정 (선택)
|
||||
*/
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
// load .env file
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
$certKeyFile = $documentRoot . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $documentRoot . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $documentRoot . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $documentRoot . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기
|
||||
$barobillCertKey = '';
|
||||
if (file_exists($certKeyFile)) {
|
||||
$content = trim(file_get_contents($certKeyFile));
|
||||
|
||||
// 설명 텍스트 필터링: 실제 CERTKEY만 추출
|
||||
// 설명 텍스트 패턴 체크
|
||||
$isPlaceholder = false;
|
||||
$placeholderPatterns = [
|
||||
'/^\[여기에/', // [여기에로 시작
|
||||
'/^=/', // =로 시작
|
||||
'/바로빌 CERTKEY/', // '바로빌 CERTKEY' 문자열 포함
|
||||
'/================================/', // 구분선 포함
|
||||
'/설정 방법:/', // '설정 방법:' 포함
|
||||
'/인증서 관리/', // '인증서 관리' 포함
|
||||
'/개발자센터/', // '개발자센터' 포함
|
||||
'/⚠️/', // 경고 이모지 포함
|
||||
'/참고:/', // '참고:' 포함
|
||||
];
|
||||
|
||||
foreach ($placeholderPatterns as $pattern) {
|
||||
if (preg_match($pattern, $content)) {
|
||||
$isPlaceholder = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 실제 CERTKEY는 보통 20자 이상의 영문/숫자 조합
|
||||
// 설명 텍스트가 아니고, 충분히 긴 경우에만 CERTKEY로 인식
|
||||
if (!empty($content) && !$isPlaceholder && strlen($content) >= 10) {
|
||||
// 추가 검증: 실제 CERTKEY는 보통 영문/숫자/하이픈 조합
|
||||
// 설명 텍스트는 한글이나 특수문자가 많음
|
||||
$koreanCharCount = preg_match_all('/[가-힣]/u', $content);
|
||||
$totalCharCount = mb_strlen($content, 'UTF-8');
|
||||
|
||||
// 한글 비율이 10% 미만이고, 길이가 적절하면 CERTKEY로 인식
|
||||
if ($koreanCharCount / max($totalCharCount, 1) < 0.1) {
|
||||
$barobillCertKey = $content;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($barobillCertKey) && file_exists($legacyApiKeyFile)) {
|
||||
$barobillCertKey = trim(file_get_contents($legacyApiKeyFile));
|
||||
}
|
||||
|
||||
// 사업자번호 읽기
|
||||
$barobillCorpNum = '';
|
||||
if (file_exists($corpNumFile)) {
|
||||
$content = trim(file_get_contents($corpNumFile));
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillCorpNum = str_replace('-', '', $content);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 모드 확인
|
||||
$isTestMode = false;
|
||||
if (file_exists($testModeFile)) {
|
||||
$testMode = trim(file_get_contents($testModeFile));
|
||||
$isTestMode = (strtolower($testMode) === 'test' || strtolower($testMode) === 'true');
|
||||
}
|
||||
|
||||
// 바로빌 사용자 ID (계좌 사용내역 조회에 필요)
|
||||
// 빈 값이면 전체 계좌 조회, 특정 사용자만 조회하려면 사용자 ID 입력
|
||||
$barobillUserIdFile = getenv('DOCUMENT_ROOT') . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserId = '';
|
||||
if (file_exists($barobillUserIdFile)) {
|
||||
$content = trim(file_get_contents($barobillUserIdFile));
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillUserId = $content;
|
||||
}
|
||||
}
|
||||
|
||||
// 테넌트별 설정 (DB에서 가져오기)
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
|
||||
// DB에서 테넌트 정보 가져오기
|
||||
if ($selectedTenantId) {
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if ($pdo) {
|
||||
$sql = "SELECT id, company_name, corp_num, barobill_user_id
|
||||
FROM {$DB}.barobill_companies
|
||||
WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$selectedTenantId]);
|
||||
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($tenant) {
|
||||
$barobillUserId = $tenant['barobill_user_id'];
|
||||
$barobillCorpNum = $tenant['corp_num'];
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('테넌트 정보 로드 실패: ' . $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
// 세션에 테넌트 ID가 없으면 '(주)주일기업'을 기본값으로 찾기
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if ($pdo) {
|
||||
// '(주)주일기업' 또는 barobill_user_id가 'juil5130'인 회사 찾기
|
||||
$sql = "SELECT id, company_name, corp_num, barobill_user_id
|
||||
FROM {$DB}.barobill_companies
|
||||
WHERE company_name LIKE '%주일기업%'
|
||||
OR company_name LIKE '%주일%'
|
||||
OR barobill_user_id = 'juil5130'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->query($sql);
|
||||
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($tenant) {
|
||||
$barobillUserId = $tenant['barobill_user_id'];
|
||||
$barobillCorpNum = $tenant['corp_num'];
|
||||
$selectedTenantId = $tenant['id'];
|
||||
// 세션에 저장
|
||||
$_SESSION['eaccount_tenant_id'] = $selectedTenantId;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('기본 테넌트 정보 로드 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 사용자 ID 반환
|
||||
*/
|
||||
function getBarobillUserId() {
|
||||
global $barobillUserId;
|
||||
return $barobillUserId;
|
||||
}
|
||||
|
||||
// 바로빌 계좌 SOAP 웹서비스 URL (BANK.asmx)
|
||||
// 바로빌 계좌 SOAP 웹서비스 URL (BANKACCOUNT.asmx)
|
||||
$barobillAccountSoapUrl = $isTestMode
|
||||
? 'https://testws.baroservice.com/BANKACCOUNT.asmx?WSDL' // 테스트 환경
|
||||
: 'https://ws.baroservice.com/BANKACCOUNT.asmx?WSDL'; // 운영 환경
|
||||
|
||||
// SOAP 클라이언트 초기화
|
||||
$barobillAccountSoapClient = null;
|
||||
$barobillInitError = '';
|
||||
|
||||
if (!empty($barobillCertKey) || $isTestMode) {
|
||||
try {
|
||||
// SSL 검증 비활성화 및 타임아웃 설정
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
]
|
||||
]);
|
||||
|
||||
$barobillAccountSoapClient = new SoapClient($barobillAccountSoapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE // WSDL 캐시 비활성화
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$barobillInitError = $e->getMessage();
|
||||
error_log('바로빌 계좌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 계좌 SOAP 웹서비스 호출 함수
|
||||
*
|
||||
* @param string $method SOAP 메서드명
|
||||
* @param array $params SOAP 메서드 파라미터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function callBarobillAccountSOAP($method, $params = []) {
|
||||
global $barobillAccountSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode, $barobillInitError, $barobillAccountSoapUrl;
|
||||
|
||||
if (!$barobillAccountSoapClient) {
|
||||
$errorMsg = $isTestMode
|
||||
? '바로빌 계좌 SOAP 클라이언트가 초기화되지 않았습니다. (' . ($barobillInitError ?: '알 수 없는 오류') . ')'
|
||||
: '바로빌 계좌 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요. (' . ($barobillInitError ?: '알 수 없는 오류') . ')';
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
'error_detail' => [
|
||||
'cert_key_file' => getenv('DOCUMENT_ROOT') . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $barobillAccountSoapUrl,
|
||||
'init_error' => $barobillInitError,
|
||||
'test_mode' => $isTestMode
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($barobillCertKey) && !$isTestMode) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'CERTKEY가 설정되지 않았습니다. apikey/barobill_cert_key.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($barobillCorpNum)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '사업자번호가 설정되지 않았습니다. apikey/barobill_corp_num.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
// CERTKEY와 CorpNum 자동 추가
|
||||
// 테스트 모드에서도 CERTKEY가 있으면 사용 (일부 API는 테스트 모드에서도 CERTKEY 필요)
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
if ($isTestMode) {
|
||||
// 테스트 모드: CERTKEY가 있으면 사용, 없으면 빈 값
|
||||
// 주의: 일부 API는 테스트 모드에서도 CERTKEY가 필요할 수 있음
|
||||
$params['CERTKEY'] = !empty($barobillCertKey) ? $barobillCertKey : '';
|
||||
} else {
|
||||
// 운영 모드: CERTKEY 필수
|
||||
$params['CERTKEY'] = $barobillCertKey;
|
||||
}
|
||||
}
|
||||
if (!isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $barobillCorpNum;
|
||||
}
|
||||
|
||||
try {
|
||||
error_log('바로빌 계좌 API 호출 - Method: ' . $method . ', CorpNum: ' . $barobillCorpNum);
|
||||
|
||||
// SOAP 요청 로그 수집 (CERTKEY는 마스킹)
|
||||
$logParams = $params;
|
||||
if (isset($logParams['CERTKEY'])) {
|
||||
$logParams['CERTKEY'] = substr($logParams['CERTKEY'], 0, 8) . '...' . substr($logParams['CERTKEY'], -4);
|
||||
}
|
||||
|
||||
$soapRequest = [
|
||||
'method' => $method,
|
||||
'url' => $barobillAccountSoapUrl,
|
||||
'params' => $logParams,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $barobillAccountSoapClient->$method($params);
|
||||
|
||||
// SOAP 요청/응답 XML 로그 수집
|
||||
$soapRequestXml = $barobillAccountSoapClient->__getLastRequest();
|
||||
$soapResponseXml = $barobillAccountSoapClient->__getLastResponse();
|
||||
|
||||
$resultProperty = $method . 'Result';
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
// 에러 코드 체크 (음수 값 또는 객체 내부의 음수 값)
|
||||
$errorCode = null;
|
||||
|
||||
// 직접 숫자로 반환된 경우
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
$errorCode = $resultData;
|
||||
}
|
||||
// 객체 내부에 BankAccountNum이 음수인 경우 (예: -10002)
|
||||
elseif (is_object($resultData)) {
|
||||
if (isset($resultData->BankAccountNum) && is_numeric($resultData->BankAccountNum) && $resultData->BankAccountNum < 0) {
|
||||
$errorCode = $resultData->BankAccountNum;
|
||||
}
|
||||
// 다른 필드에서도 음수 값 체크
|
||||
foreach (get_object_vars($resultData) as $key => $value) {
|
||||
if (is_numeric($value) && $value < 0 && ($key == 'CurrentPage' || $key == 'ErrorCode' || $key == 'ResultCode')) {
|
||||
$errorCode = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errorCode !== null) {
|
||||
$errorMsg = '바로빌 계좌 API 오류 코드: ' . $errorCode;
|
||||
|
||||
// 상세 에러 메시지 매핑
|
||||
$errorMessages = [
|
||||
-10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다. 바로빌 개발자센터에서 CERTKEY를 확인하세요.',
|
||||
-50214 => '은행 로그인 실패 (-50214). 바로빌 사이트에서 계좌 비밀번호/인증서를 점검해주세요.',
|
||||
-24005 => '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.',
|
||||
-25001 => '등록된 계좌가 없습니다 (-25001). 바로빌 사이트에서 계좌를 등록해주세요.',
|
||||
-25005 => '조회된 데이터가 없습니다 (-25005).',
|
||||
-25006 => '계좌번호가 잘못되었습니다 (-25006).',
|
||||
-25007 => '조회 기간이 잘못되었습니다 (-25007).',
|
||||
];
|
||||
|
||||
if (isset($errorMessages[$errorCode])) {
|
||||
$errorMsg = $errorMessages[$errorCode];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
'error_code' => $errorCode,
|
||||
'debug' => [
|
||||
'request' => $soapRequest,
|
||||
'request_xml' => $soapRequestXml,
|
||||
'response_xml' => $soapResponseXml
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData,
|
||||
'debug' => [
|
||||
'request' => $soapRequest,
|
||||
'request_xml' => $soapRequestXml,
|
||||
'response_xml' => $soapResponseXml
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'debug' => [
|
||||
'request' => $soapRequest,
|
||||
'request_xml' => $soapRequestXml,
|
||||
'response_xml' => $soapResponseXml
|
||||
]
|
||||
];
|
||||
|
||||
} catch (SoapFault $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류 (치명적): ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
?>
|
||||
404
barobill/eaccount/api/barobill_card_config.php
Normal file
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 카드 API 설정 파일
|
||||
*
|
||||
* ⚠️ 중요: 바로빌은 SOAP 웹서비스를 사용합니다 (REST API가 아님)
|
||||
*
|
||||
* 카드 사용내역 조회를 위해서는 바로빌 웹사이트(https://www.barobill.co.kr)에서
|
||||
* 카드를 먼저 등록해야 합니다.
|
||||
*
|
||||
* 설정 파일:
|
||||
* 1. apikey/barobill_cert_key.txt - CERTKEY (인증서 키)
|
||||
* 2. apikey/barobill_corp_num.txt - 사업자번호
|
||||
* 3. apikey/barobill_test_mode.txt - 테스트 모드 설정 (선택)
|
||||
*/
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
// load .env file
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
$certKeyFile = $documentRoot . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $documentRoot . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $documentRoot . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $documentRoot . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기
|
||||
$barobillCertKey = '';
|
||||
if (file_exists($certKeyFile)) {
|
||||
$content = trim(file_get_contents($certKeyFile));
|
||||
// 설명 텍스트가 아닌 실제 키만 추출 (대괄호 안의 내용 제외, =로 시작하는 경우 제외)
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content) && !preg_match('/^=/', $content) && strpos($content, '바로빌 CERTKEY') === false) {
|
||||
$barobillCertKey = $content;
|
||||
}
|
||||
}
|
||||
if (empty($barobillCertKey) && file_exists($legacyApiKeyFile)) {
|
||||
$barobillCertKey = trim(file_get_contents($legacyApiKeyFile));
|
||||
}
|
||||
|
||||
// 사업자번호 읽기
|
||||
$barobillCorpNum = '';
|
||||
if (file_exists($corpNumFile)) {
|
||||
$content = trim(file_get_contents($corpNumFile));
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillCorpNum = str_replace('-', '', $content);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 모드 확인
|
||||
$isTestMode = false;
|
||||
if (file_exists($testModeFile)) {
|
||||
$testMode = trim(file_get_contents($testModeFile));
|
||||
$isTestMode = (strtolower($testMode) === 'test' || strtolower($testMode) === 'true');
|
||||
}
|
||||
|
||||
// 바로빌 사용자 ID (카드 사용내역 조회에 필요)
|
||||
// 빈 값이면 전체 카드 조회, 특정 사용자만 조회하려면 사용자 ID 입력
|
||||
$barobillUserIdFile = getenv('DOCUMENT_ROOT') . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserId = '';
|
||||
if (file_exists($barobillUserIdFile)) {
|
||||
$content = trim(file_get_contents($barobillUserIdFile));
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillUserId = $content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 사용자 ID 반환
|
||||
*/
|
||||
function getBarobillUserId() {
|
||||
global $barobillUserId;
|
||||
return $barobillUserId;
|
||||
}
|
||||
|
||||
// 바로빌 카드 SOAP 웹서비스 URL
|
||||
$barobillCardSoapUrl = $isTestMode
|
||||
? 'https://testws.baroservice.com/CARD.asmx?WSDL' // 테스트 환경
|
||||
: 'https://ws.baroservice.com/CARD.asmx?WSDL'; // 운영 환경
|
||||
|
||||
// SOAP 클라이언트 초기화
|
||||
$barobillCardSoapClient = null;
|
||||
if (!empty($barobillCertKey) || $isTestMode) {
|
||||
try {
|
||||
$barobillCardSoapClient = new SoapClient($barobillCardSoapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('바로빌 카드 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 카드 SOAP 웹서비스 호출 함수
|
||||
*
|
||||
* @param string $method SOAP 메서드명
|
||||
* @param array $params SOAP 메서드 파라미터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function callBarobillCardSOAP($method, $params = []) {
|
||||
global $barobillCardSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode;
|
||||
|
||||
if (!$barobillCardSoapClient) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 카드 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요.',
|
||||
'error_detail' => [
|
||||
'cert_key_file' => getenv('DOCUMENT_ROOT') . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $isTestMode ? 'https://testws.baroservice.com/CARD.asmx?WSDL' : 'https://ws.baroservice.com/CARD.asmx?WSDL'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($barobillCertKey) && !$isTestMode) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'CERTKEY가 설정되지 않았습니다. apikey/barobill_cert_key.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($barobillCorpNum)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '사업자번호가 설정되지 않았습니다. apikey/barobill_corp_num.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
// CERTKEY와 CorpNum 자동 추가
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $barobillCertKey;
|
||||
}
|
||||
if (!isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $barobillCorpNum;
|
||||
}
|
||||
|
||||
try {
|
||||
error_log('바로빌 카드 API 호출 - Method: ' . $method . ', CorpNum: ' . $barobillCorpNum);
|
||||
|
||||
$result = $barobillCardSoapClient->$method($params);
|
||||
|
||||
$resultProperty = $method . 'Result';
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
// 에러 코드 체크 (음수 값)
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 카드 API 오류 코드: ' . $resultData,
|
||||
'error_code' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
];
|
||||
|
||||
} catch (SoapFault $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류 (치명적): ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 카드 목록 조회 (GetCardEx2 API 사용)
|
||||
* API 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2
|
||||
*
|
||||
* @param int $availOnly 0: 전체, 1: 사용가능한 카드만
|
||||
* @return array 카드 목록
|
||||
*/
|
||||
function getCardList($availOnly = 0) {
|
||||
$result = callBarobillCardSOAP('GetCardEx2', [
|
||||
'AvailOnly' => $availOnly
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
$data = $result['data'];
|
||||
|
||||
// GetCardEx2는 CardEx 배열을 반환
|
||||
if (!isset($data->CardEx)) {
|
||||
return ['success' => true, 'data' => []];
|
||||
}
|
||||
|
||||
if (!is_array($data->CardEx)) {
|
||||
$cards = [$data->CardEx];
|
||||
} else {
|
||||
$cards = $data->CardEx;
|
||||
}
|
||||
|
||||
// 에러 체크: CardNum이 음수면 에러 코드
|
||||
if (count($cards) == 1 && isset($cards[0]->CardNum) && $cards[0]->CardNum < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '카드 목록 조회 실패',
|
||||
'error_code' => $cards[0]->CardNum
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $cards];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간별 카드 사용내역 조회
|
||||
*
|
||||
* @param string $cardNum 카드번호 (빈값이면 전체)
|
||||
* @param string $startDate 시작일 (YYYYMMDD)
|
||||
* @param string $endDate 종료일 (YYYYMMDD)
|
||||
* @param int $countPerPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지
|
||||
* @param int $orderDirection 정렬 (1: 오름차순, 2: 내림차순)
|
||||
* @param string $userId 바로빌 사용자 ID (빈값이면 전체)
|
||||
* @return array 사용내역
|
||||
*/
|
||||
function getPeriodCardUsage($cardNum = '', $startDate = '', $endDate = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
global $barobillCorpNum;
|
||||
|
||||
// 바로빌 사용자 ID 파일에서 읽기 (없으면 빈값)
|
||||
$barobillUserId = getBarobillUserId();
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAP('GetPeriodCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 카드 사용내역 조회
|
||||
*
|
||||
* @param string $cardNum 카드번호 (빈값이면 전체)
|
||||
* @param string $baseDate 기준일 (YYYYMMDD)
|
||||
* @param int $countPerPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지
|
||||
* @param int $orderDirection 정렬 (1: 오름차순, 2: 내림차순)
|
||||
* @param string $userId 바로빌 사용자 ID (빈값이면 전체)
|
||||
* @return array 사용내역
|
||||
*/
|
||||
function getDailyCardUsage($cardNum = '', $baseDate = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
$barobillUserId = getBarobillUserId();
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAP('GetDailyCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'BaseDate' => $baseDate,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 카드 사용내역 조회
|
||||
*
|
||||
* @param string $cardNum 카드번호 (빈값이면 전체)
|
||||
* @param string $baseMonth 기준월 (YYYYMM)
|
||||
* @param int $countPerPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지
|
||||
* @param int $orderDirection 정렬 (1: 오름차순, 2: 내림차순)
|
||||
* @param string $userId 바로빌 사용자 ID (빈값이면 전체)
|
||||
* @return array 사용내역
|
||||
*/
|
||||
function getMonthlyCardUsage($cardNum = '', $baseMonth = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
$barobillUserId = getBarobillUserId();
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAP('GetMonthlyCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'BaseMonth' => $baseMonth,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 사용내역 결과 파싱
|
||||
*
|
||||
* @param object $data SOAP 응답 데이터
|
||||
* @return array 파싱된 결과
|
||||
*/
|
||||
function parseCardUsageResult($data) {
|
||||
// 에러 체크
|
||||
if (isset($data->CurrentPage) && $data->CurrentPage < 0) {
|
||||
$errorCode = $data->CurrentPage;
|
||||
|
||||
// -24005: 조회 데이터 없음 (정상 케이스로 처리)
|
||||
// -24001: 등록된 카드 없음
|
||||
// -24002: 조회 기간 오류
|
||||
if ($errorCode == -24005 || $errorCode == -24001) {
|
||||
// 데이터 없음 - 빈 배열 반환 (에러가 아님)
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => 50,
|
||||
'maxPageNum' => 1,
|
||||
'maxIndex' => 0,
|
||||
'logs' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '카드 사용내역 조회 실패',
|
||||
'error_code' => $errorCode
|
||||
];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
if (isset($data->CardLogList) && isset($data->CardLogList->CardApprovalLog)) {
|
||||
if (!is_array($data->CardLogList->CardApprovalLog)) {
|
||||
$logs = [$data->CardLogList->CardApprovalLog];
|
||||
} else {
|
||||
$logs = $data->CardLogList->CardApprovalLog;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => $data->CurrentPage ?? 1,
|
||||
'countPerPage' => $data->CountPerPage ?? 50,
|
||||
'maxPageNum' => $data->MaxPageNum ?? 1,
|
||||
'maxIndex' => $data->MaxIndex ?? 0,
|
||||
'logs' => $logs
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 등록
|
||||
*
|
||||
* @param array $cardData 카드 데이터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function registerCard($cardData) {
|
||||
return callBarobillCardSOAP('RegistCardEx', [
|
||||
'CollectCycle' => $cardData['collectCycle'] ?? '1', // 수집주기 (1: 1일 1회)
|
||||
'CardCompany' => $cardData['cardCompany'] ?? '', // 카드사 코드
|
||||
'CardType' => $cardData['cardType'] ?? '1', // 카드 종류 (1: 개인, 2: 법인)
|
||||
'CardNum' => $cardData['cardNum'] ?? '', // 카드번호
|
||||
'WebId' => $cardData['webId'] ?? '', // 카드사 웹 ID
|
||||
'WebPwd' => $cardData['webPwd'] ?? '', // 카드사 웹 비밀번호
|
||||
'Alias' => $cardData['alias'] ?? '', // 카드 별칭
|
||||
'Usage' => $cardData['usage'] ?? '1' // 용도 (1: 세금계산서, 2: 기타)
|
||||
]);
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
225
barobill/eaccount/api/cards.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
/**
|
||||
* 등록된 카드 목록 조회 API (GetCardEx2)
|
||||
* API 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
|
||||
try {
|
||||
$availOnly = isset($_GET['availOnly']) ? intval($_GET['availOnly']) : 0;
|
||||
|
||||
$result = getCardList($availOnly);
|
||||
|
||||
if ($result['success']) {
|
||||
$cards = [];
|
||||
foreach ($result['data'] as $card) {
|
||||
// GetCardEx2 응답 필드 매핑
|
||||
// CardCompanyCode (등록 시), CardCompanyName (조회 시)
|
||||
$cardCompanyCode = $card->CardCompanyCode ?? $card->CardCompany ?? '';
|
||||
|
||||
// 카드 브랜드 (비자, 마스터카드 등) 추측
|
||||
$cardBrand = guessCardTypeFromNumber($card->CardNum ?? '');
|
||||
|
||||
// 카드 회사명 (신한, KB 등)
|
||||
$cardCompanyName = !empty($card->CardCompanyName)
|
||||
? $card->CardCompanyName
|
||||
: getCardCompanyName($cardCompanyCode);
|
||||
|
||||
$cards[] = [
|
||||
'cardNum' => $card->CardNum ?? '',
|
||||
'cardNumMasked' => maskCardNumber($card->CardNum ?? ''),
|
||||
'cardCompany' => $cardCompanyCode,
|
||||
'cardCompanyName' => $cardCompanyName,
|
||||
'cardBrand' => $cardBrand, // 카드 브랜드 (비자, 마스터카드 등)
|
||||
'alias' => $card->Alias ?? '',
|
||||
'cardType' => $card->CardType ?? '',
|
||||
'cardTypeName' => getCardTypeName($card->CardType ?? ''),
|
||||
'status' => $card->Status ?? '',
|
||||
'statusName' => getCardStatusName($card->Status ?? ''),
|
||||
'collectCycle' => $card->CollectCycle ?? '',
|
||||
'collectCycleName' => getCollectCycleName($card->CollectCycle ?? ''),
|
||||
'lastCollectDate' => formatDate($card->LastCollectDate ?? ''),
|
||||
'lastCollectResult' => $card->LastCollectResult ?? '',
|
||||
'lastCollectResultName' => getCollectResultName($card->LastCollectResult ?? ''),
|
||||
'nextExtendDate' => formatDate($card->NextExtendDate ?? ''),
|
||||
'registDate' => formatDate($card->RegistDate ?? ''),
|
||||
'webId' => $card->WebId ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'cards' => $cards,
|
||||
'count' => count($cards)
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 마스킹
|
||||
*/
|
||||
function maskCardNumber($cardNum) {
|
||||
if (strlen($cardNum) < 8) return $cardNum;
|
||||
return substr($cardNum, 0, 4) . '-****-****-' . substr($cardNum, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
function formatDate($date) {
|
||||
if (empty($date)) return '';
|
||||
if (strlen($date) === 8) {
|
||||
return substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호로 카드 종류 추측 (BIN 코드 기반)
|
||||
*/
|
||||
function guessCardTypeFromNumber($cardNum) {
|
||||
if (empty($cardNum) || strlen($cardNum) < 4) {
|
||||
return '카드';
|
||||
}
|
||||
|
||||
$bin = substr($cardNum, 0, 4);
|
||||
|
||||
// 주요 카드사 BIN 코드
|
||||
$binMappings = [
|
||||
'4518' => '비자',
|
||||
'4092' => '비자',
|
||||
'4569' => '비자',
|
||||
'4563' => '비자',
|
||||
'5' => '마스터카드', // 5로 시작
|
||||
'3528' => 'JCB',
|
||||
'3529' => 'JCB',
|
||||
'3' => '아멕스/다이너스', // 34, 37로 시작
|
||||
'9' => '국내전용카드'
|
||||
];
|
||||
|
||||
// 정확한 매칭 시도
|
||||
if (isset($binMappings[$bin])) {
|
||||
return $binMappings[$bin];
|
||||
}
|
||||
|
||||
// 첫 번째 숫자로 매칭 시도
|
||||
$firstDigit = substr($cardNum, 0, 1);
|
||||
if (isset($binMappings[$firstDigit])) {
|
||||
return $binMappings[$firstDigit];
|
||||
}
|
||||
|
||||
return '카드';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드사 코드 -> 이름 변환
|
||||
* 바로빌 카드사 코드 참고
|
||||
*/
|
||||
function getCardCompanyName($code) {
|
||||
$companies = [
|
||||
'01' => '비씨카드',
|
||||
'02' => 'KB국민카드',
|
||||
'03' => '하나카드(외환)',
|
||||
'04' => '삼성카드',
|
||||
'06' => '신한카드',
|
||||
'07' => '현대카드',
|
||||
'08' => '롯데카드',
|
||||
'11' => 'NH농협카드',
|
||||
'12' => '수협카드',
|
||||
'13' => '씨티카드',
|
||||
'14' => '우리카드',
|
||||
'15' => '광주카드',
|
||||
'16' => '전북카드',
|
||||
'21' => '하나카드',
|
||||
'22' => '제주카드',
|
||||
'23' => 'SC제일카드',
|
||||
'25' => 'KDB산업카드',
|
||||
'26' => 'IBK기업카드',
|
||||
'27' => '새마을금고',
|
||||
'28' => '신협카드',
|
||||
'29' => '저축은행',
|
||||
'30' => '우체국카드',
|
||||
'31' => '카카오뱅크',
|
||||
'32' => 'K뱅크',
|
||||
'33' => '토스뱅크',
|
||||
'BC' => '비씨카드',
|
||||
'KB' => 'KB국민카드',
|
||||
'HANA' => '하나카드',
|
||||
'SAMSUNG' => '삼성카드',
|
||||
'SHINHAN' => '신한카드',
|
||||
'HYUNDAI' => '현대카드',
|
||||
'LOTTE' => '롯데카드',
|
||||
'NH' => 'NH농협카드',
|
||||
'SUHYUP' => '수협카드',
|
||||
'CITI' => '씨티카드',
|
||||
'WOORI' => '우리카드',
|
||||
'KJBANK' => '광주카드',
|
||||
'JBBANK' => '전북카드'
|
||||
];
|
||||
return $companies[$code] ?? $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 종류 코드 -> 이름 변환
|
||||
*/
|
||||
function getCardTypeName($type) {
|
||||
$types = [
|
||||
'1' => '개인카드',
|
||||
'2' => '법인카드'
|
||||
];
|
||||
return $types[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 상태 코드 -> 이름 변환
|
||||
*/
|
||||
function getCardStatusName($status) {
|
||||
$statuses = [
|
||||
'0' => '대기중',
|
||||
'1' => '정상',
|
||||
'2' => '해지',
|
||||
'3' => '수집오류',
|
||||
'4' => '일시중지'
|
||||
];
|
||||
return $statuses[$status] ?? $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집주기 코드 -> 이름 변환
|
||||
*/
|
||||
function getCollectCycleName($cycle) {
|
||||
$cycles = [
|
||||
'1' => '1일 1회',
|
||||
'2' => '1일 2회',
|
||||
'3' => '1일 3회'
|
||||
];
|
||||
return $cycles[$cycle] ?? $cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집결과 코드 -> 이름 변환
|
||||
*/
|
||||
function getCollectResultName($result) {
|
||||
$results = [
|
||||
'0' => '대기',
|
||||
'1' => '성공',
|
||||
'2' => '실패',
|
||||
'3' => '진행중'
|
||||
];
|
||||
return $results[$result] ?? $result;
|
||||
}
|
||||
?>
|
||||
|
||||
138
barobill/eaccount/api/check_api_config.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 API 설정 진단 페이지
|
||||
* 현재 API 키 설정 상태를 확인합니다.
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
|
||||
// 전역 변수 접근
|
||||
global $barobillCertKey, $barobillCorpNum, $barobillUserId, $isTestMode, $barobillAccountSoapUrl;
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
|
||||
$diagnostics = [
|
||||
'cert_key' => [
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_cert_key.txt'),
|
||||
'file_path' => $documentRoot . '/apikey/barobill_cert_key.txt',
|
||||
'is_set' => !empty($barobillCertKey),
|
||||
'length' => strlen($barobillCertKey),
|
||||
'preview' => !empty($barobillCertKey) ? substr($barobillCertKey, 0, 8) . '...' . substr($barobillCertKey, -4) : 'NOT SET',
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_cert_key.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt')
|
||||
: 'FILE NOT FOUND',
|
||||
'is_placeholder' => !empty($barobillCertKey) ? false : (
|
||||
file_exists($documentRoot . '/apikey/barobill_cert_key.txt')
|
||||
? (strpos(file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt'), '[여기에') !== false
|
||||
|| strpos(file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt'), '바로빌 CERTKEY') !== false
|
||||
|| strpos(file_get_contents($documentRoot . '/apikey/barobill_cert_key.txt'), '================================') !== false)
|
||||
: false
|
||||
)
|
||||
],
|
||||
'corp_num' => [
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_corp_num.txt'),
|
||||
'file_path' => $documentRoot . '/apikey/barobill_corp_num.txt',
|
||||
'is_set' => !empty($barobillCorpNum),
|
||||
'value' => $barobillCorpNum,
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_corp_num.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_corp_num.txt')
|
||||
: 'FILE NOT FOUND'
|
||||
],
|
||||
'user_id' => [
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_user_id.txt'),
|
||||
'file_path' => $documentRoot . '/apikey/barobill_user_id.txt',
|
||||
'is_set' => !empty($barobillUserId),
|
||||
'value' => $barobillUserId,
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_user_id.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_user_id.txt')
|
||||
: 'FILE NOT FOUND'
|
||||
],
|
||||
'test_mode' => [
|
||||
'file_exists' => file_exists($documentRoot . '/apikey/barobill_test_mode.txt'),
|
||||
'is_active' => $isTestMode,
|
||||
'raw_content' => file_exists($documentRoot . '/apikey/barobill_test_mode.txt')
|
||||
? file_get_contents($documentRoot . '/apikey/barobill_test_mode.txt')
|
||||
: 'FILE NOT FOUND'
|
||||
],
|
||||
'soap_client' => [
|
||||
'url' => $barobillAccountSoapUrl,
|
||||
'initialized' => isset($barobillAccountSoapClient) && $barobillAccountSoapClient !== null
|
||||
],
|
||||
'hardcoded_values' => [
|
||||
'note' => '⚠️ barobill_account_config.php에 하드코딩된 값이 있을 수 있습니다.',
|
||||
'check_file' => 'eaccount/api/barobill_account_config.php (62-68줄)'
|
||||
]
|
||||
];
|
||||
|
||||
// 실제 API 호출 테스트
|
||||
// 테스트 모드일 때는 CERTKEY가 없어도 테스트 가능
|
||||
$testResult = null;
|
||||
$canTest = false;
|
||||
if ($isTestMode) {
|
||||
// 테스트 모드: CERTKEY 불필요, 사업자번호만 확인
|
||||
$canTest = !empty($barobillCorpNum);
|
||||
} else {
|
||||
// 운영 모드: CERTKEY와 사업자번호 모두 필요
|
||||
$canTest = !empty($barobillCertKey) && !empty($barobillCorpNum);
|
||||
}
|
||||
|
||||
if ($canTest) {
|
||||
try {
|
||||
$testResult = callBarobillAccountSOAP('GetBankAccountEx', [
|
||||
'AvailOnly' => 0
|
||||
]);
|
||||
|
||||
$diagnostics['api_test'] = [
|
||||
'success' => $testResult['success'],
|
||||
'error' => $testResult['error'] ?? null,
|
||||
'error_code' => $testResult['error_code'] ?? null,
|
||||
'has_data' => isset($testResult['data']),
|
||||
'debug_available' => isset($testResult['debug'])
|
||||
];
|
||||
|
||||
if (isset($testResult['debug'])) {
|
||||
$diagnostics['api_test']['request_preview'] = $testResult['debug']['request'] ?? null;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$diagnostics['api_test'] = [
|
||||
'success' => false,
|
||||
'error' => 'Exception: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if ($isTestMode) {
|
||||
$diagnostics['api_test'] = [
|
||||
'skipped' => true,
|
||||
'reason' => '테스트 모드: 사업자번호가 설정되지 않았습니다. (CERTKEY는 불필요)'
|
||||
];
|
||||
} else {
|
||||
$diagnostics['api_test'] = [
|
||||
'skipped' => true,
|
||||
'reason' => '운영 모드: CERTKEY 또는 사업자번호가 설정되지 않았습니다.'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'diagnostics' => $diagnostics,
|
||||
'recommendations' => array_filter([
|
||||
!$isTestMode && !$diagnostics['cert_key']['is_set'] ? 'CERTKEY를 설정하세요: apikey/barobill_cert_key.txt 파일에 바로빌 CERTKEY를 입력하세요.' : null,
|
||||
!$isTestMode && isset($diagnostics['cert_key']['is_placeholder']) && $diagnostics['cert_key']['is_placeholder'] ? '⚠️ CERTKEY 파일에 설명 텍스트만 있습니다. 파일의 모든 설명을 삭제하고 실제 CERTKEY 값만 입력하세요. (예: "2DD6C76C-1234-5678-ABCD-EF1234561826")' : null,
|
||||
!$diagnostics['corp_num']['is_set'] ? '사업자번호를 설정하세요: apikey/barobill_corp_num.txt 파일에 사업자번호를 입력하세요.' : null,
|
||||
!$isTestMode && $diagnostics['cert_key']['file_exists'] && empty(trim($diagnostics['cert_key']['raw_content'])) ? 'CERTKEY 파일이 비어있습니다. 바로빌 사이트에서 CERTKEY를 확인하고 입력하세요.' : null,
|
||||
!$isTestMode && strpos($diagnostics['cert_key']['raw_content'], '[여기에') !== false ? 'CERTKEY 파일에 설명 텍스트가 남아있습니다. 실제 CERTKEY 값만 입력하세요.' : null,
|
||||
isset($diagnostics['api_test']['error_code']) && $diagnostics['api_test']['error_code'] == -24005 ? '사업자번호가 잘못되었습니다. 바로빌 사이트에 로그인하여 등록된 사업자번호를 확인하세요.' : null,
|
||||
isset($diagnostics['api_test']['error_code']) && $diagnostics['api_test']['error_code'] == -25001 ? '등록된 계좌가 없습니다. 바로빌 사이트에서 계좌를 먼저 등록하세요.' : null,
|
||||
$isTestMode ? '현재 테스트 모드입니다. CERTKEY는 필요하지 않습니다.' : null,
|
||||
]),
|
||||
'next_steps' => [
|
||||
'1. 바로빌 사이트(https://www.barobill.co.kr)에 로그인',
|
||||
'2. 마이페이지 > API 설정에서 CERTKEY 확인',
|
||||
'3. apikey/barobill_cert_key.txt 파일에 CERTKEY 입력 (설명 텍스트 제외)',
|
||||
'4. apikey/barobill_corp_num.txt 파일에 사업자번호 입력 (하이픈 제외 가능)',
|
||||
'5. (주)코드브릿지 산하 기업인 경우, barobill_account_config.php의 하드코딩된 값 확인 필요'
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
?>
|
||||
|
||||
273
barobill/eaccount/api/debug_accounts.php
Normal file
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
/**
|
||||
* 계좌 정보 디버깅 API
|
||||
* 로컬 DB와 바로빌 API에서 계좌 정보를 조회하는 과정을 상세히 로깅합니다.
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$debug = [
|
||||
'step' => [],
|
||||
'tenant_info' => [],
|
||||
'local_db' => [],
|
||||
'barobill_api' => [],
|
||||
'final_result' => []
|
||||
];
|
||||
|
||||
try {
|
||||
// Step 1: 세션에서 테넌트 ID 확인
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
$debug['step'][] = '1. 세션에서 테넌트 ID 확인';
|
||||
$debug['tenant_info']['session_tenant_id'] = $selectedTenantId;
|
||||
|
||||
// Step 2: DB에서 테넌트 정보 확인
|
||||
$pdo = db_connect();
|
||||
if (!$pdo) {
|
||||
throw new Exception("Database connection failed.");
|
||||
}
|
||||
|
||||
$debug['step'][] = '2. DB 연결 성공';
|
||||
|
||||
// 테넌트 정보 조회
|
||||
if ($selectedTenantId) {
|
||||
$sql = "SELECT id, company_name, corp_num, barobill_user_id
|
||||
FROM {$DB}.barobill_companies
|
||||
WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$selectedTenantId]);
|
||||
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$debug['tenant_info']['db_query'] = $sql;
|
||||
$debug['tenant_info']['db_params'] = [$selectedTenantId];
|
||||
$debug['tenant_info']['tenant_found'] = $tenant ? true : false;
|
||||
$debug['tenant_info']['tenant_data'] = $tenant;
|
||||
|
||||
if ($tenant) {
|
||||
$debug['step'][] = '3. 테넌트 정보 조회 성공: ' . $tenant['company_name'];
|
||||
} else {
|
||||
$debug['step'][] = '3. 테넌트 정보 조회 실패: ID ' . $selectedTenantId . '를 찾을 수 없음';
|
||||
}
|
||||
} else {
|
||||
// 기본값으로 주일기업 찾기
|
||||
$sql = "SELECT id, company_name, corp_num, barobill_user_id
|
||||
FROM {$DB}.barobill_companies
|
||||
WHERE company_name LIKE '%주일기업%'
|
||||
OR company_name LIKE '%주일%'
|
||||
OR barobill_user_id = 'juil5130'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->query($sql);
|
||||
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$debug['tenant_info']['default_search_query'] = $sql;
|
||||
$debug['tenant_info']['tenant_found'] = $tenant ? true : false;
|
||||
$debug['tenant_info']['tenant_data'] = $tenant;
|
||||
|
||||
if ($tenant) {
|
||||
$selectedTenantId = $tenant['id'];
|
||||
$debug['step'][] = '3. 기본 테넌트(주일기업) 찾기 성공: ' . $tenant['company_name'];
|
||||
} else {
|
||||
$debug['step'][] = '3. 기본 테넌트(주일기업) 찾기 실패';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: 로컬 DB에서 계좌 정보 조회
|
||||
if ($selectedTenantId) {
|
||||
$debug['step'][] = '4. 로컬 DB 계좌 정보 조회 시작';
|
||||
|
||||
$accountSql = "SELECT id, company_id, bank_code, account_num, account_pwd
|
||||
FROM {$DB}.company_accounts
|
||||
WHERE company_id = ?
|
||||
ORDER BY id DESC";
|
||||
$accountStmt = $pdo->prepare($accountSql);
|
||||
$accountStmt->execute([$selectedTenantId]);
|
||||
$localAccounts = $accountStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$debug['local_db']['query'] = $accountSql;
|
||||
$debug['local_db']['params'] = [$selectedTenantId];
|
||||
$debug['local_db']['count'] = count($localAccounts);
|
||||
$debug['local_db']['accounts'] = $localAccounts;
|
||||
$debug['step'][] = '4. 로컬 DB 계좌 정보 조회 완료: ' . count($localAccounts) . '개';
|
||||
|
||||
// 모든 회사의 계좌 정보도 확인 (디버깅용)
|
||||
$allAccountsSql = "SELECT ca.*, c.company_name, c.barobill_user_id
|
||||
FROM {$DB}.company_accounts ca
|
||||
LEFT JOIN {$DB}.barobill_companies c ON ca.company_id = c.id
|
||||
ORDER BY ca.id DESC
|
||||
LIMIT 20";
|
||||
$allAccountsStmt = $pdo->query($allAccountsSql);
|
||||
$allAccounts = $allAccountsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$debug['local_db']['all_companies_accounts'] = $allAccounts;
|
||||
$debug['local_db']['all_companies_accounts_count'] = count($allAccounts);
|
||||
} else {
|
||||
$debug['local_db']['error'] = '테넌트 ID가 없어서 로컬 DB 조회 불가';
|
||||
}
|
||||
|
||||
// Step 4: 바로빌 API 설정 확인
|
||||
global $barobillUserId, $barobillCorpNum, $barobillCertKey, $isTestMode;
|
||||
$debug['barobill_api']['config'] = [
|
||||
'user_id' => $barobillUserId,
|
||||
'corp_num' => $barobillCorpNum,
|
||||
'cert_key_length' => strlen($barobillCertKey),
|
||||
'cert_key_preview' => !empty($barobillCertKey) ? substr($barobillCertKey, 0, 8) . '...' . substr($barobillCertKey, -4) : 'NOT SET',
|
||||
'test_mode' => $isTestMode
|
||||
];
|
||||
$debug['step'][] = '5. 바로빌 API 설정 확인 완료';
|
||||
|
||||
// Step 5: 바로빌 API 호출 시도
|
||||
$debug['step'][] = '6. 바로빌 API 호출 시작';
|
||||
$result = callBarobillAccountSOAP('GetBankAccountEx', [
|
||||
'AvailOnly' => 0
|
||||
]);
|
||||
|
||||
$debug['barobill_api']['success'] = $result['success'];
|
||||
$debug['barobill_api']['error'] = $result['error'] ?? null;
|
||||
$debug['barobill_api']['error_code'] = $result['error_code'] ?? null;
|
||||
|
||||
if ($result['success']) {
|
||||
$data = $result['data'];
|
||||
$accountList = [];
|
||||
|
||||
if (isset($data->BankAccountEx)) {
|
||||
if (is_array($data->BankAccountEx)) {
|
||||
$accountList = $data->BankAccountEx;
|
||||
} else {
|
||||
$accountList = [$data->BankAccountEx];
|
||||
}
|
||||
}
|
||||
|
||||
$barobillAccounts = [];
|
||||
foreach ($accountList as $acc) {
|
||||
if (isset($acc->BankAccountNum) && is_numeric($acc->BankAccountNum) && $acc->BankAccountNum < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$barobillAccounts[] = [
|
||||
'bankAccountNum' => $acc->BankAccountNum ?? '',
|
||||
'bankCode' => $acc->BankCode ?? '',
|
||||
'bankName' => getBankName($acc->BankCode ?? ''),
|
||||
'accountName' => $acc->AccountName ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$debug['barobill_api']['accounts'] = $barobillAccounts;
|
||||
$debug['barobill_api']['count'] = count($barobillAccounts);
|
||||
$debug['step'][] = '6. 바로빌 API 호출 성공: ' . count($barobillAccounts) . '개 계좌';
|
||||
} else {
|
||||
$debug['barobill_api']['accounts'] = [];
|
||||
$debug['barobill_api']['count'] = 0;
|
||||
$debug['step'][] = '6. 바로빌 API 호출 실패: ' . ($result['error'] ?? '알 수 없는 오류');
|
||||
}
|
||||
|
||||
// Step 6: 최종 결과 통합
|
||||
$allAccounts = [];
|
||||
|
||||
// 로컬 DB 계좌 추가
|
||||
if (isset($localAccounts)) {
|
||||
foreach ($localAccounts as $localAcc) {
|
||||
$allAccounts[] = [
|
||||
'source' => 'local_db',
|
||||
'bankAccountNum' => $localAcc['account_num'],
|
||||
'bankCode' => $localAcc['bank_code'],
|
||||
'bankName' => getBankName($localAcc['bank_code']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 바로빌 API 계좌 추가
|
||||
if (isset($barobillAccounts)) {
|
||||
foreach ($barobillAccounts as $barobillAcc) {
|
||||
$exists = false;
|
||||
foreach ($allAccounts as &$existingAcc) {
|
||||
if ($existingAcc['bankAccountNum'] === $barobillAcc['bankAccountNum'] &&
|
||||
$existingAcc['source'] === 'local_db') {
|
||||
$existingAcc['source'] = 'both';
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$allAccounts[] = array_merge($barobillAcc, ['source' => 'barobill_api']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$debug['final_result']['total_count'] = count($allAccounts);
|
||||
$debug['final_result']['accounts'] = $allAccounts;
|
||||
$debug['step'][] = '7. 최종 통합 완료: ' . count($allAccounts) . '개 계좌';
|
||||
|
||||
// 모든 barobill_companies 목록도 확인
|
||||
$allCompaniesSql = "SELECT id, company_name, corp_num, barobill_user_id, parent_id
|
||||
FROM {$DB}.barobill_companies
|
||||
ORDER BY id ASC";
|
||||
$allCompaniesStmt = $pdo->query($allCompaniesSql);
|
||||
$allCompanies = $allCompaniesStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$debug['tenant_info']['all_companies'] = $allCompanies;
|
||||
$debug['tenant_info']['all_companies_count'] = count($allCompanies);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'debug' => $debug,
|
||||
'summary' => [
|
||||
'tenant_id' => $selectedTenantId,
|
||||
'tenant_name' => $tenant['company_name'] ?? '알 수 없음',
|
||||
'local_accounts_count' => count($localAccounts ?? []),
|
||||
'barobill_accounts_count' => count($barobillAccounts ?? []),
|
||||
'total_accounts_count' => count($allAccounts)
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$debug['error'] = $e->getMessage();
|
||||
$debug['error_trace'] = $e->getTraceAsString();
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'debug' => $debug
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 코드 -> 은행명 변환
|
||||
*/
|
||||
function getBankName($code) {
|
||||
$banks = [
|
||||
'002' => 'KDB산업은행',
|
||||
'003' => 'IBK기업은행',
|
||||
'004' => 'KB국민은행',
|
||||
'007' => '수협은행',
|
||||
'011' => 'NH농협은행',
|
||||
'012' => '지역농축협',
|
||||
'020' => '우리은행',
|
||||
'023' => 'SC제일은행',
|
||||
'027' => '한국씨티은행',
|
||||
'031' => '대구은행',
|
||||
'032' => '부산은행',
|
||||
'034' => '광주은행',
|
||||
'035' => '제주은행',
|
||||
'037' => '전북은행',
|
||||
'039' => '경남은행',
|
||||
'045' => '새마을금고',
|
||||
'048' => '신협',
|
||||
'050' => '저축은행',
|
||||
'064' => '산림조합',
|
||||
'071' => '우체국',
|
||||
'081' => '하나은행',
|
||||
'088' => '신한은행',
|
||||
'089' => 'K뱅크',
|
||||
'090' => '카카오뱅크',
|
||||
'092' => '토스뱅크'
|
||||
];
|
||||
return $banks[$code] ?? $code;
|
||||
}
|
||||
?>
|
||||
|
||||
97
barobill/eaccount/api/get_tenants.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/**
|
||||
* 테넌트 목록 조회 API
|
||||
* barobill_companies 테이블에서 테넌트 목록을 가져옵니다.
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
|
||||
if (!$pdo) {
|
||||
throw new Exception("Database connection failed.");
|
||||
}
|
||||
|
||||
// barobill_companies 테이블에서 모든 회사 가져오기
|
||||
$sql = "SELECT c.*, p.company_name as parent_name, p.barobill_user_id as parent_user_id
|
||||
FROM {$DB}.barobill_companies c
|
||||
LEFT JOIN {$DB}.barobill_companies p ON c.parent_id = p.id
|
||||
ORDER BY c.parent_id ASC, c.id ASC";
|
||||
$stmt = $pdo->query($sql);
|
||||
$companies = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 계좌 정보 확인 (company_accounts 테이블에서)
|
||||
$tenants = [];
|
||||
foreach ($companies as $company) {
|
||||
// 계좌 정보 확인
|
||||
$accountSql = "SELECT COUNT(*) as count FROM {$DB}.company_accounts WHERE company_id = ?";
|
||||
$accountStmt = $pdo->prepare($accountSql);
|
||||
$accountStmt->execute([$company['id']]);
|
||||
$accountResult = $accountStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$hasAccount = ($accountResult['count'] > 0);
|
||||
|
||||
$tenants[] = [
|
||||
'id' => $company['id'],
|
||||
'name' => $company['company_name'],
|
||||
'corp_num' => $company['corp_num'],
|
||||
'user_id' => $company['barobill_user_id'],
|
||||
'parent_id' => $company['parent_id'],
|
||||
'parent_name' => $company['parent_name'] ?? null,
|
||||
'has_account' => $hasAccount,
|
||||
'memo' => $company['memo'] ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
// 현재 세션의 회사 정보
|
||||
$currentCompany = $mycompany ?? '';
|
||||
|
||||
// 현재 선택된 테넌트 (세션에서 가져오거나 기본값)
|
||||
$selectedTenantId = $_SESSION['eaccount_tenant_id'] ?? null;
|
||||
|
||||
// 세션에 저장된 tenant_id가 없으면 '(주)주일기업'을 기본값으로 설정
|
||||
if ($selectedTenantId === null) {
|
||||
// '(주)주일기업' 찾기
|
||||
$defaultTenant = null;
|
||||
foreach ($tenants as $tenant) {
|
||||
if (strpos($tenant['name'], '주일기업') !== false ||
|
||||
strpos($tenant['name'], '주일') !== false ||
|
||||
$tenant['user_id'] === 'juil5130') {
|
||||
$defaultTenant = $tenant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// '(주)주일기업'을 찾지 못하면 첫 번째 테넌트 사용
|
||||
if ($defaultTenant) {
|
||||
$selectedTenantId = $defaultTenant['id'];
|
||||
} elseif (count($tenants) > 0) {
|
||||
$selectedTenantId = $tenants[0]['id'];
|
||||
}
|
||||
|
||||
if ($selectedTenantId) {
|
||||
$_SESSION['eaccount_tenant_id'] = $selectedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'tenants' => $tenants,
|
||||
'current_tenant_id' => $selectedTenantId,
|
||||
'current_company' => $currentCompany
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '테넌트 목록 조회 실패: ' . $e->getMessage(),
|
||||
'tenants' => [],
|
||||
'current_tenant_id' => null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
61
barobill/eaccount/api/set_tenant.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
/**
|
||||
* 테넌트 선택 API
|
||||
* 선택된 테넌트를 세션에 저장합니다.
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/session.php');
|
||||
require_once(getenv('DOCUMENT_ROOT') . '/lib/mydb.php');
|
||||
|
||||
$tenantId = $_POST['tenant_id'] ?? $_GET['tenant_id'] ?? '';
|
||||
|
||||
if (empty($tenantId)) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '테넌트 ID가 필요합니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// DB에서 테넌트 존재 여부 확인
|
||||
try {
|
||||
$pdo = db_connect();
|
||||
if (!$pdo) {
|
||||
throw new Exception("Database connection failed.");
|
||||
}
|
||||
|
||||
$sql = "SELECT id, company_name FROM {$DB}.barobill_companies WHERE id = ?";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$tenant) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '유효하지 않은 테넌트 ID입니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 세션에 저장
|
||||
$_SESSION['eaccount_tenant_id'] = $tenantId;
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_name' => $tenant['company_name'],
|
||||
'message' => '테넌트가 변경되었습니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '테넌트 변경 실패: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
|
||||
515
barobill/eaccount/api/transactions.php
Normal file
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
/**
|
||||
* 계좌 입출금내역 조회 API (GetPeriodBankAccountLog)
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_account_config.php');
|
||||
|
||||
try {
|
||||
$startDate = isset($_GET['startDate']) ? $_GET['startDate'] : date('Ymd');
|
||||
$endDate = isset($_GET['endDate']) ? $_GET['endDate'] : date('Ymd');
|
||||
$bankAccountNum = isset($_GET['accountNum']) ? $_GET['accountNum'] : ''; // 특정 계좌만 조회 시, 빈 값이면 전체 계좌
|
||||
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
|
||||
$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 50;
|
||||
|
||||
// 바로빌 사용자 ID (설정 파일에서 로드)
|
||||
$userId = getBarobillUserId();
|
||||
|
||||
// 계좌번호 하이픈 제거
|
||||
$bankAccountNum = str_replace('-', '', $bankAccountNum);
|
||||
|
||||
// 전체 계좌 조회: 빈 값이면 모든 계좌의 거래 내역을 조회
|
||||
if (empty($bankAccountNum)) {
|
||||
// 먼저 등록된 계좌 목록 가져오기
|
||||
$accountResult = callBarobillAccountSOAP('GetBankAccountEx', [
|
||||
'AvailOnly' => 0 // 전체 계좌
|
||||
]);
|
||||
|
||||
if ($accountResult['success']) {
|
||||
$accountData = $accountResult['data'];
|
||||
$accountList = [];
|
||||
|
||||
// BankAccount 또는 BankAccountEx에서 계좌 목록 추출
|
||||
if (isset($accountData->BankAccount)) {
|
||||
if (is_array($accountData->BankAccount)) {
|
||||
$accountList = $accountData->BankAccount;
|
||||
} else if (is_object($accountData->BankAccount)) {
|
||||
$accountList = [$accountData->BankAccount];
|
||||
}
|
||||
} else if (isset($accountData->BankAccountEx)) {
|
||||
if (is_array($accountData->BankAccountEx)) {
|
||||
$accountList = $accountData->BankAccountEx;
|
||||
} else if (is_object($accountData->BankAccountEx)) {
|
||||
$accountList = [$accountData->BankAccountEx];
|
||||
}
|
||||
}
|
||||
|
||||
// 각 계좌별로 거래 내역 조회
|
||||
$allLogs = [];
|
||||
$allSummary = ['totalDeposit' => 0, 'totalWithdraw' => 0, 'count' => 0];
|
||||
$debugAllAccounts = [];
|
||||
|
||||
foreach ($accountList as $accIndex => $acc) {
|
||||
if (!is_object($acc)) continue;
|
||||
|
||||
$accNum = $acc->BankAccountNum ?? '';
|
||||
if (empty($accNum) || (is_numeric($accNum) && $accNum < 0)) {
|
||||
continue; // 에러 코드 스킵
|
||||
}
|
||||
|
||||
// 각 계좌의 거래 내역 조회
|
||||
$accResult = callBarobillAccountSOAP('GetPeriodBankAccountTransLog', [
|
||||
'ID' => $userId,
|
||||
'BankAccountNum' => $accNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'TransDirection' => 1,
|
||||
'CountPerPage' => 1000, // 전체 조회를 위해 큰 값 사용
|
||||
'CurrentPage' => 1,
|
||||
'OrderDirection' => 2
|
||||
]);
|
||||
|
||||
if ($accResult['success']) {
|
||||
$accData = $accResult['data'];
|
||||
|
||||
// 에러 코드 체크
|
||||
$errorCode = null;
|
||||
if (isset($accData->CurrentPage) && is_numeric($accData->CurrentPage) && $accData->CurrentPage < 0) {
|
||||
$errorCode = $accData->CurrentPage;
|
||||
} elseif (isset($accData->BankAccountNum) && is_numeric($accData->BankAccountNum) && $accData->BankAccountNum < 0) {
|
||||
$errorCode = $accData->BankAccountNum;
|
||||
}
|
||||
|
||||
// -25005, -25001은 데이터 없음 (정상)
|
||||
if (!$errorCode || ($errorCode == -25005 || $errorCode == -25001)) {
|
||||
// 거래 내역 파싱
|
||||
if (isset($accData->BankAccountLogList) && isset($accData->BankAccountLogList->BankAccountTransLog)) {
|
||||
$rawLogs = is_array($accData->BankAccountLogList->BankAccountTransLog)
|
||||
? $accData->BankAccountLogList->BankAccountTransLog
|
||||
: [$accData->BankAccountLogList->BankAccountTransLog];
|
||||
|
||||
foreach ($rawLogs as $log) {
|
||||
$deposit = floatval($log->Deposit ?? 0);
|
||||
$withdraw = floatval($log->Withdraw ?? 0);
|
||||
|
||||
// 거래일시 파싱: TransDT 필드 사용 (YYYYMMDDHHmmss 형식)
|
||||
$transDT = $log->TransDT ?? '';
|
||||
$transDate = '';
|
||||
$transTime = '';
|
||||
$dateTime = '';
|
||||
|
||||
if (!empty($transDT) && strlen($transDT) >= 14) {
|
||||
// TransDT: "20251203100719" -> "2025-12-03 10:07:19"
|
||||
$transDate = substr($transDT, 0, 8); // YYYYMMDD
|
||||
$transTime = substr($transDT, 8, 6); // HHmmss
|
||||
$dateTime = substr($transDT, 0, 4) . '-' . substr($transDT, 4, 2) . '-' . substr($transDT, 6, 2) . ' ' .
|
||||
substr($transDT, 8, 2) . ':' . substr($transDT, 10, 2) . ':' . substr($transDT, 12, 2);
|
||||
} else {
|
||||
// 기존 방식도 지원 (다양한 필드명 확인)
|
||||
$transDate = $log->TransDate ?? $log->TradeDate ?? $log->Date ?? '';
|
||||
$transTime = $log->TransTime ?? $log->TradeTime ?? $log->Time ?? '';
|
||||
|
||||
if (!empty($transDate) && !empty($transTime)) {
|
||||
$dateStr = (string)$transDate;
|
||||
$timeStr = (string)$transTime;
|
||||
|
||||
if (strlen($dateStr) == 8 && strlen($timeStr) >= 4) {
|
||||
$dateTime = substr($dateStr, 0, 4) . '-' . substr($dateStr, 4, 2) . '-' . substr($dateStr, 6, 2) . ' ' .
|
||||
substr($timeStr, 0, 2) . ':' . substr($timeStr, 2, 2);
|
||||
if (strlen($timeStr) >= 6) {
|
||||
$dateTime .= ':' . substr($timeStr, 4, 2);
|
||||
}
|
||||
} elseif (strlen($dateStr) == 10 && strpos($dateStr, '-') !== false) {
|
||||
$dateTime = $dateStr . ' ' . substr($timeStr, 0, 2) . ':' . substr($timeStr, 2, 2);
|
||||
if (strlen($timeStr) >= 6) {
|
||||
$dateTime .= ':' . substr($timeStr, 4, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 적요 파싱: TransRemark1 필드 사용
|
||||
$summary = $log->TransRemark1 ?? $log->Summary ?? $log->Content ?? $log->Description ?? $log->Remark ?? $log->Note ?? '';
|
||||
|
||||
// 추가 적요 정보: TransRemark2
|
||||
$remark2 = $log->TransRemark2 ?? '';
|
||||
|
||||
// 거래 유형: TransType (예: CC)
|
||||
$transType = $log->TransType ?? '';
|
||||
|
||||
// 거래 방향: TransDirection (예: 입금, 출금)
|
||||
$transDirection = $log->TransDirection ?? '';
|
||||
|
||||
// 취급점: TransOffice
|
||||
$transOffice = $log->TransOffice ?? '';
|
||||
|
||||
// 적요 정보 결합
|
||||
$fullSummary = $summary;
|
||||
if (!empty($remark2)) {
|
||||
$fullSummary = (!empty($fullSummary) ? $fullSummary . ' ' . $remark2 : $remark2);
|
||||
}
|
||||
if (!empty($transType)) {
|
||||
$fullSummary = (!empty($fullSummary) ? $fullSummary . ' (' . $transType . ')' : '(' . $transType . ')');
|
||||
}
|
||||
|
||||
// 보낸분/받는분 정보 (기존 필드명도 지원)
|
||||
$cast = $log->Cast ?? $log->Counterpart ?? $log->Opponent ?? $log->Name ?? '';
|
||||
|
||||
// 취급점/수단 정보
|
||||
$branch = $transOffice ?: ($log->Branch ?? $log->HandlingBranch ?? $log->Method ?? '');
|
||||
|
||||
// 디버그: 첫 번째 계좌의 첫 번째 로그 정보 저장
|
||||
if ($accIndex === 0 && empty($debugAllAccounts)) {
|
||||
$debugAllAccounts['first_account_first_log'] = [
|
||||
'account_num' => $accNum,
|
||||
'log_raw_fields' => [],
|
||||
'parsed' => [
|
||||
'transDate' => $transDate,
|
||||
'transTime' => $transTime,
|
||||
'transDateTime' => $dateTime,
|
||||
'summary' => $summary,
|
||||
'fullSummary' => $fullSummary,
|
||||
'cast' => $cast,
|
||||
'branch' => $branch
|
||||
]
|
||||
];
|
||||
// 원본 로그의 모든 필드 저장
|
||||
foreach ($log as $key => $value) {
|
||||
$debugAllAccounts['first_account_first_log']['log_raw_fields'][$key] =
|
||||
is_string($value) ? substr($value, 0, 100) : (is_numeric($value) ? $value : gettype($value));
|
||||
}
|
||||
}
|
||||
|
||||
$allLogs[] = [
|
||||
'transDate' => $transDate,
|
||||
'transTime' => $transTime,
|
||||
'transDateTime' => $dateTime,
|
||||
'bankAccountNum' => $log->BankAccountNum ?? $accNum,
|
||||
'bankName' => $log->BankName ?? ($acc->BankName ?? ''),
|
||||
'deposit' => $deposit,
|
||||
'withdraw' => $withdraw,
|
||||
'depositFormatted' => number_format($deposit),
|
||||
'withdrawFormatted' => number_format($withdraw),
|
||||
'balance' => floatval($log->Balance ?? 0),
|
||||
'balanceFormatted' => number_format(floatval($log->Balance ?? 0)),
|
||||
'summary' => $fullSummary ?: $summary,
|
||||
'cast' => $cast,
|
||||
'memo' => $log->Memo ?? '',
|
||||
'identity' => $log->Identity ?? '',
|
||||
'branch' => $branch
|
||||
];
|
||||
|
||||
$allSummary['totalDeposit'] += $deposit;
|
||||
$allSummary['totalWithdraw'] += $withdraw;
|
||||
$allSummary['count']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜/시간 기준으로 정렬 (최신순)
|
||||
usort($allLogs, function($a, $b) {
|
||||
$dateA = $a['transDate'] . $a['transTime'];
|
||||
$dateB = $b['transDate'] . $b['transTime'];
|
||||
return strcmp($dateB, $dateA); // 내림차순
|
||||
});
|
||||
|
||||
// 페이지네이션 계산
|
||||
$maxPageNum = ceil($allSummary['count'] / $limit);
|
||||
$startIndex = ($page - 1) * $limit;
|
||||
$paginatedLogs = array_slice($allLogs, $startIndex, $limit);
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $paginatedLogs,
|
||||
'pagination' => [
|
||||
'currentPage' => $page,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => $maxPageNum,
|
||||
'maxIndex' => $allSummary['count']
|
||||
],
|
||||
'summary' => $allSummary
|
||||
]
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
if (!empty($debugAllAccounts)) {
|
||||
$response['debug_all_accounts'] = $debugAllAccounts;
|
||||
}
|
||||
|
||||
// 디버그: 전체 계좌 조회 시 첫 번째 로그 확인
|
||||
if (!empty($paginatedLogs) && isset($paginatedLogs[0])) {
|
||||
$response['debug_first_log_parsed'] = $paginatedLogs[0];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 단일 계좌 조회 (기존 로직)
|
||||
$result = callBarobillAccountSOAP('GetPeriodBankAccountTransLog', [
|
||||
'ID' => $userId,
|
||||
'BankAccountNum' => $bankAccountNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'TransDirection' => 1, // 1:전체? (API 예제 값), 2:입금, 3:출금 추정 (확인 필요) - 우선 예제값 1 사용
|
||||
'CountPerPage' => $limit,
|
||||
'CurrentPage' => $page,
|
||||
'OrderDirection' => 2 // 1:오름차순, 2:내림차순
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
$resultData = $result['data'];
|
||||
|
||||
// 에러 코드 체크 (다양한 필드에서 확인)
|
||||
$errorCode = null;
|
||||
|
||||
// CurrentPage가 음수인 경우
|
||||
if (isset($resultData->CurrentPage) && is_numeric($resultData->CurrentPage) && $resultData->CurrentPage < 0) {
|
||||
$errorCode = $resultData->CurrentPage;
|
||||
}
|
||||
// BankAccountNum이 음수인 경우 (예: -10002)
|
||||
elseif (isset($resultData->BankAccountNum) && is_numeric($resultData->BankAccountNum) && $resultData->BankAccountNum < 0) {
|
||||
$errorCode = $resultData->BankAccountNum;
|
||||
}
|
||||
|
||||
// -25005: 데이터 없음 (정상), -25001: 계좌 없음 (정상)
|
||||
if ($errorCode && ($errorCode == -25005 || $errorCode == -25001)) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'summary' => ['totalDeposit' => 0, 'totalWithdraw' => 0, 'count' => 0],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1]
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 오류 코드인 경우
|
||||
if ($errorCode) {
|
||||
// 상세 에러 메시지 매핑
|
||||
$errorMsg = '계좌 내역 조회 실패: ' . $errorCode;
|
||||
$errorMessages = [
|
||||
-10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다. 바로빌 개발자센터에서 CERTKEY를 확인하세요.',
|
||||
-50214 => '은행 로그인 실패 (-50214). 바로빌 사이트에서 계좌 비밀번호/인증서를 점검해주세요.',
|
||||
-24005 => '사용자 정보 불일치 (-24005). 사업자번호를 확인해주세요.',
|
||||
];
|
||||
|
||||
if (isset($errorMessages[$errorCode])) {
|
||||
$errorMsg = $errorMessages[$errorCode];
|
||||
}
|
||||
|
||||
throw new Exception($errorMsg);
|
||||
}
|
||||
|
||||
// 데이터 파싱 (BankAccountTransLog)
|
||||
$logs = [];
|
||||
$rawLogs = [];
|
||||
if (isset($resultData->BankAccountLogList) && isset($resultData->BankAccountLogList->BankAccountTransLog)) {
|
||||
if (is_array($resultData->BankAccountLogList->BankAccountTransLog)) {
|
||||
$rawLogs = $resultData->BankAccountLogList->BankAccountTransLog;
|
||||
} else {
|
||||
$rawLogs = [$resultData->BankAccountLogList->BankAccountTransLog];
|
||||
}
|
||||
}
|
||||
|
||||
// 디버그: 첫 번째 로그의 전체 구조 확인
|
||||
$debugInfo = [];
|
||||
if (!empty($rawLogs) && is_object($rawLogs[0])) {
|
||||
$firstLog = $rawLogs[0];
|
||||
$debugInfo['first_log_structure'] = [];
|
||||
$debugInfo['first_log_all_keys'] = [];
|
||||
foreach ($firstLog as $key => $value) {
|
||||
$debugInfo['first_log_all_keys'][] = $key;
|
||||
$debugInfo['first_log_structure'][$key] = [
|
||||
'type' => gettype($value),
|
||||
'value' => is_string($value) ? $value : (is_numeric($value) ? $value : gettype($value)),
|
||||
'length' => is_string($value) ? strlen($value) : null
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$totalDeposit = 0;
|
||||
$totalWithdraw = 0;
|
||||
|
||||
foreach ($rawLogs as $logIndex => $log) {
|
||||
$deposit = floatval($log->Deposit ?? 0);
|
||||
$withdraw = floatval($log->Withdraw ?? 0);
|
||||
|
||||
$totalDeposit += $deposit;
|
||||
$totalWithdraw += $withdraw;
|
||||
|
||||
// 거래일시 파싱: TransDT 필드 사용 (YYYYMMDDHHmmss 형식)
|
||||
$transDT = $log->TransDT ?? '';
|
||||
$transDate = '';
|
||||
$transTime = '';
|
||||
$dateTime = '';
|
||||
|
||||
if (!empty($transDT) && strlen($transDT) >= 14) {
|
||||
// TransDT: "20251203100719" -> "2025-12-03 10:07:19"
|
||||
$transDate = substr($transDT, 0, 8); // YYYYMMDD
|
||||
$transTime = substr($transDT, 8, 6); // HHmmss
|
||||
$dateTime = substr($transDT, 0, 4) . '-' . substr($transDT, 4, 2) . '-' . substr($transDT, 6, 2) . ' ' .
|
||||
substr($transDT, 8, 2) . ':' . substr($transDT, 10, 2) . ':' . substr($transDT, 12, 2);
|
||||
} else {
|
||||
// 기존 방식도 지원 (다양한 필드명 확인)
|
||||
$transDate = $log->TransDate ?? $log->TradeDate ?? $log->Date ?? '';
|
||||
$transTime = $log->TransTime ?? $log->TradeTime ?? $log->Time ?? '';
|
||||
|
||||
if (!empty($transDate) && !empty($transTime)) {
|
||||
$dateStr = (string)$transDate;
|
||||
$timeStr = (string)$transTime;
|
||||
|
||||
if (strlen($dateStr) == 8 && strlen($timeStr) >= 4) {
|
||||
$dateTime = substr($dateStr, 0, 4) . '-' . substr($dateStr, 4, 2) . '-' . substr($dateStr, 6, 2) . ' ' .
|
||||
substr($timeStr, 0, 2) . ':' . substr($timeStr, 2, 2);
|
||||
if (strlen($timeStr) >= 6) {
|
||||
$dateTime .= ':' . substr($timeStr, 4, 2);
|
||||
}
|
||||
} elseif (strlen($dateStr) == 10 && strpos($dateStr, '-') !== false) {
|
||||
$dateTime = $dateStr . ' ' . substr($timeStr, 0, 2) . ':' . substr($timeStr, 2, 2);
|
||||
if (strlen($timeStr) >= 6) {
|
||||
$dateTime .= ':' . substr($timeStr, 4, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 적요 파싱: TransRemark1 필드 사용
|
||||
$summary = $log->TransRemark1 ?? $log->Summary ?? $log->Content ?? $log->Description ?? $log->Remark ?? $log->Note ?? '';
|
||||
|
||||
// 추가 적요 정보: TransRemark2
|
||||
$remark2 = $log->TransRemark2 ?? '';
|
||||
|
||||
// 거래 유형: TransType (예: CC)
|
||||
$transType = $log->TransType ?? '';
|
||||
|
||||
// 거래 방향: TransDirection (예: 입금, 출금)
|
||||
$transDirection = $log->TransDirection ?? '';
|
||||
|
||||
// 취급점: TransOffice
|
||||
$transOffice = $log->TransOffice ?? '';
|
||||
|
||||
// 적요 정보 결합
|
||||
$fullSummary = $summary;
|
||||
if (!empty($remark2)) {
|
||||
$fullSummary = (!empty($fullSummary) ? $fullSummary . ' ' . $remark2 : $remark2);
|
||||
}
|
||||
if (!empty($transType)) {
|
||||
$fullSummary = (!empty($fullSummary) ? $fullSummary . ' (' . $transType . ')' : '(' . $transType . ')');
|
||||
}
|
||||
|
||||
// 보낸분/받는분 정보 (기존 필드명도 지원)
|
||||
$cast = $log->Cast ?? $log->Counterpart ?? $log->Opponent ?? $log->Name ?? '';
|
||||
|
||||
// 취급점/수단 정보
|
||||
$branch = $transOffice ?: ($log->Branch ?? $log->HandlingBranch ?? $log->Method ?? '');
|
||||
|
||||
$logs[] = [
|
||||
'transDate' => $transDate,
|
||||
'transTime' => $transTime,
|
||||
'transDateTime' => $dateTime,
|
||||
'bankAccountNum' => $log->BankAccountNum ?? '',
|
||||
'bankName' => $log->BankName ?? '',
|
||||
'deposit' => $deposit,
|
||||
'withdraw' => $withdraw,
|
||||
'depositFormatted' => number_format($deposit),
|
||||
'withdrawFormatted' => number_format($withdraw),
|
||||
'balance' => floatval($log->Balance ?? 0),
|
||||
'balanceFormatted' => number_format(floatval($log->Balance ?? 0)),
|
||||
'summary' => $fullSummary ?: $summary, // 적요 (통합 정보)
|
||||
'cast' => $cast, // 보낸분/받는분
|
||||
'memo' => $log->Memo ?? '',
|
||||
'identity' => $log->Identity ?? '', // 고유번호
|
||||
'branch' => $branch, // 취급점/수단
|
||||
'rawData' => json_encode($log, JSON_UNESCAPED_UNICODE) // 디버깅용 원본 데이터
|
||||
];
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $logs,
|
||||
'pagination' => [
|
||||
'currentPage' => $resultData->CurrentPage ?? 1,
|
||||
'countPerPage' => $resultData->CountPerPage ?? 50,
|
||||
'maxPageNum' => $resultData->MaxPageNum ?? 1,
|
||||
'maxIndex' => $resultData->MaxIndex ?? 0
|
||||
],
|
||||
'summary' => [
|
||||
'totalDeposit' => $totalDeposit,
|
||||
'totalWithdraw' => $totalWithdraw,
|
||||
'count' => count($logs)
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
if (isset($result['debug'])) {
|
||||
$response['debug'] = $result['debug'];
|
||||
}
|
||||
|
||||
// API 응답 구조 디버그 정보 추가
|
||||
if (!empty($debugInfo)) {
|
||||
$response['debug_api_structure'] = $debugInfo;
|
||||
}
|
||||
|
||||
// 첫 번째 로그의 원본 데이터도 포함 (필드명 확인용)
|
||||
if (!empty($rawLogs) && is_object($rawLogs[0])) {
|
||||
$response['debug_first_log_raw'] = [];
|
||||
foreach ($rawLogs[0] as $key => $value) {
|
||||
$response['debug_first_log_raw'][$key] = is_string($value) ? $value : (is_numeric($value) ? $value : gettype($value));
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
} else {
|
||||
// API Error Handling (Graceful Fallback)
|
||||
// If API fails (e.g., SoapClient missing), return empty list with warning
|
||||
// so the UI doesn't break.
|
||||
$response = [
|
||||
'success' => true, // Masquerade as success to render empty table
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => 1,
|
||||
'maxIndex' => 0
|
||||
],
|
||||
'summary' => [
|
||||
'totalDeposit' => 0,
|
||||
'totalWithdraw' => 0,
|
||||
'count' => 0
|
||||
]
|
||||
],
|
||||
'warning' => 'API 연동 실패: ' . $result['error'], // Custom warning field
|
||||
'api_error_code' => $result['error_code'] ?? null
|
||||
];
|
||||
|
||||
// Add debug info if available
|
||||
if (isset($result['debug'])) {
|
||||
$response['debug'] = $result['debug'];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Global Exception/Error Handling
|
||||
echo json_encode([
|
||||
'success' => true, // Return true to avoid UI breakage
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1],
|
||||
'summary' => ['totalDeposit' => 0, 'totalWithdraw' => 0, 'count' => 0]
|
||||
],
|
||||
'warning' => '시스템 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
397
barobill/eaccount/api/usage.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
/**
|
||||
* 카드 사용내역 조회 API
|
||||
*
|
||||
* 파라미터:
|
||||
* - type: daily(일별), monthly(월별), period(기간별, 기본값)
|
||||
* - cardNum: 카드번호 (빈값이면 전체)
|
||||
* - startDate: 시작일 (YYYYMMDD) - period 타입
|
||||
* - endDate: 종료일 (YYYYMMDD) - period 타입
|
||||
* - baseDate: 기준일 (YYYYMMDD) - daily 타입
|
||||
* - baseMonth: 기준월 (YYYYMM) - monthly 타입
|
||||
* - page: 페이지 번호 (기본 1)
|
||||
* - limit: 페이지당 건수 (기본 50)
|
||||
* - debug: 1이면 디버그 정보 포함
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// load .env
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
|
||||
// 디버그 모드
|
||||
$debugMode = isset($_GET['debug']) && $_GET['debug'] == '1';
|
||||
|
||||
try {
|
||||
$type = $_GET['type'] ?? 'period';
|
||||
$cardNum = $_GET['cardNum'] ?? '';
|
||||
$page = max(1, intval($_GET['page'] ?? 1));
|
||||
$limit = min(100, max(10, intval($_GET['limit'] ?? 50)));
|
||||
$orderDirection = intval($_GET['order'] ?? 2); // 2: 내림차순 (최신순)
|
||||
|
||||
$result = null;
|
||||
|
||||
// cardNum이 빈 값이면 전체 카드 조회 (각 카드별로 조회 후 병합)
|
||||
if (empty($cardNum)) {
|
||||
// 등록된 카드 목록 조회
|
||||
$cardsResult = getCardList(1); // 사용 가능한 카드만
|
||||
if (!$cardsResult['success'] || empty($cardsResult['data'])) {
|
||||
$result = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => 1,
|
||||
'maxIndex' => 0,
|
||||
'logs' => []
|
||||
]
|
||||
];
|
||||
} else {
|
||||
// 각 카드별로 조회 후 병합
|
||||
$allLogs = [];
|
||||
foreach ($cardsResult['data'] as $card) {
|
||||
$cardNumToQuery = $card->CardNum ?? '';
|
||||
if (empty($cardNumToQuery)) continue;
|
||||
|
||||
switch ($type) {
|
||||
case 'daily':
|
||||
$baseDate = $_GET['baseDate'] ?? date('Ymd');
|
||||
$tempResult = getDailyCardUsage($cardNumToQuery, $baseDate, 100, 1, $orderDirection);
|
||||
break;
|
||||
case 'monthly':
|
||||
$baseMonth = $_GET['baseMonth'] ?? date('Ym');
|
||||
$tempResult = getMonthlyCardUsage($cardNumToQuery, $baseMonth, 100, 1, $orderDirection);
|
||||
break;
|
||||
case 'period':
|
||||
default:
|
||||
$startDate = $_GET['startDate'] ?? date('Ymd', strtotime('-30 days'));
|
||||
$endDate = $_GET['endDate'] ?? date('Ymd');
|
||||
$tempResult = getPeriodCardUsage($cardNumToQuery, $startDate, $endDate, 100, 1, $orderDirection);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($tempResult['success'] && !empty($tempResult['data']['logs'])) {
|
||||
$allLogs = array_merge($allLogs, $tempResult['data']['logs']);
|
||||
}
|
||||
}
|
||||
|
||||
// UseDT 기준으로 정렬
|
||||
usort($allLogs, function($a, $b) use ($orderDirection) {
|
||||
$aTime = $a->UseDT ?? '';
|
||||
$bTime = $b->UseDT ?? '';
|
||||
return $orderDirection == 1 ? strcmp($aTime, $bTime) : strcmp($bTime, $aTime);
|
||||
});
|
||||
|
||||
// 페이징 처리
|
||||
$totalCount = count($allLogs);
|
||||
$maxPageNum = ceil($totalCount / $limit);
|
||||
$offset = ($page - 1) * $limit;
|
||||
$pagedLogs = array_slice($allLogs, $offset, $limit);
|
||||
|
||||
$result = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => $page,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => $maxPageNum,
|
||||
'maxIndex' => $totalCount,
|
||||
'logs' => $pagedLogs
|
||||
]
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 특정 카드 조회
|
||||
switch ($type) {
|
||||
case 'daily':
|
||||
$baseDate = $_GET['baseDate'] ?? date('Ymd');
|
||||
$result = getDailyCardUsage($cardNum, $baseDate, $limit, $page, $orderDirection);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$baseMonth = $_GET['baseMonth'] ?? date('Ym');
|
||||
$result = getMonthlyCardUsage($cardNum, $baseMonth, $limit, $page, $orderDirection);
|
||||
break;
|
||||
|
||||
case 'period':
|
||||
default:
|
||||
$startDate = $_GET['startDate'] ?? date('Ymd', strtotime('-30 days'));
|
||||
$endDate = $_GET['endDate'] ?? date('Ymd');
|
||||
$result = getPeriodCardUsage($cardNum, $startDate, $endDate, $limit, $page, $orderDirection);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$logs = [];
|
||||
|
||||
// 디버그: raw 로그 데이터 출력
|
||||
if ($debugMode && !empty($result['data']['logs'])) {
|
||||
$firstLog = $result['data']['logs'][0];
|
||||
error_log('CardApprovalLog raw data: ' . print_r($firstLog, true));
|
||||
// 디버그: 모든 필드명 확인
|
||||
if (is_object($firstLog)) {
|
||||
$fields = get_object_vars($firstLog);
|
||||
error_log('CardApprovalLog fields: ' . implode(', ', array_keys($fields)));
|
||||
error_log('ApprovalAmount value: ' . ($firstLog->ApprovalAmount ?? 'NOT SET'));
|
||||
error_log('Amount value: ' . ($firstLog->Amount ?? 'NOT SET'));
|
||||
error_log('TotalAmount value: ' . ($firstLog->TotalAmount ?? 'NOT SET'));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($result['data']['logs'] as $log) {
|
||||
// UseDT 형식: YYYYMMDDHHMMSS
|
||||
$useDT = $log->UseDT ?? '';
|
||||
$approvalDate = '';
|
||||
$approvalTime = '';
|
||||
if (strlen($useDT) >= 8) {
|
||||
$approvalDate = substr($useDT, 0, 4) . '-' . substr($useDT, 4, 2) . '-' . substr($useDT, 6, 2);
|
||||
}
|
||||
if (strlen($useDT) >= 14) {
|
||||
$approvalTime = substr($useDT, 8, 2) . ':' . substr($useDT, 10, 2) . ':' . substr($useDT, 12, 2);
|
||||
} elseif (strlen($useDT) >= 12) {
|
||||
$approvalTime = substr($useDT, 8, 2) . ':' . substr($useDT, 10, 2);
|
||||
}
|
||||
|
||||
$logs[] = [
|
||||
'cardNum' => maskCardNumber($log->CardNum ?? ''),
|
||||
'cardNumFull' => $log->CardNum ?? '',
|
||||
'approvalNum' => $log->ApprovalNum ?? '',
|
||||
'approvalDate' => $approvalDate,
|
||||
'approvalTime' => $approvalTime,
|
||||
'approvalDateTime' => $approvalDate . ' ' . $approvalTime,
|
||||
'merchantName' => $log->UseStoreName ?? '',
|
||||
'merchantBizNum' => $log->UseStoreCorpNum ?? '',
|
||||
// 금액 필드: 여러 가능한 필드명 시도
|
||||
// ApprovalAmount가 실제 승인금액 (화면에 표시할 금액)
|
||||
'amount' => intval($log->ApprovalAmount ?? 0),
|
||||
'amountFormatted' => number_format(intval($log->ApprovalAmount ?? 0)),
|
||||
'vat' => intval($log->Tax ?? 0),
|
||||
'vatFormatted' => number_format(intval($log->Tax ?? 0)),
|
||||
'serviceCharge' => intval($log->ServiceCharge ?? 0),
|
||||
// totalAmount는 화면에서 사용하므로 ApprovalAmount를 사용
|
||||
'totalAmount' => intval($log->ApprovalAmount ?? 0),
|
||||
'totalAmountFormatted' => number_format(intval($log->ApprovalAmount ?? 0)),
|
||||
'approvalType' => $log->ApprovalType ?? '',
|
||||
'approvalTypeName' => getApprovalTypeName($log->ApprovalType ?? ''),
|
||||
'installment' => $log->PaymentPlan ?? '',
|
||||
'installmentName' => getInstallmentName($log->PaymentPlan ?? ''),
|
||||
'currencyCode' => $log->CurrencyCode ?? 'KRW',
|
||||
'memo' => $log->Memo ?? '',
|
||||
'cardCompany' => $log->CardCompany ?? '',
|
||||
'cardCompanyName' => getCardCompanyNameFromLog($log->CardCompany ?? ''),
|
||||
// 추가 필드
|
||||
'useKey' => $log->UseKey ?? '',
|
||||
'storeAddress' => $log->UseStoreAddr ?? '',
|
||||
'storeCeo' => $log->UseStoreCeo ?? '',
|
||||
'storeBizType' => $log->UseStoreBizType ?? '',
|
||||
'storeTel' => $log->UseStoreTel ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
// 통계 계산
|
||||
$totalAmount = array_sum(array_column($logs, 'totalAmount'));
|
||||
$approvalCount = count(array_filter($logs, function($l) { return $l['approvalType'] == '1'; }));
|
||||
$cancelCount = count(array_filter($logs, function($l) { return $l['approvalType'] == '2'; }));
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $logs,
|
||||
'pagination' => [
|
||||
'currentPage' => $result['data']['currentPage'],
|
||||
'countPerPage' => $result['data']['countPerPage'],
|
||||
'maxPageNum' => $result['data']['maxPageNum'],
|
||||
'totalCount' => $result['data']['maxIndex']
|
||||
],
|
||||
'summary' => [
|
||||
'totalAmount' => $totalAmount,
|
||||
'totalAmountFormatted' => number_format($totalAmount),
|
||||
'count' => count($logs),
|
||||
'approvalCount' => $approvalCount,
|
||||
'cancelCount' => $cancelCount
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
// 디버그 정보 추가 (성공한 경우에도 항상 첫 번째 로그의 필드 정보 출력)
|
||||
if (!empty($result['data']['logs'])) {
|
||||
$firstLog = $result['data']['logs'][0];
|
||||
if (is_object($firstLog)) {
|
||||
// 모든 필드를 배열로 변환
|
||||
$allFields = get_object_vars($firstLog);
|
||||
$fieldNames = array_keys($allFields);
|
||||
|
||||
// 금액 관련 필드 찾기 (대소문자 구분 없이)
|
||||
$amountFields = [];
|
||||
foreach ($fieldNames as $fieldName) {
|
||||
if (stripos($fieldName, 'amount') !== false ||
|
||||
stripos($fieldName, 'cost') !== false ||
|
||||
stripos($fieldName, 'price') !== false ||
|
||||
stripos($fieldName, '금액') !== false) {
|
||||
$amountFields[$fieldName] = (string)($firstLog->$fieldName ?? 'NULL');
|
||||
}
|
||||
}
|
||||
|
||||
// 디버그 모드일 때만 상세 정보 출력
|
||||
if ($debugMode) {
|
||||
$response['debug'] = [
|
||||
'userId' => getBarobillUserId(),
|
||||
'params' => $_GET,
|
||||
'firstLogFields' => $fieldNames,
|
||||
'firstLogAllValues' => array_map(function($v) {
|
||||
return is_string($v) ? $v : (is_numeric($v) ? (string)$v : gettype($v));
|
||||
}, $allFields),
|
||||
'amountFields' => $amountFields
|
||||
];
|
||||
} else {
|
||||
// 디버그 모드가 아니어도 금액 필드 정보는 항상 포함 (문제 해결용)
|
||||
$response['debug'] = [
|
||||
'amountFields' => $amountFields,
|
||||
'allFields' => $fieldNames
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($debugMode) {
|
||||
$response['debug'] = [
|
||||
'userId' => getBarobillUserId(),
|
||||
'params' => $_GET,
|
||||
'message' => 'No logs found'
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
// API Error Handling (Graceful Fallback)
|
||||
// If API fails (e.g., SoapClient missing), return empty list with warning
|
||||
$response = [
|
||||
'success' => true, // Masquerade as success to render empty table
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => 1,
|
||||
'totalCount' => 0
|
||||
],
|
||||
'summary' => [
|
||||
'totalAmount' => 0,
|
||||
'totalAmountFormatted' => '0',
|
||||
'count' => 0,
|
||||
'approvalCount' => 0,
|
||||
'cancelCount' => 0
|
||||
]
|
||||
],
|
||||
'warning' => 'API 연동 실패: ' . $result['error'],
|
||||
'api_error_code' => $result['error_code'] ?? null
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
if ($debugMode) {
|
||||
$response['debug'] = [
|
||||
'userId' => getBarobillUserId(),
|
||||
'params' => $_GET
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([
|
||||
'success' => true, // Return true to avoid UI breakage
|
||||
'data' => [
|
||||
'logs' => [],
|
||||
'pagination' => ['currentPage' => 1, 'maxPageNum' => 1, 'totalCount' => 0],
|
||||
'summary' => ['totalAmount' => 0, 'count' => 0]
|
||||
],
|
||||
'warning' => '시스템 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 마스킹
|
||||
*/
|
||||
function maskCardNumber($cardNum) {
|
||||
if (strlen($cardNum) < 8) return $cardNum;
|
||||
return substr($cardNum, 0, 4) . '-****-****-' . substr($cardNum, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
function formatDate($date) {
|
||||
if (strlen($date) === 8) {
|
||||
return substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 포맷팅
|
||||
*/
|
||||
function formatTime($time) {
|
||||
if (strlen($time) === 6) {
|
||||
return substr($time, 0, 2) . ':' . substr($time, 2, 2) . ':' . substr($time, 4, 2);
|
||||
} elseif (strlen($time) === 4) {
|
||||
return substr($time, 0, 2) . ':' . substr($time, 2, 2);
|
||||
}
|
||||
return $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 유형 이름
|
||||
*/
|
||||
function getApprovalTypeName($type) {
|
||||
$types = [
|
||||
'1' => '승인',
|
||||
'2' => '취소'
|
||||
];
|
||||
return $types[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 할부 이름
|
||||
*/
|
||||
function getInstallmentName($installment) {
|
||||
if (empty($installment) || $installment == '0' || $installment == '00') {
|
||||
return '일시불';
|
||||
}
|
||||
return $installment . '개월';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드사 이름 (로그용)
|
||||
*/
|
||||
function getCardCompanyNameFromLog($code) {
|
||||
$companies = [
|
||||
'01' => '비씨',
|
||||
'02' => 'KB국민',
|
||||
'03' => '하나(외환)',
|
||||
'04' => '삼성',
|
||||
'06' => '신한',
|
||||
'07' => '현대',
|
||||
'08' => '롯데',
|
||||
'11' => 'NH농협',
|
||||
'12' => '수협',
|
||||
'13' => '씨티',
|
||||
'14' => '우리',
|
||||
'15' => '광주',
|
||||
'16' => '전북',
|
||||
'21' => '하나',
|
||||
'22' => '제주',
|
||||
'23' => 'SC제일',
|
||||
'25' => 'KDB산업',
|
||||
'26' => 'IBK기업',
|
||||
'27' => '새마을금고',
|
||||
'28' => '신협',
|
||||
'29' => '저축은행',
|
||||
'30' => '우체국',
|
||||
'31' => '카카오뱅크',
|
||||
'32' => 'K뱅크',
|
||||
'33' => '토스뱅크'
|
||||
];
|
||||
return $companies[$code] ?? $code;
|
||||
}
|
||||
?>
|
||||
|
||||
1481
barobill/eaccount/index.php
Normal file
138
barobill/ecard/README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 법인카드 사용내역 조회 모듈
|
||||
|
||||
바로빌 API를 이용한 법인카드 사용내역 조회 모듈입니다.
|
||||
|
||||
## 📋 기능
|
||||
|
||||
- 등록된 카드 목록 조회
|
||||
- 기간별/일별/월별 카드 사용내역 조회
|
||||
- 사용금액 통계 (총 사용금액, 사용건수, 취소건수)
|
||||
- 페이지네이션 지원
|
||||
|
||||
## 🔧 설정
|
||||
|
||||
### 1. API 키 설정 (기존 etax 모듈과 공유)
|
||||
|
||||
다음 파일들이 필요합니다 (`/apikey/` 폴더):
|
||||
|
||||
| 파일명 | 설명 | 예시 |
|
||||
|--------|------|------|
|
||||
| `barobill_cert_key.txt` | 바로빌 CERTKEY (인증서 키) | `ABC123...` |
|
||||
| `barobill_corp_num.txt` | 사업자번호 (하이픈 제외) | `6648603713` |
|
||||
| `barobill_test_mode.txt` | 테스트 모드 (선택) | `test` 또는 `true` |
|
||||
|
||||
### 2. 바로빌 카드 등록
|
||||
|
||||
카드 사용내역을 조회하려면 **바로빌 웹사이트**에서 카드를 먼저 등록해야 합니다.
|
||||
|
||||
1. [바로빌](https://www.barobill.co.kr) 로그인
|
||||
2. 카드조회 서비스 신청
|
||||
3. 카드 등록 (카드사 웹 ID/비밀번호 필요)
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
ecard/
|
||||
├── index.php # 메인 UI (React 기반)
|
||||
├── api/
|
||||
│ ├── barobill_card_config.php # 바로빌 카드 API 설정
|
||||
│ ├── cards.php # 등록된 카드 목록 API
|
||||
│ └── usage.php # 카드 사용내역 조회 API
|
||||
└── README.md # 이 문서
|
||||
```
|
||||
|
||||
## 🔌 API 엔드포인트
|
||||
|
||||
### 카드 목록 조회
|
||||
```
|
||||
GET /ecard/api/cards.php
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"cards": [
|
||||
{
|
||||
"cardNum": "1234-****-****-5678",
|
||||
"cardCompany": "02",
|
||||
"cardCompanyName": "KB국민",
|
||||
"alias": "법인카드1",
|
||||
"status": "1",
|
||||
"statusName": "정상"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 사용내역 조회
|
||||
```
|
||||
GET /ecard/api/usage.php?type=period&startDate=20241101&endDate=20241130
|
||||
```
|
||||
|
||||
**파라미터:**
|
||||
| 파라미터 | 설명 | 기본값 |
|
||||
|---------|------|--------|
|
||||
| `type` | 조회 타입 (period/daily/monthly) | `period` |
|
||||
| `cardNum` | 카드번호 (빈값=전체) | - |
|
||||
| `startDate` | 시작일 (YYYYMMDD) - period용 | 30일 전 |
|
||||
| `endDate` | 종료일 (YYYYMMDD) - period용 | 오늘 |
|
||||
| `baseDate` | 기준일 (YYYYMMDD) - daily용 | 오늘 |
|
||||
| `baseMonth` | 기준월 (YYYYMM) - monthly용 | 이번달 |
|
||||
| `page` | 페이지 번호 | `1` |
|
||||
| `limit` | 페이지당 건수 | `50` |
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"logs": [
|
||||
{
|
||||
"cardNum": "1234-****-****-5678",
|
||||
"approvalNum": "12345678",
|
||||
"approvalDate": "2024-11-15",
|
||||
"approvalTime": "14:30:25",
|
||||
"merchantName": "스타벅스 강남점",
|
||||
"amount": 5000,
|
||||
"totalAmountFormatted": "5,000",
|
||||
"approvalTypeName": "승인",
|
||||
"installmentName": "일시불"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 1,
|
||||
"countPerPage": 50,
|
||||
"maxPageNum": 1,
|
||||
"totalCount": 15
|
||||
},
|
||||
"summary": {
|
||||
"totalAmount": 150000,
|
||||
"count": 15,
|
||||
"approvalCount": 14,
|
||||
"cancelCount": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 UI 기능
|
||||
|
||||
- **카드 선택**: 특정 카드 또는 전체 카드 조회
|
||||
- **기간 설정**: 날짜 범위 직접 선택 또는 빠른 선택 (오늘, 7일, 30일, 3개월, 6개월)
|
||||
- **통계 대시보드**: 총 사용금액, 사용건수, 취소건수 표시
|
||||
- **사용내역 테이블**: 승인일시, 가맹점명, 금액, 할부, 승인/취소 구분
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. 바로빌 카드조회 서비스는 **유료 서비스**입니다.
|
||||
2. 카드 등록 시 **카드사 웹 ID/비밀번호**가 필요합니다.
|
||||
3. 카드사에서 데이터를 수집하므로 **실시간 조회가 아닐 수 있습니다** (보통 1일 1회 수집).
|
||||
4. 테스트 환경에서는 실제 데이터가 아닌 테스트 데이터가 조회됩니다.
|
||||
|
||||
## 🔗 참고 문서
|
||||
|
||||
- [바로빌 카드조회 API 레퍼런스](https://dev.barobill.co.kr/docs/references/카드조회-API)
|
||||
- [바로빌 개발자센터](https://dev.barobill.co.kr)
|
||||
|
||||
510
barobill/ecard/api/barobill_card_config.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 카드 API 설정 파일
|
||||
*
|
||||
* ⚠️ 중요: 바로빌은 SOAP 웹서비스를 사용합니다 (REST API가 아님)
|
||||
*
|
||||
* 카드 사용내역 조회를 위해서는 바로빌 웹사이트(https://www.barobill.co.kr)에서
|
||||
* 카드를 먼저 등록해야 합니다.
|
||||
*
|
||||
* 설정 파일:
|
||||
* 1. apikey/barobill_cert_key.txt - CERTKEY (인증서 키)
|
||||
* 2. apikey/barobill_corp_num.txt - 사업자번호
|
||||
* 3. apikey/barobill_test_mode.txt - 테스트 모드 설정 (선택)
|
||||
*
|
||||
* ============================================================================
|
||||
* 카드 정보 출처 및 데이터 흐름
|
||||
* ============================================================================
|
||||
*
|
||||
* 1. 카드 등록 정보 (카드번호, 카드사, Web ID, Web 비밀번호 등)
|
||||
* - 출처: 바로빌 웹사이트(https://www.barobill.co.kr)에서 직접 등록
|
||||
* - 등록 방법: 바로빌 웹사이트 로그인 → 카드 관리 → 카드 등록
|
||||
* - 등록 시 필요한 정보:
|
||||
* * 카드번호 (CardNum)
|
||||
* * 카드사 코드 (CardCompany: 01=BC, 02=KB, 04=삼성, 06=신한 등)
|
||||
* * 카드 종류 (CardType: 1=개인, 2=법인)
|
||||
* * 카드사 웹 ID (WebId: 카드사 홈페이지 로그인 ID)
|
||||
* * 카드사 웹 비밀번호 (WebPwd: 카드사 홈페이지 로그인 비밀번호)
|
||||
* * 카드 별칭 (Alias: 선택사항)
|
||||
* * 수집주기 (CollectCycle: 1=1일1회, 2=1일2회, 3=1일3회)
|
||||
*
|
||||
* 2. 화면에 표시되는 카드 정보 조회 경로
|
||||
* [화면] ecard/index.php
|
||||
* ↓ (JavaScript fetch)
|
||||
* [API] ecard/api/cards.php
|
||||
* ↓ (함수 호출)
|
||||
* [설정] ecard/api/barobill_card_config.php::getCardList()
|
||||
* ↓ (SOAP API 호출)
|
||||
* [바로빌] GetCardEx2 API
|
||||
* ↓ (응답)
|
||||
* [바로빌] CardEx 객체 배열 반환
|
||||
* ↓ (데이터 변환)
|
||||
* [API] cards.php에서 JSON으로 변환
|
||||
* ↓ (응답)
|
||||
* [화면] React 컴포넌트에서 카드 목록 표시
|
||||
*
|
||||
* 3. 카드 사용내역 조회 경로
|
||||
* [화면] ecard/index.php
|
||||
* ↓ (JavaScript fetch)
|
||||
* [API] ecard/api/usage.php
|
||||
* ↓ (함수 호출)
|
||||
* [설정] ecard/api/barobill_card_config.php::getPeriodCardUsage()
|
||||
* ↓ (SOAP API 호출)
|
||||
* [바로빌] GetPeriodCardApprovalLog API
|
||||
* ↓ (응답)
|
||||
* [바로빌] CardApprovalLog 객체 배열 반환
|
||||
* ↓ (데이터 변환)
|
||||
* [API] usage.php에서 JSON으로 변환
|
||||
* ↓ (응답)
|
||||
* [화면] React 컴포넌트에서 사용내역 테이블 표시
|
||||
*
|
||||
* 4. 주요 함수 설명
|
||||
* - getCardList(): 바로빌에 등록된 카드 목록 조회
|
||||
* - getPeriodCardUsage(): 기간별 카드 사용내역 조회
|
||||
* - getDailyCardUsage(): 일별 카드 사용내역 조회
|
||||
* - getMonthlyCardUsage(): 월별 카드 사용내역 조회
|
||||
* - registerCard(): 카드 등록 (프로그래밍 방식, 현재 미사용)
|
||||
*
|
||||
* 5. 주의사항
|
||||
* - 카드 정보는 바로빌 서버에 저장되며, 이 시스템은 조회만 수행합니다
|
||||
* - 카드 등록/수정/삭제는 바로빌 웹사이트에서 직접 해야 합니다
|
||||
* - Web ID와 Web 비밀번호는 카드사 홈페이지 로그인 정보입니다
|
||||
* - 카드사별로 Web ID/비밀번호 형식이 다를 수 있습니다
|
||||
*/
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$certKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_api_key.txt';
|
||||
$corpNumFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기
|
||||
$barobillCertKey = '';
|
||||
if (file_exists($certKeyFile)) {
|
||||
$content = trim(file_get_contents($certKeyFile));
|
||||
// 설명 텍스트가 아닌 실제 키만 추출 (대괄호 안의 내용 제외, =로 시작하는 경우 제외)
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content) && !preg_match('/^=/', $content) && strpos($content, '바로빌 CERTKEY') === false) {
|
||||
$barobillCertKey = $content;
|
||||
}
|
||||
}
|
||||
if (empty($barobillCertKey) && file_exists($legacyApiKeyFile)) {
|
||||
$barobillCertKey = trim(file_get_contents($legacyApiKeyFile));
|
||||
}
|
||||
|
||||
// 사업자번호 읽기
|
||||
$barobillCorpNum = '';
|
||||
if (file_exists($corpNumFile)) {
|
||||
$content = trim(file_get_contents($corpNumFile));
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillCorpNum = str_replace('-', '', $content);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 모드 확인
|
||||
$isTestMode = false;
|
||||
if (file_exists($testModeFile)) {
|
||||
$testMode = trim(file_get_contents($testModeFile));
|
||||
$isTestMode = (strtolower($testMode) === 'test' || strtolower($testMode) === 'true');
|
||||
}
|
||||
|
||||
// 바로빌 사용자 ID (카드 사용내역 조회에 필요)
|
||||
// 빈 값이면 전체 카드 조회, 특정 사용자만 조회하려면 사용자 ID 입력
|
||||
$barobillUserIdFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_user_id.txt';
|
||||
$barobillUserId = '';
|
||||
if (file_exists($barobillUserIdFile)) {
|
||||
$content = trim(file_get_contents($barobillUserIdFile));
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillUserId = $content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 사용자 ID 반환
|
||||
*/
|
||||
function getBarobillUserId() {
|
||||
global $barobillUserId;
|
||||
return $barobillUserId;
|
||||
}
|
||||
|
||||
// 바로빌 카드 SOAP 웹서비스 URL
|
||||
$barobillCardSoapUrl = $isTestMode
|
||||
? 'https://testws.baroservice.com/CARD.asmx?WSDL' // 테스트 환경
|
||||
: 'https://ws.baroservice.com/CARD.asmx?WSDL'; // 운영 환경
|
||||
|
||||
// SOAP 클라이언트 초기화
|
||||
$barobillCardSoapClient = null;
|
||||
if (!empty($barobillCertKey) || $isTestMode) {
|
||||
try {
|
||||
$barobillCardSoapClient = new SoapClient($barobillCardSoapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
error_log('바로빌 카드 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 카드 SOAP 웹서비스 호출 함수
|
||||
*
|
||||
* @param string $method SOAP 메서드명
|
||||
* @param array $params SOAP 메서드 파라미터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function callBarobillCardSOAP($method, $params = []) {
|
||||
global $barobillCardSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode;
|
||||
|
||||
if (!$barobillCardSoapClient) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 카드 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요.',
|
||||
'error_detail' => [
|
||||
'cert_key_file' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $isTestMode ? 'https://testws.baroservice.com/CARD.asmx?WSDL' : 'https://ws.baroservice.com/CARD.asmx?WSDL'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($barobillCertKey) && !$isTestMode) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'CERTKEY가 설정되지 않았습니다. apikey/barobill_cert_key.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($barobillCorpNum)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '사업자번호가 설정되지 않았습니다. apikey/barobill_corp_num.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
// CERTKEY와 CorpNum 자동 추가
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $barobillCertKey;
|
||||
}
|
||||
if (!isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $barobillCorpNum;
|
||||
}
|
||||
|
||||
try {
|
||||
error_log('바로빌 카드 API 호출 - Method: ' . $method . ', CorpNum: ' . $barobillCorpNum);
|
||||
|
||||
$result = $barobillCardSoapClient->$method($params);
|
||||
|
||||
$resultProperty = $method . 'Result';
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
// 에러 코드 체크 (음수 값)
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 카드 API 오류 코드: ' . $resultData,
|
||||
'error_code' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
];
|
||||
|
||||
} catch (SoapFault $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 카드 목록 조회 (GetCardEx2 API 사용)
|
||||
* API 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2
|
||||
*
|
||||
* 데이터 출처: 바로빌 서버에 등록된 카드 정보
|
||||
* - 카드 등록은 바로빌 웹사이트(https://www.barobill.co.kr)에서 직접 해야 함
|
||||
* - 이 함수는 등록된 카드 정보를 조회만 수행
|
||||
*
|
||||
* 반환되는 카드 정보:
|
||||
* - CardNum: 카드번호 (바로빌에 등록된 카드번호)
|
||||
* - CardCompanyCode: 카드사 코드 (01=BC, 02=KB, 04=삼성, 06=신한 등)
|
||||
* - CardCompanyName: 카드사 이름
|
||||
* - WebId: 카드사 웹 ID (카드사 홈페이지 로그인 ID)
|
||||
* - Alias: 카드 별칭
|
||||
* - CardType: 카드 종류 (1=개인, 2=법인)
|
||||
* - Status: 카드 상태 (0=대기중, 1=정상, 2=해지, 3=수집오류, 4=일시중지)
|
||||
* - CollectCycle: 수집주기 (1=1일1회, 2=1일2회, 3=1일3회)
|
||||
*
|
||||
* @param int $availOnly 0: 전체, 1: 사용가능한 카드만
|
||||
* @return array 카드 목록
|
||||
*/
|
||||
function getCardList($availOnly = 0) {
|
||||
$result = callBarobillCardSOAP('GetCardEx2', [
|
||||
'AvailOnly' => $availOnly
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
$data = $result['data'];
|
||||
|
||||
// GetCardEx2는 CardEx 배열을 반환
|
||||
if (!isset($data->CardEx)) {
|
||||
return ['success' => true, 'data' => []];
|
||||
}
|
||||
|
||||
if (!is_array($data->CardEx)) {
|
||||
$cards = [$data->CardEx];
|
||||
} else {
|
||||
$cards = $data->CardEx;
|
||||
}
|
||||
|
||||
// 에러 체크: CardNum이 음수면 에러 코드
|
||||
if (count($cards) == 1 && isset($cards[0]->CardNum) && $cards[0]->CardNum < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '카드 목록 조회 실패',
|
||||
'error_code' => $cards[0]->CardNum
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $cards];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간별 카드 사용내역 조회
|
||||
*
|
||||
* 데이터 출처: 바로빌 서버에서 수집된 카드 사용내역
|
||||
* - 바로빌이 카드사에서 자동으로 수집한 사용내역을 조회
|
||||
* - 수집주기(CollectCycle)에 따라 1일 1회~3회 자동 수집
|
||||
*
|
||||
* 반환되는 사용내역 정보:
|
||||
* - CardNum: 카드번호
|
||||
* - UseDT: 사용일시 (YYYYMMDDHHMMSS)
|
||||
* - UseStoreName: 가맹점명
|
||||
* - UseStoreCorpNum: 가맹점 사업자번호
|
||||
* - ApprovalAmount: 승인금액
|
||||
* - Tax: 부가세
|
||||
* - ServiceCharge: 봉사료
|
||||
* - ApprovalType: 승인유형 (1=승인, 2=취소)
|
||||
* - PaymentPlan: 할부개월 (0=일시불)
|
||||
* - ApprovalNum: 승인번호
|
||||
*
|
||||
* @param string $cardNum 카드번호 (빈값이면 전체)
|
||||
* @param string $startDate 시작일 (YYYYMMDD)
|
||||
* @param string $endDate 종료일 (YYYYMMDD)
|
||||
* @param int $countPerPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지
|
||||
* @param int $orderDirection 정렬 (1: 오름차순, 2: 내림차순)
|
||||
* @param string $userId 바로빌 사용자 ID (빈값이면 전체)
|
||||
* @return array 사용내역
|
||||
*/
|
||||
function getPeriodCardUsage($cardNum = '', $startDate = '', $endDate = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
global $barobillCorpNum;
|
||||
|
||||
// 바로빌 사용자 ID 파일에서 읽기 (없으면 빈값)
|
||||
$barobillUserId = getBarobillUserId();
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAP('GetPeriodCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 카드 사용내역 조회
|
||||
*
|
||||
* @param string $cardNum 카드번호 (빈값이면 전체)
|
||||
* @param string $baseDate 기준일 (YYYYMMDD)
|
||||
* @param int $countPerPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지
|
||||
* @param int $orderDirection 정렬 (1: 오름차순, 2: 내림차순)
|
||||
* @param string $userId 바로빌 사용자 ID (빈값이면 전체)
|
||||
* @return array 사용내역
|
||||
*/
|
||||
function getDailyCardUsage($cardNum = '', $baseDate = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
$barobillUserId = getBarobillUserId();
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAP('GetDailyCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'BaseDate' => $baseDate,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 카드 사용내역 조회
|
||||
*
|
||||
* @param string $cardNum 카드번호 (빈값이면 전체)
|
||||
* @param string $baseMonth 기준월 (YYYYMM)
|
||||
* @param int $countPerPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지
|
||||
* @param int $orderDirection 정렬 (1: 오름차순, 2: 내림차순)
|
||||
* @param string $userId 바로빌 사용자 ID (빈값이면 전체)
|
||||
* @return array 사용내역
|
||||
*/
|
||||
function getMonthlyCardUsage($cardNum = '', $baseMonth = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
$barobillUserId = getBarobillUserId();
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAP('GetMonthlyCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'BaseMonth' => $baseMonth,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 사용내역 결과 파싱
|
||||
*
|
||||
* @param object $data SOAP 응답 데이터
|
||||
* @return array 파싱된 결과
|
||||
*/
|
||||
function parseCardUsageResult($data) {
|
||||
// 에러 체크
|
||||
if (isset($data->CurrentPage) && $data->CurrentPage < 0) {
|
||||
$errorCode = $data->CurrentPage;
|
||||
|
||||
// -24005: 조회 데이터 없음 (정상 케이스로 처리)
|
||||
// -24001: 등록된 카드 없음
|
||||
// -24002: 조회 기간 오류
|
||||
if ($errorCode == -24005 || $errorCode == -24001) {
|
||||
// 데이터 없음 - 빈 배열 반환 (에러가 아님)
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => 50,
|
||||
'maxPageNum' => 1,
|
||||
'maxIndex' => 0,
|
||||
'logs' => []
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '카드 사용내역 조회 실패',
|
||||
'error_code' => $errorCode
|
||||
];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
if (isset($data->CardLogList) && isset($data->CardLogList->CardApprovalLog)) {
|
||||
if (!is_array($data->CardLogList->CardApprovalLog)) {
|
||||
$logs = [$data->CardLogList->CardApprovalLog];
|
||||
} else {
|
||||
$logs = $data->CardLogList->CardApprovalLog;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => $data->CurrentPage ?? 1,
|
||||
'countPerPage' => $data->CountPerPage ?? 50,
|
||||
'maxPageNum' => $data->MaxPageNum ?? 1,
|
||||
'maxIndex' => $data->MaxIndex ?? 0,
|
||||
'logs' => $logs
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 등록
|
||||
*
|
||||
* ⚠️ 주의: 현재 이 함수는 사용되지 않습니다.
|
||||
* 카드 등록은 바로빌 웹사이트(https://www.barobill.co.kr)에서 직접 해야 합니다.
|
||||
*
|
||||
* 카드 등록 시 필요한 정보:
|
||||
* - CardNum: 카드번호 (예: "1234567890123456")
|
||||
* - CardCompany: 카드사 코드 (예: "04"=삼성, "06"=신한, "02"=KB)
|
||||
* - CardType: 카드 종류 ("1"=개인, "2"=법인)
|
||||
* - WebId: 카드사 웹 ID (카드사 홈페이지 로그인 ID)
|
||||
* - WebPwd: 카드사 웹 비밀번호 (카드사 홈페이지 로그인 비밀번호)
|
||||
* - Alias: 카드 별칭 (선택사항, 예: "법인카드1")
|
||||
* - CollectCycle: 수집주기 ("1"=1일1회, "2"=1일2회, "3"=1일3회)
|
||||
* - Usage: 용도 ("1"=세금계산서, "2"=기타)
|
||||
*
|
||||
* 카드사 코드 참고:
|
||||
* - 01: BC카드
|
||||
* - 02: KB국민카드
|
||||
* - 04: 삼성카드
|
||||
* - 06: 신한카드
|
||||
* - 07: 현대카드
|
||||
* - 08: 롯데카드
|
||||
* - 11: NH농협카드
|
||||
* - 21: 하나카드
|
||||
*
|
||||
* @param array $cardData 카드 데이터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function registerCard($cardData) {
|
||||
return callBarobillCardSOAP('RegistCardEx', [
|
||||
'CollectCycle' => $cardData['collectCycle'] ?? '1', // 수집주기 (1: 1일 1회)
|
||||
'CardCompany' => $cardData['cardCompany'] ?? '', // 카드사 코드
|
||||
'CardType' => $cardData['cardType'] ?? '1', // 카드 종류 (1: 개인, 2: 법인)
|
||||
'CardNum' => $cardData['cardNum'] ?? '', // 카드번호
|
||||
'WebId' => $cardData['webId'] ?? '', // 카드사 웹 ID
|
||||
'WebPwd' => $cardData['webPwd'] ?? '', // 카드사 웹 비밀번호
|
||||
'Alias' => $cardData['alias'] ?? '', // 카드 별칭
|
||||
'Usage' => $cardData['usage'] ?? '1' // 용도 (1: 세금계산서, 2: 기타)
|
||||
]);
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
252
barobill/ecard/api/cards.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
/**
|
||||
* 등록된 카드 목록 조회 API (GetCardEx2)
|
||||
* API 레퍼런스: https://dev.barobill.co.kr/docs/references/카드조회-API#GetCardEx2
|
||||
*
|
||||
* ============================================================================
|
||||
* 데이터 흐름
|
||||
* ============================================================================
|
||||
*
|
||||
* [호출 경로]
|
||||
* 화면(ecard/index.php) → 이 API(cards.php) → barobill_card_config.php::getCardList()
|
||||
* → 바로빌 SOAP API(GetCardEx2) → 바로빌 서버
|
||||
*
|
||||
* [카드 정보 출처]
|
||||
* - 카드 정보는 바로빌 웹사이트(https://www.barobill.co.kr)에서 등록된 정보
|
||||
* - 카드번호, 카드사, Web ID, Web 비밀번호 등은 바로빌에 저장되어 있음
|
||||
* - 이 API는 바로빌 서버에서 등록된 카드 목록을 조회만 수행
|
||||
*
|
||||
* [반환 데이터]
|
||||
* - cardNum: 카드번호 (전체)
|
||||
* - cardNumMasked: 카드번호 (마스킹 처리, 예: "1234-****-****-5678")
|
||||
* - cardCompany: 카드사 코드 (예: "04", "06", "02")
|
||||
* - cardCompanyName: 카드사 이름 (예: "삼성카드", "신한카드", "KB국민카드")
|
||||
* - cardBrand: 카드 브랜드 (예: "비자", "마스터카드", "카드")
|
||||
* - alias: 카드 별칭
|
||||
* - webId: 카드사 웹 ID (카드사 홈페이지 로그인 ID)
|
||||
* - status: 카드 상태 (0=대기중, 1=정상, 2=해지, 3=수집오류, 4=일시중지)
|
||||
*
|
||||
* [주의사항]
|
||||
* - Web 비밀번호는 보안상 반환하지 않음
|
||||
* - 카드 등록/수정/삭제는 바로빌 웹사이트에서 직접 해야 함
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
|
||||
try {
|
||||
$availOnly = isset($_GET['availOnly']) ? intval($_GET['availOnly']) : 0;
|
||||
|
||||
$result = getCardList($availOnly);
|
||||
|
||||
if ($result['success']) {
|
||||
$cards = [];
|
||||
foreach ($result['data'] as $card) {
|
||||
// GetCardEx2 응답 필드 매핑
|
||||
// CardCompanyCode (등록 시), CardCompanyName (조회 시)
|
||||
$cardCompanyCode = $card->CardCompanyCode ?? $card->CardCompany ?? '';
|
||||
|
||||
// 카드 브랜드 (비자, 마스터카드 등) 추측
|
||||
$cardBrand = guessCardTypeFromNumber($card->CardNum ?? '');
|
||||
|
||||
// 카드 회사명 (신한, KB 등)
|
||||
$cardCompanyName = !empty($card->CardCompanyName)
|
||||
? $card->CardCompanyName
|
||||
: getCardCompanyName($cardCompanyCode);
|
||||
|
||||
$cards[] = [
|
||||
'cardNum' => $card->CardNum ?? '',
|
||||
'cardNumMasked' => maskCardNumber($card->CardNum ?? ''),
|
||||
'cardCompany' => $cardCompanyCode,
|
||||
'cardCompanyName' => $cardCompanyName,
|
||||
'cardBrand' => $cardBrand, // 카드 브랜드 (비자, 마스터카드 등)
|
||||
'alias' => $card->Alias ?? '',
|
||||
'cardType' => $card->CardType ?? '',
|
||||
'cardTypeName' => getCardTypeName($card->CardType ?? ''),
|
||||
'status' => $card->Status ?? '',
|
||||
'statusName' => getCardStatusName($card->Status ?? ''),
|
||||
'collectCycle' => $card->CollectCycle ?? '',
|
||||
'collectCycleName' => getCollectCycleName($card->CollectCycle ?? ''),
|
||||
'lastCollectDate' => formatDate($card->LastCollectDate ?? ''),
|
||||
'lastCollectResult' => $card->LastCollectResult ?? '',
|
||||
'lastCollectResultName' => getCollectResultName($card->LastCollectResult ?? ''),
|
||||
'nextExtendDate' => formatDate($card->NextExtendDate ?? ''),
|
||||
'registDate' => formatDate($card->RegistDate ?? ''),
|
||||
'webId' => $card->WebId ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'cards' => $cards,
|
||||
'count' => count($cards)
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 마스킹
|
||||
*/
|
||||
function maskCardNumber($cardNum) {
|
||||
if (strlen($cardNum) < 8) return $cardNum;
|
||||
return substr($cardNum, 0, 4) . '-****-****-' . substr($cardNum, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
function formatDate($date) {
|
||||
if (empty($date)) return '';
|
||||
if (strlen($date) === 8) {
|
||||
return substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호로 카드 종류 추측 (BIN 코드 기반)
|
||||
*/
|
||||
function guessCardTypeFromNumber($cardNum) {
|
||||
if (empty($cardNum) || strlen($cardNum) < 4) {
|
||||
return '카드';
|
||||
}
|
||||
|
||||
$bin = substr($cardNum, 0, 4);
|
||||
|
||||
// 주요 카드사 BIN 코드
|
||||
$binMappings = [
|
||||
'4518' => '비자',
|
||||
'4092' => '비자',
|
||||
'4569' => '비자',
|
||||
'4563' => '비자',
|
||||
'5' => '마스터카드', // 5로 시작
|
||||
'3528' => 'JCB',
|
||||
'3529' => 'JCB',
|
||||
'3' => '아멕스/다이너스', // 34, 37로 시작
|
||||
'9' => '국내전용카드'
|
||||
];
|
||||
|
||||
// 정확한 매칭 시도
|
||||
if (isset($binMappings[$bin])) {
|
||||
return $binMappings[$bin];
|
||||
}
|
||||
|
||||
// 첫 번째 숫자로 매칭 시도
|
||||
$firstDigit = substr($cardNum, 0, 1);
|
||||
if (isset($binMappings[$firstDigit])) {
|
||||
return $binMappings[$firstDigit];
|
||||
}
|
||||
|
||||
return '카드';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드사 코드 -> 이름 변환
|
||||
* 바로빌 카드사 코드 참고
|
||||
*/
|
||||
function getCardCompanyName($code) {
|
||||
$companies = [
|
||||
'01' => '비씨카드',
|
||||
'02' => 'KB국민카드',
|
||||
'03' => '하나카드(외환)',
|
||||
'04' => '삼성카드',
|
||||
'06' => '신한카드',
|
||||
'07' => '현대카드',
|
||||
'08' => '롯데카드',
|
||||
'11' => 'NH농협카드',
|
||||
'12' => '수협카드',
|
||||
'13' => '씨티카드',
|
||||
'14' => '우리카드',
|
||||
'15' => '광주카드',
|
||||
'16' => '전북카드',
|
||||
'21' => '하나카드',
|
||||
'22' => '제주카드',
|
||||
'23' => 'SC제일카드',
|
||||
'25' => 'KDB산업카드',
|
||||
'26' => 'IBK기업카드',
|
||||
'27' => '새마을금고',
|
||||
'28' => '신협카드',
|
||||
'29' => '저축은행',
|
||||
'30' => '우체국카드',
|
||||
'31' => '카카오뱅크',
|
||||
'32' => 'K뱅크',
|
||||
'33' => '토스뱅크',
|
||||
'BC' => '비씨카드',
|
||||
'KB' => 'KB국민카드',
|
||||
'HANA' => '하나카드',
|
||||
'SAMSUNG' => '삼성카드',
|
||||
'SHINHAN' => '신한카드',
|
||||
'HYUNDAI' => '현대카드',
|
||||
'LOTTE' => '롯데카드',
|
||||
'NH' => 'NH농협카드',
|
||||
'SUHYUP' => '수협카드',
|
||||
'CITI' => '씨티카드',
|
||||
'WOORI' => '우리카드',
|
||||
'KJBANK' => '광주카드',
|
||||
'JBBANK' => '전북카드'
|
||||
];
|
||||
return $companies[$code] ?? $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 종류 코드 -> 이름 변환
|
||||
*/
|
||||
function getCardTypeName($type) {
|
||||
$types = [
|
||||
'1' => '개인카드',
|
||||
'2' => '법인카드'
|
||||
];
|
||||
return $types[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 상태 코드 -> 이름 변환
|
||||
*/
|
||||
function getCardStatusName($status) {
|
||||
$statuses = [
|
||||
'0' => '대기중',
|
||||
'1' => '정상',
|
||||
'2' => '해지',
|
||||
'3' => '수집오류',
|
||||
'4' => '일시중지'
|
||||
];
|
||||
return $statuses[$status] ?? $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집주기 코드 -> 이름 변환
|
||||
*/
|
||||
function getCollectCycleName($cycle) {
|
||||
$cycles = [
|
||||
'1' => '1일 1회',
|
||||
'2' => '1일 2회',
|
||||
'3' => '1일 3회'
|
||||
];
|
||||
return $cycles[$cycle] ?? $cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수집결과 코드 -> 이름 변환
|
||||
*/
|
||||
function getCollectResultName($result) {
|
||||
$results = [
|
||||
'0' => '대기',
|
||||
'1' => '성공',
|
||||
'2' => '실패',
|
||||
'3' => '진행중'
|
||||
];
|
||||
return $results[$result] ?? $result;
|
||||
}
|
||||
?>
|
||||
|
||||
65
barobill/ecard/api/debug_raw.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 API 응답 raw 데이터 확인용 (디버그)
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
|
||||
try {
|
||||
$startDate = $_GET['startDate'] ?? date('Ymd', strtotime('-60 days'));
|
||||
$endDate = $_GET['endDate'] ?? date('Ymd');
|
||||
|
||||
// SOAP 직접 호출
|
||||
global $barobillCardSoapClient, $barobillCertKey, $barobillCorpNum;
|
||||
|
||||
$params = [
|
||||
'CERTKEY' => $barobillCertKey,
|
||||
'CorpNum' => $barobillCorpNum,
|
||||
'ID' => '',
|
||||
'CardNum' => '',
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => 10,
|
||||
'CurrentPage' => 1,
|
||||
'OrderDirection' => 2
|
||||
];
|
||||
|
||||
$result = $barobillCardSoapClient->GetPeriodCardApprovalLog($params);
|
||||
$resultData = $result->GetPeriodCardApprovalLogResult;
|
||||
|
||||
// raw 데이터 출력
|
||||
$output = [
|
||||
'params' => $params,
|
||||
'resultKeys' => is_object($resultData) ? array_keys(get_object_vars($resultData)) : 'not object',
|
||||
'CurrentPage' => $resultData->CurrentPage ?? null,
|
||||
'MaxIndex' => $resultData->MaxIndex ?? null
|
||||
];
|
||||
|
||||
// CardLogList 확인
|
||||
if (isset($resultData->CardLogList)) {
|
||||
$output['CardLogListKeys'] = is_object($resultData->CardLogList) ? array_keys(get_object_vars($resultData->CardLogList)) : 'not object';
|
||||
|
||||
if (isset($resultData->CardLogList->CardApprovalLog)) {
|
||||
$logs = $resultData->CardLogList->CardApprovalLog;
|
||||
if (!is_array($logs)) {
|
||||
$logs = [$logs];
|
||||
}
|
||||
|
||||
if (!empty($logs)) {
|
||||
$firstLog = $logs[0];
|
||||
$output['firstLogKeys'] = is_object($firstLog) ? array_keys(get_object_vars($firstLog)) : 'not object';
|
||||
$output['firstLogData'] = $firstLog;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode($output, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'error' => $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
|
||||
402
barobill/ecard/api/usage.php
Normal file
@@ -0,0 +1,402 @@
|
||||
<?php
|
||||
/**
|
||||
* 카드 사용내역 조회 API
|
||||
*
|
||||
* ============================================================================
|
||||
* 데이터 흐름
|
||||
* ============================================================================
|
||||
*
|
||||
* [호출 경로]
|
||||
* 화면(ecard/index.php) → 이 API(usage.php) → barobill_card_config.php::getPeriodCardUsage()
|
||||
* → 바로빌 SOAP API(GetPeriodCardApprovalLog) → 바로빌 서버
|
||||
*
|
||||
* [사용내역 정보 출처]
|
||||
* - 바로빌이 카드사에서 자동으로 수집한 사용내역
|
||||
* - 카드 등록 시 설정한 수집주기(CollectCycle)에 따라 1일 1회~3회 자동 수집
|
||||
* - 수집된 데이터는 바로빌 서버에 저장됨
|
||||
*
|
||||
* [수집 과정]
|
||||
* 1. 카드 등록 시 Web ID/비밀번호로 카드사 홈페이지에 로그인
|
||||
* 2. 바로빌이 설정된 수집주기에 따라 카드사에서 사용내역 자동 수집
|
||||
* 3. 수집된 데이터를 바로빌 서버에 저장
|
||||
* 4. 이 API를 통해 저장된 사용내역 조회
|
||||
*
|
||||
* 파라미터:
|
||||
* - type: daily(일별), monthly(월별), period(기간별, 기본값)
|
||||
* - cardNum: 카드번호 (빈값이면 전체)
|
||||
* - startDate: 시작일 (YYYYMMDD) - period 타입
|
||||
* - endDate: 종료일 (YYYYMMDD) - period 타입
|
||||
* - baseDate: 기준일 (YYYYMMDD) - daily 타입
|
||||
* - baseMonth: 기준월 (YYYYMM) - monthly 타입
|
||||
* - page: 페이지 번호 (기본 1)
|
||||
* - limit: 페이지당 건수 (기본 50)
|
||||
* - debug: 1이면 디버그 정보 포함
|
||||
*
|
||||
* [반환 데이터]
|
||||
* - approvalDateTime: 승인일시
|
||||
* - cardNum: 카드번호 (마스킹)
|
||||
* - cardNumFull: 카드번호 (전체)
|
||||
* - merchantName: 가맹점명
|
||||
* - merchantBizNum: 가맹점 사업자번호
|
||||
* - amount: 승인금액
|
||||
* - vat: 부가세
|
||||
* - serviceCharge: 봉사료
|
||||
* - approvalType: 승인유형 (1=승인, 2=취소)
|
||||
* - installment: 할부개월 (0=일시불)
|
||||
* - approvalNum: 승인번호
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
|
||||
// 디버그 모드
|
||||
$debugMode = isset($_GET['debug']) && $_GET['debug'] == '1';
|
||||
|
||||
try {
|
||||
$type = $_GET['type'] ?? 'period';
|
||||
$cardNum = $_GET['cardNum'] ?? '';
|
||||
$page = max(1, intval($_GET['page'] ?? 1));
|
||||
$limit = min(100, max(10, intval($_GET['limit'] ?? 50)));
|
||||
$orderDirection = intval($_GET['order'] ?? 2); // 2: 내림차순 (최신순)
|
||||
|
||||
$result = null;
|
||||
|
||||
// cardNum이 빈 값이면 전체 카드 조회 (각 카드별로 조회 후 병합)
|
||||
if (empty($cardNum)) {
|
||||
// 등록된 카드 목록 조회
|
||||
$cardsResult = getCardList(1); // 사용 가능한 카드만
|
||||
if (!$cardsResult['success'] || empty($cardsResult['data'])) {
|
||||
$result = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => 1,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => 1,
|
||||
'maxIndex' => 0,
|
||||
'logs' => []
|
||||
]
|
||||
];
|
||||
} else {
|
||||
// 각 카드별로 조회 후 병합
|
||||
$allLogs = [];
|
||||
foreach ($cardsResult['data'] as $card) {
|
||||
$cardNumToQuery = $card->CardNum ?? '';
|
||||
if (empty($cardNumToQuery)) continue;
|
||||
|
||||
switch ($type) {
|
||||
case 'daily':
|
||||
$baseDate = $_GET['baseDate'] ?? date('Ymd');
|
||||
$tempResult = getDailyCardUsage($cardNumToQuery, $baseDate, 100, 1, $orderDirection);
|
||||
break;
|
||||
case 'monthly':
|
||||
$baseMonth = $_GET['baseMonth'] ?? date('Ym');
|
||||
$tempResult = getMonthlyCardUsage($cardNumToQuery, $baseMonth, 100, 1, $orderDirection);
|
||||
break;
|
||||
case 'period':
|
||||
default:
|
||||
$startDate = $_GET['startDate'] ?? date('Ymd', strtotime('-30 days'));
|
||||
$endDate = $_GET['endDate'] ?? date('Ymd');
|
||||
$tempResult = getPeriodCardUsage($cardNumToQuery, $startDate, $endDate, 100, 1, $orderDirection);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($tempResult['success'] && !empty($tempResult['data']['logs'])) {
|
||||
$allLogs = array_merge($allLogs, $tempResult['data']['logs']);
|
||||
}
|
||||
}
|
||||
|
||||
// UseDT 기준으로 정렬
|
||||
usort($allLogs, function($a, $b) use ($orderDirection) {
|
||||
$aTime = $a->UseDT ?? '';
|
||||
$bTime = $b->UseDT ?? '';
|
||||
return $orderDirection == 1 ? strcmp($aTime, $bTime) : strcmp($bTime, $aTime);
|
||||
});
|
||||
|
||||
// 페이징 처리
|
||||
$totalCount = count($allLogs);
|
||||
$maxPageNum = ceil($totalCount / $limit);
|
||||
$offset = ($page - 1) * $limit;
|
||||
$pagedLogs = array_slice($allLogs, $offset, $limit);
|
||||
|
||||
$result = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'currentPage' => $page,
|
||||
'countPerPage' => $limit,
|
||||
'maxPageNum' => $maxPageNum,
|
||||
'maxIndex' => $totalCount,
|
||||
'logs' => $pagedLogs
|
||||
]
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 특정 카드 조회
|
||||
switch ($type) {
|
||||
case 'daily':
|
||||
$baseDate = $_GET['baseDate'] ?? date('Ymd');
|
||||
$result = getDailyCardUsage($cardNum, $baseDate, $limit, $page, $orderDirection);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$baseMonth = $_GET['baseMonth'] ?? date('Ym');
|
||||
$result = getMonthlyCardUsage($cardNum, $baseMonth, $limit, $page, $orderDirection);
|
||||
break;
|
||||
|
||||
case 'period':
|
||||
default:
|
||||
$startDate = $_GET['startDate'] ?? date('Ymd', strtotime('-30 days'));
|
||||
$endDate = $_GET['endDate'] ?? date('Ymd');
|
||||
$result = getPeriodCardUsage($cardNum, $startDate, $endDate, $limit, $page, $orderDirection);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$logs = [];
|
||||
|
||||
// 디버그: raw 로그 데이터 출력
|
||||
if ($debugMode && !empty($result['data']['logs'])) {
|
||||
$firstLog = $result['data']['logs'][0];
|
||||
error_log('CardApprovalLog raw data: ' . print_r($firstLog, true));
|
||||
// 디버그: 모든 필드명 확인
|
||||
if (is_object($firstLog)) {
|
||||
$fields = get_object_vars($firstLog);
|
||||
error_log('CardApprovalLog fields: ' . implode(', ', array_keys($fields)));
|
||||
error_log('ApprovalAmount value: ' . ($firstLog->ApprovalAmount ?? 'NOT SET'));
|
||||
error_log('Amount value: ' . ($firstLog->Amount ?? 'NOT SET'));
|
||||
error_log('TotalAmount value: ' . ($firstLog->TotalAmount ?? 'NOT SET'));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($result['data']['logs'] as $log) {
|
||||
// UseDT 형식: YYYYMMDDHHMMSS
|
||||
$useDT = $log->UseDT ?? '';
|
||||
$approvalDate = '';
|
||||
$approvalTime = '';
|
||||
if (strlen($useDT) >= 8) {
|
||||
$approvalDate = substr($useDT, 0, 4) . '-' . substr($useDT, 4, 2) . '-' . substr($useDT, 6, 2);
|
||||
}
|
||||
if (strlen($useDT) >= 14) {
|
||||
$approvalTime = substr($useDT, 8, 2) . ':' . substr($useDT, 10, 2) . ':' . substr($useDT, 12, 2);
|
||||
} elseif (strlen($useDT) >= 12) {
|
||||
$approvalTime = substr($useDT, 8, 2) . ':' . substr($useDT, 10, 2);
|
||||
}
|
||||
|
||||
$logs[] = [
|
||||
'cardNum' => maskCardNumber($log->CardNum ?? ''),
|
||||
'cardNumFull' => $log->CardNum ?? '',
|
||||
'approvalNum' => $log->ApprovalNum ?? '',
|
||||
'approvalDate' => $approvalDate,
|
||||
'approvalTime' => $approvalTime,
|
||||
'approvalDateTime' => $approvalDate . ' ' . $approvalTime,
|
||||
'merchantName' => $log->UseStoreName ?? '',
|
||||
'merchantBizNum' => $log->UseStoreCorpNum ?? '',
|
||||
// 금액 필드: 여러 가능한 필드명 시도
|
||||
// ApprovalAmount가 실제 승인금액 (화면에 표시할 금액)
|
||||
'amount' => intval($log->ApprovalAmount ?? 0),
|
||||
'amountFormatted' => number_format(intval($log->ApprovalAmount ?? 0)),
|
||||
'vat' => intval($log->Tax ?? 0),
|
||||
'vatFormatted' => number_format(intval($log->Tax ?? 0)),
|
||||
'serviceCharge' => intval($log->ServiceCharge ?? 0),
|
||||
// totalAmount는 화면에서 사용하므로 ApprovalAmount를 사용
|
||||
'totalAmount' => intval($log->ApprovalAmount ?? 0),
|
||||
'totalAmountFormatted' => number_format(intval($log->ApprovalAmount ?? 0)),
|
||||
'approvalType' => $log->ApprovalType ?? '',
|
||||
'approvalTypeName' => getApprovalTypeName($log->ApprovalType ?? ''),
|
||||
'installment' => $log->PaymentPlan ?? '',
|
||||
'installmentName' => getInstallmentName($log->PaymentPlan ?? ''),
|
||||
'currencyCode' => $log->CurrencyCode ?? 'KRW',
|
||||
'memo' => $log->Memo ?? '',
|
||||
'cardCompany' => $log->CardCompany ?? '',
|
||||
'cardCompanyName' => getCardCompanyNameFromLog($log->CardCompany ?? ''),
|
||||
// 추가 필드
|
||||
'useKey' => $log->UseKey ?? '',
|
||||
'storeAddress' => $log->UseStoreAddr ?? '',
|
||||
'storeCeo' => $log->UseStoreCeo ?? '',
|
||||
'storeBizType' => $log->UseStoreBizType ?? '',
|
||||
'storeTel' => $log->UseStoreTel ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
// 통계 계산
|
||||
$totalAmount = array_sum(array_column($logs, 'totalAmount'));
|
||||
$approvalCount = count(array_filter($logs, function($l) { return $l['approvalType'] == '1'; }));
|
||||
$cancelCount = count(array_filter($logs, function($l) { return $l['approvalType'] == '2'; }));
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $logs,
|
||||
'pagination' => [
|
||||
'currentPage' => $result['data']['currentPage'],
|
||||
'countPerPage' => $result['data']['countPerPage'],
|
||||
'maxPageNum' => $result['data']['maxPageNum'],
|
||||
'totalCount' => $result['data']['maxIndex']
|
||||
],
|
||||
'summary' => [
|
||||
'totalAmount' => $totalAmount,
|
||||
'totalAmountFormatted' => number_format($totalAmount),
|
||||
'count' => count($logs),
|
||||
'approvalCount' => $approvalCount,
|
||||
'cancelCount' => $cancelCount
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
// 디버그 정보 추가 (성공한 경우에도 항상 첫 번째 로그의 필드 정보 출력)
|
||||
if (!empty($result['data']['logs'])) {
|
||||
$firstLog = $result['data']['logs'][0];
|
||||
if (is_object($firstLog)) {
|
||||
// 모든 필드를 배열로 변환
|
||||
$allFields = get_object_vars($firstLog);
|
||||
$fieldNames = array_keys($allFields);
|
||||
|
||||
// 금액 관련 필드 찾기 (대소문자 구분 없이)
|
||||
$amountFields = [];
|
||||
foreach ($fieldNames as $fieldName) {
|
||||
if (stripos($fieldName, 'amount') !== false ||
|
||||
stripos($fieldName, 'cost') !== false ||
|
||||
stripos($fieldName, 'price') !== false ||
|
||||
stripos($fieldName, '금액') !== false) {
|
||||
$amountFields[$fieldName] = (string)($firstLog->$fieldName ?? 'NULL');
|
||||
}
|
||||
}
|
||||
|
||||
// 디버그 모드일 때만 상세 정보 출력
|
||||
if ($debugMode) {
|
||||
$response['debug'] = [
|
||||
'userId' => getBarobillUserId(),
|
||||
'params' => $_GET,
|
||||
'firstLogFields' => $fieldNames,
|
||||
'firstLogAllValues' => array_map(function($v) {
|
||||
return is_string($v) ? $v : (is_numeric($v) ? (string)$v : gettype($v));
|
||||
}, $allFields),
|
||||
'amountFields' => $amountFields
|
||||
];
|
||||
} else {
|
||||
// 디버그 모드가 아니어도 금액 필드 정보는 항상 포함 (문제 해결용)
|
||||
$response['debug'] = [
|
||||
'amountFields' => $amountFields,
|
||||
'allFields' => $fieldNames
|
||||
];
|
||||
}
|
||||
}
|
||||
} elseif ($debugMode) {
|
||||
$response['debug'] = [
|
||||
'userId' => getBarobillUserId(),
|
||||
'params' => $_GET,
|
||||
'message' => 'No logs found'
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
];
|
||||
|
||||
// 디버그 정보 추가
|
||||
if ($debugMode) {
|
||||
$response['debug'] = [
|
||||
'userId' => getBarobillUserId(),
|
||||
'params' => $_GET
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 마스킹
|
||||
*/
|
||||
function maskCardNumber($cardNum) {
|
||||
if (strlen($cardNum) < 8) return $cardNum;
|
||||
return substr($cardNum, 0, 4) . '-****-****-' . substr($cardNum, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅
|
||||
*/
|
||||
function formatDate($date) {
|
||||
if (strlen($date) === 8) {
|
||||
return substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
|
||||
}
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 포맷팅
|
||||
*/
|
||||
function formatTime($time) {
|
||||
if (strlen($time) === 6) {
|
||||
return substr($time, 0, 2) . ':' . substr($time, 2, 2) . ':' . substr($time, 4, 2);
|
||||
} elseif (strlen($time) === 4) {
|
||||
return substr($time, 0, 2) . ':' . substr($time, 2, 2);
|
||||
}
|
||||
return $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 유형 이름
|
||||
*/
|
||||
function getApprovalTypeName($type) {
|
||||
$types = [
|
||||
'1' => '승인',
|
||||
'2' => '취소'
|
||||
];
|
||||
return $types[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 할부 이름
|
||||
*/
|
||||
function getInstallmentName($installment) {
|
||||
if (empty($installment) || $installment == '0' || $installment == '00') {
|
||||
return '일시불';
|
||||
}
|
||||
return $installment . '개월';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드사 이름 (로그용)
|
||||
*/
|
||||
function getCardCompanyNameFromLog($code) {
|
||||
$companies = [
|
||||
'01' => '비씨',
|
||||
'02' => 'KB국민',
|
||||
'03' => '하나(외환)',
|
||||
'04' => '삼성',
|
||||
'06' => '신한',
|
||||
'07' => '현대',
|
||||
'08' => '롯데',
|
||||
'11' => 'NH농협',
|
||||
'12' => '수협',
|
||||
'13' => '씨티',
|
||||
'14' => '우리',
|
||||
'15' => '광주',
|
||||
'16' => '전북',
|
||||
'21' => '하나',
|
||||
'22' => '제주',
|
||||
'23' => 'SC제일',
|
||||
'25' => 'KDB산업',
|
||||
'26' => 'IBK기업',
|
||||
'27' => '새마을금고',
|
||||
'28' => '신협',
|
||||
'29' => '저축은행',
|
||||
'30' => '우체국',
|
||||
'31' => '카카오뱅크',
|
||||
'32' => 'K뱅크',
|
||||
'33' => '토스뱅크'
|
||||
];
|
||||
return $companies[$code] ?? $code;
|
||||
}
|
||||
?>
|
||||
|
||||
998
barobill/ecard/index.php
Normal file
@@ -0,0 +1,998 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>법인카드 사용내역 조회 - 바로빌 연동</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../../img/favicon.png">
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(250, 250, 250)',
|
||||
primary: {
|
||||
DEFAULT: '#059669',
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#10b981',
|
||||
light: '#d1fae5',
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
'card': '12px',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- React & ReactDOM -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- Babel for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body class="bg-background text-slate-800 antialiased">
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
// SVG Icon Components (React-safe)
|
||||
const Icons = {
|
||||
wallet: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
|
||||
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
|
||||
<path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>
|
||||
</svg>
|
||||
),
|
||||
bank: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 21h18"/>
|
||||
<path d="M5 21v-7"/>
|
||||
<path d="M19 21v-7"/>
|
||||
<path d="M10 9L3 21"/>
|
||||
<path d="M14 9l7 12"/>
|
||||
<rect x="2" y="3" width="20" height="5"/>
|
||||
<line x1="12" x2="12" y1="21" y2="8"/>
|
||||
</svg>
|
||||
),
|
||||
building: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" ry="2"/>
|
||||
<path d="M9 22v-4h6v4"/>
|
||||
<path d="M8 6h.01"/>
|
||||
<path d="M16 6h.01"/>
|
||||
<path d="M12 6h.01"/>
|
||||
<path d="M12 10h.01"/>
|
||||
<path d="M12 14h.01"/>
|
||||
<path d="M16 10h.01"/>
|
||||
<path d="M16 14h.01"/>
|
||||
<path d="M8 10h.01"/>
|
||||
<path d="M8 14h.01"/>
|
||||
</svg>
|
||||
),
|
||||
creditCard: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="20" height="14" x="2" y="5" rx="2"/>
|
||||
<line x1="2" x2="22" y1="10" y2="10"/>
|
||||
</svg>
|
||||
),
|
||||
creditCardLarge: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="opacity-50">
|
||||
<rect width="20" height="14" x="2" y="5" rx="2"/>
|
||||
<line x1="2" x2="22" y1="10" y2="10"/>
|
||||
</svg>
|
||||
),
|
||||
receipt: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1-2-1Z"/>
|
||||
<path d="M14 8H8"/>
|
||||
<path d="M16 12H8"/>
|
||||
<path d="M13 16H8"/>
|
||||
</svg>
|
||||
),
|
||||
users: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
bookOpen: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a4 4 0 0 0-4-4H2z"/>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a4 4 0 0 1 4-4h6z"/>
|
||||
</svg>
|
||||
),
|
||||
home: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
),
|
||||
search: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
download: ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" x2="12" y1="15" y2="3"/>
|
||||
</svg>
|
||||
),
|
||||
info: ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 16v-4"/>
|
||||
<path d="M12 8h.01"/>
|
||||
</svg>
|
||||
),
|
||||
alertCircle: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" x2="12" y1="8" y2="12"/>
|
||||
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
xCircle: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="m15 9-6 6"/>
|
||||
<path d="m9 9 6 6"/>
|
||||
</svg>
|
||||
),
|
||||
filter: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
),
|
||||
arrowUp: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="19" x2="12" y2="5"></line>
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
),
|
||||
arrowDown: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<polyline points="19 12 12 19 5 12"></polyline>
|
||||
</svg>
|
||||
),
|
||||
chevronsLeft: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m11 17-5-5 5-5"/>
|
||||
<path d="m18 17-5-5 5-5"/>
|
||||
</svg>
|
||||
),
|
||||
chevronLeft: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronRight: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
),
|
||||
chevronsRight: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 17 5-5-5-5"/>
|
||||
<path d="m13 17 5-5-5-5"/>
|
||||
</svg>
|
||||
),
|
||||
x: ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</svg>
|
||||
),
|
||||
fileText: () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
|
||||
<path d="M10 9H8"/>
|
||||
<path d="M16 13H8"/>
|
||||
<path d="M16 17H8"/>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// Header Component
|
||||
const Header = ({ onOpenApiInfo }) => (
|
||||
<header className="bg-white/80 backdrop-blur-md border-b border-emerald-100/50 sticky top-0 z-50 transition-all shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-18 flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-emerald-200/50 ring-4 ring-emerald-50">
|
||||
<Icons.creditCard />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900 tracking-tight leading-none">법인카드 내역</h1>
|
||||
<p className="text-[10px] text-emerald-600 font-semibold mt-1 uppercase tracking-wider opacity-70">Corporate Card History</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 font-medium">
|
||||
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/50 mr-2">
|
||||
<a href="../eaccount/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-600 hover:bg-white transition-all duration-200">
|
||||
<Icons.wallet /> <span>계좌조회</span>
|
||||
</a>
|
||||
<a href="index.php" className="flex items-center gap-4 px-4 py-2 rounded-lg bg-white text-emerald-600 shadow-sm border border-emerald-100 font-bold">
|
||||
<Icons.creditCard /> <span>카드내역</span>
|
||||
</a>
|
||||
<a href="../tenant/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-600 hover:bg-white transition-all duration-200">
|
||||
<Icons.building /> <span>테넌트</span>
|
||||
</a>
|
||||
<a href="../registration/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-600 hover:bg-white transition-all duration-200">
|
||||
<Icons.users /> <span>바로빌 회원관리</span>
|
||||
</a>
|
||||
<button onClick={(e) => { e.preventDefault(); onOpenApiInfo(); }} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-emerald-600 hover:bg-white transition-all duration-200">
|
||||
<Icons.bookOpen /> <span>API정보</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200 mx-2"></div>
|
||||
|
||||
<a href="../etax/index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-emerald-600 transition-colors">
|
||||
<Icons.receipt /> <span className="hidden lg:inline text-xs">세금계산서</span>
|
||||
</a>
|
||||
<a href="../../index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-emerald-600 transition-colors">
|
||||
<Icons.home /> <span className="hidden lg:inline text-xs">홈</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
// StatCard Component
|
||||
const StatCard = ({ title, value, subtext, icon, color = 'emerald' }) => (
|
||||
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-slate-500">{title}</h3>
|
||||
<div className={`p-2 bg-${color}-50 rounded-lg text-${color}-600`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900 mb-1">{value}</div>
|
||||
{subtext && <div className="text-xs text-slate-400">{subtext}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Card Selector Component
|
||||
const CardSelector = ({ cards, selectedCard, onSelect }) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onSelect('')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCard === ''
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-white border border-slate-200 text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
전체 카드
|
||||
</button>
|
||||
{cards.map(card => (
|
||||
<button
|
||||
key={card.cardNum}
|
||||
onClick={() => onSelect(card.cardNum)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCard === card.cardNum
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-white border border-slate-200 text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{card.cardBrand}[{card.alias}({card.cardNum.slice(-4)})]
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// TXT Export Modal Component
|
||||
const TxtExportModal = ({ isOpen, onClose, logs }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const textareaRef = React.useRef(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// TSV 형식으로 변환 (엑셀 붙여넣기 가능)
|
||||
const generateTSV = () => {
|
||||
const headers = ['승인일시', '카드번호', '가맹점명', '가맹점사업자번호', '금액', '부가세', '봉사료', '할부', '구분', '승인번호', '통화', '메모'];
|
||||
const headerRow = headers.join('\t');
|
||||
|
||||
const dataRows = logs.map(log => [
|
||||
log.approvalDateTime,
|
||||
log.cardNumFull,
|
||||
log.merchantName,
|
||||
log.merchantBizNum,
|
||||
log.amount,
|
||||
log.vat,
|
||||
log.serviceCharge,
|
||||
log.installmentName,
|
||||
log.approvalTypeName,
|
||||
log.approvalNum,
|
||||
log.currencyCode,
|
||||
log.memo
|
||||
].join('\t'));
|
||||
|
||||
return headerRow + '\n' + dataRows.join('\n');
|
||||
};
|
||||
|
||||
const tsvData = generateTSV();
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tsvData);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// 복사 실패 시 textarea 선택
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.select();
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-6xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">내역 추출</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">엑셀에 붙여넣기 가능한 형식입니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
copied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{copied ? '✓ 복사됨' : '전체 복사'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<Icons.x className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 본문 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={tsvData}
|
||||
readOnly
|
||||
className="w-full h-full min-h-[400px] font-mono text-xs border border-slate-300 rounded-lg p-4 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="p-4 border-t border-slate-200 bg-slate-50 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.info className="w-4 h-4" />
|
||||
<span>총 {logs.length}건의 내역 | Ctrl+A로 전체 선택 후 Ctrl+C로 복사하거나 '전체 복사' 버튼을 사용하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Field Export Modal Component (Input Grid)
|
||||
const FieldExportModal = ({ isOpen, onClose, logs }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const fields = [
|
||||
{ key: 'approvalDateTime', label: '승인일시' },
|
||||
{ key: 'cardNumFull', label: '카드번호' },
|
||||
{ key: 'merchantName', label: '가맹점명' },
|
||||
{ key: 'merchantBizNum', label: '가맹점사업자번호' },
|
||||
{ key: 'amount', label: '금액' },
|
||||
{ key: 'vat', label: '부가세' },
|
||||
{ key: 'serviceCharge', label: '봉사료' },
|
||||
{ key: 'installmentName', label: '할부' },
|
||||
{ key: 'approvalTypeName', label: '구분' },
|
||||
{ key: 'approvalNum', label: '승인번호' },
|
||||
{ key: 'currencyCode', label: '통화' },
|
||||
{ key: 'memo', label: '메모' }
|
||||
];
|
||||
|
||||
// TSV 형식으로 변환 (복사용)
|
||||
const generateTSV = () => {
|
||||
const headers = fields.map(f => f.label).join('\t');
|
||||
const dataRows = logs.map(log =>
|
||||
fields.map(f => log[f.key] ?? '').join('\t')
|
||||
);
|
||||
return headers + '\n' + dataRows.join('\n');
|
||||
};
|
||||
|
||||
const handleCopyAll = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generateTSV());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
alert('복사 실패: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-7xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">필드 추출</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">각 필드별로 데이터를 확인하고 복사할 수 있습니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
copied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{copied ? '✓ 복사됨' : '전체 복사'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<Icons.x className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 본문 - Input Grid */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="border border-slate-300 rounded-lg overflow-hidden">
|
||||
{/* 헤더 행 */}
|
||||
<div className="grid grid-cols-12 bg-slate-100 border-b border-slate-300">
|
||||
{fields.map((field, idx) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="px-2 py-3 text-xs font-bold text-slate-700 border-r border-slate-300 last:border-r-0 text-center"
|
||||
>
|
||||
{field.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행들 */}
|
||||
{logs.map((log, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className="grid grid-cols-12 border-b border-slate-200 last:border-b-0 hover:bg-slate-50"
|
||||
>
|
||||
{fields.map((field, colIdx) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="border-r border-slate-200 last:border-r-0"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={log[field.key] ?? ''}
|
||||
readOnly
|
||||
className="w-full px-2 py-2 text-xs border-0 focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-transparent"
|
||||
onFocus={e => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="p-4 border-t border-slate-200 bg-slate-50 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.info className="w-4 h-4" />
|
||||
<span>총 {logs.length}건의 내역 | 각 필드를 클릭하면 자동 선택됩니다 | '전체 복사' 버튼으로 엑셀 붙여넣기 가능</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiInfoModal = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-emerald-100 flex justify-between items-center bg-emerald-50/50">
|
||||
<h3 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-emerald-500 rounded-full"></span>
|
||||
바로빌 API 상세 정보
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-2 rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors">
|
||||
<Icons.x className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-50">
|
||||
<iframe
|
||||
src="../etax/barobill_api_info.php"
|
||||
className="w-full h-full border-none min-h-[600px]"
|
||||
title="API Information"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Date Range Selector Component
|
||||
const DateRangeSelector = ({ startDate, endDate, onStartChange, onEndChange, onSearch }) => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-slate-600">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartChange(e.target.value)}
|
||||
className="rounded-lg border border-slate-200 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-slate-400">~</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-slate-600">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndChange(e.target.value)}
|
||||
className="rounded-lg border border-slate-200 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSearch}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Icons.search />
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Quick Date Buttons Component
|
||||
const QuickDateButtons = ({ onSelect }) => {
|
||||
const buttons = [
|
||||
{ label: '오늘', days: 0 },
|
||||
{ label: '7일', days: 7 },
|
||||
{ label: '30일', days: 30 },
|
||||
{ label: '3개월', days: 90 },
|
||||
{ label: '6개월', days: 180 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{buttons.map(btn => (
|
||||
<button
|
||||
key={btn.days}
|
||||
onClick={() => onSelect(btn.days)}
|
||||
className="px-3 py-1.5 text-xs bg-slate-100 hover:bg-slate-200 rounded-md text-slate-600 transition-colors"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage Table Component
|
||||
const UsageTable = ({ logs, loading }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-slate-400">
|
||||
<div className="flex justify-center mb-4"><Icons.creditCardLarge /></div>
|
||||
<p>조회된 사용내역이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-slate-600">
|
||||
<thead className="bg-slate-50 text-xs uppercase font-medium text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">승인일시</th>
|
||||
<th className="px-4 py-3">카드번호</th>
|
||||
<th className="px-4 py-3">가맹점명</th>
|
||||
<th className="px-4 py-3 text-right">금액</th>
|
||||
<th className="px-4 py-3 text-center">할부</th>
|
||||
<th className="px-4 py-3 text-center">구분</th>
|
||||
<th className="px-4 py-3">승인번호</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{logs.map((log, index) => (
|
||||
<tr key={index} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="font-medium text-slate-900">{log.approvalDate}</div>
|
||||
<div className="text-xs text-slate-400">{log.approvalTime}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs">{log.cardNum}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-900">{log.merchantName}</div>
|
||||
{log.merchantBizNum && (
|
||||
<div className="text-xs text-slate-400">{log.merchantBizNum}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={`font-bold ${log.approvalType === '2' ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{log.approvalType === '2' ? '-' : ''}{log.totalAmountFormatted}원
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="text-xs">{log.installmentName}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
log.approvalType === '2'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-emerald-100 text-emerald-800'
|
||||
}`}>
|
||||
{log.approvalTypeName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-slate-500">{log.approvalNum}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Pagination Component
|
||||
const Pagination = ({ currentPage, maxPageNum, onPageChange }) => {
|
||||
if (maxPageNum <= 1) return null;
|
||||
|
||||
const pages = [];
|
||||
const start = Math.max(1, currentPage - 2);
|
||||
const end = Math.min(maxPageNum, currentPage + 2);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.chevronsLeft />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.chevronLeft />
|
||||
</button>
|
||||
|
||||
{pages.map(page => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`w-10 h-10 rounded-lg font-medium transition-colors ${
|
||||
page === currentPage
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'hover:bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === maxPageNum}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.chevronRight />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(maxPageNum)}
|
||||
disabled={currentPage === maxPageNum}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.chevronsRight />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main App Component
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cards, setCards] = useState([]);
|
||||
const [selectedCard, setSelectedCard] = useState('');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [pagination, setPagination] = useState({});
|
||||
const [summary, setSummary] = useState({});
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [txtExportModalOpen, setTxtExportModalOpen] = useState(false);
|
||||
const [fieldExportModalOpen, setFieldExportModalOpen] = useState(false);
|
||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false);
|
||||
|
||||
// 날짜 초기화
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
setEndDate(today.toISOString().split('T')[0]);
|
||||
setStartDate(thirtyDaysAgo.toISOString().split('T')[0]);
|
||||
}, []);
|
||||
|
||||
// 카드 목록 로드
|
||||
useEffect(() => {
|
||||
loadCards();
|
||||
}, []);
|
||||
|
||||
// 사용내역 로드 (날짜 설정 후)
|
||||
useEffect(() => {
|
||||
if (startDate && endDate) {
|
||||
loadUsage();
|
||||
}
|
||||
}, [startDate, endDate, selectedCard]);
|
||||
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
const response = await fetch('api/cards.php');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setCards(data.cards || []);
|
||||
} else {
|
||||
console.error('카드 목록 조회 실패:', data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('카드 목록 로드 오류:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsage = async (page = 1) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type: 'period',
|
||||
startDate: startDate.replace(/-/g, ''),
|
||||
endDate: endDate.replace(/-/g, ''),
|
||||
cardNum: selectedCard,
|
||||
page: page,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
const response = await fetch(`api/usage.php?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setLogs(data.data.logs || []);
|
||||
setPagination(data.data.pagination || {});
|
||||
setSummary(data.data.summary || {});
|
||||
} else {
|
||||
setError(data.error);
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('서버 통신 오류: ' + err.message);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDateSelect = (days) => {
|
||||
const today = new Date();
|
||||
const startDateObj = new Date(today);
|
||||
startDateObj.setDate(startDateObj.getDate() - days);
|
||||
|
||||
setEndDate(today.toISOString().split('T')[0]);
|
||||
setStartDate(startDateObj.toISOString().split('T')[0]);
|
||||
};
|
||||
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('ko-KR').format(val || 0) + '원';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20">
|
||||
<Header onOpenApiInfo={() => setIsApiInfoModalOpen(true)} />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
{/* 통계 카드 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-6">사용 현황</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="총 사용금액"
|
||||
value={formatCurrency(summary.totalAmount)}
|
||||
subtext={`조회기간 합계`}
|
||||
icon={<Icons.wallet />}
|
||||
/>
|
||||
<StatCard
|
||||
title="사용건수"
|
||||
value={`${(summary.count || 0).toLocaleString()}건`}
|
||||
subtext={`승인 ${summary.approvalCount || 0}건`}
|
||||
icon={<Icons.receipt />}
|
||||
/>
|
||||
<StatCard
|
||||
title="등록된 카드"
|
||||
value={`${cards.length}장`}
|
||||
subtext="사용 가능한 카드"
|
||||
icon={<Icons.creditCard />}
|
||||
/>
|
||||
<StatCard
|
||||
title="취소건수"
|
||||
value={`${(summary.cancelCount || 0).toLocaleString()}건`}
|
||||
subtext="취소된 거래"
|
||||
icon={<Icons.xCircle />}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 필터 섹션 */}
|
||||
<section className="bg-white rounded-card shadow-sm border border-slate-100 p-6 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<span className="text-emerald-600"><Icons.filter /></span>
|
||||
조회 조건
|
||||
</h2>
|
||||
<QuickDateButtons onSelect={handleQuickDateSelect} />
|
||||
</div>
|
||||
|
||||
{cards.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">카드 선택</label>
|
||||
<CardSelector
|
||||
cards={cards}
|
||||
selectedCard={selectedCard}
|
||||
onSelect={setSelectedCard}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">조회 기간</label>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartChange={setStartDate}
|
||||
onEndChange={setEndDate}
|
||||
onSearch={() => loadUsage(1)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 에러 표시 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-card p-4 text-red-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.alertCircle />
|
||||
<span className="font-medium">오류 발생</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용내역 테이블 */}
|
||||
<section className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-slate-900">사용내역</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-500">
|
||||
총 {(pagination.totalCount || 0).toLocaleString()}건
|
||||
</span>
|
||||
{logs.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setTxtExportModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<Icons.download className="w-4 h-4" />
|
||||
txt추출
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFieldExportModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Icons.fileText />
|
||||
필드 추출
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageTable logs={logs} loading={loading} />
|
||||
|
||||
<div className="p-4 border-t border-slate-100">
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage || 1}
|
||||
maxPageNum={pagination.maxPageNum || 1}
|
||||
onPageChange={loadUsage}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* txt추출 모달 */}
|
||||
<TxtExportModal
|
||||
isOpen={txtExportModalOpen}
|
||||
onClose={() => setTxtExportModalOpen(false)}
|
||||
logs={logs}
|
||||
/>
|
||||
|
||||
{/* 필드 추출 모달 */}
|
||||
<FieldExportModal
|
||||
isOpen={fieldExportModalOpen}
|
||||
onClose={() => setFieldExportModalOpen(false)}
|
||||
logs={logs}
|
||||
/>
|
||||
|
||||
<ApiInfoModal
|
||||
isOpen={isApiInfoModalOpen}
|
||||
onClose={() => setIsApiInfoModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
957
barobill/ecard/카드사용내역조회.md
Normal file
@@ -0,0 +1,957 @@
|
||||
# 바로빌 카드 사용내역 조회 - 멀티테넌시 개발 문서
|
||||
|
||||
## 목차
|
||||
1. [개요](#개요)
|
||||
2. [시스템 아키텍처](#시스템-아키텍처)
|
||||
3. [데이터베이스 설계](#데이터베이스-설계)
|
||||
4. [API 구조](#api-구조)
|
||||
5. [보안 고려사항](#보안-고려사항)
|
||||
6. [구현 가이드](#구현-가이드)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
멀티테넌시 환경에서 각 업체(테넌트)별로 독립적인 바로빌 카드 사용내역 조회 서비스를 제공하기 위한 개발 문서입니다.
|
||||
|
||||
### 주요 기능
|
||||
- 업체별 바로빌 인증 정보 관리
|
||||
- 업체별 카드 목록 조회
|
||||
- 업체별 카드 사용내역 조회
|
||||
- 데이터 격리 및 보안 관리
|
||||
|
||||
### 기술 스택
|
||||
- **백엔드**: PHP 7.3+
|
||||
- **데이터베이스**: MySQL/MariaDB
|
||||
- **외부 API**: 바로빌 SOAP 웹서비스
|
||||
- **프론트엔드**: React (ecard/index.php)
|
||||
|
||||
---
|
||||
|
||||
## 시스템 아키텍처
|
||||
|
||||
### 데이터 흐름도
|
||||
|
||||
```
|
||||
[업체 사용자]
|
||||
↓
|
||||
[ecard/index.php] (프론트엔드)
|
||||
↓ (업체 ID 전달)
|
||||
[API Layer]
|
||||
├─ cards.php (카드 목록 조회)
|
||||
└─ usage.php (사용내역 조회)
|
||||
↓ (업체별 인증 정보 조회)
|
||||
[Database]
|
||||
├─ companies (업체 정보)
|
||||
└─ barobill_credentials (바로빌 인증 정보)
|
||||
↓ (바로빌 API 호출)
|
||||
[바로빌 SOAP API]
|
||||
├─ GetCardEx2 (카드 목록)
|
||||
└─ GetPeriodCardApprovalLog (사용내역)
|
||||
↓ (응답)
|
||||
[API Layer] → [프론트엔드] → [사용자]
|
||||
```
|
||||
|
||||
### 멀티테넌시 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 업체 A (Company A) │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 바로빌 인증 정보 │ │
|
||||
│ │ - CERTKEY: AAAAA │ │
|
||||
│ │ - 사업자번호: 123-45-67890 │ │
|
||||
│ │ - 사용자 ID: user_a │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 카드 목록 (바로빌에서 조회) │ │
|
||||
│ │ - 카드1, 카드2, 카드3 │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 업체 B (Company B) │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 바로빌 인증 정보 │ │
|
||||
│ │ - CERTKEY: BBBBB │ │
|
||||
│ │ - 사업자번호: 987-65-43210 │ │
|
||||
│ │ - 사용자 ID: user_b │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 카드 목록 (바로빌에서 조회) │ │
|
||||
│ │ - 카드4, 카드5 │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 설계
|
||||
|
||||
### 1. companies (업체 기본 정보 테이블)
|
||||
|
||||
업체의 기본 정보를 저장하는 테이블입니다.
|
||||
|
||||
```sql
|
||||
CREATE TABLE companies (
|
||||
id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '업체 ID',
|
||||
company_name VARCHAR(255) NOT NULL COMMENT '업체명',
|
||||
business_number VARCHAR(20) NOT NULL COMMENT '사업자번호 (하이픈 포함)',
|
||||
business_number_clean VARCHAR(20) NOT NULL COMMENT '사업자번호 (하이픈 제거)',
|
||||
status ENUM('active', 'inactive', 'suspended') DEFAULT 'active' COMMENT '상태',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
|
||||
deleted_at DATETIME NULL COMMENT '삭제일시 (소프트 삭제)',
|
||||
|
||||
UNIQUE KEY uk_business_number (business_number_clean),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='업체 기본 정보';
|
||||
```
|
||||
|
||||
**주요 필드 설명:**
|
||||
- `id`: 업체 고유 ID (다른 테이블에서 외래키로 사용)
|
||||
- `company_name`: 업체명
|
||||
- `business_number`: 사업자번호 (표시용, 하이픈 포함)
|
||||
- `business_number_clean`: 사업자번호 (검색용, 하이픈 제거)
|
||||
- `status`: 업체 상태 (active=활성, inactive=비활성, suspended=정지)
|
||||
|
||||
---
|
||||
|
||||
### 2. barobill_credentials (바로빌 인증 정보 테이블)
|
||||
|
||||
각 업체별 바로빌 API 인증 정보를 저장하는 테이블입니다.
|
||||
|
||||
```sql
|
||||
CREATE TABLE barobill_credentials (
|
||||
id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '인증 정보 ID',
|
||||
company_id INT(11) UNSIGNED NOT NULL COMMENT '업체 ID',
|
||||
cert_key VARCHAR(500) NOT NULL COMMENT '바로빌 CERTKEY (암호화 권장)',
|
||||
corp_num VARCHAR(20) NOT NULL COMMENT '사업자번호 (하이픈 제거)',
|
||||
user_id VARCHAR(100) NULL COMMENT '바로빌 사용자 ID (선택사항, 빈값이면 전체 카드 조회)',
|
||||
test_mode TINYINT(1) DEFAULT 0 COMMENT '테스트 모드 (0=운영, 1=테스트)',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '상태',
|
||||
last_api_call DATETIME NULL COMMENT '마지막 API 호출 일시',
|
||||
last_error_message TEXT NULL COMMENT '마지막 에러 메시지',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
|
||||
|
||||
UNIQUE KEY uk_company_id (company_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_company_id (company_id),
|
||||
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='바로빌 인증 정보';
|
||||
```
|
||||
|
||||
**주요 필드 설명:**
|
||||
- `company_id`: 업체 ID (companies 테이블 참조)
|
||||
- `cert_key`: 바로빌 CERTKEY (⚠️ 민감정보, 암호화 권장)
|
||||
- `corp_num`: 사업자번호 (하이픈 제거)
|
||||
- `user_id`: 바로빌 사용자 ID (특정 사용자 카드만 조회 시 사용, NULL이면 전체)
|
||||
- `test_mode`: 테스트 모드 여부 (0=운영, 1=테스트)
|
||||
- `status`: 인증 정보 상태
|
||||
- `last_api_call`: 마지막 API 호출 일시 (모니터링용)
|
||||
- `last_error_message`: 마지막 에러 메시지 (디버깅용)
|
||||
|
||||
**보안 고려사항:**
|
||||
- `cert_key`는 민감정보이므로 암호화 저장 권장
|
||||
- 데이터베이스 접근 권한 최소화
|
||||
- 로그에 민감정보 출력 금지
|
||||
|
||||
---
|
||||
|
||||
### 3. barobill_cards (카드 정보 캐시 테이블)
|
||||
|
||||
바로빌에서 조회한 카드 정보를 캐싱하는 테이블입니다. (선택사항)
|
||||
|
||||
```sql
|
||||
CREATE TABLE barobill_cards (
|
||||
id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '카드 ID',
|
||||
company_id INT(11) UNSIGNED NOT NULL COMMENT '업체 ID',
|
||||
card_num VARCHAR(50) NOT NULL COMMENT '카드번호',
|
||||
card_company_code VARCHAR(10) NULL COMMENT '카드사 코드',
|
||||
card_company_name VARCHAR(50) NULL COMMENT '카드사 이름',
|
||||
card_brand VARCHAR(20) NULL COMMENT '카드 브랜드 (비자, 마스터카드 등)',
|
||||
alias VARCHAR(100) NULL COMMENT '카드 별칭',
|
||||
card_type TINYINT(1) NULL COMMENT '카드 종류 (1=개인, 2=법인)',
|
||||
status TINYINT(1) NULL COMMENT '카드 상태 (0=대기중, 1=정상, 2=해지, 3=수집오류, 4=일시중지)',
|
||||
collect_cycle TINYINT(1) NULL COMMENT '수집주기 (1=1일1회, 2=1일2회, 3=1일3회)',
|
||||
last_collect_date DATE NULL COMMENT '마지막 수집일',
|
||||
last_collect_result TINYINT(1) NULL COMMENT '마지막 수집결과',
|
||||
regist_date DATE NULL COMMENT '등록일',
|
||||
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '캐시 일시',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
|
||||
|
||||
UNIQUE KEY uk_company_card (company_id, card_num),
|
||||
INDEX idx_company_id (company_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_cached_at (cached_at),
|
||||
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='바로빌 카드 정보 캐시';
|
||||
```
|
||||
|
||||
**주요 필드 설명:**
|
||||
- `company_id`: 업체 ID
|
||||
- `card_num`: 카드번호 (바로빌에서 조회한 카드번호)
|
||||
- `card_company_code`: 카드사 코드 (01=BC, 02=KB, 04=삼성 등)
|
||||
- `card_company_name`: 카드사 이름
|
||||
- `card_brand`: 카드 브랜드 (비자, 마스터카드 등)
|
||||
- `alias`: 카드 별칭
|
||||
- `status`: 카드 상태
|
||||
- `cached_at`: 캐시 일시 (캐시 만료 판단용)
|
||||
|
||||
**사용 목적:**
|
||||
- 바로빌 API 호출 최소화 (성능 향상)
|
||||
- 오프라인 조회 가능
|
||||
- 카드 목록 변경 이력 추적
|
||||
|
||||
**캐시 전략:**
|
||||
- 카드 목록은 1시간마다 갱신 권장
|
||||
- 실시간 조회가 필요한 경우 캐시 사용 안 함
|
||||
|
||||
---
|
||||
|
||||
### 4. barobill_card_usage_logs (카드 사용내역 캐시 테이블)
|
||||
|
||||
바로빌에서 조회한 카드 사용내역을 캐싱하는 테이블입니다. (선택사항)
|
||||
|
||||
```sql
|
||||
CREATE TABLE barobill_card_usage_logs (
|
||||
id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '사용내역 ID',
|
||||
company_id INT(11) UNSIGNED NOT NULL COMMENT '업체 ID',
|
||||
card_num VARCHAR(50) NOT NULL COMMENT '카드번호',
|
||||
use_dt DATETIME NOT NULL COMMENT '사용일시',
|
||||
use_key VARCHAR(100) NULL COMMENT '사용 키 (바로빌 고유값)',
|
||||
approval_num VARCHAR(50) NULL COMMENT '승인번호',
|
||||
approval_amount INT(11) DEFAULT 0 COMMENT '승인금액',
|
||||
tax INT(11) DEFAULT 0 COMMENT '부가세',
|
||||
service_charge INT(11) DEFAULT 0 COMMENT '봉사료',
|
||||
total_amount INT(11) DEFAULT 0 COMMENT '총 금액',
|
||||
approval_type TINYINT(1) NULL COMMENT '승인유형 (1=승인, 2=취소)',
|
||||
payment_plan VARCHAR(10) NULL COMMENT '할부개월 (0=일시불)',
|
||||
currency_code VARCHAR(3) DEFAULT 'KRW' COMMENT '통화코드',
|
||||
use_store_name VARCHAR(255) NULL COMMENT '가맹점명',
|
||||
use_store_corp_num VARCHAR(20) NULL COMMENT '가맹점 사업자번호',
|
||||
use_store_addr TEXT NULL COMMENT '가맹점 주소',
|
||||
use_store_ceo VARCHAR(100) NULL COMMENT '가맹점 대표자명',
|
||||
use_store_biz_type VARCHAR(100) NULL COMMENT '가맹점 업종',
|
||||
use_store_tel VARCHAR(20) NULL COMMENT '가맹점 전화번호',
|
||||
memo TEXT NULL COMMENT '메모',
|
||||
card_company VARCHAR(10) NULL COMMENT '카드사 코드',
|
||||
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '캐시 일시',
|
||||
|
||||
UNIQUE KEY uk_use_key (company_id, use_key),
|
||||
INDEX idx_company_id (company_id),
|
||||
INDEX idx_card_num (card_num),
|
||||
INDEX idx_use_dt (use_dt),
|
||||
INDEX idx_company_use_dt (company_id, use_dt),
|
||||
INDEX idx_approval_type (approval_type),
|
||||
INDEX idx_cached_at (cached_at),
|
||||
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='바로빌 카드 사용내역 캐시';
|
||||
```
|
||||
|
||||
**주요 필드 설명:**
|
||||
- `company_id`: 업체 ID
|
||||
- `card_num`: 카드번호
|
||||
- `use_dt`: 사용일시
|
||||
- `use_key`: 바로빌 고유 사용 키 (중복 방지용)
|
||||
- `approval_amount`: 승인금액
|
||||
- `approval_type`: 승인유형 (1=승인, 2=취소)
|
||||
- `use_store_name`: 가맹점명
|
||||
- `cached_at`: 캐시 일시
|
||||
|
||||
**인덱스 전략:**
|
||||
- `idx_company_use_dt`: 업체별 기간 조회 최적화
|
||||
- `idx_use_dt`: 전체 기간 조회 최적화
|
||||
- `uk_use_key`: 중복 데이터 방지
|
||||
|
||||
**사용 목적:**
|
||||
- 바로빌 API 호출 최소화
|
||||
- 빠른 조회 성능
|
||||
- 데이터 분석 및 리포트 생성
|
||||
|
||||
**캐시 전략:**
|
||||
- 최근 3개월 데이터는 캐시 유지
|
||||
- 오래된 데이터는 주기적으로 정리
|
||||
- 실시간 조회가 필요한 경우 바로빌 API 직접 호출
|
||||
|
||||
---
|
||||
|
||||
### 5. barobill_api_logs (API 호출 로그 테이블)
|
||||
|
||||
바로빌 API 호출 이력을 기록하는 테이블입니다. (선택사항, 모니터링용)
|
||||
|
||||
```sql
|
||||
CREATE TABLE barobill_api_logs (
|
||||
id BIGINT(20) UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '로그 ID',
|
||||
company_id INT(11) UNSIGNED NOT NULL COMMENT '업체 ID',
|
||||
api_method VARCHAR(50) NOT NULL COMMENT 'API 메서드명',
|
||||
request_params TEXT NULL COMMENT '요청 파라미터 (JSON)',
|
||||
response_status VARCHAR(20) NULL COMMENT '응답 상태 (success/failure)',
|
||||
response_data TEXT NULL COMMENT '응답 데이터 (JSON, 일부만 저장)',
|
||||
error_message TEXT NULL COMMENT '에러 메시지',
|
||||
execution_time INT(11) NULL COMMENT '실행 시간 (밀리초)',
|
||||
ip_address VARCHAR(45) NULL COMMENT '요청 IP 주소',
|
||||
user_agent VARCHAR(255) NULL COMMENT '사용자 에이전트',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
|
||||
|
||||
INDEX idx_company_id (company_id),
|
||||
INDEX idx_api_method (api_method),
|
||||
INDEX idx_response_status (response_status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_company_created (company_id, created_at),
|
||||
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='바로빌 API 호출 로그';
|
||||
```
|
||||
|
||||
**주요 필드 설명:**
|
||||
- `company_id`: 업체 ID
|
||||
- `api_method`: API 메서드명 (GetCardEx2, GetPeriodCardApprovalLog 등)
|
||||
- `request_params`: 요청 파라미터 (JSON 형식)
|
||||
- `response_status`: 응답 상태 (success/failure)
|
||||
- `error_message`: 에러 메시지
|
||||
- `execution_time`: 실행 시간 (성능 모니터링용)
|
||||
|
||||
**사용 목적:**
|
||||
- API 호출 이력 추적
|
||||
- 에러 디버깅
|
||||
- 성능 모니터링
|
||||
- 사용량 통계
|
||||
|
||||
**데이터 보관 정책:**
|
||||
- 최근 6개월 데이터 보관
|
||||
- 오래된 데이터는 주기적으로 아카이빙 또는 삭제
|
||||
|
||||
---
|
||||
|
||||
## 테이블 관계도
|
||||
|
||||
```
|
||||
companies (업체)
|
||||
│
|
||||
├── 1:1 ── barobill_credentials (바로빌 인증 정보)
|
||||
│
|
||||
├── 1:N ── barobill_cards (카드 정보 캐시)
|
||||
│
|
||||
├── 1:N ── barobill_card_usage_logs (사용내역 캐시)
|
||||
│
|
||||
└── 1:N ── barobill_api_logs (API 호출 로그)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 구조
|
||||
|
||||
### 1. 카드 목록 조회 API
|
||||
|
||||
**엔드포인트**: `GET /ecard/api/cards.php`
|
||||
|
||||
**요청 파라미터:**
|
||||
```php
|
||||
[
|
||||
'company_id' => 1, // 업체 ID (필수)
|
||||
'availOnly' => 0 // 0=전체, 1=사용가능한 카드만
|
||||
]
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"cards": [
|
||||
{
|
||||
"cardNum": "1234567890123456",
|
||||
"cardNumMasked": "1234-****-****-3456",
|
||||
"cardCompany": "04",
|
||||
"cardCompanyName": "삼성카드",
|
||||
"cardBrand": "비자",
|
||||
"alias": "법인카드1",
|
||||
"status": "1",
|
||||
"statusName": "정상"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**구현 로직:**
|
||||
1. `company_id`로 `barobill_credentials` 테이블에서 인증 정보 조회
|
||||
2. 바로빌 SOAP API 호출 (GetCardEx2)
|
||||
3. 응답 데이터 변환 및 반환
|
||||
|
||||
---
|
||||
|
||||
### 2. 카드 사용내역 조회 API
|
||||
|
||||
**엔드포인트**: `GET /ecard/api/usage.php`
|
||||
|
||||
**요청 파라미터:**
|
||||
```php
|
||||
[
|
||||
'company_id' => 1, // 업체 ID (필수)
|
||||
'type' => 'period', // daily/monthly/period
|
||||
'cardNum' => '', // 카드번호 (빈값이면 전체)
|
||||
'startDate' => '20240101', // 시작일 (YYYYMMDD)
|
||||
'endDate' => '20240131', // 종료일 (YYYYMMDD)
|
||||
'page' => 1, // 페이지 번호
|
||||
'limit' => 50 // 페이지당 건수
|
||||
]
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"logs": [
|
||||
{
|
||||
"cardNum": "1234-****-****-3456",
|
||||
"approvalDateTime": "2024-01-15 14:30:00",
|
||||
"merchantName": "스타벅스 강남점",
|
||||
"merchantBizNum": "123-45-67890",
|
||||
"amount": 5000,
|
||||
"approvalType": "1",
|
||||
"approvalTypeName": "승인"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 1,
|
||||
"maxPageNum": 10,
|
||||
"totalCount": 500
|
||||
},
|
||||
"summary": {
|
||||
"totalAmount": 1000000,
|
||||
"count": 500,
|
||||
"approvalCount": 480,
|
||||
"cancelCount": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**구현 로직:**
|
||||
1. `company_id`로 `barobill_credentials` 테이블에서 인증 정보 조회
|
||||
2. 바로빌 SOAP API 호출 (GetPeriodCardApprovalLog)
|
||||
3. 응답 데이터 변환 및 반환
|
||||
4. (선택) 캐시 테이블에 저장
|
||||
|
||||
---
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
### 1. 데이터 격리
|
||||
|
||||
- **업체별 데이터 격리**: 모든 쿼리에 `company_id` 조건 필수
|
||||
- **권한 검증**: 세션에서 `company_id` 확인 후 접근 허용
|
||||
- **SQL Injection 방지**: Prepared Statement 사용
|
||||
|
||||
```php
|
||||
// 올바른 예시
|
||||
$stmt = $pdo->prepare("SELECT * FROM barobill_credentials WHERE company_id = ?");
|
||||
$stmt->execute([$company_id]);
|
||||
|
||||
// 잘못된 예시 (SQL Injection 취약)
|
||||
$sql = "SELECT * FROM barobill_credentials WHERE company_id = $company_id";
|
||||
```
|
||||
|
||||
### 2. 인증 정보 보호
|
||||
|
||||
- **CERTKEY 암호화**: 데이터베이스에 저장 시 암호화
|
||||
- **접근 로그**: 인증 정보 조회 시 로그 기록
|
||||
- **최소 권한 원칙**: 필요한 최소한의 정보만 조회
|
||||
|
||||
```php
|
||||
// CERTKEY 암호화 예시 (간단한 방법)
|
||||
function encryptCertKey($certKey) {
|
||||
// 실제 운영 환경에서는 더 강력한 암호화 사용 권장
|
||||
return base64_encode(openssl_encrypt($certKey, 'AES-256-CBC', $encryptionKey));
|
||||
}
|
||||
|
||||
function decryptCertKey($encryptedCertKey) {
|
||||
return openssl_decrypt(base64_decode($encryptedCertKey), 'AES-256-CBC', $encryptionKey);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API 호출 제한
|
||||
|
||||
- **Rate Limiting**: 업체별 API 호출 횟수 제한
|
||||
- **에러 처리**: 에러 발생 시 민감정보 노출 금지
|
||||
- **타임아웃 설정**: API 호출 타임아웃 설정
|
||||
|
||||
```php
|
||||
// Rate Limiting 예시
|
||||
function checkRateLimit($company_id) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COUNT(*) as count
|
||||
FROM barobill_api_logs
|
||||
WHERE company_id = ?
|
||||
AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
||||
");
|
||||
$stmt->execute([$company_id]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($result['count'] > 1000) { // 시간당 1000회 제한
|
||||
throw new Exception('API 호출 한도 초과');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
### 1. barobill_card_config.php 수정
|
||||
|
||||
멀티테넌시를 지원하도록 수정합니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 카드 API 설정 파일 (멀티테넌시 지원)
|
||||
*/
|
||||
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/lib/mydb.php');
|
||||
|
||||
/**
|
||||
* 업체별 바로빌 인증 정보 조회
|
||||
*
|
||||
* @param int $company_id 업체 ID
|
||||
* @return array 인증 정보
|
||||
*/
|
||||
function getBarobillCredentials($company_id) {
|
||||
global $pdo, $DB;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
bc.cert_key,
|
||||
bc.corp_num,
|
||||
bc.user_id,
|
||||
bc.test_mode,
|
||||
bc.status
|
||||
FROM {$DB}.barobill_credentials bc
|
||||
WHERE bc.company_id = ?
|
||||
AND bc.status = 'active'
|
||||
");
|
||||
$stmt->execute([$company_id]);
|
||||
$credentials = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$credentials) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 인증 정보가 등록되지 않았습니다.'
|
||||
];
|
||||
}
|
||||
|
||||
// CERTKEY 복호화 (암호화된 경우)
|
||||
if (function_exists('decryptCertKey')) {
|
||||
$credentials['cert_key'] = decryptCertKey($credentials['cert_key']);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $credentials
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 카드 SOAP 웹서비스 호출 함수 (멀티테넌시 지원)
|
||||
*
|
||||
* @param int $company_id 업체 ID
|
||||
* @param string $method SOAP 메서드명
|
||||
* @param array $params SOAP 메서드 파라미터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function callBarobillCardSOAPForCompany($company_id, $method, $params = []) {
|
||||
// 인증 정보 조회
|
||||
$credentials = getBarobillCredentials($company_id);
|
||||
if (!$credentials['success']) {
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
$certKey = $credentials['data']['cert_key'];
|
||||
$corpNum = $credentials['data']['corp_num'];
|
||||
$isTestMode = $credentials['data']['test_mode'] == 1;
|
||||
|
||||
// SOAP URL 설정
|
||||
$soapUrl = $isTestMode
|
||||
? 'https://testws.baroservice.com/CARD.asmx?WSDL'
|
||||
: 'https://ws.baroservice.com/CARD.asmx?WSDL';
|
||||
|
||||
// SOAP 클라이언트 생성
|
||||
try {
|
||||
$soapClient = new SoapClient($soapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 클라이언트 생성 실패: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
|
||||
// CERTKEY와 CorpNum 자동 추가
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $certKey;
|
||||
}
|
||||
if (!isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $corpNum;
|
||||
}
|
||||
|
||||
// API 호출 로그 기록
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$result = $soapClient->$method($params);
|
||||
|
||||
$executionTime = (microtime(true) - $startTime) * 1000; // 밀리초
|
||||
|
||||
// API 호출 로그 저장
|
||||
logBarobillApiCall($company_id, $method, $params, 'success', null, $executionTime);
|
||||
|
||||
$resultProperty = $method . 'Result';
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
// 에러 코드 체크
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
logBarobillApiCall($company_id, $method, $params, 'failure', '에러 코드: ' . $resultData, $executionTime);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 카드 API 오류 코드: ' . $resultData,
|
||||
'error_code' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result
|
||||
];
|
||||
|
||||
} catch (SoapFault $e) {
|
||||
$executionTime = (microtime(true) - $startTime) * 1000;
|
||||
logBarobillApiCall($company_id, $method, $params, 'failure', $e->getMessage(), $executionTime);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$executionTime = (microtime(true) - $startTime) * 1000;
|
||||
logBarobillApiCall($company_id, $method, $params, 'failure', $e->getMessage(), $executionTime);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 호출 로그 저장
|
||||
*/
|
||||
function logBarobillApiCall($company_id, $method, $params, $status, $error_message = null, $execution_time = null) {
|
||||
global $pdo, $DB;
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO {$DB}.barobill_api_logs
|
||||
(company_id, api_method, request_params, response_status, error_message, execution_time, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$company_id,
|
||||
$method,
|
||||
json_encode($params, JSON_UNESCAPED_UNICODE),
|
||||
$status,
|
||||
$error_message,
|
||||
$execution_time,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
$_SERVER['HTTP_USER_AGENT'] ?? null
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// 로그 저장 실패는 무시 (시스템 오류 방지)
|
||||
error_log('API 로그 저장 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체별 카드 목록 조회 (멀티테넌시 지원)
|
||||
*/
|
||||
function getCardListForCompany($company_id, $availOnly = 0) {
|
||||
$result = callBarobillCardSOAPForCompany($company_id, 'GetCardEx2', [
|
||||
'AvailOnly' => $availOnly
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
$data = $result['data'];
|
||||
|
||||
if (!isset($data->CardEx)) {
|
||||
return ['success' => true, 'data' => []];
|
||||
}
|
||||
|
||||
if (!is_array($data->CardEx)) {
|
||||
$cards = [$data->CardEx];
|
||||
} else {
|
||||
$cards = $data->CardEx;
|
||||
}
|
||||
|
||||
// 에러 체크
|
||||
if (count($cards) == 1 && isset($cards[0]->CardNum) && $cards[0]->CardNum < 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '카드 목록 조회 실패',
|
||||
'error_code' => $cards[0]->CardNum
|
||||
];
|
||||
}
|
||||
|
||||
// (선택) 캐시 테이블에 저장
|
||||
// saveCardsToCache($company_id, $cards);
|
||||
|
||||
return ['success' => true, 'data' => $cards];
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체별 기간별 카드 사용내역 조회 (멀티테넌시 지원)
|
||||
*/
|
||||
function getPeriodCardUsageForCompany($company_id, $cardNum = '', $startDate = '', $endDate = '', $countPerPage = 50, $currentPage = 1, $orderDirection = 2, $userId = '') {
|
||||
// 인증 정보 조회
|
||||
$credentials = getBarobillCredentials($company_id);
|
||||
if (!$credentials['success']) {
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
$barobillUserId = $credentials['data']['user_id'] ?? '';
|
||||
if (!empty($userId)) {
|
||||
$barobillUserId = $userId;
|
||||
}
|
||||
|
||||
$result = callBarobillCardSOAPForCompany($company_id, 'GetPeriodCardApprovalLog', [
|
||||
'ID' => $barobillUserId,
|
||||
'CardNum' => $cardNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'CountPerPage' => $countPerPage,
|
||||
'CurrentPage' => $currentPage,
|
||||
'OrderDirection' => $orderDirection
|
||||
]);
|
||||
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return parseCardUsageResult($result['data']);
|
||||
}
|
||||
|
||||
// 기존 parseCardUsageResult 함수는 그대로 사용
|
||||
// ... (기존 코드 유지)
|
||||
?>
|
||||
```
|
||||
|
||||
### 2. cards.php 수정
|
||||
|
||||
멀티테넌시를 지원하도록 수정합니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* 등록된 카드 목록 조회 API (멀티테넌시 지원)
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
|
||||
try {
|
||||
// 업체 ID 확인 (세션 또는 파라미터에서)
|
||||
$company_id = $_SESSION['company_id'] ?? $_GET['company_id'] ?? null;
|
||||
|
||||
if (!$company_id) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '업체 ID가 필요합니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$availOnly = isset($_GET['availOnly']) ? intval($_GET['availOnly']) : 0;
|
||||
|
||||
$result = getCardListForCompany($company_id, $availOnly);
|
||||
|
||||
if ($result['success']) {
|
||||
$cards = [];
|
||||
foreach ($result['data'] as $card) {
|
||||
// ... (기존 변환 로직)
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'cards' => $cards,
|
||||
'count' => count($cards)
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
### 3. usage.php 수정
|
||||
|
||||
멀티테넌시를 지원하도록 수정합니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* 카드 사용내역 조회 API (멀티테넌시 지원)
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once('barobill_card_config.php');
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . '/session.php');
|
||||
|
||||
try {
|
||||
// 업체 ID 확인
|
||||
$company_id = $_SESSION['company_id'] ?? $_GET['company_id'] ?? null;
|
||||
|
||||
if (!$company_id) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '업체 ID가 필요합니다.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$type = $_GET['type'] ?? 'period';
|
||||
$cardNum = $_GET['cardNum'] ?? '';
|
||||
$page = max(1, intval($_GET['page'] ?? 1));
|
||||
$limit = min(100, max(10, intval($_GET['limit'] ?? 50)));
|
||||
$orderDirection = intval($_GET['order'] ?? 2);
|
||||
|
||||
// ... (기존 로직을 getPeriodCardUsageForCompany로 변경)
|
||||
|
||||
$result = getPeriodCardUsageForCompany(
|
||||
$company_id,
|
||||
$cardNum,
|
||||
$startDate,
|
||||
$endDate,
|
||||
$limit,
|
||||
$page,
|
||||
$orderDirection
|
||||
);
|
||||
|
||||
// ... (기존 응답 로직)
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 가이드
|
||||
|
||||
### 기존 단일 테넌트에서 멀티테넌트로 전환
|
||||
|
||||
1. **데이터베이스 마이그레이션**
|
||||
```sql
|
||||
-- 1. companies 테이블 생성
|
||||
-- 2. barobill_credentials 테이블 생성
|
||||
-- 3. 기존 파일 기반 설정을 DB로 마이그레이션
|
||||
|
||||
INSERT INTO companies (company_name, business_number, business_number_clean, status)
|
||||
VALUES ('기본 업체', '123-45-67890', '1234567890', 'active');
|
||||
|
||||
INSERT INTO barobill_credentials (company_id, cert_key, corp_num, user_id, test_mode, status)
|
||||
VALUES (
|
||||
1,
|
||||
(SELECT cert_key FROM file), -- 파일에서 읽은 CERTKEY
|
||||
(SELECT corp_num FROM file), -- 파일에서 읽은 사업자번호
|
||||
NULL,
|
||||
0,
|
||||
'active'
|
||||
);
|
||||
```
|
||||
|
||||
2. **코드 수정**
|
||||
- `barobill_card_config.php`: 파일 기반 → DB 기반으로 변경
|
||||
- `cards.php`, `usage.php`: `company_id` 파라미터 추가
|
||||
- 세션에 `company_id` 저장
|
||||
|
||||
3. **테스트**
|
||||
- 각 업체별로 독립적인 카드 조회 확인
|
||||
- 데이터 격리 확인
|
||||
- 권한 검증 확인
|
||||
|
||||
---
|
||||
|
||||
## 모니터링 및 유지보수
|
||||
|
||||
### 1. 주요 모니터링 지표
|
||||
|
||||
- API 호출 성공률
|
||||
- API 호출 응답 시간
|
||||
- 에러 발생 빈도
|
||||
- 캐시 적중률 (캐시 사용 시)
|
||||
|
||||
### 2. 정기 점검 사항
|
||||
|
||||
- 인증 정보 만료 확인
|
||||
- 캐시 데이터 정리
|
||||
- API 로그 분석
|
||||
- 성능 최적화
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [바로빌 개발자 문서](https://dev.barobill.co.kr/)
|
||||
- [바로빌 카드 API 레퍼런스](https://dev.barobill.co.kr/docs/references/카드조회-API)
|
||||
- PHP SOAP 클라이언트 문서
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경 내용 | 작성자 |
|
||||
|------|------|----------|--------|
|
||||
| 1.0 | 2025-12-08 | 초기 문서 작성 | - |
|
||||
|
||||
---
|
||||
|
||||
**문서 작성일**: 2025년 12월
|
||||
**최종 수정일**: 2025년 12월
|
||||
**문서 버전**: 1.0
|
||||
132
barobill/etax/README_DB.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 전자세금계산서 데이터베이스 스키마
|
||||
|
||||
## 테이블 구조
|
||||
|
||||
### 1. etax_tenants (테넌트 관리)
|
||||
멀티테넌시 환경에서 각 고객사(테넌트)의 기본 정보를 저장합니다.
|
||||
|
||||
**주요 필드:**
|
||||
- `id`: 테넌트 고유 ID
|
||||
- `name`: 기업명
|
||||
- `bizno`: 사업자번호 (UNIQUE)
|
||||
- `barobill_api_key`: 바로빌 API 키
|
||||
- `barobill_cert_id`: 바로빌 인증서 ID
|
||||
- `status`: 활성 상태 (active/inactive/suspended)
|
||||
|
||||
### 2. etax_certificates (인증서 관리)
|
||||
각 테넌트의 공동인증서 또는 금융인증서 정보를 관리합니다.
|
||||
|
||||
**주요 필드:**
|
||||
- `id`: 인증서 고유 ID
|
||||
- `tenant_id`: 테넌트 ID (FK)
|
||||
- `type`: 인증서 유형 (public/financial)
|
||||
- `valid_from`, `valid_to`: 인증서 유효기간
|
||||
- `barobill_cert_id`: 바로빌 인증서 ID
|
||||
|
||||
### 3. etax_tax_invoices (세금계산서 메인)
|
||||
전자세금계산서의 기본 정보를 저장합니다.
|
||||
|
||||
**주요 필드:**
|
||||
- `id`: 세금계산서 고유 ID
|
||||
- `tenant_id`: 테넌트 ID (FK)
|
||||
- `issue_key`: 바로빌 발행 키 (UNIQUE)
|
||||
- `supply_amt`, `vat`, `total`: 금액 정보
|
||||
- `write_date`, `supply_date`: 작성일자, 공급일자
|
||||
- `state`: 상태 (draft/issued/sent/cancelled)
|
||||
- `nts_receipt_no`: 국세청 접수번호
|
||||
|
||||
### 4. etax_parties (공급자/수취자 정보)
|
||||
세금계산서의 공급자와 수취자 정보를 저장합니다.
|
||||
|
||||
**주요 필드:**
|
||||
- `id`: 고유 ID
|
||||
- `invoice_id`: 세금계산서 ID (FK)
|
||||
- `role`: 역할 (supplier/recipient/trustee)
|
||||
- `bizno`, `corp_name`, `ceo`: 사업자 정보
|
||||
- `addr`, `email`, `tel`: 연락처 정보
|
||||
|
||||
### 5. etax_line_items (품목 상세)
|
||||
세금계산서의 품목별 상세 정보를 저장합니다.
|
||||
|
||||
**주요 필드:**
|
||||
- `id`: 고유 ID
|
||||
- `invoice_id`: 세금계산서 ID (FK)
|
||||
- `item_name`: 품목명
|
||||
- `qty`, `unit_price`: 수량, 단가
|
||||
- `supply_amt`, `vat`: 공급가액, 부가세
|
||||
- `vat_type`: 부가세 유형 (vat/zero/exempt)
|
||||
|
||||
### 6. etax_transmission_logs (전송 로그)
|
||||
바로빌 API 호출 및 국세청 전송 로그를 기록합니다.
|
||||
|
||||
**주요 필드:**
|
||||
- `id`: 로그 고유 ID
|
||||
- `invoice_id`: 세금계산서 ID (FK)
|
||||
- `transmission_type`: 전송 유형 (issue/cancel/nts_send)
|
||||
- `status`: 상태 (success/failed/pending)
|
||||
- `response_data`: API 응답 데이터
|
||||
- `error_message`: 에러 메시지
|
||||
|
||||
## 설치 방법
|
||||
|
||||
### 1. 데이터베이스 생성 (선택사항)
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS `chandj` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE `chandj`;
|
||||
```
|
||||
|
||||
### 2. 스키마 실행
|
||||
```bash
|
||||
# MySQL/MariaDB 접속
|
||||
mysql -u chandj -p chandj < etax/db_schema.sql
|
||||
```
|
||||
|
||||
또는 phpMyAdmin이나 다른 DB 관리 도구에서 `etax/db_schema.sql` 파일을 실행하세요.
|
||||
|
||||
### 3. 권한 확인
|
||||
테이블이 정상적으로 생성되었는지 확인:
|
||||
```sql
|
||||
SHOW TABLES LIKE 'etax_%';
|
||||
```
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### PHP에서 데이터베이스 연결
|
||||
```php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||||
$pdo = db_connect();
|
||||
|
||||
// 테넌트 조회 예시
|
||||
$stmt = $pdo->prepare("SELECT * FROM etax_tenants WHERE id = ?");
|
||||
$stmt->execute([$tenant_id]);
|
||||
$tenant = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
```
|
||||
|
||||
### 멀티테넌시 쿼리 예시
|
||||
```php
|
||||
// 항상 tenant_id로 필터링
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT * FROM etax_tax_invoices
|
||||
WHERE tenant_id = ? AND state = 'issued'
|
||||
ORDER BY created_at DESC
|
||||
");
|
||||
$stmt->execute([$tenant_id]);
|
||||
$invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **외래키 제약조건**: 모든 외래키는 CASCADE 삭제로 설정되어 있어, 부모 레코드 삭제 시 자식 레코드도 자동 삭제됩니다.
|
||||
|
||||
2. **인덱스**: 자주 조회되는 필드에 인덱스를 설정했습니다. 추가 인덱스가 필요하면 성능 모니터링 후 추가하세요.
|
||||
|
||||
3. **문자셋**: utf8mb4를 사용하여 이모지 등 모든 유니코드 문자를 지원합니다.
|
||||
|
||||
4. **보안**:
|
||||
- `barobill_api_key`는 암호화하여 저장하는 것을 권장합니다.
|
||||
- `kms_key_ref`는 암호화된 인증서 참조를 저장합니다.
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
기존 JSON 파일(`invoices_data.json`)에서 데이터를 마이그레이션하려면 별도의 마이그레이션 스크립트를 작성해야 합니다.
|
||||
|
||||
73
barobill/etax/api/API_URL_GUIDE.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 바로빌 API 엔드포인트 URL 설정 가이드
|
||||
|
||||
## 현재 오류
|
||||
|
||||
```
|
||||
Could not resolve host: test-api.barobill.co.kr
|
||||
```
|
||||
|
||||
이 오류는 DNS 해결 실패로, API 엔드포인트 URL이 잘못되었을 가능성이 높습니다.
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. 바로빌 개발자센터에서 정확한 URL 확인
|
||||
|
||||
1. [바로빌 개발자센터](https://dev.barobill.co.kr) 로그인
|
||||
2. API 문서 또는 샘플 코드 확인
|
||||
3. 실제 API 엔드포인트 URL 확인
|
||||
|
||||
### 2. 일반적인 바로빌 API URL 패턴
|
||||
|
||||
바로빌 API는 다음과 같은 URL 패턴을 사용할 수 있습니다:
|
||||
|
||||
#### 운영 환경
|
||||
- `https://api.barobill.co.kr`
|
||||
- `https://www.barobill.co.kr/api`
|
||||
- `https://barobill.co.kr/api`
|
||||
|
||||
#### 테스트 환경
|
||||
- `https://test.barobill.co.kr`
|
||||
- `https://test-api.barobill.co.kr` (현재 사용 중 - DNS 오류 발생)
|
||||
- `https://dev.barobill.co.kr/api`
|
||||
- 개발자센터에서 제공하는 별도 테스트 URL
|
||||
|
||||
### 3. 설정 파일 수정
|
||||
|
||||
`etax/api/barobill_config.php` 파일의 다음 부분을 수정하세요:
|
||||
|
||||
```php
|
||||
$barobillApiBaseUrl = $isTestMode
|
||||
? 'https://실제_테스트_URL' // 바로빌 개발자센터에서 확인한 URL
|
||||
: 'https://실제_운영_URL'; // 바로빌 개발자센터에서 확인한 URL
|
||||
```
|
||||
|
||||
### 4. API 키 확인
|
||||
|
||||
현재 `apikey/barobill_api_key.txt` 파일에 API 키가 제대로 입력되어 있는지 확인하세요.
|
||||
|
||||
- API 키가 없으면 시뮬레이션 모드로 동작합니다
|
||||
- API 키가 있으면 실제 바로빌 API를 호출합니다
|
||||
|
||||
### 5. 네트워크/DNS 확인
|
||||
|
||||
만약 URL이 맞는데도 DNS 오류가 발생한다면:
|
||||
|
||||
1. **인터넷 연결 확인**
|
||||
2. **방화벽 설정 확인**
|
||||
3. **DNS 서버 변경** (Google DNS: 8.8.8.8, 8.8.4.4)
|
||||
4. **호스트 파일 확인** (Windows: C:\Windows\System32\drivers\etc\hosts)
|
||||
|
||||
### 6. 임시 해결책 (시뮬레이션 모드)
|
||||
|
||||
실제 API 연동 전까지는:
|
||||
|
||||
1. `apikey/barobill_api_key.txt` 파일을 비워두거나
|
||||
2. 파일 내용을 주석 처리하면
|
||||
3. 시뮬레이션 모드로 동작하여 테스트할 수 있습니다
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [바로빌 개발자센터](https://dev.barobill.co.kr)
|
||||
- [바로빌 세금계산서 API 문서](https://dev.barobill.co.kr/docs/references/%EC%84%B8%EA%B8%88%EA%B3%84%EC%82%B0%EC%84%9C-API)
|
||||
- 바로빌 고객지원: 1544-9256
|
||||
|
||||
147
barobill/etax/api/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 바로빌 API 연동 가이드
|
||||
|
||||
## ⚠️ 중요: 바로빌은 SOAP 웹서비스를 사용합니다
|
||||
|
||||
바로빌 API는 REST API가 아닌 **SOAP 웹서비스**를 사용합니다.
|
||||
- 테스트 환경: `https://testws.baroservice.com/TI.asmx?WSDL`
|
||||
- 운영 환경: `https://ws.baroservice.com/TI.asmx?WSDL`
|
||||
|
||||
## API 키 설정
|
||||
|
||||
### 1. 필수 파일 생성
|
||||
|
||||
`apikey` 폴더에 다음 파일들을 생성하세요:
|
||||
|
||||
#### 필수 파일
|
||||
- `barobill_cert_key.txt`: CERTKEY (인증서 키)
|
||||
- 바로빌 개발자센터에서 인증서 등록 후 발급받은 CERTKEY
|
||||
- 세금계산서 발행에 필수
|
||||
|
||||
- `barobill_corp_num.txt`: 사업자번호
|
||||
- 세금계산서를 발행할 회사의 사업자번호
|
||||
- 하이픈(-) 없이 숫자만 입력
|
||||
- 예: `1234567890`
|
||||
|
||||
#### 선택 파일
|
||||
- `barobill_test_mode.txt`: 테스트 모드 사용 시 "test" 또는 "true" 입력
|
||||
- 테스트 환경: `https://testws.baroservice.com/TI.asmx?WSDL`
|
||||
- 운영 환경: `https://ws.baroservice.com/TI.asmx?WSDL`
|
||||
|
||||
### 2. CERTKEY 발급 방법
|
||||
|
||||
1. [바로빌 개발자센터](https://dev.barobill.co.kr) 접속
|
||||
2. 회원가입 및 로그인
|
||||
3. 개발자센터 메뉴에서 "인증서 관리" 또는 "인증서 등록" 메뉴 찾기
|
||||
4. 공동인증서(.pfx 또는 .p12 파일) 또는 금융인증서 업로드
|
||||
5. 인증서 비밀번호 입력 및 등록
|
||||
6. 등록 완료 후 발급받은 **CERTKEY** 확인
|
||||
- 예: "CERT-2024-ABC123-XYZ789" 또는 숫자/문자 조합의 고유 키
|
||||
7. `apikey/barobill_cert_key.txt` 파일에 CERTKEY 저장
|
||||
|
||||
### 3. 사업자번호 설정
|
||||
|
||||
1. 세금계산서를 발행할 회사의 사업자번호 확인
|
||||
2. 하이픈(-) 없이 숫자만 입력
|
||||
- 예: `123-45-67890` → `1234567890`
|
||||
3. `apikey/barobill_corp_num.txt` 파일에 저장
|
||||
|
||||
### 4. 테스트 모드 설정
|
||||
|
||||
테스트 환경을 사용하려면:
|
||||
|
||||
1. `apikey/barobill_test_mode.txt` 파일 생성
|
||||
2. 파일 내용에 `test` 또는 `true` 입력
|
||||
3. 저장
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 세금계산서 발행 (저장 + 발급)
|
||||
- **SOAP 메서드**: `RegistAndIssueTaxInvoice`
|
||||
- **파일**: `issue.php`
|
||||
- **문서**: `etax/docs/barobill-api-doc/TAXINVOICE/RegistAndIssueTaxInvoice.php`
|
||||
|
||||
### 세금계산서 조회
|
||||
- **SOAP 메서드**: `GetTaxInvoice`
|
||||
- **파일**: `invoices.php`
|
||||
- **문서**: `etax/docs/barobill-api-doc/TAXINVOICE/GetTaxInvoice.php`
|
||||
|
||||
### 세금계산서 상태 조회
|
||||
- **SOAP 메서드**: `GetTaxInvoiceStateEX`
|
||||
- **문서**: `etax/docs/barobill-api-doc/TAXINVOICE/GetTaxInvoiceStateEX.php`
|
||||
|
||||
### 국세청 전송
|
||||
- **SOAP 메서드**: `SendToNTS`
|
||||
- **파일**: `status.php`
|
||||
- **문서**: `etax/docs/barobill-api-doc/TAXINVOICE/SendToNTS.php`
|
||||
|
||||
## SOAP API 사용 예제
|
||||
|
||||
### 세금계산서 발행
|
||||
|
||||
```php
|
||||
require_once(__DIR__ . '/barobill_config.php');
|
||||
|
||||
$invoiceData = [
|
||||
'issueKey' => 'MGT20241201123456', // 관리번호 (MgtKey)
|
||||
'supplierBizno' => '1234567890',
|
||||
'supplierName' => '공급자 회사명',
|
||||
'recipientBizno' => '0987654321',
|
||||
'recipientName' => '수취인 회사명',
|
||||
'supplyDate' => '2024-12-01',
|
||||
'items' => [
|
||||
[
|
||||
'name' => '품목명',
|
||||
'spec' => '규격',
|
||||
'qty' => '1',
|
||||
'unitPrice' => 10000,
|
||||
'supplyAmt' => 10000,
|
||||
'vat' => 1000
|
||||
]
|
||||
],
|
||||
'memo' => '비고'
|
||||
];
|
||||
|
||||
$result = issueTaxInvoice($invoiceData);
|
||||
```
|
||||
|
||||
### 국세청 전송
|
||||
|
||||
```php
|
||||
require_once(__DIR__ . '/barobill_config.php');
|
||||
|
||||
$mgtKey = 'MGT20241201123456'; // 관리번호
|
||||
$result = sendToNTS($mgtKey);
|
||||
```
|
||||
|
||||
## 참고 문서
|
||||
|
||||
- [바로빌 개발자센터](https://dev.barobill.co.kr)
|
||||
- [바로빌 세금계산서 API 문서](https://dev.barobill.co.kr/docs/references/%EC%84%B8%EA%B8%88%EA%B3%84%EC%82%B0%EC%84%9C-API)
|
||||
- 로컬 문서: `etax/docs/barobill-api-doc/` 폴더
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **CERTKEY와 사업자번호는 절대 공개 저장소에 커밋하지 마세요**
|
||||
2. `.gitignore`에 `apikey/*.txt` (예제 파일 제외) 추가 권장
|
||||
3. 테스트 환경과 운영 환경의 CERTKEY를 분리하여 관리하세요
|
||||
4. SOAP 클라이언트는 PHP의 `SoapClient` 클래스를 사용합니다
|
||||
5. PHP 7.3 이상에서 `SoapClient` 확장이 활성화되어 있어야 합니다
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### SOAP 클라이언트 생성 실패
|
||||
|
||||
- PHP `SoapClient` 확장이 설치되어 있는지 확인
|
||||
- `php -m | grep soap` 명령으로 확인
|
||||
- 설치되지 않았다면 PHP 확장 설치 필요
|
||||
|
||||
### CERTKEY 오류
|
||||
|
||||
- CERTKEY가 올바르게 입력되었는지 확인
|
||||
- 바로빌 개발자센터에서 인증서가 정상적으로 등록되었는지 확인
|
||||
- 테스트 모드에서는 CERTKEY가 선택사항일 수 있음
|
||||
|
||||
### 사업자번호 오류
|
||||
|
||||
- 하이픈(-) 없이 숫자만 입력했는지 확인
|
||||
- 발행자 사업자번호가 올바른지 확인
|
||||
418
barobill/etax/api/barobill_config.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
/**
|
||||
* 바로빌 API 설정 파일
|
||||
*
|
||||
* ⚠️ 중요: 바로빌은 SOAP 웹서비스를 사용합니다 (REST API가 아님)
|
||||
*
|
||||
* 사용 방법:
|
||||
* 1. apikey/barobill_cert_key.txt 파일에 CERTKEY(인증서 키)를 저장하세요
|
||||
* - 바로빌 개발자센터에서 인증서 등록 후 발급받은 CERTKEY
|
||||
*
|
||||
* 2. apikey/barobill_corp_num.txt 파일에 사업자번호를 저장하세요
|
||||
* - 세금계산서를 발행할 회사의 사업자번호 (하이픈 제외)
|
||||
*
|
||||
* 3. 테스트 환경인 경우 apikey/barobill_test_mode.txt 파일에 "test" 또는 "true"를 저장하세요
|
||||
*/
|
||||
|
||||
|
||||
// load .env file
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
|
||||
// 인증서 키(CERTKEY) 파일 경로
|
||||
$documentRoot = getenv('DOCUMENT_ROOT');
|
||||
$certKeyFile = $documentRoot . '/apikey/barobill_cert_key.txt';
|
||||
$legacyApiKeyFile = $documentRoot . '/apikey/barobill_api_key.txt'; // 기존 호환성
|
||||
$corpNumFile = $documentRoot . '/apikey/barobill_corp_num.txt';
|
||||
$testModeFile = $documentRoot . '/apikey/barobill_test_mode.txt';
|
||||
|
||||
// CERTKEY 읽기 (인증서 키)
|
||||
// 우선순위: barobill_cert_key.txt > barobill_api_key.txt (기존 호환성)
|
||||
$barobillCertKey = '';
|
||||
if (file_exists($certKeyFile)) {
|
||||
$content = trim(file_get_contents($certKeyFile));
|
||||
// 설명 텍스트가 아닌 실제 키만 추출 (대괄호 안의 내용 제외, =로 시작하는 경우 제외)
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content) && !preg_match('/^=/', $content) && strpos($content, '바로빌 CERTKEY') === false) {
|
||||
$barobillCertKey = $content;
|
||||
}
|
||||
}
|
||||
// 기존 barobill_api_key.txt도 CERTKEY로 사용 (호환성)
|
||||
if (empty($barobillCertKey) && file_exists($legacyApiKeyFile)) {
|
||||
$barobillCertKey = trim(file_get_contents($legacyApiKeyFile));
|
||||
}
|
||||
|
||||
// 사업자번호 읽기
|
||||
$barobillCorpNum = '';
|
||||
if (file_exists($corpNumFile)) {
|
||||
$content = trim(file_get_contents($corpNumFile));
|
||||
// 설명 텍스트가 아닌 실제 사업자번호만 추출 (대괄호 안의 내용 제외)
|
||||
if (!empty($content) && !preg_match('/^\[여기에/', $content)) {
|
||||
$barobillCorpNum = $content;
|
||||
// 하이픈 제거
|
||||
$barobillCorpNum = str_replace('-', '', $barobillCorpNum);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 모드 확인
|
||||
$isTestMode = false;
|
||||
if (file_exists($testModeFile)) {
|
||||
$testMode = trim(file_get_contents($testModeFile));
|
||||
$isTestMode = (strtolower($testMode) === 'test' || strtolower($testMode) === 'true');
|
||||
}
|
||||
|
||||
// 바로빌 SOAP 웹서비스 URL
|
||||
// 문서 참고: etax/docs/barobill-api-doc/_lib/BaroService_TI.php
|
||||
$barobillSoapUrl = $isTestMode
|
||||
? 'https://testws.baroservice.com/TI.asmx?WSDL' // 테스트 환경
|
||||
: 'https://ws.baroservice.com/TI.asmx?WSDL'; // 운영 환경
|
||||
|
||||
// SOAP 클라이언트 초기화
|
||||
$barobillSoapClient = null;
|
||||
// 테스트 모드에서는 CERTKEY 없이도 SOAP 클라이언트 초기화 시도
|
||||
if (!empty($barobillCertKey) || $isTestMode) {
|
||||
try {
|
||||
$barobillSoapClient = new SoapClient($barobillSoapUrl, [
|
||||
'trace' => true,
|
||||
'encoding' => 'UTF-8',
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// SOAP 클라이언트 생성 실패 시 null 유지 (Class not found 등 Fatal Error 포함)
|
||||
error_log('바로빌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 SOAP 웹서비스 호출 함수
|
||||
*
|
||||
* @param string $method SOAP 메서드명 (예: 'RegistAndIssueTaxInvoice')
|
||||
* @param array $params SOAP 메서드 파라미터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function callBarobillSOAP($method, $params = []) {
|
||||
global $barobillSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode;
|
||||
|
||||
if (!$barobillSoapClient) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '바로빌 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요.',
|
||||
'error_detail' => [
|
||||
'cert_key_file' => getenv('DOCUMENT_ROOT') . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $isTestMode ? 'https://testws.baroservice.com/TI.asmx?WSDL' : 'https://ws.baroservice.com/TI.asmx?WSDL'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// 테스트 모드가 아닌 경우 CERTKEY 필수
|
||||
if (empty($barobillCertKey) && !$isTestMode) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'CERTKEY가 설정되지 않았습니다. apikey/barobill_cert_key.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
|
||||
// 테스트 모드에서 CERTKEY가 없으면 빈 문자열로 처리 (바로빌 테스트 API가 허용할 수 있음)
|
||||
if (empty($barobillCertKey) && $isTestMode) {
|
||||
$barobillCertKey = ''; // 빈 문자열로 시도
|
||||
}
|
||||
|
||||
// 테스트 모드에서 사업자번호가 없으면 더미 사업자번호 사용
|
||||
if (empty($barobillCorpNum)) {
|
||||
if ($isTestMode) {
|
||||
// 테스트 모드: 더미 사업자번호 사용 (바로빌 테스트 API가 허용할 수 있음)
|
||||
$barobillCorpNum = '1234567890'; // 테스트용 더미 사업자번호
|
||||
error_log('바로빌 테스트 모드: 사업자번호가 없어서 더미 사업자번호를 사용합니다.');
|
||||
} else {
|
||||
// 운영 모드: 사업자번호 필수
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '사업자번호가 설정되지 않았습니다. apikey/barobill_corp_num.txt 파일을 확인하세요.'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// CERTKEY와 CorpNum을 파라미터에 자동 추가
|
||||
if (!isset($params['CERTKEY'])) {
|
||||
$params['CERTKEY'] = $barobillCertKey;
|
||||
}
|
||||
if (!isset($params['CorpNum']) && !isset($params['CorpNum'])) {
|
||||
// CorpNum이 파라미터에 없으면 추가 (일부 메서드는 Invoice 내부에 있음)
|
||||
if (!isset($params['Invoice']['InvoicerParty']['CorpNum'])) {
|
||||
// Invoice 구조가 없으면 최상위에 추가
|
||||
if (!isset($params['CorpNum'])) {
|
||||
$params['CorpNum'] = $barobillCorpNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 디버깅: 전달되는 파라미터 로그 (민감 정보는 마스킹)
|
||||
error_log('바로빌 API 호출 - Method: ' . $method . ', CorpNum: ' . $barobillCorpNum . ', CERTKEY: ' . (empty($barobillCertKey) ? '(없음)' : substr($barobillCertKey, 0, 10) . '...'));
|
||||
|
||||
// SOAP 메서드 호출
|
||||
$result = $barobillSoapClient->$method($params);
|
||||
|
||||
// 결과에서 Result 속성 추출
|
||||
$resultProperty = $method . 'Result';
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
// 결과가 음수면 오류 코드
|
||||
if (is_numeric($resultData) && $resultData < 0) {
|
||||
// 오류 코드에 따른 메시지 매핑
|
||||
$errorMessages = [
|
||||
-11101 => '사업자번호가 설정되지 않았거나 유효하지 않습니다. apikey/barobill_corp_num.txt 파일에 올바른 사업자번호를 입력하세요.',
|
||||
-11102 => 'CERTKEY가 유효하지 않습니다. 바로빌 개발자센터에서 발급받은 CERTKEY를 확인하세요.',
|
||||
-11103 => '인증서가 만료되었거나 유효하지 않습니다.',
|
||||
-26001 => '발행에 필요한 공동인증서가 등록되어 있지 않습니다. 바로빌 웹사이트(https://www.barobill.co.kr)에 로그인하여 공동인증서를 등록하고, CERTKEY와 연결되어 있는지 확인하세요.',
|
||||
-32000 => '알 수 없는 오류가 발생했습니다.',
|
||||
];
|
||||
|
||||
$errorMessage = isset($errorMessages[$resultData])
|
||||
? $errorMessages[$resultData]
|
||||
: '바로빌 API 오류 코드: ' . $resultData;
|
||||
|
||||
// GetErrString API로 상세 오류 메시지 조회 시도 (CERTKEY가 있는 경우)
|
||||
$detailedError = null;
|
||||
if (!empty($barobillCertKey) && $barobillSoapClient) {
|
||||
try {
|
||||
$errStringResult = $barobillSoapClient->GetErrString([
|
||||
'CERTKEY' => $barobillCertKey,
|
||||
'ErrCode' => $resultData
|
||||
]);
|
||||
if (isset($errStringResult->GetErrStringResult) && $errStringResult->GetErrStringResult >= 0) {
|
||||
$detailedError = $errStringResult->GetErrStringResult;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// GetErrString 호출 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMessage,
|
||||
'error_code' => $resultData,
|
||||
'error_detail' => $detailedError ? "상세 오류: " . $detailedError : null,
|
||||
'soap_request' => $barobillSoapClient->__getLastRequest(),
|
||||
'soap_response' => $barobillSoapClient->__getLastResponse()
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $resultData,
|
||||
'soap_request' => $barobillSoapClient->__getLastRequest(),
|
||||
'soap_response' => $barobillSoapClient->__getLastResponse()
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'soap_request' => $barobillSoapClient->__getLastRequest(),
|
||||
'soap_response' => $barobillSoapClient->__getLastResponse()
|
||||
];
|
||||
|
||||
} catch (SoapFault $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode(),
|
||||
'error_detail' => [
|
||||
'fault_code' => $e->faultcode ?? null,
|
||||
'fault_string' => $e->faultstring ?? null,
|
||||
'soap_request' => $barobillSoapClient ? $barobillSoapClient->__getLastRequest() : null,
|
||||
'soap_response' => $barobillSoapClient ? $barobillSoapClient->__getLastResponse() : null
|
||||
]
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류 (치명적): ' . $e->getMessage(),
|
||||
'error_detail' => [
|
||||
'exception_type' => get_class($e),
|
||||
'soap_request' => $barobillSoapClient ? $barobillSoapClient->__getLastRequest() : null,
|
||||
'soap_response' => $barobillSoapClient ? $barobillSoapClient->__getLastResponse() : null
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 세금계산서 발행 (저장 + 발급)
|
||||
*
|
||||
* 문서 참고: etax/docs/barobill-api-doc/TAXINVOICE/RegistAndIssueTaxInvoice.php
|
||||
*
|
||||
* @param array $invoiceData 세금계산서 데이터
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function issueTaxInvoice($invoiceData) {
|
||||
global $barobillCorpNum;
|
||||
|
||||
// MgtKey 생성 (관리번호) - 유니크한 키 생성
|
||||
$mgtKey = $invoiceData['issueKey'] ?? 'MGT' . date('YmdHis') . rand(1000, 9999);
|
||||
|
||||
// 공급가액, 부가세, 합계 계산
|
||||
$supplyAmt = 0;
|
||||
$vat = 0;
|
||||
foreach ($invoiceData['items'] ?? [] as $item) {
|
||||
$itemSupplyAmt = floatval($item['supplyAmt'] ?? 0);
|
||||
$itemVat = floatval($item['vat'] ?? 0);
|
||||
$supplyAmt += $itemSupplyAmt;
|
||||
$vat += $itemVat;
|
||||
}
|
||||
$total = $supplyAmt + $vat;
|
||||
|
||||
// TaxType 결정: 부가세가 0원이면 영세(2) 또는 면세(3)로 설정
|
||||
// 과세(1)는 부가세가 0원 이상이어야 함
|
||||
$taxType = 1; // 기본값: 과세
|
||||
if ($vat == 0) {
|
||||
// 부가세가 0원이면 영세로 설정 (또는 면세로 설정 가능)
|
||||
$taxType = 2; // 2: 영세
|
||||
}
|
||||
|
||||
// 바로빌 SOAP API 스펙에 맞게 데이터 변환
|
||||
// 문서 참고: etax/docs/barobill-api-doc/TAXINVOICE/RegistAndIssueTaxInvoice.php
|
||||
$taxInvoice = [
|
||||
'IssueDirection' => 1, // 1: 정발행, 2: 역발행
|
||||
'TaxInvoiceType' => 1, // 1: 세금계산서, 2: 계산서
|
||||
'ModifyCode' => '', // 수정사유코드 (신규발행시 빈값)
|
||||
'TaxType' => $taxType, // 1: 과세, 2: 영세, 3: 면세 (부가세가 0이면 영세로 설정)
|
||||
'TaxCalcType' => 1, // 1: 소계합계, 2: 항목합계
|
||||
'PurposeType' => 2, // 1: 영수, 2: 청구, 3: 없음
|
||||
'WriteDate' => date('Ymd', strtotime($invoiceData['writeDate'] ?? date('Y-m-d'))), // 작성일자 (YYYYMMDD)
|
||||
'AmountTotal' => number_format($supplyAmt, 0, '', ''), // 공급가액 합계
|
||||
'TaxTotal' => number_format($vat, 0, '', ''), // 부가세 합계
|
||||
'TotalAmount' => number_format($total, 0, '', ''), // 합계금액
|
||||
'Cash' => '0', // 현금
|
||||
'ChkBill' => '0', // 어음
|
||||
'Note' => '0', // 외상
|
||||
'Credit' => number_format($total, 0, '', ''), // 외상미수금 (합계금액과 일치해야 함)
|
||||
'Remark1' => $invoiceData['memo'] ?? '', // 비고1
|
||||
'Remark2' => '', // 비고2
|
||||
'Remark3' => '', // 비고3
|
||||
'Kwon' => '', // 권
|
||||
'Ho' => '', // 호
|
||||
'SerialNum' => '', // 일련번호
|
||||
'InvoicerParty' => [
|
||||
'MgtNum' => $mgtKey, // 관리번호
|
||||
'CorpNum' => $barobillCorpNum, // 발행자 사업자번호 (CERTKEY와 연결된 사업자번호 사용)
|
||||
'TaxRegID' => '', // 종사업장번호
|
||||
'CorpName' => $invoiceData['supplierName'] ?? '', // 상호
|
||||
'CEOName' => $invoiceData['supplierCeo'] ?? '', // 대표자명
|
||||
'Addr' => $invoiceData['supplierAddr'] ?? '', // 주소
|
||||
'BizType' => '', // 업태
|
||||
'BizClass' => '', // 종목
|
||||
'ContactID' => $invoiceData['supplierContactId'] ?? 'cbx0913', // 담당자 아이디 (바로빌 웹페이지 ID)
|
||||
'ContactName' => $invoiceData['supplierContact'] ?? '', // 담당자명
|
||||
'TEL' => $invoiceData['supplierTel'] ?? '', // 전화번호
|
||||
'HP' => '', // 휴대폰
|
||||
'Email' => $invoiceData['supplierEmail'] ?? '', // 이메일
|
||||
],
|
||||
'InvoiceeParty' => [
|
||||
'MgtNum' => '', // 관리번호
|
||||
'CorpNum' => str_replace('-', '', $invoiceData['recipientBizno'] ?? ''), // 사업자번호
|
||||
'TaxRegID' => '', // 종사업장번호
|
||||
'CorpName' => $invoiceData['recipientName'] ?? '', // 상호
|
||||
'CEOName' => $invoiceData['recipientCeo'] ?? '', // 대표자명
|
||||
'Addr' => $invoiceData['recipientAddr'] ?? '', // 주소
|
||||
'BizType' => '', // 업태
|
||||
'BizClass' => '', // 종목
|
||||
'ContactID' => '', // 담당자 아이디
|
||||
'ContactName' => $invoiceData['recipientContact'] ?? '', // 담당자명
|
||||
'TEL' => $invoiceData['recipientTel'] ?? '', // 전화번호
|
||||
'HP' => '', // 휴대폰
|
||||
'Email' => $invoiceData['recipientEmail'] ?? '', // 이메일
|
||||
],
|
||||
'BrokerParty' => [], // 위수탁 거래시에만 사용
|
||||
'TaxInvoiceTradeLineItems' => [
|
||||
'TaxInvoiceTradeLineItem' => []
|
||||
]
|
||||
];
|
||||
|
||||
// 품목 데이터 변환
|
||||
foreach ($invoiceData['items'] ?? [] as $item) {
|
||||
$taxInvoice['TaxInvoiceTradeLineItems']['TaxInvoiceTradeLineItem'][] = [
|
||||
'PurchaseExpiry' => '', // 공제기한
|
||||
'Name' => $item['name'] ?? '', // 품명
|
||||
'Information' => $item['spec'] ?? '', // 규격
|
||||
'ChargeableUnit' => $item['qty'] ?? '1', // 수량
|
||||
'UnitPrice' => number_format(floatval($item['unitPrice'] ?? 0), 0, '', ''), // 단가
|
||||
'Amount' => number_format(floatval($item['supplyAmt'] ?? 0), 0, '', ''), // 공급가액
|
||||
'Tax' => number_format(floatval($item['vat'] ?? 0), 0, '', ''), // 부가세
|
||||
'Description' => $item['description'] ?? '', // 비고
|
||||
];
|
||||
}
|
||||
|
||||
// SOAP 메서드 호출
|
||||
$params = [
|
||||
'CorpNum' => $barobillCorpNum, // 발행자 사업자번호
|
||||
'Invoice' => $taxInvoice,
|
||||
'SendSMS' => false, // SMS 발송 여부
|
||||
'ForceIssue' => false, // 강제발행 여부
|
||||
'MailTitle' => '', // 이메일 제목
|
||||
];
|
||||
|
||||
return callBarobillSOAP('RegistAndIssueTaxInvoice', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 세금계산서 조회
|
||||
*
|
||||
* 문서 참고: etax/docs/barobill-api-doc/TAXINVOICE/GetTaxInvoice.php
|
||||
*
|
||||
* @param string $mgtKey 관리번호 (MgtKey)
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function getTaxInvoice($mgtKey) {
|
||||
global $barobillCorpNum;
|
||||
|
||||
$params = [
|
||||
'CorpNum' => $barobillCorpNum,
|
||||
'MgtKey' => $mgtKey
|
||||
];
|
||||
|
||||
return callBarobillSOAP('GetTaxInvoice', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 세금계산서 상태 조회
|
||||
*
|
||||
* 문서 참고: etax/docs/barobill-api-doc/TAXINVOICE/GetTaxInvoiceStateEX.php
|
||||
*
|
||||
* @param string $mgtKey 관리번호 (MgtKey)
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function getTaxInvoiceState($mgtKey) {
|
||||
global $barobillCorpNum;
|
||||
|
||||
$params = [
|
||||
'CorpNum' => $barobillCorpNum,
|
||||
'MgtKey' => $mgtKey
|
||||
];
|
||||
|
||||
return callBarobillSOAP('GetTaxInvoiceStateEX', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 세금계산서 국세청 전송
|
||||
*
|
||||
* 문서 참고: etax/docs/barobill-api-doc/TAXINVOICE/SendToNTS.php
|
||||
*
|
||||
* @param string $mgtKey 관리번호 (MgtKey)
|
||||
* @return array 응답 데이터
|
||||
*/
|
||||
function sendToNTS($mgtKey) {
|
||||
global $barobillCorpNum;
|
||||
|
||||
$params = [
|
||||
'CorpNum' => $barobillCorpNum,
|
||||
'MgtKey' => $mgtKey
|
||||
];
|
||||
|
||||
return callBarobillSOAP('SendToNTS', $params);
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
34
barobill/etax/api/debug_test.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
echo "Current Dir: " . __DIR__ . "\n";
|
||||
echo "DotEnv Path: " . __DIR__ . '/../../../lib/DotEnv.php' . "\n";
|
||||
echo "Env File Path: " . __DIR__ . '/../../../.env' . "\n";
|
||||
|
||||
if (file_exists(__DIR__ . '/../../../lib/DotEnv.php')) {
|
||||
echo "DotEnv file exists.\n";
|
||||
require_once __DIR__ . '/../../../lib/DotEnv.php';
|
||||
echo "DotEnv loaded.\n";
|
||||
} else {
|
||||
echo "DotEnv file NOT found.\n";
|
||||
}
|
||||
|
||||
if (file_exists(__DIR__ . '/../../../.env')) {
|
||||
echo ".env file exists.\n";
|
||||
try {
|
||||
(new DotEnv(__DIR__ . '/../../../.env'))->load();
|
||||
echo ".env loaded.\n";
|
||||
} catch (Exception $e) {
|
||||
echo "Error loading .env: " . $e->getMessage() . "\n";
|
||||
}
|
||||
} else {
|
||||
echo ".env file NOT found.\n";
|
||||
}
|
||||
|
||||
$root = getenv('DOCUMENT_ROOT');
|
||||
echo "DOCUMENT_ROOT from getenv: " . var_export($root, true) . "\n";
|
||||
echo "DOCUMENT_ROOT from \$_ENV: " . var_export($_ENV['DOCUMENT_ROOT'] ?? 'unset', true) . "\n";
|
||||
echo "DOCUMENT_ROOT from \$_SERVER: " . var_export($_SERVER['DOCUMENT_ROOT'] ?? 'unset', true) . "\n";
|
||||
163
barobill/etax/api/delete.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method !== 'POST' && $method !== 'DELETE') {
|
||||
http_response_code(405);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Method not allowed"
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 요청 본문에서 invoiceId 가져오기
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true);
|
||||
$invoiceId = $input['invoiceId'] ?? $_GET['invoiceId'] ?? null;
|
||||
|
||||
// 디버깅: 요청 데이터 로그
|
||||
error_log("Delete request - Raw input: " . $rawInput);
|
||||
error_log("Delete request - Parsed input: " . print_r($input, true));
|
||||
error_log("Delete request - invoiceId: " . var_export($invoiceId, true));
|
||||
|
||||
if (!$invoiceId) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "invoiceId is required",
|
||||
"debug" => [
|
||||
"rawInput" => $rawInput,
|
||||
"parsedInput" => $input,
|
||||
"getParams" => $_GET
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dataFile = __DIR__ . '/invoices_data.json';
|
||||
|
||||
// 파일에서 데이터 읽기
|
||||
$existingData = [];
|
||||
if (file_exists($dataFile)) {
|
||||
$fileContent = file_get_contents($dataFile);
|
||||
if ($fileContent !== false) {
|
||||
$existingData = json_decode($fileContent, true);
|
||||
if (!is_array($existingData)) {
|
||||
$existingData = [];
|
||||
}
|
||||
} else {
|
||||
error_log("Failed to read invoices_data.json file");
|
||||
}
|
||||
} else {
|
||||
error_log("invoices_data.json file does not exist");
|
||||
}
|
||||
|
||||
if (!isset($existingData['invoices'])) {
|
||||
$existingData['invoices'] = [];
|
||||
}
|
||||
|
||||
// 디버깅: 현재 파일에 있는 ID 목록
|
||||
$currentIds = array_map(function($inv) {
|
||||
return $inv['id'] ?? 'no-id';
|
||||
}, $existingData['invoices']);
|
||||
error_log("Current invoice IDs in file: " . implode(', ', $currentIds));
|
||||
error_log("Looking for invoiceId: " . $invoiceId);
|
||||
|
||||
// 삭제된 ID 목록 파일
|
||||
$deletedIdsFile = __DIR__ . '/deleted_ids.json';
|
||||
$deletedIds = [];
|
||||
|
||||
// 삭제된 ID 목록 읽기
|
||||
if (file_exists($deletedIdsFile)) {
|
||||
$deletedContent = file_get_contents($deletedIdsFile);
|
||||
if ($deletedContent !== false) {
|
||||
$deletedIds = json_decode($deletedContent, true);
|
||||
if (!is_array($deletedIds)) {
|
||||
$deletedIds = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 mock 데이터인 경우, 삭제된 ID 목록에 추가
|
||||
$defaultMockIds = ['inv_001', 'inv_002', 'inv_003'];
|
||||
if (in_array($invoiceId, $defaultMockIds)) {
|
||||
// 이미 삭제된 경우
|
||||
if (in_array($invoiceId, $deletedIds)) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Invoice not found",
|
||||
"message" => "이미 삭제된 항목입니다."
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 삭제된 ID 목록에 추가
|
||||
$deletedIds[] = $invoiceId;
|
||||
$deletedIds = array_unique($deletedIds); // 중복 제거
|
||||
file_put_contents($deletedIdsFile, json_encode($deletedIds, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"message" => "Invoice deleted successfully",
|
||||
"deletedId" => $invoiceId
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 해당 ID의 인보이스 찾아서 삭제
|
||||
$beforeCount = count($existingData['invoices']);
|
||||
$foundInvoice = null;
|
||||
foreach ($existingData['invoices'] as $index => $invoice) {
|
||||
$currentId = $invoice['id'] ?? '';
|
||||
// 문자열 비교 (타입 변환)
|
||||
if ((string)$currentId === (string)$invoiceId) {
|
||||
$foundInvoice = $invoice;
|
||||
unset($existingData['invoices'][$index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$existingData['invoices'] = array_values($existingData['invoices']); // 인덱스 재정렬
|
||||
$afterCount = count($existingData['invoices']);
|
||||
|
||||
if ($beforeCount === $afterCount) {
|
||||
// 삭제할 항목이 없었음
|
||||
error_log("Invoice not found - beforeCount: $beforeCount, afterCount: $afterCount");
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Invoice not found",
|
||||
"message" => "해당 세금계산서를 찾을 수 없습니다. 이미 삭제되었거나 존재하지 않는 항목일 수 있습니다.",
|
||||
"debug" => [
|
||||
"invoiceId" => $invoiceId,
|
||||
"invoiceIdType" => gettype($invoiceId),
|
||||
"totalInvoices" => $beforeCount,
|
||||
"availableIds" => $currentIds
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 파일에 저장
|
||||
$jsonData = json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
$saveResult = @file_put_contents($dataFile, $jsonData);
|
||||
|
||||
if ($saveResult === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Failed to save data"
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
"success" => true,
|
||||
"message" => "Invoice deleted successfully",
|
||||
"deletedId" => $invoiceId
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
5
barobill/etax/api/deleted_ids.json
Normal file
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"inv_001",
|
||||
"inv_002",
|
||||
"inv_003"
|
||||
]
|
||||
170
barobill/etax/api/invoices.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// Mock Data - 가상의 전자세금계산서 발행 내역
|
||||
$invoices = [
|
||||
[
|
||||
"id" => "inv_001",
|
||||
"issueKey" => "BARO-2024-001",
|
||||
"supplierBizno" => "123-45-67890",
|
||||
"supplierName" => "(주)건축자재",
|
||||
"recipientBizno" => "987-65-43210",
|
||||
"recipientName" => "대박 건설(주)",
|
||||
"supplyDate" => "2024-11-01",
|
||||
"items" => [
|
||||
[
|
||||
"name" => "시멘트 50kg",
|
||||
"qty" => 100,
|
||||
"unitPrice" => 8000,
|
||||
"supplyAmt" => 800000,
|
||||
"vat" => 80000,
|
||||
"vatType" => "vat"
|
||||
],
|
||||
[
|
||||
"name" => "철근 10mm",
|
||||
"qty" => 50,
|
||||
"unitPrice" => 12000,
|
||||
"supplyAmt" => 600000,
|
||||
"vat" => 60000,
|
||||
"vatType" => "vat"
|
||||
]
|
||||
],
|
||||
"totalSupplyAmt" => 1400000,
|
||||
"totalVat" => 140000,
|
||||
"total" => 1540000,
|
||||
"status" => "sent",
|
||||
"ntsReceiptNo" => "NTS-2024-001234",
|
||||
"memo" => "정기 납품",
|
||||
"createdAt" => "2024-11-01T10:30:00",
|
||||
"sentAt" => "2024-11-01T10:35:00"
|
||||
],
|
||||
[
|
||||
"id" => "inv_002",
|
||||
"issueKey" => "BARO-2024-002",
|
||||
"supplierBizno" => "123-45-67890",
|
||||
"supplierName" => "(주)건축자재",
|
||||
"recipientBizno" => "111-22-33333",
|
||||
"recipientName" => "강남 부동산(주)",
|
||||
"supplyDate" => "2024-11-05",
|
||||
"items" => [
|
||||
[
|
||||
"name" => "도배지",
|
||||
"qty" => 200,
|
||||
"unitPrice" => 5000,
|
||||
"supplyAmt" => 1000000,
|
||||
"vat" => 100000,
|
||||
"vatType" => "vat"
|
||||
]
|
||||
],
|
||||
"totalSupplyAmt" => 1000000,
|
||||
"totalVat" => 100000,
|
||||
"total" => 1100000,
|
||||
"status" => "issued",
|
||||
"memo" => "",
|
||||
"createdAt" => "2024-11-05T14:20:00"
|
||||
],
|
||||
[
|
||||
"id" => "inv_003",
|
||||
"issueKey" => "BARO-2024-003",
|
||||
"supplierBizno" => "123-45-67890",
|
||||
"supplierName" => "(주)건축자재",
|
||||
"recipientBizno" => "555-66-77777",
|
||||
"recipientName" => "성수 인테리어(주)",
|
||||
"supplyDate" => "2024-11-10",
|
||||
"items" => [
|
||||
[
|
||||
"name" => "타일 30x30",
|
||||
"qty" => 500,
|
||||
"unitPrice" => 3000,
|
||||
"supplyAmt" => 1500000,
|
||||
"vat" => 150000,
|
||||
"vatType" => "vat"
|
||||
],
|
||||
[
|
||||
"name" => "접착제",
|
||||
"qty" => 20,
|
||||
"unitPrice" => 15000,
|
||||
"supplyAmt" => 300000,
|
||||
"vat" => 30000,
|
||||
"vatType" => "vat"
|
||||
]
|
||||
],
|
||||
"totalSupplyAmt" => 1800000,
|
||||
"totalVat" => 180000,
|
||||
"total" => 1980000,
|
||||
"status" => "sent",
|
||||
"ntsReceiptNo" => "NTS-2024-001567",
|
||||
"memo" => "긴급 납품",
|
||||
"createdAt" => "2024-11-10T09:15:00",
|
||||
"sentAt" => "2024-11-10T09:20:00"
|
||||
]
|
||||
];
|
||||
|
||||
// 삭제된 ID 목록 읽기
|
||||
$deletedIdsFile = __DIR__ . '/deleted_ids.json';
|
||||
$deletedIds = [];
|
||||
if (file_exists($deletedIdsFile)) {
|
||||
$deletedContent = file_get_contents($deletedIdsFile);
|
||||
if ($deletedContent !== false) {
|
||||
$deletedIds = json_decode($deletedContent, true);
|
||||
if (!is_array($deletedIds)) {
|
||||
$deletedIds = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 mock 데이터에서 삭제된 항목 제외
|
||||
$invoices = array_filter($invoices, function($invoice) use ($deletedIds) {
|
||||
return !in_array($invoice['id'] ?? '', $deletedIds);
|
||||
});
|
||||
$invoices = array_values($invoices); // 인덱스 재정렬
|
||||
|
||||
// 파일에서 저장된 데이터 읽기 (있는 경우)
|
||||
$dataFile = __DIR__ . '/invoices_data.json';
|
||||
if (file_exists($dataFile)) {
|
||||
$fileContent = file_get_contents($dataFile);
|
||||
if ($fileContent !== false) {
|
||||
$savedData = json_decode($fileContent, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && isset($savedData['invoices']) && is_array($savedData['invoices']) && count($savedData['invoices']) > 0) {
|
||||
// 저장된 데이터에서도 삭제된 항목 제외
|
||||
$savedData['invoices'] = array_filter($savedData['invoices'], function($invoice) use ($deletedIds) {
|
||||
return !in_array($invoice['id'] ?? '', $deletedIds);
|
||||
});
|
||||
$savedData['invoices'] = array_values($savedData['invoices']);
|
||||
|
||||
// 저장된 데이터를 우선하고, 기본 mock 데이터와 병합
|
||||
// 저장된 데이터가 최신이므로 먼저 배치
|
||||
$invoices = array_merge($savedData['invoices'], $invoices);
|
||||
// 중복 제거 (id 기준) - 먼저 나온 것이 유지됨 (저장된 데이터 우선)
|
||||
$uniqueInvoices = [];
|
||||
$seenIds = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
if (!in_array($invoice['id'], $seenIds)) {
|
||||
$uniqueInvoices[] = $invoice;
|
||||
$seenIds[] = $invoice['id'];
|
||||
}
|
||||
}
|
||||
$invoices = $uniqueInvoices;
|
||||
} elseif (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_log("invoices_data.json JSON 파싱 오류: " . json_last_error_msg());
|
||||
}
|
||||
} else {
|
||||
error_log("invoices_data.json 파일 읽기 실패: " . $dataFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 최신순 정렬
|
||||
usort($invoices, function($a, $b) {
|
||||
return strtotime($b['createdAt'] ?? $b['supplyDate']) - strtotime($a['createdAt'] ?? $a['supplyDate']);
|
||||
});
|
||||
|
||||
$response = [
|
||||
"success" => true,
|
||||
"invoices" => $invoices,
|
||||
"count" => count($invoices)
|
||||
];
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
346
barobill/etax/api/invoices_data.json
Normal file
@@ -0,0 +1,346 @@
|
||||
{
|
||||
"invoices": [
|
||||
{
|
||||
"id": "inv_1764734934",
|
||||
"issueKey": "MGT202512031308547398",
|
||||
"mgtKey": "MGT202512031308547398",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "843-22-01859",
|
||||
"recipientName": "조은지게차",
|
||||
"supplyDate": "2025-11-06",
|
||||
"items": [
|
||||
{
|
||||
"name": "시멘트 50kg",
|
||||
"qty": 49,
|
||||
"unitPrice": 37827,
|
||||
"vatType": "zero",
|
||||
"supplyAmt": 1853523,
|
||||
"vat": 0,
|
||||
"total": 1853523
|
||||
},
|
||||
{
|
||||
"name": "접착제",
|
||||
"qty": 32,
|
||||
"unitPrice": 377796,
|
||||
"vatType": "exempt",
|
||||
"supplyAmt": 12089472,
|
||||
"vat": 0,
|
||||
"total": 12089472
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 13942995,
|
||||
"totalVat": 0,
|
||||
"total": 13942995,
|
||||
"status": "issued",
|
||||
"memo": "긴급 납품",
|
||||
"createdAt": "2025-12-03T13:08:54",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1764735037",
|
||||
"issueKey": "MGT202512031310379183",
|
||||
"mgtKey": "MGT202512031310379183",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "311-46-00378",
|
||||
"recipientName": "김인태",
|
||||
"supplyDate": "2025-11-23",
|
||||
"items": [
|
||||
{
|
||||
"name": "타일 30x30",
|
||||
"qty": 19,
|
||||
"unitPrice": 152719,
|
||||
"vatType": "exempt",
|
||||
"supplyAmt": 2901661,
|
||||
"vat": 0,
|
||||
"total": 2901661
|
||||
},
|
||||
{
|
||||
"name": "욕조",
|
||||
"qty": 48,
|
||||
"unitPrice": 202442,
|
||||
"vatType": "exempt",
|
||||
"supplyAmt": 9717216,
|
||||
"vat": 0,
|
||||
"total": 9717216
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 12618877,
|
||||
"totalVat": 0,
|
||||
"total": 12618877,
|
||||
"status": "issued",
|
||||
"memo": "샘플 납품",
|
||||
"createdAt": "2025-12-03T13:10:37",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765062685",
|
||||
"issueKey": "MGT202512070811254622",
|
||||
"mgtKey": "MGT202512070811254622",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "406-05-25709",
|
||||
"recipientName": "스카이익스프레스",
|
||||
"supplyDate": "2025-11-09",
|
||||
"items": [
|
||||
{
|
||||
"name": "배관자재",
|
||||
"qty": 82,
|
||||
"unitPrice": 283655,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 23259710,
|
||||
"vat": 2325971,
|
||||
"total": 25585681
|
||||
},
|
||||
{
|
||||
"name": "목재 합판",
|
||||
"qty": 97,
|
||||
"unitPrice": 196766,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 19086302,
|
||||
"vat": 1908630,
|
||||
"total": 20994932
|
||||
},
|
||||
{
|
||||
"name": "유리 5mm",
|
||||
"qty": 51,
|
||||
"unitPrice": 240496,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 12265296,
|
||||
"vat": 1226529,
|
||||
"total": 13491825
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 54611308,
|
||||
"totalVat": 5461130,
|
||||
"total": 60072438,
|
||||
"status": "issued",
|
||||
"memo": "계약 납품",
|
||||
"createdAt": "2025-12-07T08:11:25",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765114676",
|
||||
"issueKey": "MGT202512072237565827",
|
||||
"mgtKey": "MGT202512072237565827",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "107-81-78114",
|
||||
"recipientName": "(주)이상네트웍스",
|
||||
"supplyDate": "2025-11-14",
|
||||
"items": [
|
||||
{
|
||||
"name": "배관자재",
|
||||
"qty": 6,
|
||||
"unitPrice": 238878,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 1433268,
|
||||
"vat": 143326,
|
||||
"total": 1576594
|
||||
},
|
||||
{
|
||||
"name": "접착제",
|
||||
"qty": 79,
|
||||
"unitPrice": 313735,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 24785065,
|
||||
"vat": 2478506,
|
||||
"total": 27263571
|
||||
},
|
||||
{
|
||||
"name": "변기",
|
||||
"qty": 80,
|
||||
"unitPrice": 441883,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 35350640,
|
||||
"vat": 3535064,
|
||||
"total": 38885704
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 61568973,
|
||||
"totalVat": 6156896,
|
||||
"total": 67725869,
|
||||
"status": "issued",
|
||||
"memo": "보수 납품",
|
||||
"createdAt": "2025-12-07T22:37:56",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765114749",
|
||||
"issueKey": "MGT202512072239096510",
|
||||
"mgtKey": "MGT202512072239096510",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "843-22-01859",
|
||||
"recipientName": "조은지게차",
|
||||
"supplyDate": "2025-11-24",
|
||||
"items": [
|
||||
{
|
||||
"name": "유리 5mm",
|
||||
"qty": 91,
|
||||
"unitPrice": 409294,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 37245754,
|
||||
"vat": 3724575,
|
||||
"total": 40970329
|
||||
},
|
||||
{
|
||||
"name": "접착제",
|
||||
"qty": 39,
|
||||
"unitPrice": 320606,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 12503634,
|
||||
"vat": 1250363,
|
||||
"total": 13753997
|
||||
},
|
||||
{
|
||||
"name": "변기",
|
||||
"qty": 33,
|
||||
"unitPrice": 140978,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 4652274,
|
||||
"vat": 465227,
|
||||
"total": 5117501
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 54401662,
|
||||
"totalVat": 5440165,
|
||||
"total": 59841827,
|
||||
"status": "issued",
|
||||
"memo": "정기 납품",
|
||||
"createdAt": "2025-12-07T22:39:09",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765120032",
|
||||
"issueKey": "MGT202512080007129289",
|
||||
"mgtKey": "MGT202512080007129289",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "107-81-78114",
|
||||
"recipientName": "(주)이상네트웍스",
|
||||
"supplyDate": "2025-11-13",
|
||||
"items": [
|
||||
{
|
||||
"name": "유리 5mm",
|
||||
"qty": 90,
|
||||
"unitPrice": 122746,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 11047140,
|
||||
"vat": 1104714,
|
||||
"total": 12151854
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 11047140,
|
||||
"totalVat": 1104714,
|
||||
"total": 12151854,
|
||||
"status": "issued",
|
||||
"memo": "보수 납품",
|
||||
"createdAt": "2025-12-08T00:07:12",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1768349618",
|
||||
"issueKey": "MGT202601140913384780",
|
||||
"mgtKey": "MGT202601140913384780",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "107-81-78114",
|
||||
"recipientName": "(주)이상네트웍스",
|
||||
"supplyDate": "2026-01-10",
|
||||
"items": [
|
||||
{
|
||||
"name": "페인트 18L",
|
||||
"qty": 79,
|
||||
"unitPrice": 90747,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 7169013,
|
||||
"vat": 716901,
|
||||
"total": 7885914
|
||||
},
|
||||
{
|
||||
"name": "접착제",
|
||||
"qty": 67,
|
||||
"unitPrice": 162463,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 10885021,
|
||||
"vat": 1088502,
|
||||
"total": 11973523
|
||||
},
|
||||
{
|
||||
"name": "조명기구",
|
||||
"qty": 25,
|
||||
"unitPrice": 408336,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 10208400,
|
||||
"vat": 1020840,
|
||||
"total": 11229240
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 28262434,
|
||||
"totalVat": 2826243,
|
||||
"total": 31088677,
|
||||
"status": "issued",
|
||||
"memo": "교체 납품",
|
||||
"createdAt": "2026-01-14T09:13:38",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1768349638",
|
||||
"issueKey": "MGT202601140913583219",
|
||||
"mgtKey": "MGT202601140913583219",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "311-46-00378",
|
||||
"recipientName": "김인태",
|
||||
"supplyDate": "2026-01-14",
|
||||
"items": [
|
||||
{
|
||||
"name": "변기",
|
||||
"qty": 68,
|
||||
"unitPrice": 136303,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 9268604,
|
||||
"vat": 926860,
|
||||
"total": 10195464
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 9268604,
|
||||
"totalVat": 926860,
|
||||
"total": 10195464,
|
||||
"status": "issued",
|
||||
"memo": "A\/S 납품",
|
||||
"createdAt": "2026-01-14T09:13:58",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1768384628",
|
||||
"issueKey": "MGT202601141857081230",
|
||||
"mgtKey": "MGT202601141857081230",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "311-46-00378",
|
||||
"recipientName": "김인태",
|
||||
"supplyDate": "2025-12-22",
|
||||
"items": [
|
||||
{
|
||||
"name": "조명기구",
|
||||
"qty": 21,
|
||||
"unitPrice": 173624,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 3646104,
|
||||
"vat": 364610,
|
||||
"total": 4010714
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 3646104,
|
||||
"totalVat": 364610,
|
||||
"total": 4010714,
|
||||
"status": "issued",
|
||||
"memo": "A\/S 납품",
|
||||
"createdAt": "2026-01-14T18:57:08",
|
||||
"barobillInvoiceId": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
226
barobill/etax/api/issue.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// 바로빌 API 설정 로드
|
||||
require_once(__DIR__ . '/barobill_config.php');
|
||||
|
||||
// POST 데이터 읽기
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Invalid request data"
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 바로빌 API 호출 또는 시뮬레이션
|
||||
// 테스트 모드: SOAP 클라이언트만 있으면 실제 API 호출 시도 (CERTKEY는 선택사항)
|
||||
// 운영 모드: SOAP 클라이언트와 CERTKEY 모두 필요
|
||||
$useRealAPI = !empty($barobillSoapClient) && ($isTestMode || !empty($barobillCertKey));
|
||||
|
||||
// 디버깅 정보: 실제 API 호출 여부 확인
|
||||
$debugInfo = [
|
||||
'hasSoapClient' => !empty($barobillSoapClient),
|
||||
'hasCertKey' => !empty($barobillCertKey),
|
||||
'hasCorpNum' => !empty($barobillCorpNum),
|
||||
'isTestMode' => $isTestMode ?? false,
|
||||
'soapUrl' => $barobillSoapUrl ?? null,
|
||||
'willUseRealAPI' => $useRealAPI
|
||||
];
|
||||
|
||||
if ($useRealAPI) {
|
||||
// 실제 바로빌 SOAP API 호출
|
||||
$apiResult = issueTaxInvoice($input);
|
||||
|
||||
if ($apiResult['success']) {
|
||||
$apiData = $apiResult['data'];
|
||||
|
||||
// SOAP 응답에서 MgtKey 추출 (RegistAndIssueTaxInvoice는 MgtKey를 반환)
|
||||
// 응답이 숫자(양수)면 성공, 그 값이 바로빌 세금계산서 ID일 수 있음
|
||||
// 또는 객체일 경우 MgtKey 속성 확인
|
||||
$mgtKey = '';
|
||||
if (is_object($apiData) && isset($apiData->MgtKey)) {
|
||||
$mgtKey = $apiData->MgtKey;
|
||||
} elseif (is_array($apiData) && isset($apiData['MgtKey'])) {
|
||||
$mgtKey = $apiData['MgtKey'];
|
||||
} else {
|
||||
// MgtKey가 없으면 입력에서 생성한 키 사용
|
||||
$mgtKey = $input['issueKey'] ?? 'MGT' . date('YmdHis') . rand(1000, 9999);
|
||||
}
|
||||
|
||||
// 새 세금계산서 데이터 생성
|
||||
$newInvoice = [
|
||||
"id" => "inv_" . time(),
|
||||
"issueKey" => $mgtKey, // MgtKey를 issueKey로 사용
|
||||
"mgtKey" => $mgtKey, // 바로빌 관리번호
|
||||
"supplierBizno" => $input['supplierBizno'] ?? '',
|
||||
"supplierName" => $input['supplierName'] ?? '',
|
||||
"recipientBizno" => $input['recipientBizno'] ?? '',
|
||||
"recipientName" => $input['recipientName'] ?? '',
|
||||
"supplyDate" => $input['supplyDate'] ?? date('Y-m-d'),
|
||||
"items" => $input['items'] ?? [],
|
||||
"totalSupplyAmt" => $input['totalSupplyAmt'] ?? 0,
|
||||
"totalVat" => $input['totalVat'] ?? 0,
|
||||
"total" => $input['total'] ?? 0,
|
||||
"status" => "issued",
|
||||
"memo" => $input['memo'] ?? '',
|
||||
"createdAt" => date('Y-m-d\TH:i:s'),
|
||||
"barobillInvoiceId" => is_numeric($apiData) ? (string)$apiData : ($apiData->InvoiceID ?? '')
|
||||
];
|
||||
|
||||
// 파일에 저장
|
||||
$dataFile = __DIR__ . '/invoices_data.json';
|
||||
$existingData = [];
|
||||
if (file_exists($dataFile)) {
|
||||
$fileContent = file_get_contents($dataFile);
|
||||
if ($fileContent !== false) {
|
||||
$existingData = json_decode($fileContent, true);
|
||||
if (!is_array($existingData)) {
|
||||
$existingData = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($existingData['invoices'])) {
|
||||
$existingData['invoices'] = [];
|
||||
}
|
||||
|
||||
$existingData['invoices'][] = $newInvoice;
|
||||
|
||||
// 최대 100개까지만 저장
|
||||
if (count($existingData['invoices']) > 100) {
|
||||
$existingData['invoices'] = array_slice($existingData['invoices'], -100);
|
||||
}
|
||||
|
||||
// 파일 저장 시도 및 에러 확인
|
||||
$jsonData = json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
$saveResult = @file_put_contents($dataFile, $jsonData);
|
||||
$saveSuccess = ($saveResult !== false);
|
||||
|
||||
if (!$saveSuccess) {
|
||||
$errorMsg = "파일 저장 실패: " . $dataFile;
|
||||
if (!is_writable(dirname($dataFile))) {
|
||||
$errorMsg .= " (디렉토리 쓰기 권한 없음)";
|
||||
} elseif (!is_writable($dataFile) && file_exists($dataFile)) {
|
||||
$errorMsg .= " (파일 쓰기 권한 없음)";
|
||||
}
|
||||
error_log("세금계산서 저장 실패: " . $errorMsg);
|
||||
}
|
||||
|
||||
$response = [
|
||||
"success" => true,
|
||||
"message" => "세금계산서가 성공적으로 발행되었습니다.",
|
||||
"data" => [
|
||||
"issueKey" => $newInvoice['issueKey'],
|
||||
"mgtKey" => $newInvoice['mgtKey'],
|
||||
"barobillInvoiceId" => $newInvoice['barobillInvoiceId'],
|
||||
"status" => "issued",
|
||||
"issuedAt" => $newInvoice['createdAt']
|
||||
],
|
||||
"invoice" => $newInvoice,
|
||||
"api_response" => $apiData,
|
||||
"soap_request" => $apiResult['soap_request'] ?? null,
|
||||
"soap_response" => $apiResult['soap_response'] ?? null,
|
||||
"saved" => $saveSuccess,
|
||||
"dataFile" => $saveSuccess ? null : $dataFile,
|
||||
"simulation" => false,
|
||||
"debug" => $debugInfo,
|
||||
"note" => "✅ 실제 바로빌 API를 호출했습니다."
|
||||
];
|
||||
} else {
|
||||
$response = [
|
||||
"success" => false,
|
||||
"error" => $apiResult['error'] ?? "API 호출 실패",
|
||||
"error_code" => $apiResult['error_code'] ?? null,
|
||||
"error_detail" => $apiResult['error_detail'] ?? null,
|
||||
"soap_request" => $apiResult['soap_request'] ?? null,
|
||||
"soap_response" => $apiResult['soap_response'] ?? null
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 시뮬레이션 모드 (API 키가 없을 때)
|
||||
$issueKey = "BARO-" . date('Y') . "-" . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$newInvoice = [
|
||||
"id" => "inv_" . time(),
|
||||
"issueKey" => $issueKey,
|
||||
"supplierBizno" => $input['supplierBizno'] ?? '',
|
||||
"supplierName" => $input['supplierName'] ?? '',
|
||||
"recipientBizno" => $input['recipientBizno'] ?? '',
|
||||
"recipientName" => $input['recipientName'] ?? '',
|
||||
"supplyDate" => $input['supplyDate'] ?? date('Y-m-d'),
|
||||
"items" => $input['items'] ?? [],
|
||||
"totalSupplyAmt" => $input['totalSupplyAmt'] ?? 0,
|
||||
"totalVat" => $input['totalVat'] ?? 0,
|
||||
"total" => $input['total'] ?? 0,
|
||||
"status" => "issued",
|
||||
"memo" => $input['memo'] ?? '',
|
||||
"createdAt" => date('Y-m-d\TH:i:s'),
|
||||
"barobillInvoiceId" => "BB-" . uniqid()
|
||||
];
|
||||
|
||||
// 파일에 저장
|
||||
$dataFile = __DIR__ . '/invoices_data.json';
|
||||
$existingData = [];
|
||||
if (file_exists($dataFile)) {
|
||||
$fileContent = file_get_contents($dataFile);
|
||||
if ($fileContent !== false) {
|
||||
$existingData = json_decode($fileContent, true);
|
||||
if (!is_array($existingData)) {
|
||||
$existingData = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($existingData['invoices'])) {
|
||||
$existingData['invoices'] = [];
|
||||
}
|
||||
|
||||
$existingData['invoices'][] = $newInvoice;
|
||||
|
||||
if (count($existingData['invoices']) > 100) {
|
||||
$existingData['invoices'] = array_slice($existingData['invoices'], -100);
|
||||
}
|
||||
|
||||
// 파일 저장 시도 및 에러 확인
|
||||
$jsonData = json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
$saveResult = @file_put_contents($dataFile, $jsonData);
|
||||
$saveSuccess = ($saveResult !== false);
|
||||
|
||||
if (!$saveSuccess) {
|
||||
$errorMsg = "파일 저장 실패: " . $dataFile;
|
||||
if (!is_writable(dirname($dataFile))) {
|
||||
$errorMsg .= " (디렉토리 쓰기 권한 없음)";
|
||||
} elseif (!is_writable($dataFile) && file_exists($dataFile)) {
|
||||
$errorMsg .= " (파일 쓰기 권한 없음)";
|
||||
}
|
||||
error_log("세금계산서 저장 실패: " . $errorMsg);
|
||||
}
|
||||
|
||||
$response = [
|
||||
"success" => true,
|
||||
"message" => "세금계산서가 성공적으로 발행되었습니다. (시뮬레이션 모드)",
|
||||
"data" => [
|
||||
"issueKey" => $issueKey,
|
||||
"barobillInvoiceId" => $newInvoice['barobillInvoiceId'],
|
||||
"status" => "issued",
|
||||
"issuedAt" => $newInvoice['createdAt']
|
||||
],
|
||||
"invoice" => $newInvoice,
|
||||
"simulation" => true,
|
||||
"saved" => $saveSuccess,
|
||||
"dataFile" => $saveSuccess ? null : $dataFile,
|
||||
"debug" => $debugInfo,
|
||||
"warning" => "⚠️ 시뮬레이션 모드입니다. 실제 바로빌 API를 호출하려면 CERTKEY를 설정하세요."
|
||||
];
|
||||
|
||||
usleep(500000); // 0.5초 지연 시뮬레이션
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
168
barobill/etax/api/status.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// 바로빌 API 설정 로드
|
||||
require_once(__DIR__ . '/barobill_config.php');
|
||||
|
||||
// POST 데이터 읽기
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input || !isset($input['invoiceId'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Invoice ID is required"
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$invoiceId = $input['invoiceId'];
|
||||
// SOAP 클라이언트가 초기화되었고 CERTKEY가 있으면 실제 API 호출
|
||||
$useRealAPI = !empty($barobillSoapClient) && !empty($barobillCertKey);
|
||||
|
||||
// 파일에서 데이터 읽기
|
||||
$dataFile = __DIR__ . '/invoices_data.json';
|
||||
$existingData = [];
|
||||
if (file_exists($dataFile)) {
|
||||
$existingData = json_decode(file_get_contents($dataFile), true);
|
||||
if (!is_array($existingData)) {
|
||||
$existingData = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 데이터에서도 찾기
|
||||
$defaultInvoices = [
|
||||
[
|
||||
"id" => "inv_001",
|
||||
"issueKey" => "BARO-2024-001",
|
||||
"status" => "sent"
|
||||
],
|
||||
[
|
||||
"id" => "inv_002",
|
||||
"issueKey" => "BARO-2024-002",
|
||||
"status" => "issued"
|
||||
],
|
||||
[
|
||||
"id" => "inv_003",
|
||||
"issueKey" => "BARO-2024-003",
|
||||
"status" => "sent"
|
||||
]
|
||||
];
|
||||
|
||||
$allInvoices = array_merge($defaultInvoices, $existingData['invoices'] ?? []);
|
||||
|
||||
// 해당 세금계산서 찾기
|
||||
$invoice = null;
|
||||
foreach ($allInvoices as $inv) {
|
||||
if ($inv['id'] === $invoiceId) {
|
||||
$invoice = $inv;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$invoice) {
|
||||
http_response_code(404);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Invoice not found"
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($useRealAPI && isset($invoice['issueKey'])) {
|
||||
// 실제 바로빌 SOAP API 호출 - 국세청 전송
|
||||
// issueKey를 MgtKey로 사용
|
||||
$mgtKey = $invoice['mgtKey'] ?? $invoice['issueKey'];
|
||||
$apiResult = sendToNTS($mgtKey);
|
||||
|
||||
if ($apiResult['success']) {
|
||||
$apiData = $apiResult['data'];
|
||||
|
||||
// SOAP 응답에서 NTS 접수번호 추출
|
||||
$ntsReceiptNo = '';
|
||||
if (is_object($apiData) && isset($apiData->NTSConfirmNum)) {
|
||||
$ntsReceiptNo = $apiData->NTSConfirmNum;
|
||||
} elseif (is_array($apiData) && isset($apiData['NTSConfirmNum'])) {
|
||||
$ntsReceiptNo = $apiData['NTSConfirmNum'];
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
$invoice['status'] = 'sent';
|
||||
$invoice['ntsReceiptNo'] = $ntsReceiptNo;
|
||||
$invoice['sentAt'] = date('Y-m-d\TH:i:s');
|
||||
|
||||
// 파일에 저장된 데이터 업데이트
|
||||
if (isset($existingData['invoices'])) {
|
||||
foreach ($existingData['invoices'] as &$inv) {
|
||||
if ($inv['id'] === $invoiceId) {
|
||||
$inv = $invoice;
|
||||
break;
|
||||
}
|
||||
}
|
||||
file_put_contents($dataFile, json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
$response = [
|
||||
"success" => true,
|
||||
"message" => "국세청 전송이 완료되었습니다.",
|
||||
"data" => [
|
||||
"issueKey" => $invoice['issueKey'] ?? '',
|
||||
"mgtKey" => $mgtKey,
|
||||
"ntsReceiptNo" => $invoice['ntsReceiptNo'],
|
||||
"status" => "sent",
|
||||
"sentAt" => $invoice['sentAt']
|
||||
],
|
||||
"invoice" => $invoice,
|
||||
"api_response" => $apiData,
|
||||
"soap_request" => $apiResult['soap_request'] ?? null,
|
||||
"soap_response" => $apiResult['soap_response'] ?? null
|
||||
];
|
||||
} else {
|
||||
$response = [
|
||||
"success" => false,
|
||||
"error" => $apiResult['error'] ?? "API 호출 실패",
|
||||
"error_code" => $apiResult['error_code'] ?? null,
|
||||
"error_detail" => $apiResult['error_detail'] ?? null,
|
||||
"soap_request" => $apiResult['soap_request'] ?? null,
|
||||
"soap_response" => $apiResult['soap_response'] ?? null
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 시뮬레이션 모드
|
||||
$ntsReceiptNo = "NTS-" . date('Y') . "-" . str_pad(rand(100000, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$invoice['status'] = 'sent';
|
||||
$invoice['ntsReceiptNo'] = $ntsReceiptNo;
|
||||
$invoice['sentAt'] = date('Y-m-d\TH:i:s');
|
||||
|
||||
// 파일에 저장된 데이터 업데이트
|
||||
if (isset($existingData['invoices'])) {
|
||||
foreach ($existingData['invoices'] as &$inv) {
|
||||
if ($inv['id'] === $invoiceId) {
|
||||
$inv = $invoice;
|
||||
break;
|
||||
}
|
||||
}
|
||||
file_put_contents($dataFile, json_encode($existingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
usleep(800000); // 0.8초 지연 시뮬레이션
|
||||
|
||||
$response = [
|
||||
"success" => true,
|
||||
"message" => "국세청 전송이 완료되었습니다. (시뮬레이션 모드)",
|
||||
"data" => [
|
||||
"issueKey" => $invoice['issueKey'] ?? '',
|
||||
"ntsReceiptNo" => $ntsReceiptNo,
|
||||
"status" => "sent",
|
||||
"sentAt" => $invoice['sentAt']
|
||||
],
|
||||
"invoice" => $invoice,
|
||||
"simulation" => true
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
|
||||
396
barobill/etax/barobill_api_info.php
Normal file
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../lib/DotEnv.php';
|
||||
(new DotEnv(__DIR__ . '/../../.env'))->load();
|
||||
require_once(getenv('DOCUMENT_ROOT') . "/session.php");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>바로빌 API 정보</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 3px solid #43cea2;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #185a9d;
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #185a9d;
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #43cea2;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
color: #43cea2;
|
||||
font-size: 1.4em;
|
||||
margin: 20px 0 15px 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #43cea2;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.info-box.warning {
|
||||
border-left-color: #f5576c;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.info-box.success {
|
||||
border-left-color: #43cea2;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.api-endpoint {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.api-endpoint .method {
|
||||
color: #4ec9b0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.api-endpoint .url {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
table th {
|
||||
background: #43cea2;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
table tr:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
margin: 10px 10px 10px 0;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 30px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #e83e8c;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📋 바로빌 API 정보</h1>
|
||||
<p>전자세금계산서 솔루션 개발을 위한 바로빌 API 가이드</p>
|
||||
</div>
|
||||
|
||||
<!-- 바로빌 개요 -->
|
||||
<div class="section">
|
||||
<h2>1. 바로빌 개요</h2>
|
||||
<div class="info-box success">
|
||||
<p><strong>바로빌</strong>은 기업의 재무회계 디지털 전환을 지원하는 Fin-tech 플랫폼입니다.</p>
|
||||
<p>다양한 전자문서 발행 및 관리 API를 제공하여 기업의 업무 자동화를 지원합니다.</p>
|
||||
</div>
|
||||
<p><strong>개발자센터 URL</strong>: <a href="https://dev.barobill.co.kr" target="_blank">https://dev.barobill.co.kr</a></p>
|
||||
</div>
|
||||
|
||||
<!-- 주요 API 서비스 -->
|
||||
<div class="section">
|
||||
<h2>2. 주요 API 서비스</h2>
|
||||
|
||||
<h3>2.1 전자문서 API</h3>
|
||||
<p>전자세금계산서, 거래명세서, 청구서, 견적서 등 다양한 전자문서의 발행 및 관리 기능을 제공합니다.</p>
|
||||
<ul>
|
||||
<li><strong>전자세금계산서 발행</strong>: 세금계산서 발행 및 국세청 전송</li>
|
||||
<li><strong>전자세금계산서 취소</strong>: 발행된 세금계산서 취소</li>
|
||||
<li><strong>전자세금계산서 조회</strong>: 발행 내역 및 상태 조회</li>
|
||||
<li><strong>거래명세서 발행</strong>: 거래명세서 발행 및 관리</li>
|
||||
<li><strong>청구서/견적서 발행</strong>: 다양한 전자문서 발행</li>
|
||||
</ul>
|
||||
<p><a href="https://dev.barobill.co.kr/services/edoc" target="_blank" class="btn">전자문서 API 상세보기</a></p>
|
||||
|
||||
<h3>2.2 메시징 API</h3>
|
||||
<p>SMS, LMS, MMS 등 다양한 형태의 문자 전송 기능을 제공합니다.</p>
|
||||
<ul>
|
||||
<li>세금계산서 발행 알림</li>
|
||||
<li>인증서 만료 알림</li>
|
||||
<li>시스템 알림</li>
|
||||
</ul>
|
||||
<p><a href="https://dev.barobill.co.kr/services/message" target="_blank" class="btn">메시징 API 상세보기</a></p>
|
||||
|
||||
<h3>2.3 팩스 전송 API</h3>
|
||||
<p>HWP, DOC, XLS, PDF 등 다양한 문서 파일의 팩스 전송 기능을 제공합니다.</p>
|
||||
<p><a href="https://dev.barobill.co.kr/services/fax" target="_blank" class="btn">팩스 API 상세보기</a></p>
|
||||
</div>
|
||||
|
||||
<!-- 전자세금계산서 API 상세 -->
|
||||
<div class="section">
|
||||
<h2>3. 전자세금계산서 API 상세</h2>
|
||||
|
||||
<h3>3.1 필수 요구사항</h3>
|
||||
<div class="info-box warning">
|
||||
<p><strong>⚠️ 중요:</strong> 전자세금계산서 발행을 위해서는 공동인증서 또는 금융인증서가 필요합니다.</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>공동인증서 또는 금융인증서 등록</li>
|
||||
<li>바로빌 개발자센터 회원가입</li>
|
||||
<li>API 키 발급</li>
|
||||
<li>인증서 등록 및 연동</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.2 주요 API 기능</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>기능</th>
|
||||
<th>설명</th>
|
||||
<th>필수 정보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>세금계산서 발행</strong></td>
|
||||
<td>전자세금계산서 발행 및 국세청 전송</td>
|
||||
<td>공급자/수취자 정보, 품목 정보, 금액 정보</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>세금계산서 취소</strong></td>
|
||||
<td>발행된 세금계산서 취소</td>
|
||||
<td>발행 키, 취소 사유</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>세금계산서 조회</strong></td>
|
||||
<td>발행 내역 및 상태 조회</td>
|
||||
<td>발행 키 또는 검색 조건</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>국세청 전송</strong></td>
|
||||
<td>발행된 세금계산서를 국세청으로 전송</td>
|
||||
<td>발행 키</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>전송 상태 확인</strong></td>
|
||||
<td>국세청 전송 상태 및 접수번호 확인</td>
|
||||
<td>발행 키</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>3.3 API 요청 예시 (예상)</h3>
|
||||
<div class="api-endpoint">
|
||||
<span class="method">POST</span> <span class="url">/api/taxinvoice/issue</span>
|
||||
</div>
|
||||
<p><strong>Headers:</strong></p>
|
||||
<div class="api-endpoint">
|
||||
Authorization: Bearer {API_KEY}<br>
|
||||
Content-Type: application/json
|
||||
</div>
|
||||
<p><strong>Request Body (예상):</strong></p>
|
||||
<div class="api-endpoint">
|
||||
{<br>
|
||||
"certId": "인증서ID",<br>
|
||||
"supplier": {<br>
|
||||
"bizno": "사업자번호",<br>
|
||||
"corpName": "상호",<br>
|
||||
"ceo": "대표자명"<br>
|
||||
},<br>
|
||||
"recipient": { ... },<br>
|
||||
"items": [ ... ],<br>
|
||||
"writeDate": "2024-01-01",<br>
|
||||
"supplyDate": "2024-01-01"<br>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 개발자 지원 -->
|
||||
<div class="section">
|
||||
<h2>4. 개발자 지원</h2>
|
||||
<ul>
|
||||
<li><strong>개발 가이드</strong>: 상세한 API 문서 및 가이드 제공</li>
|
||||
<li><strong>샘플 코드</strong>: Java, Python, PHP, .NET 등 다양한 언어 지원</li>
|
||||
<li><strong>테스트 환경</strong>: 실제 개발 환경과 동일한 테스트 환경 제공</li>
|
||||
<li><strong>전담 엔지니어 지원</strong>: 개발 상담 및 기술 지원 제공</li>
|
||||
</ul>
|
||||
<p><a href="https://dev.barobill.co.kr" target="_blank" class="btn">개발자센터 바로가기</a></p>
|
||||
</div>
|
||||
|
||||
<!-- 연동 절차 -->
|
||||
<div class="section">
|
||||
<h2>5. API 연동 절차</h2>
|
||||
<ol>
|
||||
<li><strong>회원가입</strong>: 바로빌 개발자센터에서 회원가입</li>
|
||||
<li><strong>API 키 발급</strong>: 개발자센터에서 API 키 발급</li>
|
||||
<li><strong>인증서 등록</strong>: 공동인증서 또는 금융인증서 등록</li>
|
||||
<li><strong>테스트 환경 설정</strong>: 바로빌 테스트 환경에서 연동 테스트</li>
|
||||
<li><strong>개발 및 연동</strong>: API 가이드를 참고하여 연동 및 개발 진행</li>
|
||||
<li><strong>프로덕션 연동</strong>: 실제 운영 환경 구축 후 서비스 오픈</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 요금 안내 -->
|
||||
<div class="section">
|
||||
<h2>6. 요금 안내</h2>
|
||||
<div class="info-box">
|
||||
<p>바로빌은 파트너 유형에 따라 할인형과 수익형으로 구분하여 연동 서비스를 제공합니다.</p>
|
||||
<p>자세한 요금 안내는 바로빌 개발자센터의 파트너/요금 페이지를 참고하시기 바랍니다.</p>
|
||||
</div>
|
||||
<p><a href="https://dev.barobill.co.kr/partners/cost/partner" target="_blank" class="btn btn-secondary">요금 안내 보기</a></p>
|
||||
</div>
|
||||
|
||||
<!-- 지원 언어 -->
|
||||
<div class="section">
|
||||
<h2>7. 지원 언어</h2>
|
||||
<p>바로빌 API는 다음과 같은 개발 언어를 지원합니다:</p>
|
||||
<ul>
|
||||
<li>Java</li>
|
||||
<li>Python</li>
|
||||
<li>PHP</li>
|
||||
<li>.NET (C#)</li>
|
||||
<li>기타 REST API를 지원하는 모든 언어</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 보안 고려사항 -->
|
||||
<div class="section">
|
||||
<h2>8. 보안 고려사항</h2>
|
||||
<div class="info-box warning">
|
||||
<p><strong>⚠️ 보안 주의사항:</strong></p>
|
||||
<ul>
|
||||
<li>API 키는 절대 공개되지 않도록 주의하세요.</li>
|
||||
<li>인증서는 암호화하여 안전하게 저장하세요.</li>
|
||||
<li>HTTPS를 통해서만 API를 호출하세요.</li>
|
||||
<li>민감한 정보는 로그에 기록하지 마세요.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 유용한 링크 -->
|
||||
<div class="section">
|
||||
<h2>9. 유용한 링크</h2>
|
||||
<ul>
|
||||
<li><a href="https://dev.barobill.co.kr" target="_blank">바로빌 개발자센터</a></li>
|
||||
<li><a href="https://dev.barobill.co.kr/services/edoc" target="_blank">전자문서 API</a></li>
|
||||
<li><a href="https://dev.barobill.co.kr/services/message" target="_blank">메시징 API</a></li>
|
||||
<li><a href="https://dev.barobill.co.kr/services/fax" target="_blank">팩스 API</a></li>
|
||||
<li><a href="https://dev.barobill.co.kr/partners/cost/partner" target="_blank">파트너/요금 안내</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 다음 단계 -->
|
||||
<div class="section">
|
||||
<h2>10. 다음 단계</h2>
|
||||
<ol>
|
||||
<li>바로빌 개발자센터 회원가입 및 API 키 발급</li>
|
||||
<li>개발 가이드 및 샘플 코드 다운로드</li>
|
||||
<li>테스트 환경에서 API 연동 테스트</li>
|
||||
<li>인증서 등록 및 연동</li>
|
||||
<li>실제 프로젝트에 통합</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
27
barobill/etax/dev.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Etax 개발 노트
|
||||
|
||||
## API 오류 해결 가이드
|
||||
|
||||
### 바로빌 SOAP 클라이언트 미설치 오류 (500 Error)
|
||||
|
||||
**문제 상황:**
|
||||
서버에 PHP SOAP 확장 모듈이 설치되어 있지 않은 경우(`Class 'SoapClient' not found`), `new SoapClient()` 호출 시 치명적인 오류(Fatal Error)가 발생하여 HTTP 500 상태 코드를 반환합니다.
|
||||
|
||||
**해결 방법:**
|
||||
`soapClient` 생성 로직을 `try-catch` 블록으로 감싸되, `Exception`이 아닌 **`Throwable`**을 catch해야 합니다. PHP 7+에서는 치명적인 오류가 `Error` 객체로 던져지며, 이는 `Exception`이 아닌 `Throwable` 인터페이스를 구현하기 때문입니다.
|
||||
|
||||
**수정 예시:**
|
||||
```php
|
||||
$barobillSoapClient = null;
|
||||
try {
|
||||
$barobillSoapClient = new SoapClient($url, $options);
|
||||
} catch (Throwable $e) {
|
||||
// Class not found 등의 Fatal Error도 여기서 잡힘
|
||||
error_log('SOAP Client 생성 실패: ' . $e->getMessage());
|
||||
// 이후 로직에서 $barobillSoapClient가 null일 경우의 대체 로직(예: 시뮬레이션 모드) 수행
|
||||
}
|
||||
```
|
||||
|
||||
**적용 파일:**
|
||||
- `etax/api/barobill_config.php`
|
||||
- `etax/api/issue.php` (전역 에러 핸들링)
|
||||
1153
barobill/etax/index.php
Normal file
1925
barobill/index.php
Normal file
64
barobill/planning.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# [기획서] 바로빌 API 기반 멀티테넌시 회계 지원 솔루션
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
- **목적**: 바로빌(Barobill) API를 활용한 멀티테넌시 환경의 회계 자동화 시스템 구축
|
||||
- **주요 기능**:
|
||||
- API 기반 회원사 가입 및 관리
|
||||
- 공인인증서 플러그인 연동
|
||||
- 전자세금계산서 발행 및 관리
|
||||
- 카드 내역 및 계좌 거래 내역 실시간 조회
|
||||
|
||||
---
|
||||
|
||||
## 2. 통합 워크플로우 (Workflow)
|
||||
|
||||
### 2.1 회원 온보딩 프로세스
|
||||
|
||||
1. **회원 가입**: 솔루션 내에서 사업자 정보 입력 -> 바로빌 `RegistCorp` API 호출 -> 연동키 발급 및 DB 저장.
|
||||
2. **인증서 등록**: 바로빌 전용 플러그인 호출 -> 사용자 인증서 선택 및 서명 -> 바로빌 보안 서버로 인증서 전송.
|
||||
3. **연동 확인**: 등록 완료 후 솔루션 내에서 연동 상태 '활성(Active)'으로 변경.
|
||||
|
||||
### 2.2 서비스 이용 프로세스
|
||||
|
||||
- **전자세금계산서**: 매출 데이터 생성 -> `RegistTaxInvoice` 호출 -> 국세청 전송 및 상태 모니터링.
|
||||
- **금융 데이터 수집**:
|
||||
- **카드**: 카드 정보 등록 -> `GetCardLog` 호출 -> 지출 내역 스크래핑 및 장부 기록.
|
||||
- **계좌**: 계좌 연결 -> `GetBankAccountLog` 호출 -> 입출금 내역 동기화 및 미수금 대조.
|
||||
|
||||
---
|
||||
|
||||
## 3. 메뉴 구조도 (Information Architecture)
|
||||
|
||||
- **대메뉴 1: 대시보드** (회계 요약, 알림)
|
||||
- **대메뉴 2: 매출/매입 관리**
|
||||
- 전자세금계산서 발행/목록
|
||||
- 카드 이용 내역
|
||||
- 계좌 거래 내역
|
||||
- **대메뉴 3: 설정 및 연동**
|
||||
- 회사 정보 관리
|
||||
- 바로빌 연동 설정 (인증서 관리)
|
||||
- 사용자 권한 설정
|
||||
|
||||
---
|
||||
|
||||
## 4. 멀티테넌시 데이터 관리 전략
|
||||
|
||||
### 4.1 데이터 격리 (Data Isolation)
|
||||
|
||||
- 모든 테이블에 `TenantID` 또는 `PartnerBarobillID`를 부여하여 회원사 간 데이터 혼선 방지.
|
||||
- API 호출 시 해당 테넌트의 고유 연동키를 동적으로 매핑.
|
||||
|
||||
### 4.2 보안 및 예외 처리
|
||||
|
||||
- **인증 보안**: HTTPS 통신 및 바로빌 인증 토큰 기반 보안 강화.
|
||||
- **오류 대응**: 인증서 만료, API 호출 한도 초과 등에 대한 사용자 알림 로직 구현.
|
||||
- **로그 관리**: API 요청 및 응답 전문을 로그로 기록하여 이슈 발생 시 추적 가능하도록 설계.
|
||||
|
||||
---
|
||||
|
||||
## 5. 향후 상세 설계 항목 (Next Steps)
|
||||
|
||||
1. **화면 설계서(Wireframe)**: 각 단계별 UI 레이아웃 확정.
|
||||
2. **API 매핑 상세**: 솔루션 필드와 바로빌 파라미터 1:1 매칭 테이블 작성.
|
||||
3. **DB 스키마 설계**: 테넌트 및 금융 데이터 저장을 위한 ERD 작성.
|
||||
138
barobill/registration/api.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
if (!file_exists("../../lib/mydb.php")) {
|
||||
throw new Exception("Required library file ../../lib/mydb.php not found.");
|
||||
}
|
||||
require_once("../../lib/mydb.php");
|
||||
|
||||
$pdo = db_connect();
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
handleGet($pdo);
|
||||
break;
|
||||
case 'POST':
|
||||
handlePost($pdo);
|
||||
break;
|
||||
case 'PUT':
|
||||
handlePut($pdo);
|
||||
break;
|
||||
case 'DELETE':
|
||||
handleDelete($pdo);
|
||||
break;
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
break;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'error' => 'Backend Error',
|
||||
'message' => $e->getMessage(),
|
||||
'hint' => 'Check if the database table exists by running init_db.php'
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGet($pdo) {
|
||||
if (isset($_GET['id'])) {
|
||||
$stmt = $pdo->prepare("SELECT * FROM barobill_members WHERE id = ?");
|
||||
$stmt->execute([$_GET['id']]);
|
||||
echo json_encode($stmt->fetch(PDO::FETCH_ASSOC));
|
||||
} else {
|
||||
$stmt = $pdo->query("SELECT * FROM barobill_members ORDER BY created_at DESC");
|
||||
echo json_encode(['members' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePost($pdo) {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Simple duplicate check
|
||||
$check = $pdo->prepare("SELECT id FROM barobill_members WHERE biz_no = ?");
|
||||
$check->execute([$data['bizNo']]);
|
||||
if ($check->fetch()) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Business number already registered.']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO barobill_members
|
||||
(biz_no, corp_name, ceo_name, addr, biz_type, biz_class, barobill_id, barobill_pwd, manager_name, manager_email, manager_hp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
|
||||
$stmt->execute([
|
||||
$data['bizNo'],
|
||||
$data['corpName'],
|
||||
$data['ceoName'],
|
||||
$data['addr'],
|
||||
$data['bizType'],
|
||||
$data['bizClass'],
|
||||
$data['id'],
|
||||
password_hash($data['pwd'], PASSWORD_DEFAULT), // Note: In real API, pwd might not be stored like this or handled by Barobill
|
||||
$data['managerName'],
|
||||
$data['managerEmail'],
|
||||
$data['managerHP']
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePut($pdo) {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if (!isset($data['id'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing member ID']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("UPDATE barobill_members SET
|
||||
corp_name = ?, ceo_name = ?, addr = ?, biz_type = ?, biz_class = ?,
|
||||
manager_name = ?, manager_email = ?, manager_hp = ?
|
||||
WHERE id = ?");
|
||||
|
||||
$stmt->execute([
|
||||
$data['corpName'],
|
||||
$data['ceoName'],
|
||||
$data['addr'],
|
||||
$data['bizType'],
|
||||
$data['bizClass'],
|
||||
$data['managerName'],
|
||||
$data['managerEmail'],
|
||||
$data['managerHP'],
|
||||
$data['id']
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete($pdo) {
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing member ID']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("DELETE FROM barobill_members WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode(['success' => true]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
474
barobill/registration/index.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>바로빌 회원관리 - CodeBridgeX</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../../img/favicon.png">
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Pretendard', 'sans-serif'] },
|
||||
colors: {
|
||||
background: 'rgb(250, 250, 250)',
|
||||
primary: { DEFAULT: '#2563eb', foreground: '#ffffff' },
|
||||
},
|
||||
borderRadius: { 'card': '12px' }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- React & ReactDOM (Production) -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
|
||||
<!--
|
||||
Note: For production, Babel and Tailwind CDN should be pre-compiled.
|
||||
Currently kept for rapid development and mobility.
|
||||
-->
|
||||
<!-- Babel for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- Icons: Lucide -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-background text-slate-800 antialiased font-sans">
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
// --- Layout Components ---
|
||||
|
||||
const Header = ({ onOpenApiInfo }) => (
|
||||
<header className="bg-white/80 backdrop-blur-md border-b border-blue-100/50 sticky top-0 z-50 transition-all shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 lg:px-8 h-18 flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200/50 ring-4 ring-blue-50">
|
||||
<i data-lucide="users" className="w-5 h-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900 tracking-tight leading-none">바로빌 회원관리</h1>
|
||||
<p className="text-[10px] text-blue-600 font-semibold mt-1 uppercase tracking-wider opacity-70">Barobill Member Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 font-medium">
|
||||
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/50 mr-2">
|
||||
<a href="../eaccount/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="wallet" className="w-4 h-4 text-blue-500"></i> <span>계좌조회</span>
|
||||
</a>
|
||||
<a href="../ecard/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="credit-card" className="w-4 h-4 text-purple-500"></i> <span>카드내역</span>
|
||||
</a>
|
||||
<a href="../tenant/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="building" className="w-4 h-4 text-teal-500"></i> <span>테넌트</span>
|
||||
</a>
|
||||
<a href="index.php" className="flex items-center gap-4 px-4 py-2 rounded-lg bg-white text-blue-600 shadow-sm border border-blue-100 font-bold transition-all">
|
||||
<i data-lucide="users" className="w-4 h-4 text-blue-600"></i> <span>바로빌 회원관리</span>
|
||||
</a>
|
||||
<button onClick={(e) => { e.preventDefault(); onOpenApiInfo(); }} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="book-open" className="w-4 h-4 text-orange-500"></i> <span>API정보</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200 mx-2"></div>
|
||||
|
||||
<a href="../index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<i data-lucide="layout-dashboard" className="w-4 h-4"></i> <span className="hidden lg:inline text-xs">현황</span>
|
||||
</a>
|
||||
<a href="../etax/index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<i data-lucide="file-text" className="w-4 h-4"></i> <span className="hidden lg:inline text-xs">세금계산서</span>
|
||||
</a>
|
||||
<a href="../../index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<i data-lucide="home" className="w-4 h-4"></i> <span className="hidden lg:inline text-xs">홈</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
const StatCard = ({ title, value, subtext, icon, color = "blue" }) => {
|
||||
const colors = {
|
||||
blue: "bg-blue-50 text-blue-600",
|
||||
green: "bg-green-50 text-green-600",
|
||||
purple: "bg-purple-50 text-purple-600",
|
||||
orange: "bg-orange-50 text-orange-600"
|
||||
};
|
||||
return (
|
||||
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-slate-400 uppercase">{title}</h3>
|
||||
<div className={`p-1.5 rounded-lg ${colors[color]}`}>{icon}</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{value}</div>
|
||||
<div className="text-[10px] text-slate-400 mt-1">{subtext}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Forms & Modals ---
|
||||
|
||||
const MemberForm = ({ initialData = {}, onSubmit, isEditing = false, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
bizNo: '', corpName: '', ceoName: '', addr: '',
|
||||
bizType: '', bizClass: '', id: '', pwd: '',
|
||||
managerName: '', managerEmail: '', managerHP: '',
|
||||
...initialData
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit(formData); }} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">사업자번호</label>
|
||||
<input name="bizNo" value={formData.bizNo} onChange={handleChange} disabled={isEditing} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm disabled:opacity-50" required />
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">상호명</label>
|
||||
<input name="corpName" value={formData.corpName} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">대표자명</label>
|
||||
<input name="ceoName" value={formData.ceoName} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">업태</label>
|
||||
<input name="bizType" value={formData.bizType} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">종목</label>
|
||||
<input name="bizClass" value={formData.bizClass} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">주소</label>
|
||||
<input name="addr" value={formData.addr} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">바로빌 아이디</label>
|
||||
<input name="id" value={formData.id} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">비밀번호</label>
|
||||
<input type="password" name="pwd" value={formData.pwd} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" required />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">담당자명</label>
|
||||
<input name="managerName" value={formData.managerName} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">담당자 HP</label>
|
||||
<input name="managerHP" value={formData.managerHP} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase mb-1 block">담당자 이메일</label>
|
||||
<input type="email" name="managerEmail" value={formData.managerEmail} onChange={handleChange} className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button type="submit" className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700">
|
||||
{isEditing ? '정보 수정하기' : '회원사 등록하기'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} className="px-6 py-3 bg-slate-100 text-slate-600 rounded-lg font-bold hover:bg-slate-200">
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Modal = ({ isOpen, onClose, title, children, maxWidth = "max-w-2xl" }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className={`bg-white rounded-2xl w-full ${maxWidth} overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200`}>
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<h3 className="font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
|
||||
{title}
|
||||
</h3>
|
||||
<button onClick={onClose} className="w-10 h-10 flex items-center justify-center rounded-full text-slate-600 hover:text-slate-900 hover:bg-slate-200 transition-all duration-200 bg-white/50 shadow-sm border border-slate-200">
|
||||
<i data-lucide="x" className="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-0 overflow-y-auto max-h-[85vh]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiInfoModal = ({ isOpen, onClose }) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="바로빌 API 상세 정보" maxWidth="max-w-5xl">
|
||||
<div className="relative w-full h-[700px] bg-slate-50">
|
||||
<iframe
|
||||
src="../etax/barobill_api_info.php"
|
||||
className="w-full h-full border-none"
|
||||
title="API Information"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main App ---
|
||||
|
||||
const App = () => {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('list');
|
||||
const [editingMember, setEditingMember] = useState(null);
|
||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false);
|
||||
|
||||
// Auto-fill feature states
|
||||
const [registerKey, setRegisterKey] = useState(0);
|
||||
const [initialTestData, setInitialTestData] = useState({});
|
||||
|
||||
const fetchMembers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('api.php');
|
||||
const text = await res.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (data.error) {
|
||||
alert(`서버 오류: ${data.message || data.error}\n${data.hint || ''}`);
|
||||
}
|
||||
setMembers(data.members || []);
|
||||
} catch (parseError) {
|
||||
console.error("Invalid JSON response:", text);
|
||||
alert("서버로부터 올바르지 않은 응답이 수신되었습니다.\n\n[응답 내용]\n" + text.substring(0, 200) + (text.length > 200 ? '...' : '') + "\n\n요소: DB 연동 또는 테이블(init_db.php) 상태를 확인해주세요.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("통신 오류가 발생했습니다.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => lucide.createIcons(), 100);
|
||||
}, [activeTab, members, editingMember, isApiInfoModalOpen]);
|
||||
|
||||
const handleRegister = async (data) => {
|
||||
try {
|
||||
const res = await fetch('api.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
alert('회원사가 성공적으로 등록되었습니다.');
|
||||
fetchMembers();
|
||||
setActiveTab('list');
|
||||
} else {
|
||||
alert(`오류: ${result.error}`);
|
||||
}
|
||||
} catch (e) { alert('등록 중 통신 오류가 발생했습니다.'); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (data) => {
|
||||
try {
|
||||
const res = await fetch('api.php', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
alert('회원 정보가 수정되었습니다.');
|
||||
setEditingMember(null);
|
||||
fetchMembers();
|
||||
} else {
|
||||
alert(`오류: ${result.error}`);
|
||||
}
|
||||
} catch (e) { alert('수정 중 통신 오류가 발생했습니다.'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('정말로 이 회원사를 삭제하시겠습니까? 관련 데이터가 모두 삭제될 수 있습니다.')) return;
|
||||
try {
|
||||
const res = await fetch(`api.php?id=${id}`, { method: 'DELETE' });
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
alert('회원사가 삭제되었습니다.');
|
||||
fetchMembers();
|
||||
} else {
|
||||
alert(`오류: ${result.error}`);
|
||||
}
|
||||
} catch (e) { alert('삭제 중 통신 오류가 발생했습니다.'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header onOpenApiInfo={() => setIsApiInfoModalOpen(true)} />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard title="연동 회원사" value={members.length} subtext="DB 실시간 합계" icon={<i data-lucide="building"></i>} />
|
||||
<StatCard title="API 키 상태" value="정상" subtext="바로빌 연동 중" icon={<i data-lucide="key"></i>} color="green" />
|
||||
<StatCard title="트래픽" value="최적" subtext="최근 24시간" icon={<i data-lucide="zap"></i>} color="purple" />
|
||||
<StatCard title="서버 상태" value="Excellent" subtext="지연시간 45ms" icon={<i data-lucide="server"></i>} color="orange" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-xl mb-8 w-fit">
|
||||
<button onClick={() => setActiveTab('list')} className={`px-6 py-2 text-sm font-bold rounded-lg ${activeTab === 'list' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-800'}`}>목록 조회</button>
|
||||
<button onClick={() => setActiveTab('register')} className={`px-6 py-2 text-sm font-bold rounded-lg ${activeTab === 'register' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-800'}`}>신규 등록</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'list' ? (
|
||||
<div className="bg-white rounded-card shadow-sm border border-slate-100 overflow-hidden min-h-[400px]">
|
||||
{loading ? (
|
||||
<div className="p-20 text-center text-slate-400">데이터를 불러오는 중입니다...</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="p-20 text-center text-slate-400">등록된 회원사가 없습니다. 신규 등록을 진행해주세요.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 text-slate-500 font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-4">사업자번호</th>
|
||||
<th className="px-6 py-4">상호 / 대표자</th>
|
||||
<th className="px-6 py-4">바로빌 ID</th>
|
||||
<th className="px-6 py-4">담당자 정보</th>
|
||||
<th className="px-6 py-4 text-right">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{members.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="px-6 py-4 font-mono text-slate-500">{m.biz_no}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-slate-900">{m.corp_name}</div>
|
||||
<div className="text-xs text-slate-400">{m.ceo_name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-600 font-medium">{m.barobill_id}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-slate-700">{m.manager_name}</div>
|
||||
<div className="text-[10px] text-slate-400">{m.manager_email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-1 opacity-10 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setEditingMember(m)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg" title="수정"><i data-lucide="edit-3" className="w-4 h-4"></i></button>
|
||||
<button onClick={() => handleDelete(m.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg" title="삭제"><i data-lucide="trash-2" className="w-4 h-4"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">신규 회원사 가입</h3>
|
||||
<p className="text-xs text-slate-400">입력된 정보로 바로빌 RegistCorp API가 호출됩니다.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const randomId = Math.random().toString(36).substring(2, 8);
|
||||
const randomBiz = `123-45-${Math.floor(10000 + Math.random() * 90000)}`;
|
||||
const testData = {
|
||||
bizNo: randomBiz,
|
||||
corpName: `테스트기업_${randomId}`,
|
||||
ceoName: '홍길동',
|
||||
addr: '서울특별시 강남구 테헤란로 123',
|
||||
bizType: '서비스',
|
||||
bizClass: '소프트웨어',
|
||||
id: `test_${randomId}`,
|
||||
pwd: 'password123!',
|
||||
managerName: '김철수',
|
||||
managerHP: '010-1234-5678',
|
||||
managerEmail: `test_${randomId}@example.com`
|
||||
};
|
||||
// Trigger a custom event or use a state update mechanism
|
||||
// provided by the child component if available.
|
||||
// Since we're in the parent, we'll pass it down via a key or ref if needed,
|
||||
// but for simpler implementation, we'll use a unique key to reset the form component with new initialData.
|
||||
setRegisterKey(prev => prev + 1);
|
||||
setInitialTestData(testData);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-100 text-amber-700 rounded-lg hover:bg-amber-200 transition-all font-bold text-xs"
|
||||
title="랜덤 테스트 데이터 입력"
|
||||
>
|
||||
<i data-lucide="zap" className="w-4 h-4 fill-amber-500"></i>
|
||||
자동 완성
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-white rounded-card p-6 shadow-sm border border-slate-100">
|
||||
<MemberForm key={registerKey} initialData={initialTestData} onSubmit={handleRegister} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={!!editingMember} onClose={() => setEditingMember(null)} title="회원사 정보 수정">
|
||||
<MemberForm
|
||||
initialData={{
|
||||
id: editingMember?.id,
|
||||
bizNo: editingMember?.biz_no,
|
||||
corpName: editingMember?.corp_name,
|
||||
ceoName: editingMember?.ceo_name,
|
||||
addr: editingMember?.addr,
|
||||
bizType: editingMember?.biz_type,
|
||||
bizClass: editingMember?.biz_class,
|
||||
managerName: editingMember?.manager_name,
|
||||
managerEmail: editingMember?.manager_email,
|
||||
managerHP: editingMember?.manager_hp
|
||||
}}
|
||||
isEditing={true}
|
||||
onSubmit={handleUpdate}
|
||||
onCancel={() => setEditingMember(null)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ApiInfoModal
|
||||
isOpen={isApiInfoModalOpen}
|
||||
onClose={() => setIsApiInfoModalOpen(false)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="mt-20 py-8 border-t border-slate-100 text-center">
|
||||
<p className="text-slate-300 text-[10px]">© 2026 CodeBridgeX. Real-time DB CRUD Interface enabled.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
31
barobill/registration/init_db.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
try {
|
||||
if (!file_exists("../../lib/mydb.php")) {
|
||||
throw new Exception("Required library file ../../lib/mydb.php not found.");
|
||||
}
|
||||
require_once("../../lib/mydb.php");
|
||||
|
||||
$pdo = db_connect();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS barobill_members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
biz_no VARCHAR(20) NOT NULL UNIQUE,
|
||||
corp_name VARCHAR(100) NOT NULL,
|
||||
ceo_name VARCHAR(50) NOT NULL,
|
||||
addr VARCHAR(255),
|
||||
biz_type VARCHAR(50),
|
||||
biz_class VARCHAR(50),
|
||||
barobill_id VARCHAR(50) NOT NULL,
|
||||
barobill_pwd VARCHAR(100),
|
||||
manager_name VARCHAR(50),
|
||||
manager_email VARCHAR(100),
|
||||
manager_hp VARCHAR(20),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
|
||||
|
||||
$pdo->exec($sql);
|
||||
echo "Table 'barobill_members' initialized successfully.";
|
||||
} catch (Throwable $e) {
|
||||
echo "Error: [" . get_class($e) . "] " . $e->getMessage();
|
||||
}
|
||||
225
barobill/tenant/api.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
include '../../lib/mydb.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method === 'OPTIONS') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 퍼미션 체크 (레벨 1 관리자만 접근 가능)
|
||||
// if (!isset($_SESSION['level']) || $_SESSION['level'] != '1') {
|
||||
// echo json_encode(['success' => false, 'message' => '권한이 없습니다.']);
|
||||
// exit;
|
||||
// }
|
||||
|
||||
$pdo = db_connect();
|
||||
$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : '';
|
||||
|
||||
try {
|
||||
if (!$pdo) throw new Exception("Database connection failed.");
|
||||
|
||||
// DB명이 정의되지 않았을 경우를 대비해 기본값 설정 혹은 mydb.php의 $DB 사용
|
||||
// 보통 mydb.php에서 $DB 변수를 제공한다고 가정
|
||||
if (!isset($DB)) {
|
||||
global $DB;
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'get_companies':
|
||||
// 모든 회사 가져오기 (파트너-자식 구조)
|
||||
$sql = "SELECT c.*, p.company_name as parent_name, p.barobill_user_id as parent_user_id
|
||||
FROM {$DB}.barobill_companies c
|
||||
LEFT JOIN {$DB}.barobill_companies p ON c.parent_id = p.id
|
||||
ORDER BY c.parent_id ASC, c.id ASC";
|
||||
$stmt = $pdo->query($sql);
|
||||
$companies = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $companies]);
|
||||
break;
|
||||
|
||||
case 'save_company':
|
||||
// 회사 추가/수정
|
||||
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||
|
||||
$company_name = $_POST['company_name'];
|
||||
$corp_num = $_POST['corp_num'];
|
||||
$barobill_user_id = $_POST['barobill_user_id'];
|
||||
$memo = $_POST['memo'];
|
||||
|
||||
// 1. Find ID of 'cbx0913' (Parent)
|
||||
$parent_sql = "SELECT id FROM {$DB}.barobill_companies WHERE barobill_user_id = 'cbx0913' LIMIT 1";
|
||||
$stmt = $pdo->query($parent_sql);
|
||||
$parent_row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// 만약 'cbx0913' 본인이면 parent_id는 NULL
|
||||
if ($barobill_user_id === 'cbx0913') {
|
||||
$parent_id = null;
|
||||
} else {
|
||||
// 부모가 있으면 그 ID, 없으면 NULL (혹은 에러처리)
|
||||
$parent_id = $parent_row ? $parent_row['id'] : null;
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$sql = "UPDATE {$DB}.barobill_companies SET
|
||||
parent_id = :parent_id,
|
||||
company_name = :company_name,
|
||||
corp_num = :corp_num,
|
||||
barobill_user_id = :barobill_user_id,
|
||||
memo = :memo
|
||||
WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
} else {
|
||||
$sql = "INSERT INTO {$DB}.barobill_companies (parent_id, company_name, corp_num, barobill_user_id, memo)
|
||||
VALUES (:parent_id, :company_name, :corp_num, :barobill_user_id, :memo)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
}
|
||||
|
||||
$stmt->bindValue(':parent_id', $parent_id, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':company_name', $company_name);
|
||||
$stmt->bindValue(':corp_num', $corp_num);
|
||||
$stmt->bindValue(':barobill_user_id', $barobill_user_id);
|
||||
$stmt->bindValue(':memo', $memo);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception(implode(", ", $stmt->errorInfo()));
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'delete_company':
|
||||
$id = intval($_POST['id']);
|
||||
$sql = "DELETE FROM {$DB}.barobill_companies WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception(implode(", ", $stmt->errorInfo()));
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'get_cards':
|
||||
$company_id = intval($_GET['company_id']);
|
||||
$sql = "SELECT * FROM {$DB}.company_cards WHERE company_id = :company_id ORDER BY id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':company_id', $company_id, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$cards = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $cards]);
|
||||
break;
|
||||
|
||||
case 'save_card':
|
||||
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||
$company_id = intval($_POST['company_id']);
|
||||
$card_company_code = $_POST['card_company_code'];
|
||||
$card_num = $_POST['card_num'];
|
||||
$web_id = $_POST['web_id'];
|
||||
$web_pwd = $_POST['web_pwd'];
|
||||
|
||||
if ($id > 0) {
|
||||
$sql = "UPDATE {$DB}.company_cards SET
|
||||
card_company_code = :card_company_code,
|
||||
card_num = :card_num,
|
||||
web_id = :web_id,
|
||||
web_pwd = :web_pwd
|
||||
WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
} else {
|
||||
$sql = "INSERT INTO {$DB}.company_cards (company_id, card_company_code, card_num, web_id, web_pwd)
|
||||
VALUES (:company_id, :card_company_code, :card_num, :web_id, :web_pwd)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':company_id', $company_id, PDO::PARAM_INT);
|
||||
}
|
||||
|
||||
$stmt->bindValue(':card_company_code', $card_company_code);
|
||||
$stmt->bindValue(':card_num', $card_num);
|
||||
$stmt->bindValue(':web_id', $web_id);
|
||||
$stmt->bindValue(':web_pwd', $web_pwd);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception(implode(", ", $stmt->errorInfo()));
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'delete_card':
|
||||
$id = intval($_POST['id']);
|
||||
$sql = "DELETE FROM {$DB}.company_cards WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception(implode(", ", $stmt->errorInfo()));
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'get_accounts':
|
||||
$company_id = intval($_GET['company_id']);
|
||||
$sql = "SELECT * FROM {$DB}.company_accounts WHERE company_id = :company_id ORDER BY id DESC";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':company_id', $company_id, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$accounts = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
echo json_encode(['success' => true, 'data' => $accounts]);
|
||||
break;
|
||||
|
||||
case 'save_account':
|
||||
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
|
||||
$company_id = intval($_POST['company_id']);
|
||||
$bank_code = $_POST['bank_code'];
|
||||
$account_num = $_POST['account_num'];
|
||||
$account_pwd = $_POST['account_pwd'];
|
||||
|
||||
if ($id > 0) {
|
||||
$sql = "UPDATE {$DB}.company_accounts SET
|
||||
bank_code = :bank_code,
|
||||
account_num = :account_num,
|
||||
account_pwd = :account_pwd
|
||||
WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
} else {
|
||||
$sql = "INSERT INTO {$DB}.company_accounts (company_id, bank_code, account_num, account_pwd)
|
||||
VALUES (:company_id, :bank_code, :account_num, :account_pwd)";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':company_id', $company_id, PDO::PARAM_INT);
|
||||
}
|
||||
|
||||
$stmt->bindValue(':bank_code', $bank_code);
|
||||
$stmt->bindValue(':account_num', $account_num);
|
||||
$stmt->bindValue(':account_pwd', $account_pwd);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception(implode(", ", $stmt->errorInfo()));
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
case 'delete_account':
|
||||
$id = intval($_POST['id']);
|
||||
$sql = "DELETE FROM {$DB}.company_accounts WHERE id = :id";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
throw new Exception(implode(", ", $stmt->errorInfo()));
|
||||
}
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid action']);
|
||||
break;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
?>
|
||||
550
barobill/tenant/index.php
Normal file
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||||
|
||||
// 권한 체크
|
||||
// if ($_SESSION['level'] != '1') {
|
||||
// echo "<script>alert('접근 권한이 없습니다.'); location.href='/';</script>";
|
||||
// exit;
|
||||
// }
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>바로빌 테넌트 관리</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../../img/favicon.png">
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Pretendard', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
background: 'rgb(250, 250, 250)',
|
||||
primary: {
|
||||
DEFAULT: '#2563eb', // blue-600
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in-up': 'fadeInUp 0.3s ease-out forwards',
|
||||
},
|
||||
keyframes: {
|
||||
fadeInUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
|
||||
<!-- React & ReactDOM -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- Babel for JSX -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- Icons: Lucide React -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body class="bg-background text-slate-800 antialiased overflow-hidden h-screen flex flex-col">
|
||||
<div id="root" class="h-full flex flex-col"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
// --- Header Component ---
|
||||
const Header = ({ onOpenApiInfo }) => (
|
||||
<header className="bg-white/80 backdrop-blur-md border-b border-blue-100/50 sticky top-0 z-50 transition-all shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-18 flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200/50 ring-4 ring-blue-50">
|
||||
<i data-lucide="building" className="w-5 h-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900 tracking-tight leading-none">테넌트 관리</h1>
|
||||
<p className="text-[10px] text-blue-600 font-semibold mt-1 uppercase tracking-wider opacity-70">Tenant Configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 font-medium">
|
||||
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/50 mr-2">
|
||||
<a href="../eaccount/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="wallet" className="w-4 h-4 text-blue-500"></i> <span>계좌조회</span>
|
||||
</a>
|
||||
<a href="../ecard/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="credit-card" className="w-4 h-4 text-purple-500"></i> <span>카드내역</span>
|
||||
</a>
|
||||
<a href="index.php" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white text-blue-600 shadow-sm border border-blue-100 font-bold">
|
||||
<i data-lucide="building" className="w-4 h-4 text-blue-600"></i> <span>테넌트</span>
|
||||
</a>
|
||||
<a href="../registration/index.php" className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="users" className="w-4 h-4 text-teal-500"></i> <span>바로빌 회원관리</span>
|
||||
</a>
|
||||
<button onClick={(e) => { e.preventDefault(); onOpenApiInfo(); }} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:text-blue-600 hover:bg-white transition-all duration-200">
|
||||
<i data-lucide="book-open" className="w-4 h-4 text-orange-500"></i> <span>API정보</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-slate-200 mx-2"></div>
|
||||
|
||||
<a href="../etax/index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<i data-lucide="file-text" className="w-4 h-4"></i> <span className="hidden lg:inline text-xs">세금계산서</span>
|
||||
</a>
|
||||
<a href="../../index.php" className="flex items-center gap-1.5 px-3 py-2 text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<i data-lucide="home" className="w-4 h-4"></i> <span className="hidden lg:inline text-xs">홈</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
// --- Icons ---
|
||||
const TrashIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
);
|
||||
const EditIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
);
|
||||
const CreditCardIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
||||
);
|
||||
const BankIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/><path d="M10 16h4"/><path d="M12 12v4"/></svg> // Simplified bank/money icon
|
||||
);
|
||||
const PlusIcon = ({ className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
||||
);
|
||||
|
||||
// --- Main App Component ---
|
||||
const App = () => {
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Modals state
|
||||
const [isCompanyModalOpen, setIsCompanyModalOpen] = useState(false);
|
||||
const [editingCompany, setEditingCompany] = useState(null);
|
||||
|
||||
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||
const [selectedCompanyForCards, setSelectedCompanyForCards] = useState(null);
|
||||
|
||||
const [isAccountModalOpen, setIsAccountModalOpen] = useState(false);
|
||||
const [selectedCompanyForAccounts, setSelectedCompanyForAccounts] = useState(null);
|
||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCompanies();
|
||||
lucide.createIcons();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
lucide.createIcons();
|
||||
}, [companies, isCompanyModalOpen, isCardModalOpen, isAccountModalOpen, isApiInfoModalOpen]);
|
||||
|
||||
const fetchCompanies = async () => {
|
||||
try {
|
||||
const res = await fetch('api.php?action=get_companies');
|
||||
const json = await res.json();
|
||||
if (json.success) setCompanies(json.data);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async (id) => {
|
||||
if (!confirm("정말 삭제하시겠습니까? 관련 데이터가 모두 삭제됩니다.")) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
await fetch('api.php?action=delete_company', { method: 'POST', body: fd });
|
||||
fetchCompanies();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50">
|
||||
<Header onOpenApiInfo={() => setIsApiInfoModalOpen(true)} />
|
||||
|
||||
<main className="flex-1 overflow-auto p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">등록된 회사 목록</h2>
|
||||
<button
|
||||
onClick={() => { setEditingCompany(null); setIsCompanyModalOpen(true); }}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
회사 등록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩중...</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">회사명</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">파트너</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">사업자번호</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">바로빌 ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">비고</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">리소스</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200 text-sm">
|
||||
{companies.length === 0 && (
|
||||
<tr><td colSpan="7" className="px-6 py-8 text-center text-gray-400">등록된 회사가 없습니다.</td></tr>
|
||||
)}
|
||||
{companies.map(company => (
|
||||
<tr key={company.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">{company.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap font-medium text-gray-900">
|
||||
{company.parent_user_id ? <span className="text-blue-600 mr-1">[{company.parent_user_id}]</span> : null}
|
||||
{company.company_name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{company.parent_name ? (
|
||||
<span>{company.parent_name} <span className="text-xs text-gray-400">({company.parent_user_id})</span></span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">{company.corp_num}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">{company.barobill_user_id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{company.memo && company.memo.length > 10 ? company.memo.substring(0, 10) + '...' : company.memo}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap space-x-2">
|
||||
<button
|
||||
onClick={() => { setSelectedCompanyForCards(company); setIsCardModalOpen(true); }}
|
||||
className="inline-flex items-center px-2.5 py-1.5 border border-indigo-200 text-xs font-medium rounded text-indigo-700 bg-indigo-50 hover:bg-indigo-100"
|
||||
>
|
||||
카드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedCompanyForAccounts(company); setIsAccountModalOpen(true); }}
|
||||
className="inline-flex items-center px-2.5 py-1.5 border border-emerald-200 text-xs font-medium rounded text-emerald-700 bg-emerald-50 hover:bg-emerald-100"
|
||||
>
|
||||
계좌
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right space-x-2">
|
||||
<button onClick={() => { setEditingCompany(company); setIsCompanyModalOpen(true); }} className="text-blue-600 hover:text-blue-900 transition-colors"><EditIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDeleteCompany(company.id)} className="text-red-500 hover:text-red-700 transition-colors"><TrashIcon className="w-4 h-4" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Company Modal */}
|
||||
{isCompanyModalOpen && <CompanyModal
|
||||
isOpen={isCompanyModalOpen}
|
||||
onClose={() => setIsCompanyModalOpen(false)}
|
||||
company={editingCompany}
|
||||
onSaved={fetchCompanies}
|
||||
/>}
|
||||
|
||||
{/* Cards Modal */}
|
||||
{isCardModalOpen && <CardsModal
|
||||
isOpen={isCardModalOpen}
|
||||
onClose={() => setIsCardModalOpen(false)}
|
||||
company={selectedCompanyForCards}
|
||||
/>}
|
||||
|
||||
{/* Accounts Modal */}
|
||||
{isAccountModalOpen && <AccountsModal
|
||||
isOpen={isAccountModalOpen}
|
||||
onClose={() => setIsAccountModalOpen(false)}
|
||||
company={selectedCompanyForAccounts}
|
||||
/>}
|
||||
|
||||
<ApiInfoModal
|
||||
isOpen={isApiInfoModalOpen}
|
||||
onClose={() => setIsApiInfoModalOpen(false)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub Components ---
|
||||
const ModalLayout = ({ title, onClose, children }) => {
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleEsc = (e) => { if(e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 backdrop-blur-sm animate-fade-in-up">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-200 transition-all">
|
||||
<i data-lucide="x" className="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CompanyModal = ({ isOpen, onClose, company, onSaved }) => {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
if (company) formData.append('id', company.id);
|
||||
|
||||
const res = await fetch('api.php?action=save_company', { method: 'POST', body: formData });
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
alert('저장 실패: ' + json.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalLayout title={company ? '회사 정보 수정' : '새 회사 등록'} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">회사명</label>
|
||||
<input type="text" name="company_name" defaultValue={company?.company_name} required className="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 px-3 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">사업자번호 (10자리)</label>
|
||||
<input type="text" name="corp_num" defaultValue={company?.corp_num} required className="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 px-3 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">바로빌 User ID</label>
|
||||
<input type="text" name="barobill_user_id" defaultValue={company?.barobill_user_id} required className="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 px-3 border" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
||||
<textarea name="memo" defaultValue={company?.memo} className="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-2 px-3 border" rows="3"></textarea>
|
||||
</div>
|
||||
<div className="pt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const CardsModal = ({ isOpen, onClose, company }) => {
|
||||
const [cards, setCards] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if(company) loadCards();
|
||||
}, [company]);
|
||||
|
||||
const loadCards = async () => {
|
||||
const res = await fetch(`api.php?action=get_cards&company_id=${company.id}`);
|
||||
const json = await res.json();
|
||||
if(json.success) setCards(json.data);
|
||||
};
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
fd.append('company_id', company.id);
|
||||
await fetch('api.php?action=save_card', { method: 'POST', body: fd });
|
||||
e.target.reset();
|
||||
loadCards();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if(!confirm('삭제하시겠습니까?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
await fetch('api.php?action=delete_card', { method: 'POST', body: fd });
|
||||
loadCards();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalLayout title={`${company?.company_name} - 법인카드 관리`} onClose={onClose}>
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleAdd} className="bg-gray-50 p-4 rounded-lg border border-gray-100 grid grid-cols-2 gap-3">
|
||||
<div className="col-span-1">
|
||||
<label className="text-xs font-semibold text-gray-500">카드사</label>
|
||||
<select name="card_company_code" className="w-full border-gray-300 rounded text-sm py-1.5 mt-1">
|
||||
<option value="Samsung">삼성</option>
|
||||
<option value="Hyundai">현대</option>
|
||||
<option value="Shinhan">신한</option>
|
||||
<option value="Kb">국민</option>
|
||||
<option value="Bc">BC</option>
|
||||
<option value="Lotte">롯데</option>
|
||||
<option value="Hana">하나</option>
|
||||
<option value="Nonghyup">농협</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="text-xs font-semibold text-gray-500">카드번호</label>
|
||||
<input type="text" name="card_num" required className="w-full border-gray-300 rounded text-sm py-1.5 mt-1" placeholder="1234-5678..." />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Web ID</label>
|
||||
<input type="text" name="web_id" required className="w-full border-gray-300 rounded text-sm py-1.5 mt-1" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="text-xs font-semibold text-gray-500">Web PW</label>
|
||||
<input type="password" name="web_pwd" required className="w-full border-gray-300 rounded text-sm py-1.5 mt-1" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<button type="submit" className="w-full bg-indigo-600 text-white py-2 rounded text-sm font-medium hover:bg-indigo-700">카드 추가</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-bold text-gray-700">등록된 카드 목록</h4>
|
||||
{cards.length === 0 ? <p className="text-xs text-gray-400">등록된 카드가 없습니다.</p> : (
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-lg overflow-hidden">
|
||||
{cards.map(c => (
|
||||
<li key={c.id} className="p-3 flex justify-between items-center bg-white hover:bg-gray-50">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{c.card_company_code} <span className="text-gray-400 font-normal">|</span> {c.card_num}</p>
|
||||
<p className="text-xs text-gray-400">ID: {c.web_id}</p>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-600 p-1"><TrashIcon className="w-4 h-4"/></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountsModal = ({ isOpen, onClose, company }) => {
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if(company) loadAccounts();
|
||||
}, [company]);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
const res = await fetch(`api.php?action=get_accounts&company_id=${company.id}`);
|
||||
const json = await res.json();
|
||||
if(json.success) setAccounts(json.data);
|
||||
};
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
fd.append('company_id', company.id);
|
||||
await fetch('api.php?action=save_account', { method: 'POST', body: fd });
|
||||
e.target.reset();
|
||||
loadAccounts();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if(!confirm('삭제하시겠습니까?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
await fetch('api.php?action=delete_account', { method: 'POST', body: fd });
|
||||
loadAccounts();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalLayout title={`${company?.company_name} - 계좌 관리`} onClose={onClose}>
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleAdd} className="bg-gray-50 p-4 rounded-lg border border-gray-100 grid grid-cols-2 gap-3">
|
||||
<div className="col-span-1">
|
||||
<label className="text-xs font-semibold text-gray-500">은행코드</label>
|
||||
<input type="text" name="bank_code" required className="w-full border-gray-300 rounded text-sm py-1.5 mt-1" placeholder="004 (국민)" />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="text-xs font-semibold text-gray-500">계좌번호</label>
|
||||
<input type="text" name="account_num" required className="w-full border-gray-300 rounded text-sm py-1.5 mt-1" placeholder="123-456-..." />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-semibold text-gray-500">계좌 비밀번호</label>
|
||||
<input type="password" name="account_pwd" required className="w-full border-gray-300 rounded text-sm py-1.5 mt-1" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<button type="submit" className="w-full bg-emerald-600 text-white py-2 rounded text-sm font-medium hover:bg-emerald-700">계좌 추가</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-bold text-gray-700">등록된 계좌 목록</h4>
|
||||
{accounts.length === 0 ? <p className="text-xs text-gray-400">등록된 계좌가 없습니다.</p> : (
|
||||
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-lg overflow-hidden">
|
||||
{accounts.map(a => (
|
||||
<li key={a.id} className="p-3 flex justify-between items-center bg-white hover:bg-gray-50">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Code: {a.bank_code}</p>
|
||||
<p className="text-xs text-gray-500">{a.account_num}</p>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(a.id)} className="text-red-400 hover:text-red-600 p-1"><TrashIcon className="w-4 h-4"/></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ApiInfoModal = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl w-full max-w-5xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<h3 className="font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
|
||||
바로빌 API 상세 정보
|
||||
</h3>
|
||||
<button onClick={onClose} className="w-10 h-10 flex items-center justify-center rounded-full text-slate-600 hover:text-slate-900 hover:bg-slate-200 transition-all duration-200 bg-white/50 shadow-sm border border-slate-200">
|
||||
<i data-lucide="x" className="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative w-full h-[700px] bg-slate-50">
|
||||
<iframe
|
||||
src="../etax/barobill_api_info.php"
|
||||
className="w-full h-full border-none"
|
||||
title="API Information"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
68
check_db_user.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* 서버에서 실행할 DB 사용자 확인 스크립트
|
||||
* 사용법: php check_db_user.php
|
||||
*/
|
||||
|
||||
echo "=== 데이터베이스 연결 정보 확인 ===\n\n";
|
||||
|
||||
// .env 파일 로드
|
||||
$envFile = __DIR__ . '/.env';
|
||||
$db_host = 'localhost';
|
||||
$db_name = 'chandj';
|
||||
$db_user = 'root';
|
||||
$db_pass = '';
|
||||
|
||||
if (file_exists($envFile)) {
|
||||
echo "✅ .env 파일 발견: $envFile\n";
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
if ($key === 'DB_HOST') {
|
||||
$db_host = ($value === 'mysql' || strpos($value, 'mysql') !== false) ? 'localhost' : $value;
|
||||
}
|
||||
if ($key === 'DB_NAME') $db_name = $value;
|
||||
if ($key === 'DB_USER') $db_user = $value;
|
||||
if ($key === 'DB_PASS') $db_pass = $value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "⚠️ .env 파일을 찾을 수 없습니다: $envFile\n";
|
||||
echo "기본값을 사용합니다.\n";
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "DB_HOST: $db_host\n";
|
||||
echo "DB_NAME: $db_name\n";
|
||||
echo "DB_USER: $db_user\n";
|
||||
echo "DB_PASS: " . (empty($db_pass) ? '(설정되지 않음)' : '***') . "\n";
|
||||
echo "\n";
|
||||
|
||||
// PHP로 DB 연결 테스트
|
||||
echo "=== PHP로 DB 연결 테스트 ===\n";
|
||||
try {
|
||||
$dsn = "mysql:host=$db_host;dbname=$db_name;charset=utf8mb4";
|
||||
$pdo = new PDO($dsn, $db_user, $db_pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
echo "✅ PHP로 DB 연결 성공!\n";
|
||||
echo "사용 가능한 사용자: $db_user\n";
|
||||
echo "\n";
|
||||
echo "다음 명령으로 덤프를 생성하세요:\n";
|
||||
if (empty($db_pass)) {
|
||||
echo " mysqldump -u $db_user -p $db_name > /tmp/chandj_backup.sql\n";
|
||||
} else {
|
||||
echo " MYSQL_PWD='$db_pass' mysqldump -u $db_user $db_name > /tmp/chandj_backup.sql\n";
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo "❌ PHP로 DB 연결 실패: " . $e->getMessage() . "\n";
|
||||
echo "\n";
|
||||
echo "다른 사용자를 시도해보세요:\n";
|
||||
echo " 1. mysqldump -u codebridge -p chandj > /tmp/chandj_backup.sql\n";
|
||||
echo " 2. mysqldump -u root -p chandj > /tmp/chandj_backup.sql\n";
|
||||
echo " 3. sudo mysqldump -u root -p chandj > /tmp/chandj_backup.sql\n";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SAM Code Development Policy & AI Memory Kit</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
||||
<!-- Icons -->
|
||||
@@ -749,6 +754,41 @@ $table->boolean('is_active')->default(true);
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 12: Coocon API Inquiry -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<i data-lucide="mail"></i>
|
||||
<h2>✉️ Coocon API 도입 문의 현황</h2>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<p style="font-size: 1.1rem; color: #fff; margin-bottom: 1.5rem;">[도입문의] ERP 기반 기업 신용정보 및 조기경보 API 도입 관련</p>
|
||||
|
||||
<div class="rule-box ai-solution">
|
||||
<h4><i data-lucide="search"></i> 검토 중인 상품 및 목적</h4>
|
||||
<ul>
|
||||
<li><strong>검토 상품</strong>: 기업 신용정보, 재무정보, 기업 휴폐업 조회 및 기업 리스크 모니터링 API</li>
|
||||
<li><strong>주요 목적</strong>: ERP 내 거래처 신용 상태 가공 노출 (신호등 체계 구현)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rule-box mandatory" style="background: rgba(59, 130, 246, 0.05); border-color: rgba(59, 130, 246, 0.3);">
|
||||
<h4><i data-lucide="help-circle"></i> 주요 문의 사항</h4>
|
||||
<ul style="font-size: 0.95rem;">
|
||||
<li>위 기능 구현을 위한 최적의 API 상품 추천 요청</li>
|
||||
<li>과금 체계 (도입비, 기본료, 건당 비용 등) 및 스타트업 대상 할인 혜택 여부</li>
|
||||
<li>데이터의 재가공(UI 구성) 허용 범위 및 법적 준수 사항 확인</li>
|
||||
<li>테스트용 API Key 발급 가능 여부</li>
|
||||
<li><strong>일정</strong>: 2026년 1월말 정식 런칭 목표 (제안서 및 API 명세서 요청 중)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 1rem; color: var(--text-muted); font-size: 0.9rem;">
|
||||
<i data-lucide="info" size="14" style="vertical-align: middle;"></i>
|
||||
이 내용은 쿠콘 문의사항에 공식 접수된 상태이며, API 도입 시 SAM 시스템의 기업 리스크 관리 모듈에 통합될 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
|
||||
154
company/index.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>기업 분석 리스트 | SAM Sales</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', 'Noto Sans KR', sans-serif; }
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
.shard-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-50 min-h-screen">
|
||||
<!-- Navigation -->
|
||||
<nav class="sticky top-0 z-40 w-full border-b bg-white/80 backdrop-blur-md">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<a href="../index.php" class="flex items-center gap-3 cursor-pointer">
|
||||
<img src="../img/favicon-32x32.png" alt="SAM" class="w-10 h-10 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||||
<span class="text-xs text-slate-500 leading-tight">Smart Automation Management</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="../index.php" class="text-sm font-medium text-slate-500 hover:text-slate-900 transition-colors">홈</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-12">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-slate-900 mb-2">기업 분석 라이브러리</h1>
|
||||
<p class="text-slate-500 max-w-2xl">
|
||||
다양한 산업군의 주요 기업들에 대한 심층적인 재무, 성장, 경쟁 분석 리포트를 확인하고 비즈니스 인사이트를 얻으세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Loudsourcing Card -->
|
||||
<a href="loudsourcing/index.php" class="group">
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-6 flex flex-col h-full transition-all duration-300 hover:shadow-lg hover:border-blue-200 shard-glow relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-blue-50 -mr-12 -mt-12 rounded-full transition-transform group-hover:scale-110"></div>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between relative">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
|
||||
<i data-lucide="palette" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">IT/디자인</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">라우드소싱 (LOUDSOURCING)</h3>
|
||||
<p class="text-slate-500 text-sm mb-6 flex-grow">
|
||||
국내 최대 디자인 크라우드소싱 플랫폼. 디자이너와 기업을 연결하는 혁신적인 콘테스트 기반 비즈니스 모델.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center text-blue-600 text-sm font-semibold mt-auto">
|
||||
분석 리포트 보기
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- PeopleLife Card -->
|
||||
<a href="peoplelife/index.php" class="group">
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-6 flex flex-col h-full transition-all duration-300 hover:shadow-lg hover:border-blue-200 shard-glow relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-indigo-50 -mr-12 -mt-12 rounded-full transition-transform group-hover:scale-110"></div>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between relative">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center text-indigo-600">
|
||||
<i data-lucide="shield-check" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-700/10">금융/GA</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">피플라이프 (PeopleLife)</h3>
|
||||
<p class="text-slate-500 text-sm mb-6 flex-grow">
|
||||
대한민국 대표 초대형 GA. 법인 컨설팅의 전문성을 바탕으로 O2O 플랫폼 보험클리닉을 운영하는 보험 금융 그룹.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center text-indigo-600 text-sm font-semibold mt-auto">
|
||||
분석 리포트 보기
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Looka vs Brandmark Card -->
|
||||
<a href="looka/index.php" class="group">
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-6 flex flex-col h-full transition-all duration-300 hover:shadow-lg hover:border-blue-200 shard-glow relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-purple-50 -mr-12 -mt-12 rounded-full transition-transform group-hover:scale-110"></div>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between relative">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-purple-600">
|
||||
<i data-lucide="sparkles" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">AI/디자인</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Looka vs Brandmark</h3>
|
||||
<p class="text-slate-500 text-sm mb-6 flex-grow">
|
||||
AI 기반 로고 디자인 툴 비교. 비용, 기능, 서비스 규모를 바탕으로 비즈니스에 최적화된 로고 제작 도구를 추천합니다.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center text-purple-600 text-sm font-semibold mt-auto">
|
||||
비교 결과 보기
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 ml-1 transition-transform group-hover:translate-x-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Placeholder for future addition -->
|
||||
<div class="bg-white rounded-xl border border-dashed border-slate-300 p-6 flex flex-col items-center justify-center h-full text-slate-400 group">
|
||||
<div class="w-12 h-12 rounded-lg border border-dashed border-slate-300 flex items-center justify-center mb-4 group-hover:bg-slate-50 transition-colors">
|
||||
<i data-lucide="plus" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<p class="text-sm font-medium">새로운 기업 추가 예정</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="border-t bg-white mt-20 py-10">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-slate-400 text-sm">
|
||||
<p>© 2026 SAM Corporate Analysis Dashboard. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Initialize Lucide Icons
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
271
company/looka/index.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Looka vs Brandmark 비교 분석</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { font-family: 'Noto Sans KR', sans-serif; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.nav-active { background-color: #2563eb; color: white; shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
|
||||
.fade-in { animation: fadeIn 0.5s ease-out forwards; }
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-slate-50 to-indigo-50 p-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8 mb-6 fade-in">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<i data-lucide="sparkles" class="text-white w-8 h-8"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">Looka vs Brandmark</h1>
|
||||
<p class="text-gray-500">생성형 AI 기반 로고 디자인 플랫폼 비교 분석</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
디자인 전문 지식 없이도 고품질 로고와 브랜드 키트를 제작할 수 있는 대표적인 AI 디자인 툴입니다.
|
||||
Looka의 범용성과 Brandmark의 창의성을 중심으로 비즈니스 목적에 맞는 최적의 선택을 제안합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Metrics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-md p-5 border-l-4 border-indigo-500 fade-in" style="animation-delay: 0.1s">
|
||||
<div class="flex items-center gap-2 text-indigo-600 mb-2">
|
||||
<i data-lucide="users" size="20"></i>
|
||||
<span class="text-sm font-medium">Looka 인기</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">2,000만+</p>
|
||||
<p class="text-xs text-gray-500">글로벌 압도적 1위</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-5 border-l-4 border-green-500 fade-in" style="animation-delay: 0.2s">
|
||||
<div class="flex items-center gap-2 text-green-600 mb-2">
|
||||
<i data-lucide="dollar-sign" size="20"></i>
|
||||
<span class="text-sm font-medium">최저 비용</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">$20~</p>
|
||||
<p class="text-xs text-gray-500">전문가 외주 대비 저렴</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-5 border-l-4 border-amber-500 fade-in" style="animation-delay: 0.3s">
|
||||
<div class="flex items-center gap-2 text-amber-600 mb-2">
|
||||
<i data-lucide="thumbs-up" size="20"></i>
|
||||
<span class="text-sm font-medium">추천 선택</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">Looka</p>
|
||||
<p class="text-xs text-gray-500">종합 완성도 9/10</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto">
|
||||
<button onclick="showTab('popular')" id="tab-popular" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap nav-active">인기 & 규모</button>
|
||||
<button onclick="showTab('pricing')" id="tab-pricing" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap bg-white text-gray-600 hover:bg-gray-100">가격 비교</button>
|
||||
<button onclick="showTab('strength')" id="tab-strength" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap bg-white text-gray-600 hover:bg-gray-100">강점 & 약점</button>
|
||||
<button onclick="showTab('recommend')" id="tab-recommend" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap bg-white text-gray-600 hover:bg-gray-100">최종 추천</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 min-h-[400px]">
|
||||
|
||||
<!-- Popularity Tab -->
|
||||
<div id="popular" class="tab-content active space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="bar-chart-3" class="text-indigo-600" size="24"></i>
|
||||
인기도 및 사용자 규모
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b">
|
||||
<th class="p-4 text-left font-semibold text-slate-700">항목</th>
|
||||
<th class="p-4 text-center font-bold text-indigo-600">Looka</th>
|
||||
<th class="p-4 text-center font-semibold text-slate-700">Brandmark</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
<tr>
|
||||
<td class="p-4 font-medium text-slate-600 text-center">사용자 수</td>
|
||||
<td class="p-4 text-center font-semibold text-slate-900">2,000만명 이상</td>
|
||||
<td class="p-4 text-center text-slate-500">상대적으로 적음</td>
|
||||
</tr>
|
||||
<tr class="bg-slate-50/50">
|
||||
<td class="p-4 font-medium text-slate-600 text-center">Trustpilot 리뷰</td>
|
||||
<td class="p-4 text-center font-semibold text-slate-900">14,352건</td>
|
||||
<td class="p-4 text-center text-slate-500">리뷰 거의 없음</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-4 font-medium text-slate-600 text-center">서비스 국가</td>
|
||||
<td class="p-4 text-center font-semibold text-slate-900">188개국</td>
|
||||
<td class="p-4 text-center text-slate-500">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-4 bg-indigo-50 rounded-xl border border-indigo-100 text-sm text-indigo-800">
|
||||
<i data-lucide="info" class="inline-block w-4 h-4 mr-1 mb-0.5"></i>
|
||||
<strong>결론:</strong> Looka가 압도적으로 더 인기 있고, 검증된 사용자 기반을 가지고 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Tab -->
|
||||
<div id="pricing" class="tab-content space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="tags" class="text-green-600" size="24"></i>
|
||||
가격 플랜 비교
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-white border rounded-xl p-5 hover:border-indigo-300 transition-colors">
|
||||
<h3 class="font-bold text-lg mb-4 text-slate-800">Looka</h3>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex justify-between text-sm"><span>기본 로고 (저해상도 PNG)</span><span class="font-bold">$20</span></li>
|
||||
<li class="flex justify-between text-sm"><span>프리미엄 (고해상도 + 벡터)</span><span class="font-bold">$65</span></li>
|
||||
<li class="flex justify-between text-sm text-slate-500 italic"><span>브랜드 키트 (연간 구독)</span><span class="font-bold">$96~129/년</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-white border rounded-xl p-5 hover:border-indigo-300 transition-colors">
|
||||
<h3 class="font-bold text-lg mb-4 text-slate-800">Brandmark</h3>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex justify-between text-sm"><span>기초 플랜</span><span class="font-bold">$25</span></li>
|
||||
<li class="flex justify-between text-sm"><span>디자이너 플랜 (벡터 포함)</span><span class="font-bold">$65</span></li>
|
||||
<li class="flex justify-between text-sm"><span>엔터프라이즈 플랜</span><span class="font-bold">$175</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-slate-50 rounded-xl text-sm text-slate-600">
|
||||
전문가 평가에 따르면 두 서비스 모두 "심각한 브랜딩을 위해 가장 좋은 가치를 제공"하며, 특히 $65 플랜이 두 서비스 모두에서 가장 권장되는 선택입니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Strength Tab -->
|
||||
<div id="strength" class="tab-content space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="award" class="text-amber-600" size="24"></i>
|
||||
강점 및 약점 상세
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Looka -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
|
||||
<h3 class="font-bold text-indigo-900 mb-2 flex items-center gap-2"><i data-lucide="check-circle" size="18"></i> Looka 강점</h3>
|
||||
<ul class="text-sm text-indigo-800 space-y-1">
|
||||
<li>• 압도적으로 쉬운 인터페이스 (초보자용)</li>
|
||||
<li>• 빠르고 친절한 고객 지원 (주말 응대)</li>
|
||||
<li>• 종합 브랜드 패키지 (명함, SNS 에셋 등)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-rose-50 p-4 rounded-xl border border-rose-100">
|
||||
<h3 class="font-bold text-rose-900 mb-2 flex items-center gap-2"><i data-lucide="alert-circle" size="18"></i> Looka 약점</h3>
|
||||
<ul class="text-sm text-rose-800 space-y-1">
|
||||
<li>• 기본 패키지가 다소 빈약 (저해상도)</li>
|
||||
<li>• 커스터마이징의 세부 조정 한계</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Brandmark -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-emerald-50 p-4 rounded-xl border border-emerald-100">
|
||||
<h3 class="font-bold text-emerald-900 mb-2 flex items-center gap-2"><i data-lucide="check-circle" size="18"></i> Brandmark 강점</h3>
|
||||
<ul class="text-sm text-emerald-800 space-y-1">
|
||||
<li>• 1회 결제 로직과 무제한 수정</li>
|
||||
<li>• 더 독창적인 생성형 AI 엔진</li>
|
||||
<li>• 미니멀하고 모던한 미학 특화</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-amber-50 p-4 rounded-xl border border-amber-100">
|
||||
<h3 class="font-bold text-amber-900 mb-2 flex items-center gap-2"><i data-lucide="alert-circle" size="18"></i> Brandmark 약점</h3>
|
||||
<ul class="text-sm text-amber-800 space-y-1">
|
||||
<li>• 결과물이 때때로 지나치게 단순함</li>
|
||||
<li>• Looka 대비 제한적인 브랜드 킷</li>
|
||||
<li>• 시각적 감각이 부족하다는 평가 존재</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommend Tab -->
|
||||
<div id="recommend" class="tab-content space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="check-square" class="text-blue-600" size="24"></i>
|
||||
상황별 최종 추천
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="p-5 bg-gradient-to-br from-indigo-50 to-white border border-indigo-200 rounded-2xl shadow-sm">
|
||||
<h3 class="text-lg font-bold text-indigo-900 mb-3">✅ Looka를 추천하는 경우</h3>
|
||||
<ul class="text-sm text-indigo-700 space-y-2">
|
||||
<li class="flex items-start gap-2"><i data-lucide="arrow-right-circle" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> 기업용 종합 마케팅 자료가 필요한 경우</li>
|
||||
<li class="flex items-start gap-2"><i data-lucide="arrow-right-circle" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> 검증된 서비스와 안정적인 지원을 선호하는 경우</li>
|
||||
<li class="flex items-start gap-2"><i data-lucide="arrow-right-circle" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> 디자인 경험이 전무하여 최고의 UI를 원하는 경우</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-5 bg-gradient-to-br from-indigo-50 to-white border border-indigo-200 rounded-2xl shadow-sm">
|
||||
<h3 class="text-lg font-bold text-indigo-900 mb-3">✅ Brandmark를 추천하는 경우</h3>
|
||||
<ul class="text-sm text-indigo-700 space-y-2">
|
||||
<li class="flex items-start gap-2"><i data-lucide="arrow-right-circle" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> 1회성 결제로 평생 수정을 원하는 경우</li>
|
||||
<li class="flex items-start gap-2"><i data-lucide="arrow-right-circle" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> 더욱 독창적이고 미니멀한 로고를 찾는 경우</li>
|
||||
<li class="flex items-start gap-2"><i data-lucide="arrow-right-circle" class="w-4 h-4 mt-0.5 flex-shrink-0"></i> 예산이 극도로 제한적인 경우 ($25 플랜)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-slate-900 rounded-2xl p-6 text-white text-center">
|
||||
<h3 class="text-xl font-bold mb-3 flex items-center justify-center gap-2">
|
||||
<i data-lucide="lightbulb" class="text-amber-400"></i>
|
||||
Antigravity의 한 마디
|
||||
</h3>
|
||||
<p class="text-indigo-100 text-sm leading-relaxed max-w-2xl mx-auto">
|
||||
"먼저 **Looka**에서 무료로 로고를 생성해 보세요. 다운로드 전까지는 비용이 들지 않으므로 시안의 품질을 직접 확인할 수 있습니다.
|
||||
폰트나 컬러 조합이 마음에 든다면 $20 기본 패키지로 방향성을 잡고, 필요 시 프리미엄으로 업그레이드하는 전략이 가장 효율적입니다."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-6 text-center text-sm text-gray-500">
|
||||
<p>데이터 출처: Fahimai, Trustpilot, 공식 홈페이지 등</p>
|
||||
<p>분석 기준일: 2026년 1월</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Lucide 아이콘 렌더링
|
||||
lucide.createIcons();
|
||||
|
||||
// 탭 전환 기능
|
||||
function showTab(tabId) {
|
||||
// 모든 탭 컨텐츠 숨기기
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
// 선택된 탭 컨텐츠 보이기
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
// 탭 버튼 스타일 업데이트
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('nav-active');
|
||||
btn.classList.add('bg-white', 'text-gray-600', 'hover:bg-gray-100');
|
||||
});
|
||||
const activeBtn = document.getElementById('tab-' + tabId);
|
||||
activeBtn.classList.add('nav-active');
|
||||
activeBtn.classList.remove('bg-white', 'text-gray-600', 'hover:bg-gray-100');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
431
company/loudsourcing/index.php
Normal file
@@ -0,0 +1,431 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>라우드소싱 (LOUDSOURCING) 기업분석</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { font-family: 'Noto Sans KR', sans-serif; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.nav-active { background-color: #2563eb; color: white; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
|
||||
.fade-in { animation: fadeIn 0.5s ease-out forwards; }
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8 mb-6 fade-in">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<span class="text-white font-bold text-2xl">L</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">라우드소싱 (LOUDSOURCING)</h1>
|
||||
<p class="text-gray-500">운영사: 주식회사 스터닝 (STUNNING INC.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600 leading-relaxed">
|
||||
국내 최대 디자인 크라우드소싱 플랫폼으로, 디자이너와 기업을 연결하는 콘테스트 기반 비즈니스 모델을 운영합니다.
|
||||
2020년 포트폴리오 플랫폼 '노트폴리오'와 합병하여 통합 크리에이티브 플랫폼으로 성장했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-md p-5 fade-in" style="animation-delay: 0.1s">
|
||||
<div class="flex items-center gap-2 text-blue-600 mb-2">
|
||||
<i data-lucide="users" size="20"></i>
|
||||
<span class="text-sm font-medium">등록 디자이너</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">28만명</p>
|
||||
<p class="text-xs text-gray-500">국내 디자이너 80%</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-5 fade-in" style="animation-delay: 0.2s">
|
||||
<div class="flex items-center gap-2 text-green-600 mb-2">
|
||||
<i data-lucide="dollar-sign" size="20"></i>
|
||||
<span class="text-sm font-medium">2023 매출</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">64.3억</p>
|
||||
<p class="text-xs text-gray-500">연 50~60% 성장</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-5 fade-in" style="animation-delay: 0.3s">
|
||||
<div class="flex items-center gap-2 text-purple-600 mb-2">
|
||||
<i data-lucide="briefcase" size="20"></i>
|
||||
<span class="text-sm font-medium">임직원 수</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">43명</p>
|
||||
<p class="text-xs text-gray-500">2025년 7월 기준</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-md p-5 fade-in" style="animation-delay: 0.4s">
|
||||
<div class="flex items-center gap-2 text-orange-600 mb-2">
|
||||
<i data-lucide="award" size="20"></i>
|
||||
<span class="text-sm font-medium">누적 상금</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-800">200억+</p>
|
||||
<p class="text-xs text-gray-500">콘테스트 2만건+</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto">
|
||||
<button onclick="showTab('overview')" id="tab-overview" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap nav-active">기업개요</button>
|
||||
<button onclick="showTab('financial')" id="tab-financial" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap bg-white text-gray-600 hover:bg-gray-100">재무/성장</button>
|
||||
<button onclick="showTab('history')" id="tab-history" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap bg-white text-gray-600 hover:bg-gray-100">연혁</button>
|
||||
<button onclick="showTab('competition')" id="tab-competition" class="tab-btn px-5 py-2.5 rounded-lg font-medium transition-all whitespace-nowrap bg-white text-gray-600 hover:bg-gray-100">경쟁분석</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 min-h-[400px]">
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview" class="tab-content active space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="building-2" class="text-blue-600" size="24"></i>
|
||||
기업 개요
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2">기본 정보</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">대표</span><span class="font-medium">김승환</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">설립일</span><span class="font-medium">2011년 6월</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">업력</span><span class="font-medium">14.2년</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">사업자번호</span><span class="font-medium">120-87-69298</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">자본금</span><span class="font-medium">1억 468만원</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">기업형태</span><span class="font-medium">비상장 / 중소기업</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2">소재지</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
서울특별시 강남구 선릉로 636,<br />
|
||||
키스톤빌딩 3층 302호 (삼성동)
|
||||
</p>
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2 mt-4">운영 서비스</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">라우드소싱</span>
|
||||
<span class="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm">노트폴리오</span>
|
||||
<span class="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">스터닝 에이전시</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2 mb-4">비즈니스 모델</h3>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-gradient-to-r from-blue-50 to-blue-100 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-blue-800 mb-2">🏆 콘테스트</h4>
|
||||
<p class="text-sm text-gray-600">의뢰자가 상금을 걸고 공모전 개최, 다수의 디자이너가 참여하여 경쟁</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-purple-50 to-purple-100 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-purple-800 mb-2">🛒 라우드마켓</h4>
|
||||
<p class="text-sm text-gray-600">1:1 직접 의뢰 방식, 원하는 디자이너를 선택해 프로젝트 진행</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-green-50 to-green-100 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-green-800 mb-2">🤝 디자이너 매칭</h4>
|
||||
<p class="text-sm text-gray-600">23만명 디자이너로부터 무료 비교견적 수령 가능</p>
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-orange-50 to-orange-100 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-orange-800 mb-2">🎯 에이전시</h4>
|
||||
<p class="text-sm text-gray-600">전담 매니저가 프로젝트 매니징, 컨시어지 서비스 제공</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2 mb-4">주요 고객사</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<?php
|
||||
$clients = ['티몬', '농협', '포스코', '삼성 테크윈', '제주도 개발공사', '바카디코리아', '잡코리아', '도미노피자', 'BC카드', 'LG그램'];
|
||||
foreach($clients as $client) {
|
||||
echo '<span class="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-sm">'.$client.'</span>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Tab -->
|
||||
<div id="financial" class="tab-content space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="trending-up" class="text-green-600" size="24"></i>
|
||||
재무 및 성장 현황
|
||||
</h2>
|
||||
|
||||
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">매출 추이</h3>
|
||||
<div class="flex items-end justify-around h-40 mb-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-green-400 w-16 rounded-t-lg" style="height: 51%"></div>
|
||||
<span class="text-xs mt-2 text-gray-600">2020년</span>
|
||||
<span class="text-sm font-bold">33억+</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-green-500 w-16 rounded-t-lg" style="height: 100%"></div>
|
||||
<span class="text-xs mt-2 text-gray-600">2023년</span>
|
||||
<span class="text-sm font-bold">64.3억</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 text-center">연평균 50~60% 성장률 유지</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-4">직원 수 변화</h3>
|
||||
<div class="flex items-end justify-around h-32 mb-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-blue-400 w-16 rounded-t-lg" style="height: 28%"></div>
|
||||
<span class="text-xs mt-2 text-gray-600">2020년</span>
|
||||
<span class="text-sm font-bold">12명</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="bg-blue-500 w-16 rounded-t-lg" style="height: 100%"></div>
|
||||
<span class="text-xs mt-2 text-gray-600">2025년</span>
|
||||
<span class="text-sm font-bold">43명</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 text-center">5년간 약 3.5배 인력 확대</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2 mb-4">투자 유치 현황</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<span class="font-medium">Series A</span>
|
||||
<span class="text-gray-500 text-sm ml-2">2020년 3월</span>
|
||||
</div>
|
||||
<span class="font-bold text-green-600">20억원</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<span class="font-medium">Series A Bridge</span>
|
||||
<span class="text-gray-500 text-sm ml-2">2021년 9월</span>
|
||||
</div>
|
||||
<span class="font-bold text-green-600">60억원</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-4 bg-blue-50 rounded-lg border-2 border-blue-200">
|
||||
<span class="font-semibold">누적 투자금</span>
|
||||
<span class="font-bold text-blue-600 text-lg">60~80억원</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 p-4 bg-gray-100 rounded-lg text-sm text-gray-600">
|
||||
<strong>투자사:</strong> DSC 인베스트먼트, 미래에셋벤처투자, 나이스투자파트너스, KDB캐피탈, 신한캐피탈
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2 mb-4">핵심 성장 지표</h3>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div class="text-center p-4 bg-purple-50 rounded-xl">
|
||||
<p class="text-3xl font-bold text-purple-600">3.7배</p>
|
||||
<p class="text-sm text-gray-600">MAU 증가 (2년간)</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-blue-50 rounded-xl">
|
||||
<p class="text-3xl font-bold text-blue-600">2.5배</p>
|
||||
<p class="text-sm text-gray-600">디자이너 수 증가</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-green-50 rounded-xl">
|
||||
<p class="text-3xl font-bold text-green-600">2.7배</p>
|
||||
<p class="text-sm text-gray-600">등록 작품 수 증가</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Tab -->
|
||||
<div id="history" class="tab-content space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="calendar" class="text-purple-600" size="24"></i>
|
||||
성장 연혁
|
||||
</h2>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gradient-to-b from-blue-500 to-purple-500"></div>
|
||||
|
||||
<?php
|
||||
$history = [
|
||||
['year' => '2011', 'title' => '창업', 'desc' => '(주)라우더스 창업, 라우드소싱 론칭', 'color' => 'blue'],
|
||||
['year' => '2012', 'title' => '노트폴리오 설립', 'desc' => '디자이너 포트폴리오 커뮤니티 시작', 'color' => 'purple'],
|
||||
['year' => '2017', 'title' => '벤처기업 인증', 'desc' => '기술혁신형 벤처기업 인증 획득', 'color' => 'green'],
|
||||
['year' => '2020.03', 'title' => 'Series A 투자', 'desc' => '20억원 투자 유치', 'color' => 'blue'],
|
||||
['year' => '2020.09', 'title' => '합병 & 스터닝 설립', 'desc' => '라우드소싱 + 노트폴리오 합병', 'color' => 'purple'],
|
||||
['year' => '2021', 'title' => 'Main-Biz 인증', 'desc' => '경영혁신형 중소기업 인증', 'color' => 'green'],
|
||||
['year' => '2021.09', 'title' => 'Bridge 투자', 'desc' => '60억원 추가 투자 유치', 'color' => 'blue'],
|
||||
['year' => '2022', 'title' => '라우드마켓 출시', 'desc' => '1:1 의뢰 서비스 런칭', 'color' => 'purple'],
|
||||
['year' => '2022.하반기', 'title' => '에이전시 출시', 'desc' => '컨시어지 서비스 시작', 'color' => 'orange'],
|
||||
['year' => '2023', 'title' => '매칭 서비스', 'desc' => '디자이너 매칭 서비스 런칭', 'color' => 'green'],
|
||||
];
|
||||
foreach($history as $idx => $item) {
|
||||
$color = $item['color'];
|
||||
echo '
|
||||
<div class="relative pl-10 pb-6">
|
||||
<div class="absolute left-2 w-5 h-5 rounded-full bg-'.$color.'-500 border-4 border-white shadow"></div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="px-2 py-0.5 bg-'.$color.'-100 text-'.$color.'-700 rounded text-xs font-medium">
|
||||
'.$item['year'].'
|
||||
</span>
|
||||
<span class="font-semibold text-gray-800">'.$item['title'].'</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">'.$item['desc'].'</p>
|
||||
</div>
|
||||
</div>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Competition Tab -->
|
||||
<div id="competition" class="tab-content space-y-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i data-lucide="target" class="text-orange-600" size="24"></i>
|
||||
경쟁 분석
|
||||
</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-100">
|
||||
<th class="p-3 text-left font-semibold">구분</th>
|
||||
<th class="p-3 text-center font-semibold text-blue-600">라우드소싱</th>
|
||||
<th class="p-3 text-center font-semibold">크몽</th>
|
||||
<th class="p-3 text-center font-semibold">숨고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="p-3 font-medium">특화 분야</td>
|
||||
<td class="p-3 text-center text-blue-600">디자인 전문</td>
|
||||
<td class="p-3 text-center">종합 프리랜서</td>
|
||||
<td class="p-3 text-center">생활 서비스</td>
|
||||
</tr>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="p-3 font-medium">핵심 방식</td>
|
||||
<td class="p-3 text-center text-blue-600">콘테스트/공모전</td>
|
||||
<td class="p-3 text-center">판매자 상품 등록</td>
|
||||
<td class="p-3 text-center">요청서 기반 견적</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="p-3 font-medium">가격 결정</td>
|
||||
<td class="p-3 text-center text-blue-600">의뢰자가 상금 설정</td>
|
||||
<td class="p-3 text-center">판매자가 단가 설정</td>
|
||||
<td class="p-3 text-center">고수가 견적 제시</td>
|
||||
</tr>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="p-3 font-medium">수수료</td>
|
||||
<td class="p-3 text-center text-blue-600">19.7%</td>
|
||||
<td class="p-3 text-center">~20%</td>
|
||||
<td class="p-3 text-center">포인트 차감</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-3 font-medium">차별점</td>
|
||||
<td class="p-3 text-center text-blue-600">다수 시안 비교</td>
|
||||
<td class="p-3 text-center">빠른 매칭</td>
|
||||
<td class="p-3 text-center">지역 기반</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-700 border-b pb-2 mb-4">SWOT 분석</h3>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="bg-green-50 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-green-700 mb-3">💪 강점 (Strengths)</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 국내 디자이너 80% 점유율</li>
|
||||
<li>• 14년 운영 노하우</li>
|
||||
<li>• 다양한 시안 비교 가능</li>
|
||||
<li>• 노트폴리오와 시너지</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-red-700 mb-3">⚠️ 약점 (Weaknesses)</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 낙선 디자이너 보상 없음</li>
|
||||
<li>• 수수료 부담 (19.7%)</li>
|
||||
<li>• 콘테스트 방식 한계</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-blue-700 mb-3">🚀 기회 (Opportunities)</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• 18조 국내 디자인 시장</li>
|
||||
<li>• 프리랜서 경제 성장</li>
|
||||
<li>• 아이디어 콘테스트 확장</li>
|
||||
<li>• 기업 디자인 아웃소싱 증가</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-xl p-4">
|
||||
<h4 class="font-semibold text-orange-700 mb-3">⚡ 위협 (Threats)</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li>• AI 디자인 툴 부상</li>
|
||||
<li>• Canva, 미리캔버스 등 경쟁</li>
|
||||
<li>• 크몽, 숨고 등 플랫폼 경쟁</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">시장 규모</h3>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="text-center">
|
||||
<p class="text-4xl font-bold text-indigo-600">18조원</p>
|
||||
<p class="text-sm text-gray-600">국내 디자인 시장</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-4xl font-bold text-purple-600">1조원</p>
|
||||
<p class="text-sm text-gray-600">국내 프리랜서 시장</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-6 text-center text-sm text-gray-500">
|
||||
<p>데이터 출처: THE VC, 혁신의숲, 원티드, 인크루트, 잡플래닛, 나무위키 등</p>
|
||||
<p>분석 기준일: 2026년 1월</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Lucide 아이콘 렌더링
|
||||
lucide.createIcons();
|
||||
|
||||
// 탭 전환 기능
|
||||
function showTab(tabId) {
|
||||
// 모든 탭 컨텐츠 숨기기
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
// 선택된 탭 컨텐츠 보이기
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
// 탭 버튼 스타일 업데이트
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('nav-active');
|
||||
btn.classList.add('bg-white', 'text-gray-600', 'hover:bg-gray-100');
|
||||
});
|
||||
const activeBtn = document.getElementById('tab-' + tabId);
|
||||
activeBtn.classList.add('nav-active');
|
||||
activeBtn.classList.remove('bg-white', 'text-gray-600', 'hover:bg-gray-100');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
525
company/peoplelife/index.php
Normal file
@@ -0,0 +1,525 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>피플라이프(PeopleLife) 기업 분석 리포트</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||
<script src="https://cdn.plot.ly/plotly-2.24.1.min.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Noto Sans KR', sans-serif; background-color: #f8fafc; }
|
||||
.card { background-color: white; border-radius: 0.75rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); transition: transform 0.2s; }
|
||||
.card:hover { transform: translateY(-2px); }
|
||||
.nav-item.active { border-bottom: 2px solid #2563eb; color: #2563eb; font-weight: 700; }
|
||||
.timeline-line { position: absolute; left: 50%; width: 2px; height: 100%; background-color: #e2e8f0; transform: translateX(-50%); }
|
||||
.chart-container { position: relative; width: 100%; height: 350px; }
|
||||
@media (max-width: 768px) {
|
||||
.timeline-line { left: 1.5rem; }
|
||||
.chart-container { height: 300px; }
|
||||
}
|
||||
</style>
|
||||
<!-- Chosen Palette: Trustworthy Blue & Clean White (Blue-600 primary, Slate-50 background) -->
|
||||
<!-- Application Structure Plan: Dashboard layout. Top Hero section for instant summary. Tabbed navigation (Financials, Workforce, History, Business Model) to allow deep dives without scrolling fatigue. Interactive charts for data exploration. Timeline for qualitative history. -->
|
||||
<!-- Visualization & Content Choices:
|
||||
1. Financials: Combo Bar/Line Chart (Chart.js) to show Revenue vs Operating Profit correlation over time.
|
||||
2. Workforce: Line Chart (Chart.js) for headcount trends.
|
||||
3. Channel Mix: Donut Chart (Plotly) to visualize the diversification of sales channels.
|
||||
4. History: Vertical Timeline (HTML/Tailwind) for clear chronological storytelling.
|
||||
5. Interaction: Tab switching, hover effects on charts, dynamic text updates based on chart selection.
|
||||
-->
|
||||
<!-- CONFIRMATION: NO SVG graphics used. NO Mermaid JS used. -->
|
||||
</head>
|
||||
<body class="text-slate-700">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg mr-3 flex items-center justify-center text-white font-bold">P</div>
|
||||
<span class="font-bold text-xl tracking-tight text-slate-900">피플라이프 기업분석</span>
|
||||
</div>
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<button onclick="switchTab('overview')" id="nav-overview" class="nav-item active px-3 py-2 text-sm font-medium text-slate-500 hover:text-slate-900 transition-colors">개요</button>
|
||||
<button onclick="switchTab('financials')" id="nav-financials" class="nav-item px-3 py-2 text-sm font-medium text-slate-500 hover:text-slate-900 transition-colors">재무성과</button>
|
||||
<button onclick="switchTab('growth')" id="nav-growth" class="nav-item px-3 py-2 text-sm font-medium text-slate-500 hover:text-slate-900 transition-colors">성장과정</button>
|
||||
<button onclick="switchTab('business')" id="nav-business" class="nav-item px-3 py-2 text-sm font-medium text-slate-500 hover:text-slate-900 transition-colors">사업모델</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Nav -->
|
||||
<div class="md:hidden border-t border-slate-100 flex justify-around py-2">
|
||||
<button onclick="switchTab('overview')" class="text-xs font-medium text-slate-600">개요</button>
|
||||
<button onclick="switchTab('financials')" class="text-xs font-medium text-slate-600">재무</button>
|
||||
<button onclick="switchTab('growth')" class="text-xs font-medium text-slate-600">성장</button>
|
||||
<button onclick="switchTab('business')" class="text-xs font-medium text-slate-600">모델</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Dynamic Content Section -->
|
||||
<div id="content-area">
|
||||
<!-- Content will be injected here via JS, defaulting to Overview -->
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="bg-slate-800 text-slate-300 py-8 mt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 text-center">
|
||||
<p class="text-sm">© 2024 PeopleLife Analysis Dashboard. 본 자료는 공개된 기업 정보를 바탕으로 구성된 분석 시뮬레이션입니다.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// --- Data Store ---
|
||||
const companyData = {
|
||||
summary: {
|
||||
revenue: "3,035억 원", // Approx 2022-2023 figures
|
||||
employees: "4,000+",
|
||||
founded: "2003년",
|
||||
slogan: "보험을 연구합니다, 고객을 연구합니다."
|
||||
},
|
||||
financials: {
|
||||
years: ['2019', '2020', '2021', '2022', '2023(E)'],
|
||||
revenue: [2186, 2468, 2580, 3035, 3200], // Unit: 100M KRW
|
||||
profit: [-120, -50, 10, 85, 120], // Operating Profit
|
||||
commentary: "2019년부터 공격적인 '보험클리닉' 오프라인 매장 투자로 인해 일시적인 영업손실을 기록했으나, 2021년 흑자 전환에 성공하며 매출과 이익이 동반 성장하는 턴어라운드 국면에 진입했습니다."
|
||||
},
|
||||
workforce: {
|
||||
years: ['2018', '2019', '2020', '2021', '2022'],
|
||||
fp_count: [3500, 3800, 4100, 4050, 3950], // Approximate FA/FP counts
|
||||
channel_mix: {
|
||||
labels: ['법인영업(Corporate)', '개인영업(Individual)', '보험클리닉(OTC/In-bound)'],
|
||||
values: [40, 35, 25] // Estimated percentage
|
||||
}
|
||||
},
|
||||
history: [
|
||||
{ year: "2003", title: "설립", desc: "법인 컨설팅 전문 기업으로 출범" },
|
||||
{ year: "2013", title: "GA 전환", desc: "초대형 GA(법인보험대리점)로 비즈니스 모델 확장" },
|
||||
{ year: "2018", title: "보험클리닉 런칭", desc: "국내 최초 내방형 오프라인 보험샵 '보험클리닉' 오픈" },
|
||||
{ year: "2021", title: "흑자 전환", desc: "공격적 투자의 결실로 영업이익 흑자 달성 및 내실 다지기" },
|
||||
{ year: "2023", title: "디지털 전환", desc: "온-오프라인 통합 플랫폼 고도화 및 신규 시장 공략" }
|
||||
]
|
||||
};
|
||||
|
||||
// --- View Rendering Functions ---
|
||||
|
||||
function renderOverview() {
|
||||
return `
|
||||
<div class="animate-fade-in space-y-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center py-10 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-2xl text-white shadow-lg">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">피플라이프(PeopleLife)</h1>
|
||||
<p class="text-blue-100 text-lg mb-8 max-w-2xl mx-auto">법인 컨설팅의 전문성을 바탕으로 개인 보험 시장과 O2O 플랫폼까지 혁신하는 대한민국 대표 GA</p>
|
||||
<div class="grid grid-cols-3 gap-4 max-w-3xl mx-auto px-4">
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<div class="text-2xl md:text-3xl font-bold">${companyData.summary.revenue}</div>
|
||||
<div class="text-xs md:text-sm text-blue-200 mt-1">최근 연매출</div>
|
||||
</div>
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<div class="text-2xl md:text-3xl font-bold">${companyData.summary.employees}</div>
|
||||
<div class="text-xs md:text-sm text-blue-200 mt-1">금융 전문가(FA)</div>
|
||||
</div>
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<div class="text-2xl md:text-3xl font-bold">${companyData.summary.founded}</div>
|
||||
<div class="text-xs md:text-sm text-blue-200 mt-1">설립 연도</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Highlights Grid -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-bold text-slate-800 mb-4 flex items-center">
|
||||
<span class="w-2 h-8 bg-blue-500 mr-2 rounded"></span> 핵심 경쟁력
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start">
|
||||
<span class="bg-blue-100 text-blue-600 rounded-full p-1 mr-3 mt-1 text-xs">✓</span>
|
||||
<span><strong>보험클리닉(OTC):</strong> 국내 최초의 내방형 점포로 고객 접근성 극대화 및 DB 퀄리티 차별화</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="bg-blue-100 text-blue-600 rounded-full p-1 mr-3 mt-1 text-xs">✓</span>
|
||||
<span><strong>법인 영업 전문성:</strong> 설립 초기부터 다져온 독보적인 법인 CEO 컨설팅 노하우</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="bg-blue-100 text-blue-600 rounded-full p-1 mr-3 mt-1 text-xs">✓</span>
|
||||
<span><strong>정규직 상담 매니저:</strong> 고용 불안정을 해소하고 상담 품질을 높이는 정규직 모델 도입 시도</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card p-6 flex flex-col justify-center items-center bg-slate-50 border border-slate-100">
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-slate-600 mb-2">분석 요약</h3>
|
||||
<p class="text-slate-500 leading-relaxed text-sm">
|
||||
피플라이프는 단순 보험 판매를 넘어, '보험클리닉'이라는 브랜드를 통해 보험 유통의 패러다임을 '찾아가는 영업'에서 '찾아오는 서비스'로 전환하고자 노력했습니다. 최근 수익성 개선과 함께 디지털 플랫폼과의 시너지를 통해 2차 도약을 준비하고 있습니다.
|
||||
</p>
|
||||
<button onclick="switchTab('financials')" class="mt-4 px-6 py-2 bg-white border border-blue-500 text-blue-600 rounded-full text-sm font-medium hover:bg-blue-50 transition">재무 상세 보기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFinancials() {
|
||||
setTimeout(() => initFinancialChart(), 100);
|
||||
return `
|
||||
<div class="animate-fade-in space-y-6">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-slate-800">재무 성과 분석</h2>
|
||||
<p class="text-slate-500 mt-2">매출의 꾸준한 성장세와 최근 수익성 개선 추이를 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- Main Chart Area -->
|
||||
<div class="lg:col-span-2 card p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-lg text-slate-700">연도별 매출 및 영업이익 추이</h3>
|
||||
<span class="text-xs text-slate-400 bg-slate-100 px-2 py-1 rounded">(단위: 억원)</span>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="financialChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insight Card -->
|
||||
<div class="card p-6 bg-gradient-to-b from-white to-blue-50 border-t-4 border-blue-500">
|
||||
<h3 class="font-bold text-lg text-slate-800 mb-4">Key Insight</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-slate-500 mb-1">매출 성장률 (CAGR)</div>
|
||||
<div class="text-2xl font-bold text-blue-600">약 10.5%</div>
|
||||
<div class="text-xs text-slate-400">최근 4년 평균</div>
|
||||
</div>
|
||||
<hr class="border-slate-200">
|
||||
<div>
|
||||
<div class="text-sm text-slate-500 mb-1">턴어라운드</div>
|
||||
<p class="text-sm text-slate-700 leading-relaxed">
|
||||
2019-2020년 오프라인 매장 확장을 위한 대규모 투자로 적자를 기록했으나,
|
||||
<strong>2021년 흑자 전환</strong>에 성공하며 '규모의 경제'와 '브랜드 인지도' 효과가 나타나기 시작했습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white p-3 rounded border border-blue-100 mt-4 shadow-sm">
|
||||
<p class="text-xs text-slate-600">
|
||||
💡 <strong>Note:</strong> 보험클리닉의 브랜드 인지도가 상승하며 마케팅 비용 효율화가 이루어지고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Table -->
|
||||
<div class="card p-6 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm text-slate-600">
|
||||
<thead class="bg-slate-50 text-slate-700 font-bold border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3">구분</th>
|
||||
<th class="px-4 py-3 text-right">2019</th>
|
||||
<th class="px-4 py-3 text-right">2020</th>
|
||||
<th class="px-4 py-3 text-right">2021</th>
|
||||
<th class="px-4 py-3 text-right">2022</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium">매출액 (억원)</td>
|
||||
<td class="px-4 py-3 text-right">2,186</td>
|
||||
<td class="px-4 py-3 text-right">2,468</td>
|
||||
<td class="px-4 py-3 text-right">2,580</td>
|
||||
<td class="px-4 py-3 text-right">3,035</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 text-blue-600">
|
||||
<td class="px-4 py-3 font-medium">영업이익 (억원)</td>
|
||||
<td class="px-4 py-3 text-right">-120</td>
|
||||
<td class="px-4 py-3 text-right">-50</td>
|
||||
<td class="px-4 py-3 text-right">10</td>
|
||||
<td class="px-4 py-3 text-right">85</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGrowth() {
|
||||
return `
|
||||
<div class="animate-fade-in space-y-8">
|
||||
<div class="mb-6 text-center">
|
||||
<h2 class="text-2xl font-bold text-slate-800">피플라이프 성장 연혁</h2>
|
||||
<p class="text-slate-500 mt-2">법인 컨설팅 강자에서 대한민국 대표 GA로의 진화 과정</p>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-4xl mx-auto py-8">
|
||||
<!-- Vertical Line -->
|
||||
<div class="timeline-line hidden md:block"></div>
|
||||
|
||||
<!-- Timeline Items -->
|
||||
${companyData.history.map((item, index) => `
|
||||
<div class="relative flex items-center justify-between mb-8 flex-col md:flex-row ${index % 2 !== 0 ? 'md:flex-row-reverse' : ''}">
|
||||
<!-- Dot -->
|
||||
<div class="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 bg-blue-500 rounded-full border-4 border-white shadow hidden md:block"></div>
|
||||
|
||||
<!-- Content Left/Right -->
|
||||
<div class="w-full md:w-5/12 card p-5 hover:border-blue-500 border-2 border-transparent transition-colors mb-4 md:mb-0">
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="text-2xl font-bold text-blue-600 mr-3">${item.year}</span>
|
||||
<h3 class="text-lg font-bold text-slate-800">${item.title}</h3>
|
||||
</div>
|
||||
<p class="text-slate-600 text-sm leading-relaxed">${item.desc}</p>
|
||||
</div>
|
||||
<div class="w-full md:w-5/12"></div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div class="card p-8 bg-slate-800 text-white text-center mt-8">
|
||||
<h3 class="text-xl font-bold mb-4">The Next Step</h3>
|
||||
<p class="text-slate-300 max-w-2xl mx-auto">
|
||||
피플라이프는 이제 축적된 오프라인 인프라와 데이터를 바탕으로 <strong>디지털 헬스케어 및 핀테크 플랫폼</strong>으로의 확장을 준비하고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBusiness() {
|
||||
setTimeout(() => initWorkforceCharts(), 100);
|
||||
return `
|
||||
<div class="animate-fade-in space-y-8">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-slate-800">조직 규모 및 사업 모델</h2>
|
||||
<p class="text-slate-500 mt-2">다각화된 영업 채널과 전문화된 인력 구조</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<!-- Business Pillars -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-bold text-slate-700 mb-2">3대 핵심 사업축</h3>
|
||||
|
||||
<div class="card p-5 border-l-4 border-indigo-500 flex items-start">
|
||||
<div class="bg-indigo-100 p-3 rounded-lg mr-4 text-indigo-600 font-bold text-xl">01</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 text-lg">법인 컨설팅 (Corporate)</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">상속, 증여, 가업승계, 세무 리스크 관리 등 중소/중견기업 CEO 대상 특화 솔루션 제공. 피플라이프의 뿌리이자 고수익 창출원.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-5 border-l-4 border-blue-500 flex items-start">
|
||||
<div class="bg-blue-100 p-3 rounded-lg mr-4 text-blue-600 font-bold text-xl">02</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 text-lg">보험클리닉 (OTC Shop)</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">대형 마트, 백화점 등 유동인구가 많은 곳에 입점한 내방형 점포. 객관적인 보장 분석과 상품 비교 서비스를 제공하며 신규 고객 DB 확보.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-5 border-l-4 border-sky-400 flex items-start">
|
||||
<div class="bg-sky-100 p-3 rounded-lg mr-4 text-sky-600 font-bold text-xl">03</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 text-lg">개인 영업 (EFA)</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">전국 네트워크를 보유한 전문 재무 설계사들이 개인 고객의 생애 주기에 맞춘 맞춤형 포트폴리오 제안.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workforce Charts -->
|
||||
<div class="space-y-6">
|
||||
<div class="card p-6">
|
||||
<h3 class="font-bold text-slate-700 mb-4">영업 채널 비중 (추정)</h3>
|
||||
<div class="chart-container" style="height: 300px;">
|
||||
<div id="channelChart" class="w-full h-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<h3 class="font-bold text-slate-700 mb-4">FA(재무설계사) 인원 추이</h3>
|
||||
<div class="chart-container" style="height: 250px;">
|
||||
<canvas id="fpChart"></canvas>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2 text-center">* 위촉직 및 정규직 포함 추산치</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Chart Initialization ---
|
||||
|
||||
function initFinancialChart() {
|
||||
const ctx = document.getElementById('financialChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: companyData.financials.years,
|
||||
datasets: [
|
||||
{
|
||||
label: '영업이익 (Line)',
|
||||
data: companyData.financials.profit,
|
||||
type: 'line',
|
||||
borderColor: '#ef4444', // Red for profit/loss visibility
|
||||
backgroundColor: '#ef4444',
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
},
|
||||
{
|
||||
label: '매출액 (Bar)',
|
||||
data: companyData.financials.revenue,
|
||||
backgroundColor: '#3b82f6', // Blue
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: '매출액 (억원)' },
|
||||
grid: { display: false }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '영업이익 (억원)' },
|
||||
grid: { borderDash: [2, 2] }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y + ' 억원';
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initWorkforceCharts() {
|
||||
// Plotly Donut Chart
|
||||
const channelData = [{
|
||||
values: companyData.workforce.channel_mix.values,
|
||||
labels: companyData.workforce.channel_mix.labels,
|
||||
type: 'pie',
|
||||
hole: .6,
|
||||
marker: {
|
||||
colors: ['#4f46e5', '#3b82f6', '#93c5fd'] // Indigo, Blue, Light Blue
|
||||
},
|
||||
textinfo: 'label+percent',
|
||||
textposition: 'outside',
|
||||
automargin: true
|
||||
}];
|
||||
|
||||
const channelLayout = {
|
||||
showlegend: false,
|
||||
margin: { t: 0, b: 0, l: 0, r: 0 },
|
||||
height: 300,
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: { family: 'Noto Sans KR' }
|
||||
};
|
||||
|
||||
Plotly.newPlot('channelChart', channelData, channelLayout, {displayModeBar: false, responsive: true});
|
||||
|
||||
// Chart.js Line Chart for Headcount
|
||||
const ctxFP = document.getElementById('fpChart').getContext('2d');
|
||||
new Chart(ctxFP, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: companyData.workforce.years,
|
||||
datasets: [{
|
||||
label: 'FA 인원 수',
|
||||
data: companyData.workforce.fp_count,
|
||||
borderColor: '#0f172a',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
min: 3000
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Interaction Logic ---
|
||||
function switchTab(tabId) {
|
||||
// Update Nav State
|
||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||
const activeNav = document.getElementById(`nav-${tabId}`);
|
||||
if (activeNav) activeNav.classList.add('active');
|
||||
|
||||
// Render Content
|
||||
const contentArea = document.getElementById('content-area');
|
||||
|
||||
// Simple fade out/in effect manually handled by replacing HTML
|
||||
contentArea.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
switch(tabId) {
|
||||
case 'overview':
|
||||
contentArea.innerHTML = renderOverview();
|
||||
break;
|
||||
case 'financials':
|
||||
contentArea.innerHTML = renderFinancials();
|
||||
break;
|
||||
case 'growth':
|
||||
contentArea.innerHTML = renderGrowth();
|
||||
break;
|
||||
case 'business':
|
||||
contentArea.innerHTML = renderBusiness();
|
||||
break;
|
||||
default:
|
||||
contentArea.innerHTML = renderOverview();
|
||||
}
|
||||
// Trigger reflow/opacity change
|
||||
requestAnimationFrame(() => {
|
||||
contentArea.style.transition = 'opacity 0.3s ease-in-out';
|
||||
contentArea.style.opacity = '1';
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
switchTab('overview');
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
506
coocon/index.php
Normal file
@@ -0,0 +1,506 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>쿠콘 (COOCON) 기업 분석 보고서</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Noto Sans KR', sans-serif; background-color: #f8fafc; color: #1e293b; }
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
height: 300px;
|
||||
max-height: 400px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.chart-container { height: 350px; }
|
||||
}
|
||||
.nav-item.active { border-bottom: 2px solid #2563eb; color: #2563eb; font-weight: 700; }
|
||||
.timeline-line { position: absolute; left: 15px; top: 0; bottom: 0; width: 2px; background: #e2e8f0; }
|
||||
.timeline-dot { position: absolute; left: 6px; width: 20px; height: 20px; border-radius: 50%; border: 4px solid #fff; z-index: 10; cursor: pointer; transition: all 0.3s; }
|
||||
.timeline-dot:hover { transform: scale(1.2); }
|
||||
.card-shadow { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
|
||||
.fade-in { animation: fadeIn 0.5s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
<!-- Chosen Palette: Modern Fintech Blue (Trust & Data) - Slate backgrounds, Blue accents, Teal for growth indicators -->
|
||||
<!-- Application Structure Plan:
|
||||
1. Hero Dashboard: High-level snapshot (Revenue, Profit, API Count) to grab attention.
|
||||
2. Financial Growth (Main Engine): Interactive combo chart showing Revenue & Operating Profit trends.
|
||||
3. The API Core (Deep Dive): Dedicated section for the user's specific request on "API Business Growth".
|
||||
Uses a donut chart for revenue mix and a stepped text interaction to explain the evolution (Scraping -> API -> MyData).
|
||||
4. Human Capital: Employee growth analysis to show R&D investment.
|
||||
5. Corporate Timeline: Vertical interactive list to explore key milestones.
|
||||
Rationale: This structure moves from the "What" (Numbers) to the "How" (API Strategy) and "Who" (Employees), ending with "When" (History).
|
||||
-->
|
||||
<!-- Visualization & Content Choices:
|
||||
- Financials: Bar (Revenue) + Line (Profit) Combo Chart. Best for correlating scale with efficiency.
|
||||
- API Mix: Doughnut Chart. Shows the dominance/growth of the Data Service sector.
|
||||
- Employee: Bar Chart. Simple trend visualization.
|
||||
- Timeline: CSS/HTML Interactive List. No SVG required.
|
||||
- Logic: Vanilla JS for tab switching and chart rendering/updates.
|
||||
-->
|
||||
<!-- CONFIRMATION: NO SVG graphics used. NO Mermaid JS used. -->
|
||||
</head>
|
||||
<body class="bg-slate-50">
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<a href="../index.php" class="flex items-center gap-3 cursor-pointer">
|
||||
<img src="../img/favicon-32x32.png" alt="SAM" class="w-10 h-10 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||||
<span class="text-xs text-slate-500 leading-tight">Smart Automation Management</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="hidden sm:flex space-x-8 items-center">
|
||||
<a href="../index.php" class="nav-item px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:text-blue-600 flex items-center">
|
||||
<span class="mr-1">🏠</span> 홈으로
|
||||
</a>
|
||||
<button onclick="scrollToSection('overview')" class="nav-item active text-slate-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors">개요 & 실적</button>
|
||||
<button onclick="scrollToSection('api-growth')" class="nav-item text-slate-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors">API 사업 성장</button>
|
||||
<button onclick="scrollToSection('employees')" class="nav-item text-slate-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors">인적 자원</button>
|
||||
<button onclick="scrollToSection('history')" class="nav-item text-slate-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors">연혁</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-12">
|
||||
|
||||
<!-- Section 1: Hero & Financial Overview -->
|
||||
<section id="overview" class="scroll-mt-24 fade-in">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-slate-900">비즈니스 데이터 플랫폼, 쿠콘</h1>
|
||||
<p class="mt-2 text-lg text-slate-600">
|
||||
쿠콘은 금융, 공공, 의료, 물류 등 다양한 데이터를 수집, 연결하여 표준화된 <strong>API 형태</strong>로 제공하는 국내 선두 데이터 기업입니다.
|
||||
아래 대시보드에서 최근 기업의 성장 추이를 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white p-6 rounded-xl card-shadow border-l-4 border-blue-500">
|
||||
<h3 class="text-sm font-medium text-slate-500">연간 매출액 (최근)</h3>
|
||||
<p class="text-3xl font-bold text-slate-800 mt-2" id="metric-revenue">Loading...</p>
|
||||
<p class="text-xs text-green-600 mt-1 font-medium">↑ 꾸준한 우상향 성장</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-xl card-shadow border-l-4 border-teal-500">
|
||||
<h3 class="text-sm font-medium text-slate-500">영업이익률</h3>
|
||||
<p class="text-3xl font-bold text-slate-800 mt-2" id="metric-profit">Loading...</p>
|
||||
<p class="text-xs text-slate-400 mt-1">고부가가치 데이터 사업</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-xl card-shadow border-l-4 border-indigo-500">
|
||||
<h3 class="text-sm font-medium text-slate-500">제공 API 수</h3>
|
||||
<p class="text-3xl font-bold text-slate-800 mt-2">250+</p>
|
||||
<p class="text-xs text-slate-400 mt-1">국내외 금융기관 연결</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Chart Section -->
|
||||
<div class="bg-white p-6 rounded-xl card-shadow">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-bold text-slate-800">📊 연도별 실적 추이 (성장성 분석)</h2>
|
||||
<div class="bg-slate-100 p-1 rounded-lg flex space-x-1">
|
||||
<button class="px-3 py-1 text-xs font-bold rounded shadow-sm bg-white text-blue-600" disabled>연간 (Annual)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="financialChart"></canvas>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-slate-500 text-center">
|
||||
*매출액과 영업이익 모두 안정적인 성장세를 보이며, 특히 데이터 부문의 마진율이 이익 성장을 견인하고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2: API Business Deep Dive -->
|
||||
<section id="api-growth" class="scroll-mt-24 fade-in">
|
||||
<div class="border-b border-slate-200 pb-4 mb-6">
|
||||
<h2 class="text-2xl font-bold text-slate-900 flex items-center">
|
||||
<span class="bg-blue-100 text-blue-700 p-2 rounded-lg mr-3 text-lg">🚀</span>
|
||||
API 사업 성장 과정 및 구조
|
||||
</h2>
|
||||
<p class="mt-2 text-slate-600">
|
||||
쿠콘 성장의 핵심은 <strong>데이터 서비스(API)</strong> 부문입니다. 단순 중계가 아닌 데이터를 표준화하여 제공하는 '데이터 허브' 전략이 주효했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Revenue Mix Chart -->
|
||||
<div class="bg-white p-6 rounded-xl card-shadow">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-4">사업 부문별 매출 비중</h3>
|
||||
<p class="text-sm text-slate-500 mb-4">페이먼트(결제) 사업에서 고마진의 데이터(API) 사업으로 중심축이 이동하고 있습니다.</p>
|
||||
<div class="chart-container" style="max-height: 300px;">
|
||||
<canvas id="revenueMixChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-4 text-center">
|
||||
<div class="p-3 bg-blue-50 rounded-lg">
|
||||
<span class="block text-xs text-slate-500">데이터(API) 부문</span>
|
||||
<span class="block text-lg font-bold text-blue-700">고성장/고마진</span>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-lg">
|
||||
<span class="block text-xs text-slate-500">페이먼트 부문</span>
|
||||
<span class="block text-lg font-bold text-slate-600">안정적 캐시카우</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Growth Process Timeline/Tabs -->
|
||||
<div class="bg-white p-6 rounded-xl card-shadow flex flex-col">
|
||||
<h3 class="text-lg font-bold text-slate-800 mb-4">API 비즈니스 진화 3단계</h3>
|
||||
<div class="flex-1 space-y-4">
|
||||
<!-- Stage 1 -->
|
||||
<div class="group cursor-pointer p-4 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all" onclick="highlightStage(1)">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-bold text-slate-400 bg-slate-100 px-2 py-1 rounded">초기 (2006~2015)</span>
|
||||
<span class="text-xl">🕸️</span>
|
||||
</div>
|
||||
<h4 class="font-bold text-slate-800">인프라 구축 및 스크래핑 기술</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
국내 전 금융기관과 연결하는 물리적 네트워크를 구축하고, 화면 정보를 데이터로 변환하는 스크래핑 기술을 고도화하여 데이터 수집의 기반을 마련했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2 -->
|
||||
<div class="group cursor-pointer p-4 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all" onclick="highlightStage(2)">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-bold text-blue-600 bg-blue-100 px-2 py-1 rounded">성장기 (2016~2020)</span>
|
||||
<span class="text-xl">🔌</span>
|
||||
</div>
|
||||
<h4 class="font-bold text-slate-800">API 스토어 & 연결 플랫폼화</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
'쿠콘닷넷'을 통해 데이터를 API 상품처럼 판매하기 시작했습니다. 핀테크 붐과 함께 간편결제, 자산관리 앱들이 쿠콘의 API를 필수재로 채택하며 폭발적 성장을 이뤘습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stage 3 -->
|
||||
<div class="group cursor-pointer p-4 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all" onclick="highlightStage(3)">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-bold text-indigo-600 bg-indigo-100 px-2 py-1 rounded">도약기 (2021~현재)</span>
|
||||
<span class="text-xl">💎</span>
|
||||
</div>
|
||||
<h4 class="font-bold text-slate-800">마이데이터 & 데이터 허브</h4>
|
||||
<p class="text-sm text-slate-600 mt-1">
|
||||
마이데이터 사업자로서의 지위 확보와 빅데이터 융합. 단순 금융 정보를 넘어 의료, 공공, 유통 물류 데이터로 범위를 확장하며 '데이터 허브'로서의 독점적 지위를 공고히 했습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 3: Employees & HR -->
|
||||
<section id="employees" class="scroll-mt-24 fade-in">
|
||||
<div class="bg-white p-8 rounded-xl card-shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
|
||||
<div class="md:col-span-1">
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-4">인적 자원 현황</h2>
|
||||
<p class="text-slate-600 mb-6">
|
||||
쿠콘은 기술 중심 회사입니다. 전체 임직원의 상당수가 <strong>개발 및 전문 기술 인력</strong>으로 구성되어 있으며, 지속적인 채용을 통해 R&D 역량을 강화하고 있습니다.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between p-3 bg-slate-50 rounded">
|
||||
<span class="text-sm font-medium text-slate-600">전체 임직원 수</span>
|
||||
<span class="text-lg font-bold text-blue-700" id="total-employees">Loading...</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-slate-50 rounded">
|
||||
<span class="text-sm font-medium text-slate-600">평균 근속연수</span>
|
||||
<span class="text-lg font-bold text-slate-700">약 5~6년</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="chart-container">
|
||||
<canvas id="employeeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 4: History & Milestones -->
|
||||
<section id="history" class="scroll-mt-24 mb-12 fade-in">
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-6 border-l-4 border-blue-600 pl-3">주요 연혁 (Milestones)</h2>
|
||||
|
||||
<div class="relative bg-white p-6 rounded-xl card-shadow">
|
||||
<div class="timeline-line ml-6"></div>
|
||||
|
||||
<div class="space-y-8 pl-12">
|
||||
<!-- Item 2021 -->
|
||||
<div class="relative group">
|
||||
<div class="timeline-dot bg-blue-500 border-white shadow-sm ml-6 top-1"></div>
|
||||
<div class="bg-slate-50 hover:bg-blue-50 p-4 rounded-lg transition-colors border border-transparent hover:border-blue-200">
|
||||
<span class="text-blue-600 font-bold text-sm block mb-1">2021</span>
|
||||
<h4 class="text-lg font-bold text-slate-800">코스닥(KOSDAQ) 상장</h4>
|
||||
<p class="text-sm text-slate-600">성공적인 기업공개(IPO)를 통해 대규모 자금을 확보하고, 마이데이터 사업 본격화를 알렸습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2019 -->
|
||||
<div class="relative group">
|
||||
<div class="timeline-dot bg-slate-400 border-white shadow-sm ml-6 top-1"></div>
|
||||
<div class="bg-slate-50 hover:bg-slate-100 p-4 rounded-lg transition-colors">
|
||||
<span class="text-slate-500 font-bold text-sm block mb-1">2019</span>
|
||||
<h4 class="text-lg font-bold text-slate-800">금융위원회 혁신금융서비스 지정</h4>
|
||||
<p class="text-sm text-slate-600">데이터 수집 및 연결 기술의 혁신성을 인정받아 핀테크 리딩 기업으로 도약.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2016 -->
|
||||
<div class="relative group">
|
||||
<div class="timeline-dot bg-slate-400 border-white shadow-sm ml-6 top-1"></div>
|
||||
<div class="bg-slate-50 hover:bg-slate-100 p-4 rounded-lg transition-colors">
|
||||
<span class="text-slate-500 font-bold text-sm block mb-1">2016</span>
|
||||
<h4 class="text-lg font-bold text-slate-800">API 스토어 '쿠콘닷넷' 오픈</h4>
|
||||
<p class="text-sm text-slate-600">국내 최대 비즈니스 정보 API 스토어 런칭. B2B 데이터 유통 시장을 개척했습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2006 -->
|
||||
<div class="relative group">
|
||||
<div class="timeline-dot bg-slate-400 border-white shadow-sm ml-6 top-1"></div>
|
||||
<div class="bg-slate-50 hover:bg-slate-100 p-4 rounded-lg transition-colors">
|
||||
<span class="text-slate-500 font-bold text-sm block mb-1">2006</span>
|
||||
<h4 class="text-lg font-bold text-slate-800">회사 설립</h4>
|
||||
<p class="text-sm text-slate-600">데이터 정보 수집 기술을 기반으로 비즈니스 시작.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="bg-slate-800 text-slate-300 py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 text-center">
|
||||
<p class="text-sm">© 2024 Coocon Corporate Analysis SPA. Based on Publicly Available Data.</p>
|
||||
<p class="text-xs text-slate-500 mt-2">본 페이지는 기업 분석 목적의 시각화 자료이며, 실제 투자 지표로 활용되기 위해서는 최신 공시 자료를 확인하시기 바랍니다.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Data Store (Simulated based on historical trends of Coocon)
|
||||
const cooconData = {
|
||||
financials: {
|
||||
years: ['2019', '2020', '2021', '2022', '2023'],
|
||||
revenue: [412, 514, 613, 658, 685], // Unit: 억 원 (Approximate)
|
||||
profit: [62, 112, 168, 195, 188] // Unit: 억 원 (Approximate)
|
||||
},
|
||||
revenueMix: {
|
||||
labels: ['데이터 부문 (API)', '페이먼트 부문', '기타'],
|
||||
data: [48, 45, 7], // 2020-21 era split roughly
|
||||
colors: ['#2563eb', '#94a3b8', '#e2e8f0']
|
||||
},
|
||||
employees: {
|
||||
years: ['2019', '2020', '2021', '2022', '2023'],
|
||||
count: [180, 210, 260, 295, 310] // Approximate growth
|
||||
}
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
const metricRev = document.getElementById('metric-revenue');
|
||||
const metricProf = document.getElementById('metric-profit');
|
||||
const totalEmp = document.getElementById('total-employees');
|
||||
|
||||
// Init State
|
||||
let currentSection = 'overview';
|
||||
|
||||
// Helper: Format Currency
|
||||
const formatCurrency = (num) => `₩${num}억`;
|
||||
|
||||
// Interaction: Scroll
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' });
|
||||
updateNav(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction: Update Nav Active State
|
||||
function updateNav(id) {
|
||||
document.querySelectorAll('.nav-item').forEach(btn => {
|
||||
btn.classList.remove('active', 'text-blue-600');
|
||||
btn.classList.add('text-slate-600');
|
||||
if (btn.getAttribute('onclick').includes(id)) {
|
||||
btn.classList.add('active', 'text-blue-600');
|
||||
btn.classList.remove('text-slate-600');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Interaction: Highlight API Stage
|
||||
window.highlightStage = function(stageNum) {
|
||||
// Visual feedback could be added here, e.g., filtering the chart to show only that era's data
|
||||
// For now, we utilize the hover CSS effects in Tailwind
|
||||
console.log("Exploring Stage: " + stageNum);
|
||||
}
|
||||
|
||||
// Chart 1: Financials (Bar + Line)
|
||||
function renderFinancialChart() {
|
||||
const ctx = document.getElementById('financialChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: cooconData.financials.years,
|
||||
datasets: [
|
||||
{
|
||||
label: '영업이익 (억 원)',
|
||||
data: cooconData.financials.profit,
|
||||
type: 'line',
|
||||
borderColor: '#0d9488', // Teal
|
||||
backgroundColor: '#0d9488',
|
||||
borderWidth: 3,
|
||||
yAxisID: 'y1',
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: '매출액 (억 원)',
|
||||
data: cooconData.financials.revenue,
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.7)', // Blue
|
||||
yAxisID: 'y',
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.dataset.label + ': ' + context.raw + '억';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: '매출액' },
|
||||
grid: { display: false }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '영업이익' },
|
||||
grid: { drawOnChartArea: true, color: '#f1f5f9' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update Metrics Counters
|
||||
const lastIdx = cooconData.financials.revenue.length - 1;
|
||||
metricRev.innerText = formatCurrency(cooconData.financials.revenue[lastIdx]);
|
||||
|
||||
// Calc Margin
|
||||
const margin = ((cooconData.financials.profit[lastIdx] / cooconData.financials.revenue[lastIdx]) * 100).toFixed(1);
|
||||
metricProf.innerText = margin + '%';
|
||||
}
|
||||
|
||||
// Chart 2: Revenue Mix (Doughnut)
|
||||
function renderMixChart() {
|
||||
const ctx = document.getElementById('revenueMixChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: cooconData.revenueMix.labels,
|
||||
datasets: [{
|
||||
data: cooconData.revenueMix.data,
|
||||
backgroundColor: cooconData.revenueMix.colors,
|
||||
hoverOffset: 10,
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { boxWidth: 15, padding: 20 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return ' ' + context.label + ': ' + context.raw + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cutout: '65%'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Chart 3: Employees (Bar)
|
||||
function renderEmpChart() {
|
||||
const ctx = document.getElementById('employeeChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: cooconData.employees.years,
|
||||
datasets: [{
|
||||
label: '임직원 수 (명)',
|
||||
data: cooconData.employees.count,
|
||||
backgroundColor: 'rgba(71, 85, 105, 0.6)', // Slate
|
||||
hoverBackgroundColor: '#2563eb',
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: false, min: 100 }
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
totalEmp.innerText = cooconData.employees.count[cooconData.employees.count.length - 1] + '명';
|
||||
}
|
||||
|
||||
// Initialization
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
renderFinancialChart();
|
||||
renderMixChart();
|
||||
renderEmpChart();
|
||||
|
||||
// Scroll Spy
|
||||
window.addEventListener('scroll', () => {
|
||||
const sections = ['overview', 'api-growth', 'employees', 'history'];
|
||||
let current = '';
|
||||
sections.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= 150) {
|
||||
current = id;
|
||||
}
|
||||
});
|
||||
if (current) updateNav(current);
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
corp/css_style.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# CSS 및 디자인 가이드라인 (Tone and Manner)
|
||||
|
||||
이 문서는 `kodata.php` 파일에 적용된 디자인 철학과 스타일 표준을 정의합니다. 향후 프로젝트의 일관성을 유지하기 위한 지침으로 활용합니다.
|
||||
|
||||
## 1. 디자인 철학 (Core Philosophy)
|
||||
|
||||
- **주제**: Professional Trust (신뢰 기반의 전문성)
|
||||
- **분위기**: 깔끔함, 신뢰감, 데이터 중심적인 명확성
|
||||
- **핵심 키워드**: Teal, Navy, White, Light Gray, Modern Typography
|
||||
|
||||
## 2. 색상 사양 (Color Palette)
|
||||
|
||||
### 기본 색상 (Brand Colors)
|
||||
|
||||
- **Primary (Teal)**: `#0d9488` (Tailwind: `teal-600`) - 핵심 강조 색상, 활성화 상태.
|
||||
- **Secondary (Navy/Slate)**: `#1e293b` (Tailwind: `slate-800`) - 제목, 텍스트, 신뢰감을 주는 배경.
|
||||
- **Accent (Amber)**: `#f59e0b` (Tailwind: `amber-500`) - 차트의 추세선 등 보조 강조.
|
||||
|
||||
### 배경 및 테두리 (Backgrounds & Borders)
|
||||
|
||||
- **Body Background**: `#f3f4f6` (Tailwind: `gray-100`) - 부드러운 중립 톤의 연회색.
|
||||
- **Container Background**: `#ffffff` (White) - 기본 카드 및 섹션 배경.
|
||||
- **Border**: `#e2e8f0` (Tailwind: `slate-200`) - 최소한의 구분선.
|
||||
|
||||
## 3. 타이포그래피 (Typography)
|
||||
|
||||
- **Font Family**: `'Noto Sans KR', sans-serif` (Google Fonts)
|
||||
- **Heading Styles**:
|
||||
- Main Title: `text-2xl font-bold text-slate-800`
|
||||
- Sub Title: `text-lg font-bold text-slate-700`
|
||||
- **Body Text**: `text-slate-600 leading-relaxed`
|
||||
|
||||
## 4. UI 컴포넌트 표준 (UI Components)
|
||||
|
||||
### 상단 네비게이션 (Sticky Header)
|
||||
|
||||
- 하얀색 배경(`bg-white`)에 그림자(`shadow-sm`)를 적용하고 상단 고정(`sticky top-0`).
|
||||
- 활성화된 탭(`nav-active`): 하단에 3px 두께의 Teal 테두리 적용.
|
||||
|
||||
### 정보 카드 (Information Cards)
|
||||
|
||||
- 사각 모서리 둥글게(`rounded-xl`), 가벼운 그림자(`shadow-sm`).
|
||||
- 호버 시 효과: `hover:shadow-md transition-shadow`.
|
||||
- 왼쪽 테두리 강조: 특정 카드는 `border-l-4 border-teal-600`으로 시각적 계층 부여.
|
||||
|
||||
### 차트 (Charts)
|
||||
|
||||
- **라이브러리**: Chart.js 사용.
|
||||
- **스타일**: `max-height: 400px`로 제한하여 화면의 정체성을 유지.
|
||||
- **색상**: Teal(`rgba(13, 148, 136, 0.2)`) 또는 Blue(`rgba(59, 130, 246, 0.2)`) 투명도 적용.
|
||||
|
||||
### 타임라인 (Timeline)
|
||||
|
||||
- 세로선: `border-l-2 border-slate-200`.
|
||||
- 포인트: `bg-teal-600` 원형 포인트.
|
||||
|
||||
## 5. 코딩 규칙 (Coding Rules)
|
||||
|
||||
- **CSS Framework**: Tailwind CSS (CDN 방식 사용).
|
||||
- **SVG 지양**: 최대한 유니코드 이모지(🏢, 📈, 💰)를 사용하여 가볍고 직관적인 UI 구성.
|
||||
- **애니메이션**: `fade-in` 유틸리티를 통한 부드러운 섹션 전환 (`opacity` & `translateY`).
|
||||
- **반응형**: 모바일 우선(Mobile-first) 접근 후 `md:` 접두사를 활용한 데스크탑 레이아웃 최적화.
|
||||
|
||||
## 6. 특이사항
|
||||
|
||||
- **스크롤바 커스텀**: `.custom-scroll` 클래스를 통해 슬림하고 둥근 형태의 스크롤바 디자인 적용.
|
||||
- **인터랙션**: 데이터가 많은 경우 `hidden` 클래스와 JavaScript `switchTab` 함수를 사용하여 SPA(Single Page Application) 느낌의 탭 변환 구현.
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KODATA 기업 분석 보고서</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
@@ -89,10 +94,13 @@
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl mr-2">🏢</span>
|
||||
<h1 class="text-xl font-bold text-slate-800 tracking-tight">KODATA <span class="text-sm font-normal text-slate-500 ml-2">기업 분석 리포트</span></h1>
|
||||
</div>
|
||||
<a href="../index.php" class="flex items-center gap-3 cursor-pointer">
|
||||
<img src="../img/favicon-32x32.png" alt="SAM" class="w-10 h-10 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||||
<span class="text-xs text-slate-500 leading-tight">Smart Automation Management</span>
|
||||
</div>
|
||||
</a>
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<a href="../index.php" class="nav-item px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:text-teal-700 flex items-center">
|
||||
<span class="mr-1">🏠</span> 홈으로
|
||||
|
||||
439
creditreport/index.php
Normal file
@@ -0,0 +1,439 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SAM 기업 신용분석 리포트</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
/* Chart Container Styling */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 300px;
|
||||
max-height: 400px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.chart-container { height: 350px; }
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #f5f5f4; }
|
||||
::-webkit-scrollbar-thumb { background: #d6d3d1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a8a29e; }
|
||||
|
||||
.tab-active { border-bottom: 2px solid #3b82f6; color: #3b82f6; font-weight: 600; }
|
||||
.tab-inactive { color: #78716c; }
|
||||
.tab-inactive:hover { color: #44403c; }
|
||||
|
||||
.traffic-light { width: 20px; height: 20px; border-radius: 50%; display: inline-block; }
|
||||
.light-red { background-color: #ef4444; box-shadow: 0 0 10px #ef4444; }
|
||||
.light-orange { background-color: #f97316; box-shadow: 0 0 10px #f97316; }
|
||||
.light-yellow { background-color: #eab308; box-shadow: 0 0 10px #eab308; }
|
||||
.light-green { background-color: #22c55e; box-shadow: 0 0 10px #22c55e; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-stone-50 text-stone-800 font-sans antialiased selection:bg-blue-100">
|
||||
|
||||
<!-- Navigation / Header -->
|
||||
<header class="bg-white border-b border-stone-200 sticky top-0 z-50">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<a href="../index.php" class="flex items-center gap-3 cursor-pointer">
|
||||
<img src="../img/favicon-32x32.png" alt="SAM" class="w-10 h-10 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||||
<span class="text-xs text-slate-500 leading-tight">Smart Automation Management</span>
|
||||
</div>
|
||||
</a>
|
||||
<nav class="hidden md:flex space-x-8">
|
||||
<a href="#summary" class="text-stone-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition">종합 상태</a>
|
||||
<a href="#analysis" class="text-stone-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition">리스크 분석</a>
|
||||
<a href="#details" class="text-stone-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition">상세 이력</a>
|
||||
<a href="#simulator" class="text-stone-600 hover:text-blue-600 px-3 py-2 text-sm font-medium transition">한도 시뮬레이터</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
<div class="text-center mb-16">
|
||||
<div class="inline-flex items-center gap-2 bg-blue-50 text-blue-600 px-4 py-1.5 rounded-full text-sm font-semibold mb-6 border border-blue-100">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
||||
</span>
|
||||
실시간 연동: Coocon API (v2.4)
|
||||
</div>
|
||||
<h2 class="text-4xl font-extrabold text-stone-900 mb-4">(주)가상테크놀로지 기업 신용 분석</h2>
|
||||
<p class="text-lg text-stone-600 max-w-3xl mx-auto leading-relaxed">
|
||||
ERP 데이터를 기반으로 거래처의 <strong>부실 여부를 선제적으로 판단</strong>합니다.
|
||||
한국신용정보원 및 신용정보사 데이터를 실시간으로 수집하여 종합 신용 등급과 리스크 지표를 제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Summary & Trends -->
|
||||
<section id="summary" class="mb-20 scroll-mt-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
|
||||
|
||||
<!-- Left: Credit Traffic Light -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-stone-100 p-8">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-1">종합 신용 신호등</h3>
|
||||
<p class="text-sm text-stone-500">거래처 건전성 실시간 모니터링</p>
|
||||
</div>
|
||||
<div class="flex gap-2 bg-stone-100 p-2 rounded-full border border-stone-200">
|
||||
<span class="traffic-light light-red opacity-20"></span>
|
||||
<span class="traffic-light light-orange opacity-20"></span>
|
||||
<span class="traffic-light light-yellow"></span>
|
||||
<span class="traffic-light light-green opacity-20"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-6 bg-stone-50 rounded-2xl mb-6 border border-amber-100">
|
||||
<div class="text-5xl font-black text-amber-500 mb-2">주의 (Level 3)</div>
|
||||
<p class="text-stone-600 font-medium">단기 연체 이력 발견 - <span class="text-amber-600 underline">거래 한도 축소</span> 권장</p>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas id="trendChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Risk Profile -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-stone-100 p-8">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold text-stone-900 mb-1">신용 리스크 프로필</h3>
|
||||
<p class="text-sm text-stone-500">5대 핵심 지표 다각도 분석</p>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="radarChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-6 space-y-3">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-stone-500">한국신용정보원 연체</span>
|
||||
<span class="font-bold text-red-500">2건 발생</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-stone-500">금융질서문란 정보</span>
|
||||
<span class="font-bold text-stone-800">해당 없음</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-stone-500">공공기록 정보</span>
|
||||
<span class="font-bold text-amber-600">1건 등록</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2: Detailed Intelligence -->
|
||||
<section id="details" class="mb-20 scroll-mt-20">
|
||||
<div class="mb-8">
|
||||
<h3 class="text-2xl font-bold text-stone-900 text-center">신용 상세 정보 (Coocon API 데이터)</h3>
|
||||
</div>
|
||||
|
||||
<!-- Custom Tabs -->
|
||||
<div class="flex space-x-6 border-b border-stone-200 mb-8 overflow-x-auto justify-center">
|
||||
<button onclick="updateFeatureView('delinquency')" class="tab-btn tab-active pb-3 px-1 transition whitespace-nowrap" id="btn-delinquency">단기연체정보</button>
|
||||
<button onclick="updateFeatureView('judgment')" class="tab-btn tab-inactive pb-3 px-1 transition whitespace-nowrap" id="btn-judgment">신용도판단정보</button>
|
||||
<button onclick="updateFeatureView('reputation')" class="tab-btn tab-inactive pb-3 px-1 transition whitespace-nowrap" id="btn-reputation">법정관리/워크아웃</button>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Content Area -->
|
||||
<div id="feature-content" class="fade-in">
|
||||
<!-- Content injected via JS -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 3: Credit Simulator -->
|
||||
<section id="simulator" class="mb-20 scroll-mt-20 bg-stone-900 rounded-3xl p-8 lg:p-12 text-white">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<div class="lg:col-span-1">
|
||||
<h3 class="text-2xl font-bold mb-4 italic">한도 가이드 시뮬레이션</h3>
|
||||
<p class="text-stone-400 mb-8">
|
||||
거래 규모에 따른 부실 위험도를 예측합니다.
|
||||
</p>
|
||||
|
||||
<div class="mb-8">
|
||||
<label for="userRange" class="block text-sm font-medium text-stone-300 mb-2">예상 거래액: <span id="userCountDisplay" class="text-blue-400 font-bold text-lg">50</span>M KRW</label>
|
||||
<input type="range" id="userRange" min="1" max="500" value="50" class="w-full h-2 bg-stone-700 rounded-lg appearance-none cursor-pointer accent-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 text-stone-900">
|
||||
<div class="bg-white p-4 rounded-xl shadow-sm border-l-4 border-amber-500">
|
||||
<div class="text-xs text-stone-500 uppercase font-semibold">부실 예상 확률</div>
|
||||
<div class="text-2xl font-bold" id="riskProb">12.5%</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-xl shadow-sm border-l-4 border-blue-500">
|
||||
<div class="text-xs text-stone-500 uppercase font-semibold">추천 결제 조건</div>
|
||||
<div class="text-2xl font-bold" id="payTerm">선입금 30%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2 flex flex-col justify-center">
|
||||
<div class="chart-container">
|
||||
<canvas id="pricingChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 4: Approval Engine -->
|
||||
<section id="verdict" class="mb-20 scroll-mt-20">
|
||||
<div class="max-w-4xl mx-auto bg-white border border-stone-200 rounded-2xl shadow-lg overflow-hidden">
|
||||
<div class="bg-blue-600 p-6 text-center">
|
||||
<h3 class="text-2xl font-bold text-white">최종 거래 승인 판정</h3>
|
||||
<p class="text-blue-100 mt-2">정성적 요소를 포함한 통합 의사결정</p>
|
||||
</div>
|
||||
|
||||
<div class="p-8">
|
||||
<div id="quiz-container" class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<p class="font-semibold text-lg text-stone-800">Q1. 해당 업체와 과거에 성공적으로 거래한 이력이 있습니까?</p>
|
||||
<div class="flex gap-4">
|
||||
<button onclick="handleQuiz(1, true)" class="flex-1 py-3 border-2 border-stone-200 rounded-xl hover:border-blue-500 hover:bg-blue-50 transition font-medium">있음</button>
|
||||
<button onclick="handleQuiz(1, false)" class="flex-1 py-3 border-2 border-stone-200 rounded-xl hover:border-blue-500 hover:bg-blue-50 transition font-medium">없음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result-container" class="hidden text-center fade-in">
|
||||
<div id="result-icon" class="mb-6"></div>
|
||||
<h4 id="result-title" class="text-3xl font-bold mb-4 text-stone-900"></h4>
|
||||
<p id="result-desc" class="text-lg text-stone-600 mb-8 max-w-2xl mx-auto"></p>
|
||||
<button onclick="resetQuiz()" class="px-6 py-2 bg-stone-200 text-stone-700 rounded-lg hover:bg-stone-300 transition text-sm font-semibold">데이터 갱신</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="text-center text-stone-400 py-10 border-t border-stone-200">
|
||||
<p class="text-sm">Powered by Coocon Enterprise Intelligence API. Internal Use Only.</p>
|
||||
<p class="text-xs mt-2">SAM System - Credit Analysis Module v2026.1</p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
amount: 50,
|
||||
currentTab: 'delinquency',
|
||||
riskScore: 65 // Base score
|
||||
};
|
||||
|
||||
const featureData = {
|
||||
delinquency: {
|
||||
title: "한국신용정보원 단기연체정보",
|
||||
color: "border-red-100 bg-red-50/30",
|
||||
records: [
|
||||
{ label: "연체 금액", value: "15,400,000 KRW", type: "strong" },
|
||||
{ label: "연체 기간", value: "45일", type: "text" },
|
||||
{ label: "발생 기관", value: "가상국민은행", type: "text" },
|
||||
{ label: "계정과목", value: "일반자금대출 (101)", type: "text" },
|
||||
{ label: "기준일자", value: "2026-01-05", type: "text" }
|
||||
]
|
||||
},
|
||||
judgment: {
|
||||
title: "신용도 판단 정보 (공공기록 등)",
|
||||
color: "border-amber-100 bg-amber-50/30",
|
||||
records: [
|
||||
{ label: "채무불이행 건수", value: "0건", type: "text" },
|
||||
{ label: "공공기록정보", value: "1건 (세금체납)", type: "strong" },
|
||||
{ label: "등록사유명", value: "지방세 체납 (국세청)", type: "text" },
|
||||
{ label: "등록일자", value: "2025-11-20", type: "text" },
|
||||
{ label: "해제코드", value: "해당없음", type: "text" }
|
||||
]
|
||||
},
|
||||
reputation: {
|
||||
title: "법정관리 및 특수 정보",
|
||||
color: "border-stone-200 bg-white",
|
||||
records: [
|
||||
{ label: "당좌거래정지", value: "정상", type: "text" },
|
||||
{ label: "법정관리유형", value: "해당없음", type: "text" },
|
||||
{ label: "워크아웃 여부", value: "정상", type: "text" },
|
||||
{ label: "사건번호", value: "-", type: "text" },
|
||||
{ label: "담당법원", value: "-", type: "text" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initCharts();
|
||||
updateFeatureView('delinquency');
|
||||
updateSimulator();
|
||||
});
|
||||
|
||||
let pricingChart;
|
||||
|
||||
function initCharts() {
|
||||
// Line Chart: Credit Score Trend
|
||||
const ctxTrend = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(ctxTrend, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['10월', '11월', '12월', '1월(현재)'],
|
||||
datasets: [{
|
||||
label: '신용점수 추이',
|
||||
data: [780, 750, 690, 640],
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { min: 400, max: 1000 } }
|
||||
}
|
||||
});
|
||||
|
||||
// Radar Chart: Risk Profile
|
||||
const ctxRadar = document.getElementById('radarChart').getContext('2d');
|
||||
new Chart(ctxRadar, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['유동성', '공공기록', '안정성', '성장성', '연체이력'],
|
||||
datasets: [{
|
||||
label: '현재 기업 리스크',
|
||||
data: [60, 40, 50, 70, 30],
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.2)',
|
||||
borderColor: '#3b82f6',
|
||||
pointBackgroundColor: '#3b82f6'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { r: { suggestedMin: 0, suggestedMax: 100 } }
|
||||
}
|
||||
});
|
||||
|
||||
// Bar Chart: Risk vs Amount
|
||||
const ctxPrice = document.getElementById('pricingChart').getContext('2d');
|
||||
pricingChart = new Chart(ctxPrice, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['담보 부족분', '신용 한도', '예상 리스크액'],
|
||||
datasets: [{
|
||||
data: [15, 30, 8.5],
|
||||
backgroundColor: ['#d1d5db', '#3b82f6', '#ef4444'],
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFeatureView(type) {
|
||||
state.currentTab = type;
|
||||
['delinquency', 'judgment', 'reputation'].forEach(t => {
|
||||
document.getElementById(`btn-${t}`).className = t === type ? "tab-btn tab-active pb-3 px-1 transition whitespace-nowrap" : "tab-btn tab-inactive pb-3 px-1 transition whitespace-nowrap";
|
||||
});
|
||||
|
||||
const data = featureData[type];
|
||||
const contentDiv = document.getElementById('feature-content');
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="max-w-4xl mx-auto rounded-3xl p-6 border-2 ${data.color} transition-all">
|
||||
<h4 class="text-xl font-bold mb-6 flex items-center gap-2">
|
||||
<span class="w-1.5 h-6 bg-stone-900 rounded-full"></span>
|
||||
${data.title}
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
${data.records.map(r => `
|
||||
<div class="flex justify-between p-4 bg-white rounded-xl shadow-sm">
|
||||
<span class="text-stone-500 text-sm">${r.label}</span>
|
||||
<span class="${r.type === 'strong' ? 'font-bold text-red-600' : 'font-semibold'}">${r.value}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const userRange = document.getElementById('userRange');
|
||||
userRange.addEventListener('input', (e) => {
|
||||
state.amount = parseInt(e.target.value);
|
||||
updateSimulator();
|
||||
});
|
||||
|
||||
function updateSimulator() {
|
||||
const amount = state.amount;
|
||||
document.getElementById('userCountDisplay').innerText = amount;
|
||||
|
||||
const prob = (amount * 0.15 + (100 - state.riskScore) * 0.5).toFixed(1);
|
||||
document.getElementById('riskProb').innerText = `${prob}%`;
|
||||
|
||||
let term = "외상 (30일)";
|
||||
if(prob > 20) term = "선입금 50%";
|
||||
if(prob > 40) term = "거래 금지 권고";
|
||||
document.getElementById('payTerm').innerText = term;
|
||||
|
||||
pricingChart.data.datasets[0].data = [amount * 0.3, amount * 0.6, amount * (prob/100)];
|
||||
pricingChart.update();
|
||||
}
|
||||
|
||||
function handleQuiz(step, answerYes) {
|
||||
if (step === 1) {
|
||||
state.riskScore += answerYes ? 15 : -10;
|
||||
document.getElementById('quiz-container').innerHTML = `
|
||||
<div class="space-y-4 fade-in">
|
||||
<p class="font-semibold text-lg text-stone-800">Q2. 대표자의 업계 경력이 10년 이상입니까?</p>
|
||||
<div class="flex gap-4">
|
||||
<button onclick="handleQuiz(2, true)" class="flex-1 py-3 border-2 border-stone-200 rounded-xl hover:border-blue-500 hover:bg-blue-50 transition font-medium">예</button>
|
||||
<button onclick="handleQuiz(2, false)" class="flex-1 py-3 border-2 border-stone-200 rounded-xl hover:border-blue-500 hover:bg-blue-50 transition font-medium">아니오</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (step === 2) {
|
||||
state.riskScore += answerYes ? 10 : -5;
|
||||
showResult();
|
||||
}
|
||||
}
|
||||
|
||||
function showResult() {
|
||||
document.getElementById('quiz-container').classList.add('hidden');
|
||||
const resultDiv = document.getElementById('result-container');
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
const title = document.getElementById('result-title');
|
||||
const desc = document.getElementById('result-desc');
|
||||
const icon = document.getElementById('result-icon');
|
||||
|
||||
if (state.riskScore > 60) {
|
||||
icon.innerHTML = `<span class="bg-blue-600 text-white rounded-2xl px-8 py-3 text-3xl font-bold">승인 권장</span>`;
|
||||
title.innerText = "조건부 거래 승인";
|
||||
desc.innerHTML = `정량적 리스크(연체)가 존재하나, <strong>정성적 신뢰도와 거래 이력</strong>이 보완 가능합니다. 한도 설정 하에 거래를 권장합니다.`;
|
||||
} else {
|
||||
icon.innerHTML = `<span class="bg-red-600 text-white rounded-2xl px-8 py-3 text-3xl font-bold">보류 권장</span>`;
|
||||
title.innerText = "거래 제한 권고";
|
||||
desc.innerHTML = `현재 신용상태가 매우 불안정하며 추가 담보 확보가 필수적입니다. <strong>현금 거래 위주</strong>의 정책 수립을 권고합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetQuiz() {
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
754
debt/index.php
Normal file
@@ -0,0 +1,754 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>채권추심 프로세스 가이드 | SAM SaaS</title>
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { font-family: 'Noto Sans KR', sans-serif; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.nav-active { background-color: #4f46e5; color: white; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
|
||||
.fade-in { animation: fadeIn 0.5s ease-out forwards; }
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.progress-line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50">
|
||||
<!-- Navigation -->
|
||||
<nav class="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-slate-200">
|
||||
<div class="max-w-6xl mx-auto px-6">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<a href="../index.php" class="flex items-center gap-3 cursor-pointer">
|
||||
<img src="../img/favicon-32x32.png" alt="SAM" class="w-10 h-10 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||||
<span class="text-xs text-slate-500 leading-tight">Smart Automation Management</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="../index.php" class="text-sm font-medium text-slate-600 hover:text-indigo-600 transition-colors">홈</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<!-- Header & Dashboard Summary -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 mb-6 fade-in">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-14 h-14 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-200 text-white">
|
||||
<i data-lucide="gavel" class="w-7 h-7"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">채권추심 프로세스 관리</h1>
|
||||
<p class="text-slate-500 text-sm">변호사와 기업 담당자의 실시간 협업 워크플로우</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<div class="px-4 py-2 bg-emerald-50 border border-emerald-100 rounded-lg">
|
||||
<p class="text-[10px] text-emerald-600 font-bold uppercase tracking-wider">회수 성공률</p>
|
||||
<p class="text-xl font-bold text-emerald-700">84.2%</p>
|
||||
</div>
|
||||
<div class="px-4 py-2 bg-indigo-50 border border-indigo-100 rounded-lg">
|
||||
<p class="text-[10px] text-indigo-600 font-bold uppercase tracking-wider">평균 회수 기간</p>
|
||||
<p class="text-xl font-bold text-indigo-700">62일</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Tracking -->
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 mb-6 fade-in" style="animation-delay: 0.1s">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-base font-bold text-slate-700">전체 공정 진행률</span>
|
||||
<span id="progress-text" class="text-lg font-black text-indigo-600">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 rounded-full h-3 overflow-hidden mb-8">
|
||||
<div id="progress-bar" class="bg-gradient-to-r from-indigo-500 to-blue-600 h-full transition-all duration-700 ease-out" style="width: 0%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Specific Progress -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 border-t border-slate-50 pt-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[11px] font-bold text-slate-400 uppercase tracking-tighter">Phase 1. 기초조사</span>
|
||||
<span id="phase1-text" class="text-xs font-bold text-indigo-500">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-50 rounded-full h-1.5 overflow-hidden">
|
||||
<div id="phase1-bar" class="bg-indigo-400 h-full transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[11px] font-bold text-slate-400 uppercase tracking-tighter">Phase 2. 보전처분</span>
|
||||
<span id="phase2-text" class="text-xs font-bold text-amber-500">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-50 rounded-full h-1.5 overflow-hidden">
|
||||
<div id="phase2-bar" class="bg-amber-400 h-full transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[11px] font-bold text-slate-400 uppercase tracking-tighter">Phase 3. 집행권원</span>
|
||||
<span id="phase3-text" class="text-xs font-bold text-blue-500">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-50 rounded-full h-1.5 overflow-hidden">
|
||||
<div id="phase3-bar" class="bg-blue-400 h-full transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[11px] font-bold text-slate-400 uppercase tracking-tighter">Phase 4. 강제집행</span>
|
||||
<span id="phase4-text" class="text-xs font-bold text-emerald-500">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-50 rounded-full h-1.5 overflow-hidden">
|
||||
<div id="phase4-bar" class="bg-emerald-400 h-full transition-all duration-500" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-400 mt-6 text-center italic">* 각 상세 단계의 체크박스를 완료하면 해당 단계 및 전체 진행률에 반영됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- Workflow Tab Navigation -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
<button onclick="showTab('phase1')" id="tab-phase1" class="tab-btn px-6 py-3 rounded-xl font-semibold transition-all whitespace-nowrap nav-active flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-white/20 flex items-center justify-center text-[10px]">1</span>
|
||||
기초조사 및 독촉
|
||||
</button>
|
||||
<button onclick="showTab('phase2')" id="tab-phase2" class="tab-btn px-6 py-3 rounded-xl font-semibold transition-all whitespace-nowrap bg-white text-slate-600 hover:bg-slate-100 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-[10px]">2</span>
|
||||
보전 처분
|
||||
</button>
|
||||
<button onclick="showTab('phase3')" id="tab-phase3" class="tab-btn px-6 py-3 rounded-xl font-semibold transition-all whitespace-nowrap bg-white text-slate-600 hover:bg-slate-100 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-[10px]">3</span>
|
||||
집행권원 확보
|
||||
</button>
|
||||
<button onclick="showTab('phase4')" id="tab-phase4" class="tab-btn px-6 py-3 rounded-xl font-semibold transition-all whitespace-nowrap bg-white text-slate-600 hover:bg-slate-100 flex items-center gap-2">
|
||||
<span class="w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-[10px]">4</span>
|
||||
강제집행 및 회수
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content Area -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 min-h-[500px]">
|
||||
|
||||
<!-- Phase 1 Content -->
|
||||
<div id="phase1" class="tab-content active space-y-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-bold text-slate-800">Phase 1. 기초 조사 및 독촉 (Preparation)</h2>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-bold flex items-center gap-1">
|
||||
<i data-lucide="clock" size="12"></i> 1~2주 소요
|
||||
</span>
|
||||
<span class="px-2.5 py-1 bg-indigo-100 text-indigo-700 rounded-full text-xs font-bold">목표: 기초 조사</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th class="p-4 text-left font-semibold text-slate-600 w-12">체크</th>
|
||||
<th class="p-4 text-left font-semibold text-slate-600">세부 항목</th>
|
||||
<th class="p-4 text-center font-semibold text-slate-600">담당</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">원인 서류 확보</span><br><span class="text-xs text-slate-500">계약서, 세금계산서, 거래명세서 등</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-1 bg-blue-50 text-blue-600 rounded text-[11px] font-bold">기업</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">채무자 소재 조사</span><br><span class="text-xs text-slate-500">사업자 등록증, 휴/폐업 여부 확인</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-1 bg-indigo-50 text-indigo-600 rounded text-[11px] font-bold">변호사</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">신용 상태 조사</span><br><span class="text-xs text-slate-500">주거래은행 및 신용 등급 파악</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-1 bg-indigo-50 text-indigo-600 rounded text-[11px] font-bold">변호사</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">내용증명 발송</span><br><span class="text-xs text-slate-500">최후 독촉 및 증거력 확보</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-1 bg-indigo-50 text-indigo-600 rounded text-[11px] font-bold">변호사</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 2 Content -->
|
||||
<div id="phase2" class="tab-content space-y-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-bold text-slate-800">Phase 2. 보전 처분 (Provisional Attachment)</h2>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-bold flex items-center gap-1">
|
||||
<i data-lucide="clock" size="12"></i> 1~2주 소요
|
||||
</span>
|
||||
<span class="px-2.5 py-1 bg-amber-100 text-amber-700 rounded-full text-xs font-bold">목표: 자산 은닉 방지</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th class="p-4 text-left font-semibold text-slate-600 w-12">체크</th>
|
||||
<th class="p-4 text-left font-semibold text-slate-600">절차 항목</th>
|
||||
<th class="p-4 text-center font-semibold text-slate-600">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">가압류 대상 목적물 특정</span><br><span class="text-xs text-slate-500">부동산, 채권(은행), 유체동산 등</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold">변호사</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">가압류 신청서 접수</span><br><span class="text-xs text-slate-500">관할 법원 전자 접수</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold">변호사</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">공탁금 납부 (현금/보증보험)</span><br><span class="text-xs text-slate-500">법원 결정에 따른 담보 제공</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[10px] font-bold">기업</span></td>
|
||||
</tr>
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="p-4 text-center"><input type="checkbox" onchange="updateProgress()" class="step-checkbox w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"></td>
|
||||
<td class="p-4"><span class="font-medium text-slate-800">가압류 결정문 수령</span><br><span class="text-xs text-slate-500">결정 통지 및 효력 발생 확인</span></td>
|
||||
<td class="p-4 text-center"><span class="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold">변호사</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3 Content -->
|
||||
<div id="phase3" class="tab-content space-y-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-bold text-slate-800">Phase 3. 집행권원 확보 (Litigation)</h2>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-bold flex items-center gap-1">
|
||||
<i data-lucide="clock" size="12"></i> 2주~1년 소요
|
||||
</span>
|
||||
<span class="px-2.5 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-bold">목표: 법적 권리 확정</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="p-5 border rounded-2xl hover:border-indigo-300 transition-all group relative">
|
||||
<div class="absolute top-4 right-4">
|
||||
<input type="checkbox" onchange="updateProgress()" class="step-checkbox w-5 h-5 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500">
|
||||
</div>
|
||||
<h3 class="font-bold text-slate-800 mb-1 flex items-center gap-2">
|
||||
<i data-lucide="Zap" size="18" class="text-amber-500"></i>
|
||||
지급명령 신청
|
||||
</h3>
|
||||
<div class="flex items-center gap-1 text-[10px] text-amber-600 font-bold mb-2">
|
||||
<i data-lucide="timer" size="10"></i> 평균 2~4주 소요
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mb-4 pr-8 leading-relaxed">채무자가 빚을 인정할 때 유리. 가장 신속한 집행권원 확보 방법.</p>
|
||||
<div class="flex items-center gap-2 text-[10px] font-bold text-indigo-600 uppercase tracking-wider">
|
||||
<span class="px-2 py-0.5 bg-indigo-50 rounded">변호사 담당</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 border rounded-2xl hover:border-indigo-300 transition-all group relative">
|
||||
<div class="absolute top-4 right-4">
|
||||
<input type="checkbox" onchange="updateProgress()" class="step-checkbox w-5 h-5 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500">
|
||||
</div>
|
||||
<h3 class="font-bold text-slate-800 mb-1 flex items-center gap-2">
|
||||
<i data-lucide="Scale" size="18" class="text-slate-600"></i>
|
||||
민사소송 접수
|
||||
</h3>
|
||||
<div class="flex items-center gap-1 text-[10px] text-slate-500 font-bold mb-2">
|
||||
<i data-lucide="timer" size="10"></i> 약 6~12개월 소요
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mb-4 pr-8 leading-relaxed">채권채무 다툼이 있거나 지급명령 이의신청 시 본안 소송 진행.</p>
|
||||
<div class="flex items-center gap-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<span class="px-2 py-0.5 bg-slate-100 rounded">변호사 담당</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 4 Content -->
|
||||
<div id="phase4" class="tab-content space-y-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-bold text-slate-800">Phase 4. 강제 집행 및 회수 (Execution)</h2>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-bold flex items-center gap-1">
|
||||
<i data-lucide="clock" size="12"></i> 1~3개월 소요
|
||||
</span>
|
||||
<span class="px-2.5 py-1 bg-emerald-100 text-emerald-700 rounded-full text-xs font-bold">목표: 실제 회수 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="p-4 bg-white border border-slate-200 rounded-2xl flex items-center justify-between hover:border-indigo-200 transition-colors">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="checkbox" onchange="updateProgress()" class="step-checkbox w-5 h-5 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<h4 class="text-sm font-bold text-slate-800">재산 명시 및 재산 조회 신청</h4>
|
||||
<span class="text-[10px] font-medium text-slate-400 bg-slate-50 px-1.5 rounded">1~2개월</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500">법원을 통해 채무자의 숨겨진 재산을 찾아내는 단계</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden md:block text-[10px] font-bold text-slate-400">Phase 4-1</span>
|
||||
</div>
|
||||
<div class="p-4 bg-white border border-slate-200 rounded-2xl flex items-center justify-between hover:border-indigo-200 transition-colors">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="checkbox" onchange="updateProgress()" class="step-checkbox w-5 h-5 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<h4 class="text-sm font-bold text-slate-800">강제 집행 (압류/경매) 실시</h4>
|
||||
<span class="text-[10px] font-medium text-slate-400 bg-slate-50 px-1.5 rounded">집행 대상별 상이</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500">통장 압류, 유체동산 압류(빨간딱지), 부동산 경매 등</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hidden md:block text-[10px] font-bold text-slate-400">Phase 4-2</span>
|
||||
</div>
|
||||
<div class="p-4 bg-emerald-50 border border-emerald-100 rounded-2xl flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="checkbox" onchange="updateProgress()" class="step-checkbox w-5 h-5 text-emerald-600 rounded border-slate-300 focus:ring-emerald-500">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-emerald-800">회수 완료 및 성공보수 정산</h4>
|
||||
<p class="text-[11px] text-emerald-600">실제 금액 입금 확인 후 사건 최종 종결</p>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="Party-Popper" class="w-5 h-5 text-emerald-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Content -->
|
||||
<div class="space-y-6">
|
||||
<!-- Financial Summary Card -->
|
||||
<div class="bg-slate-900 rounded-3xl p-6 text-white shadow-xl shadow-slate-200 fade-in" style="animation-delay: 0.3s">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center text-indigo-400">
|
||||
<i data-lucide="Credit-Card" size="22"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold">채무 및 상환 현황</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Amount Info -->
|
||||
<div class="p-4 bg-white/5 rounded-2xl border border-white/10">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-xs text-slate-400">총 청구 금액 (원금)</span>
|
||||
<span class="text-sm font-bold">50,000,000원</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-slate-400">법정 이율 (연 12%)</span>
|
||||
<span class="text-sm font-bold text-amber-400">+ 6,000,000원</span>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-white/10 flex justify-between items-center">
|
||||
<span class="text-sm font-bold">최종 회수 목표액</span>
|
||||
<span class="text-lg font-black text-indigo-400">56,000,000원</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repayment Plan -->
|
||||
<div class="space-y-4">
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest pl-1">상환 방식 및 시작일 설정</label>
|
||||
|
||||
<!-- Start Date Selection -->
|
||||
<div class="p-3 bg-white/5 rounded-xl border border-white/10 flex items-center justify-between group">
|
||||
<span class="text-[11px] text-slate-400">회수 시작일</span>
|
||||
<input type="date" id="recovery-start-date" onchange="generateSchedule()"
|
||||
class="bg-transparent text-xs font-bold text-indigo-400 border-none outline-none focus:ring-0 p-0 text-right w-28">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button onclick="setPaymentType('full')" id="btn-pay-full" class="p-3 bg-white/5 rounded-xl border border-white/10 flex flex-col items-center gap-1 group transition-all hover:bg-white/10">
|
||||
<span class="text-[11px] text-slate-400">일시납</span>
|
||||
<span class="text-xs font-bold font-mono">FULL PAID</span>
|
||||
</button>
|
||||
<button onclick="setPaymentType('split')" id="btn-pay-split" class="p-3 bg-indigo-500/20 rounded-xl border border-indigo-500/30 flex flex-col items-center gap-1 transition-all">
|
||||
<span class="text-[11px] text-indigo-300">분할 납부</span>
|
||||
<span class="text-xs font-bold font-mono text-white" id="display-months">12회 분할</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Months Slider (Visible when split is selected) -->
|
||||
<div id="slider-container" class="space-y-2 px-1">
|
||||
<div class="flex justify-between text-[10px] text-slate-500 font-bold">
|
||||
<span>분할 기간 설정</span>
|
||||
<span id="slider-val" class="text-indigo-400">12개월</span>
|
||||
</div>
|
||||
<input type="range" id="months-slider" min="2" max="36" value="12" oninput="updateMonths(this.value)"
|
||||
class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500">
|
||||
<div class="flex justify-between text-[9px] text-slate-600">
|
||||
<span>2개월</span>
|
||||
<span>36개월</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Repayment (Visible when split is selected) -->
|
||||
<div id="monthly-info" class="p-4 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-slate-400">월 예상 상환액</span>
|
||||
<span id="monthly-amount" class="text-sm font-bold text-indigo-300">4,666,667원</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repayment Schedule List (Dynamic) -->
|
||||
<div id="schedule-container" class="space-y-2 mt-4 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar hidden">
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest pl-1">납부 일정 체크리스트</label>
|
||||
<div id="schedule-list" class="space-y-2">
|
||||
<!-- Dynamic items here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settlement Options -->
|
||||
<div class="p-4 bg-amber-500/5 rounded-2xl border border-amber-500/20">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i data-lucide="Handshake" size="14" class="text-amber-400"></i>
|
||||
<span class="text-xs font-bold text-amber-200">할인 및 합의 처리</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] text-slate-400 leading-tight">합의 목표가: <span id="settlement-target-display">51,000,000</span>원<br>수수료 면제 협의 가능</span>
|
||||
<button onclick="openSettlementModal()" class="px-3 py-1.5 bg-amber-500/20 text-amber-400 text-[10px] font-bold rounded-lg hover:bg-amber-500/30 transition-colors">
|
||||
합의안 작성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-white/10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-slate-400">현재 회수 진행 상태</span>
|
||||
<span class="text-xs font-bold text-emerald-400 px-2 py-0.5 bg-emerald-500/10 rounded-full">정상 상환 중</span>
|
||||
</div>
|
||||
<div class="w-full bg-white/5 rounded-full h-1.5 overflow-hidden">
|
||||
<div class="bg-emerald-500 w-[45%] h-full rounded-full shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Helper Card -->
|
||||
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 fade-in" style="animation-delay: 0.4s">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-500">
|
||||
<i data-lucide="Help-Circle" size="22"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-slate-800">도움말</h3>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 leading-relaxed mb-6">
|
||||
설정된 상환 계획은 법적 효력이 있는 합의서를 기반으로 합니다. 분할 납부 지연 시 <span class="font-bold text-rose-500">기한의 이익</span>이 상실되어 즉시 강제집행 절차로 전환될 수 있습니다.
|
||||
</p>
|
||||
<button class="w-full py-3 border border-slate-200 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2">
|
||||
채무 합의서 양식 다운로드
|
||||
<i data-lucide="Download" size="14"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quote Card -->
|
||||
<div class="bg-slate-50 rounded-3xl p-6 border border-slate-100 fade-in" style="animation-delay: 0.5s">
|
||||
<p class="text-sm text-slate-600 italic text-center leading-relaxed">
|
||||
"성공적인 채권 회수는 철저한<br>
|
||||
<span class="font-bold text-indigo-600">심리적 전술</span>과 <span class="font-bold text-indigo-600">법적 타이밍</span>의 결합입니다."
|
||||
</p>
|
||||
<div class="mt-4 flex items-center justify-center gap-2">
|
||||
<div class="w-6 h-6 bg-indigo-100 rounded-full"></div>
|
||||
<span class="text-xs font-bold text-slate-400">Antigravity Advisor</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-[11px] text-slate-400">© 2026 SAM SaaS System. For Legal Professional Collaboration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settlement Modal -->
|
||||
<div id="settlementModal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-3xl w-full max-w-md shadow-2xl overflow-hidden fade-in">
|
||||
<div class="p-6 border-b flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold text-slate-800">채무 합의안 작성</h3>
|
||||
<button onclick="closeSettlementModal()" class="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<i data-lucide="X" size="24"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">현재 최종 목표액</label>
|
||||
<div class="text-2xl font-black text-slate-900">56,000,000원</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-indigo-600 uppercase tracking-wider mb-2">합의 제안 금액 (원)</label>
|
||||
<input type="number" id="settlement-input" value="51000000"
|
||||
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-lg font-bold focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all">
|
||||
<p class="mt-2 text-[11px] text-slate-400 leading-relaxed">
|
||||
* 합의 금액 수정 시 원금 대비 감면율이 자동으로 계산됩니다.<br>
|
||||
* 변호사 검토 후 최종 합의안이 발송됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-amber-50 border border-amber-100 p-4 rounded-2xl flex items-start gap-3">
|
||||
<i data-lucide="Alert-Triangle" class="text-amber-500 mt-1 flex-shrink-0" size="18"></i>
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
합의 처리 시 법정 이율 전액 감면 및 원금의 일부가 조정될 수 있습니다. 신중하게 결정해 주시기 바랍니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 bg-slate-50 flex gap-3">
|
||||
<button onclick="closeSettlementModal()" class="flex-1 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-100 transition-colors">취소</button>
|
||||
<button onclick="saveSettlement()" class="flex-1 py-3 bg-indigo-600 text-white rounded-xl text-sm font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-colors">합의안 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Lucide 아이콘 렌더링
|
||||
lucide.createIcons();
|
||||
|
||||
let currentPaymentType = 'full';
|
||||
let currentMonths = 12;
|
||||
const totalAmount = 56000000;
|
||||
|
||||
function setPaymentType(type) {
|
||||
currentPaymentType = type;
|
||||
const btnFull = document.getElementById('btn-pay-full');
|
||||
const btnSplit = document.getElementById('btn-pay-split');
|
||||
const sliderCont = document.getElementById('slider-container');
|
||||
const monthlyInfo = document.getElementById('monthly-info');
|
||||
|
||||
if (type === 'full') {
|
||||
btnFull.classList.remove('bg-white/5', 'border-white/10');
|
||||
btnFull.classList.add('bg-indigo-500/20', 'border-indigo-500/30');
|
||||
btnFull.querySelector('span:first-child').classList.replace('text-slate-400', 'text-indigo-300');
|
||||
|
||||
btnSplit.classList.add('bg-white/5', 'border-white/10');
|
||||
btnSplit.classList.remove('bg-indigo-500/20', 'border-indigo-500/30');
|
||||
btnSplit.querySelector('span:first-child').classList.replace('text-indigo-300', 'text-slate-400');
|
||||
btnSplit.querySelector('#display-months').classList.replace('text-white', 'text-slate-400');
|
||||
|
||||
sliderCont.classList.add('opacity-30', 'pointer-events-none');
|
||||
monthlyInfo.classList.add('hidden');
|
||||
} else {
|
||||
btnSplit.classList.remove('bg-white/5', 'border-white/10');
|
||||
btnSplit.classList.add('bg-indigo-500/20', 'border-indigo-500/30');
|
||||
btnSplit.querySelector('span:first-child').classList.replace('text-slate-400', 'text-indigo-300');
|
||||
btnSplit.querySelector('#display-months').classList.replace('text-slate-400', 'text-white');
|
||||
|
||||
btnFull.classList.add('bg-white/5', 'border-white/10');
|
||||
btnFull.classList.remove('bg-indigo-500/20', 'border-indigo-500/30');
|
||||
btnFull.querySelector('span:first-child').classList.replace('text-indigo-300', 'text-slate-400');
|
||||
|
||||
sliderCont.classList.remove('opacity-30', 'pointer-events-none');
|
||||
monthlyInfo.classList.remove('hidden');
|
||||
}
|
||||
calculateRepayment();
|
||||
}
|
||||
|
||||
function updateMonths(val) {
|
||||
currentMonths = val;
|
||||
document.getElementById('slider-val').innerText = val + '개월';
|
||||
document.getElementById('display-months').innerText = val + '회 분할';
|
||||
calculateRepayment();
|
||||
}
|
||||
|
||||
function generateSchedule() {
|
||||
const list = document.getElementById('schedule-list');
|
||||
const container = document.getElementById('schedule-container');
|
||||
const startDateInput = document.getElementById('recovery-start-date');
|
||||
|
||||
if (!startDateInput.value) {
|
||||
const today = new Date();
|
||||
startDateInput.value = today.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
container.classList.remove('hidden');
|
||||
list.innerHTML = '';
|
||||
|
||||
const baseDate = new Date(startDateInput.value);
|
||||
|
||||
if (currentPaymentType === 'full') {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'p-4 bg-indigo-500/10 rounded-2xl border border-indigo-500/20 flex items-center justify-between group fade-in';
|
||||
item.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" onchange="toggleScheduleRow(this)" class="w-5 h-5 rounded border-indigo-400/30 bg-transparent text-indigo-500 focus:ring-indigo-500">
|
||||
<div>
|
||||
<div class="text-[10px] font-bold text-indigo-300 uppercase tracking-widest">일시납 총액</div>
|
||||
<div class="text-sm font-black text-white">${totalAmount.toLocaleString()}원</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[11px] text-indigo-200 font-mono font-bold">${startDateInput.value}</div>
|
||||
<div class="text-[10px] text-indigo-400 font-bold status-label">미납</div>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const monthly = Math.round(totalAmount / currentMonths);
|
||||
|
||||
for (let i = 1; i <= currentMonths; i++) {
|
||||
const dueDate = new Date(baseDate);
|
||||
dueDate.setMonth(baseDate.getMonth() + (i - 1)); // i-1 because first payment is usually on start date or 1 month after. Let's make it 1 month after as per standard.
|
||||
dueDate.setMonth(baseDate.getMonth() + i);
|
||||
|
||||
const dateStr = dueDate.toISOString().split('T')[0];
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'p-3 bg-white/5 rounded-xl border border-white/5 flex items-center justify-between group hover:bg-white/10 transition-all';
|
||||
item.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" onchange="toggleScheduleRow(this)" class="w-4 h-4 rounded border-white/20 bg-transparent text-indigo-500 focus:ring-indigo-500">
|
||||
<div>
|
||||
<div class="text-[10px] font-bold text-slate-400">${i}회차</div>
|
||||
<div class="text-xs font-mono text-white">${monthly.toLocaleString()}원</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-[10px] text-slate-400 font-mono">${dateStr}</div>
|
||||
<div class="text-[9px] text-slate-600 font-bold status-label">미납</div>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScheduleRow(cb) {
|
||||
const row = cb.closest('.group');
|
||||
const statusLabel = row.querySelector('.status-label');
|
||||
if (cb.checked) {
|
||||
row.classList.add('bg-emerald-500/10', 'border-emerald-500/20');
|
||||
row.classList.remove('bg-white/5', 'border-white/5');
|
||||
statusLabel.innerText = '입금완료';
|
||||
statusLabel.classList.add('text-emerald-400');
|
||||
statusLabel.classList.remove('text-slate-600');
|
||||
} else {
|
||||
row.classList.remove('bg-emerald-500/10', 'border-emerald-500/20');
|
||||
row.classList.add('bg-white/5', 'border-white/5');
|
||||
statusLabel.innerText = '미납';
|
||||
statusLabel.classList.remove('text-emerald-400');
|
||||
statusLabel.classList.add('text-slate-600');
|
||||
}
|
||||
}
|
||||
|
||||
function calculateRepayment() {
|
||||
if (currentPaymentType === 'split') {
|
||||
const monthly = Math.round(totalAmount / currentMonths);
|
||||
document.getElementById('monthly-amount').innerText = monthly.toLocaleString() + '원';
|
||||
}
|
||||
generateSchedule();
|
||||
}
|
||||
|
||||
// Modal Functions
|
||||
function openSettlementModal() {
|
||||
document.getElementById('settlementModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeSettlementModal() {
|
||||
document.getElementById('settlementModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function saveSettlement() {
|
||||
const newVal = document.getElementById('settlement-input').value;
|
||||
document.getElementById('settlement-target-display').innerText = parseInt(newVal).toLocaleString();
|
||||
closeSettlementModal();
|
||||
|
||||
// 시각적 효과
|
||||
const targetEl = document.getElementById('settlement-target-display').parentElement;
|
||||
targetEl.classList.add('text-indigo-400', 'scale-110', 'transition-all');
|
||||
setTimeout(() => {
|
||||
targetEl.classList.remove('scale-110');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Tab Management
|
||||
function showTab(tabId) {
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('nav-active');
|
||||
btn.classList.add('bg-white', 'text-slate-600', 'hover:bg-slate-100');
|
||||
const badge = btn.querySelector('span');
|
||||
if (badge) {
|
||||
badge.classList.remove('bg-white/20');
|
||||
badge.classList.add('bg-slate-200');
|
||||
}
|
||||
});
|
||||
|
||||
const activeBtn = document.getElementById('tab-' + tabId);
|
||||
activeBtn.classList.add('nav-active');
|
||||
activeBtn.classList.remove('bg-white', 'text-slate-600', 'hover:bg-slate-100');
|
||||
const activeBadge = activeBtn.querySelector('span');
|
||||
if (activeBadge) {
|
||||
activeBadge.classList.add('bg-white/20');
|
||||
activeBadge.classList.remove('bg-slate-200');
|
||||
}
|
||||
}
|
||||
|
||||
// Progress Tracking Function
|
||||
function updateProgress() {
|
||||
// Overall calculation
|
||||
const allCheckboxes = document.querySelectorAll('.step-checkbox');
|
||||
const total = allCheckboxes.length;
|
||||
const checked = Array.from(allCheckboxes).filter(cb => cb.checked).length;
|
||||
const overallPercentage = total > 0 ? Math.round((checked / total) * 100) : 0;
|
||||
|
||||
document.getElementById('progress-bar').style.width = overallPercentage + '%';
|
||||
document.getElementById('progress-text').innerText = overallPercentage + '%';
|
||||
|
||||
// Individual Phase calculation
|
||||
['phase1', 'phase2', 'phase3', 'phase4'].forEach(phaseId => {
|
||||
const phaseSection = document.getElementById(phaseId);
|
||||
const phaseCheckboxes = phaseSection.querySelectorAll('.step-checkbox');
|
||||
const pTotal = phaseCheckboxes.length;
|
||||
const pChecked = Array.from(phaseCheckboxes).filter(cb => cb.checked).length;
|
||||
const pPercentage = pTotal > 0 ? Math.round((pChecked / pTotal) * 100) : 0;
|
||||
|
||||
const textElem = document.getElementById(phaseId + '-text');
|
||||
const barElem = document.getElementById(phaseId + '-bar');
|
||||
|
||||
if (textElem) textElem.innerText = pPercentage + '%';
|
||||
if (barElem) barElem.style.width = pPercentage + '%';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
window.onload = function() {
|
||||
updateProgress();
|
||||
setPaymentType('full'); // Set Initial UI state
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
74
debt/readme.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 채권 추심 프로세스 관리 시스템 (Legal Presentation Guide)
|
||||
|
||||
본 문서는 변호사 및 법무 전문가를 위해 설계된 **채권 추심 프로세스 대시보드**의 작동 원리와 법적/수학적 계산 로직을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 채권 추심 4단계 프로세스 (Legal Workflow)
|
||||
|
||||
본 시스템은 체계적인 회수를 위해 채권 추심의 전 과정을 4개의 핵심 단계로 구조화하였습니다.
|
||||
|
||||
### Phase 1. 기초 조사 및 독촉 (1~2주)
|
||||
|
||||
- **목적**: 채무자의 상태 파악 및 자발적 이행 유도
|
||||
- **주요 공정**: 신용/재산 조사, 내용증명 발송, 독촉 절차 안내
|
||||
|
||||
### Phase 2. 보전 처분 (2~4주)
|
||||
|
||||
- **목적**: 채무자의 자산 은닉 방지 및 집행 자산 사전 확보
|
||||
- **주요 공정**: 부동산 가압류, 채권 가압류(은행 등), 공탁 절차 관리
|
||||
|
||||
### Phase 3. 집행권원 확보 (3~6개월)
|
||||
|
||||
- **목적**: 강제 집행을 위한 법적 근거(판결문 등) 확보
|
||||
- **주요 공정**: 지급명령 신청, 민사 소송 접수, 조정 및 판결 확인
|
||||
|
||||
### Phase 4. 강제 집행 및 회수 (6~12개월)
|
||||
|
||||
- **목적**: 실제 자산 현금화 및 채권 종료
|
||||
- **주요 공정**: 재산 명시/조회, 유동자산 압류, 경매 신청, 최종 완납 처리
|
||||
|
||||
---
|
||||
|
||||
## 2. 금융 계산 및 상환 시뮬레이션 원리
|
||||
|
||||
대시보드 사이드바의 금융 계산기는 다음과 같은 로직으로 작동합니다.
|
||||
|
||||
### A. 최종 회수 목표액 산정
|
||||
|
||||
- **수식**: `총 청구 금액(원금) + 법정 이율(연 12%) = 최종 회수 목표액`
|
||||
- **설명**: 소송 촉진 등에 관한 특례법에 따른 법정 이율을 기본 적용하며, 기간에 따른 가산 이자를 투명하게 산출합니다.
|
||||
|
||||
### B. 분할 납부 시뮬레이션
|
||||
|
||||
- **수식**: `최종 목표액 ÷ 설정 개월 수 (2~36개월) = 월 예상 상환액`
|
||||
- **설명**: 사용자가 슬라이더를 통해 기간을 조절하면, `Math.round` 함수를 통해 실시간으로 월별 균등 상환액을 계산하여 표시합니다.
|
||||
|
||||
### C. 합의 및 할인 처리 (Settlement)
|
||||
|
||||
- **설명**: 채무자가 조기 상환을 조건으로 원금 감면이나 이자 면제를 요청할 경우, '합의안 작성' 모달을 통해 조정된 목표가를 설정할 수 있습니다. 이는 시스템 내에서 별도의 '합의 목표가'로 관리됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 동적 상환 스케줄링 알고리즘
|
||||
|
||||
분할 납부 선택 시 생성되는 체크리스트의 생성 원리는 다음과 같습니다.
|
||||
|
||||
1. **시작일 기준 (Base Date)**: 사용자가 설정한 '회수 시작일'을 시점으로 잡습니다.
|
||||
2. **월 단위 증분 (Month Increment)**:
|
||||
- `Date.setMonth(BaseDate.getMonth() + n)` 로직을 사용하여 매월 동일한 날짜에 상환 기일이 도래하도록 자동 세팅합니다.
|
||||
3. **회차별 상태 관리**:
|
||||
- 각 회차는 독립적인 입금 여부(Paid/Unpaid)를 체크할 수 있습니다.
|
||||
- 입금 체크 시 해당 회차의 배경색이 변경되며 시각적으로 완료 상태를 기록합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 기대 효과
|
||||
|
||||
- **변호사-기업 간 투명성**: 정기적인 리포트 없이도 기업 담당자가 실시간으로 진행률 및 회수 금액을 확인할 수 있습니다.
|
||||
- **심리적 전술 활용**: 체계적인 스케줄러와 D-Day 알림을 통해 채무자에게 명확한 압박을 가하고 합의를 유도하는 도구로 활용됩니다.
|
||||
- **데이터 자산화**: 단순 추심을 넘어, 회수 패턴과 기간에 대한 데이터를 축적하여 향후 리스크 관리에 활용합니다.
|
||||
|
||||
---
|
||||
|
||||
_Created by Antigravity Advisor System (2026-01)_
|
||||
36
download_db.ps1
Normal file
@@ -0,0 +1,36 @@
|
||||
# 서버에서 chandj 데이터베이스 다운로드 스크립트
|
||||
# 사용법: .\download_db.ps1
|
||||
|
||||
$REMOTE_USER = "pro"
|
||||
$REMOTE_HOST = "114.203.209.83"
|
||||
$DB_NAME = "chandj"
|
||||
$DB_USER = "codebridge" # 서버 DB 사용자 (필요시 변경)
|
||||
$OUTPUT_FILE = "chandj_backup_$(Get-Date -Format 'yyyyMMdd_HHmmss').sql"
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "데이터베이스 덤프 다운로드 시작" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "서버: $REMOTE_USER@$REMOTE_HOST" -ForegroundColor Yellow
|
||||
Write-Host "데이터베이스: $DB_NAME" -ForegroundColor Yellow
|
||||
Write-Host "출력 파일: $OUTPUT_FILE" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# 방법 1: SSH를 통해 직접 mysqldump 실행 (비밀번호 입력 필요)
|
||||
Write-Host "방법 1: SSH를 통해 직접 덤프 다운로드" -ForegroundColor Green
|
||||
Write-Host "명령어 실행 중..." -ForegroundColor Yellow
|
||||
|
||||
# SSH를 통해 mysqldump 실행하고 로컬 파일로 저장
|
||||
ssh "$REMOTE_USER@$REMOTE_HOST" "mysqldump -u $DB_USER -p $DB_NAME" | Out-File -FilePath $OUTPUT_FILE -Encoding UTF8
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "✅ 덤프 다운로드 완료: $OUTPUT_FILE" -ForegroundColor Green
|
||||
Write-Host "파일 크기: $((Get-Item $OUTPUT_FILE).Length / 1MB) MB" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "❌ 덤프 다운로드 실패" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "대안 방법:" -ForegroundColor Yellow
|
||||
Write-Host "1. 서버에 SSH 접속" -ForegroundColor White
|
||||
Write-Host "2. 다음 명령 실행: mysqldump -u $DB_USER -p $DB_NAME > /tmp/chandj_backup.sql" -ForegroundColor White
|
||||
Write-Host "3. SFTP로 /tmp/chandj_backup.sql 파일 다운로드" -ForegroundColor White
|
||||
}
|
||||
|
||||
110
dump_db.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* 서버에서 실행할 데이터베이스 덤프 생성 스크립트
|
||||
* 사용법: php dump_db.php
|
||||
*/
|
||||
|
||||
// .env 파일 로드
|
||||
$envFile = __DIR__ . '/.env';
|
||||
$db_host = 'localhost';
|
||||
$db_name = 'chandj';
|
||||
$db_user = 'root';
|
||||
$db_pass = '';
|
||||
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
||||
list($key, $value) = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
if ($key === 'DB_HOST') {
|
||||
// 서버 환경에서는 mysql 호스트명을 localhost로 변환
|
||||
$db_host = ($value === 'mysql' || strpos($value, 'mysql') !== false) ? 'localhost' : $value;
|
||||
}
|
||||
if ($key === 'DB_NAME') $db_name = $value;
|
||||
if ($key === 'DB_USER') $db_user = $value;
|
||||
if ($key === 'DB_PASS') $db_pass = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== 데이터베이스 덤프 생성 ===\n";
|
||||
echo "DB_HOST: $db_host\n";
|
||||
echo "DB_NAME: $db_name\n";
|
||||
echo "DB_USER: $db_user\n";
|
||||
echo "\n";
|
||||
|
||||
$outputFile = '/tmp/chandj_backup_' . date('Ymd_His') . '.sql';
|
||||
|
||||
// 비밀번호가 없으면 수동 실행 안내
|
||||
if (empty($db_pass)) {
|
||||
echo "⚠️ .env 파일에 DB_PASS가 설정되지 않았습니다.\n";
|
||||
echo "다음 명령을 터미널에서 직접 실행하세요:\n";
|
||||
echo "\n";
|
||||
if ($db_host === 'localhost' || $db_host === '127.0.0.1') {
|
||||
echo " mysqldump -u $db_user -p $db_name > $outputFile\n";
|
||||
} else {
|
||||
echo " mysqldump -h $db_host -u $db_user -p $db_name > $outputFile\n";
|
||||
}
|
||||
echo "\n";
|
||||
echo "비밀번호를 입력하라는 프롬프트가 나타나면 MySQL root 비밀번호를 입력하세요.\n";
|
||||
echo "덤프 파일이 생성되면 다음 명령으로 다운로드하세요:\n";
|
||||
echo " SFTP: Download -> $outputFile\n";
|
||||
echo " 또는: scp pro@114.203.209.83:$outputFile .\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// mysqldump 명령 구성
|
||||
// localhost인 경우 -h 옵션 생략 (소켓 연결 사용)
|
||||
if ($db_host === 'localhost' || $db_host === '127.0.0.1') {
|
||||
// 비밀번호를 환경 변수로 전달 (더 안전)
|
||||
$command = sprintf(
|
||||
'MYSQL_PWD=%s mysqldump -u %s %s > %s 2>&1',
|
||||
escapeshellarg($db_pass),
|
||||
escapeshellarg($db_user),
|
||||
escapeshellarg($db_name),
|
||||
escapeshellarg($outputFile)
|
||||
);
|
||||
$commandDisplay = "mysqldump -u $db_user [비밀번호] $db_name > $outputFile";
|
||||
} else {
|
||||
$command = sprintf(
|
||||
'MYSQL_PWD=%s mysqldump -h %s -u %s %s > %s 2>&1',
|
||||
escapeshellarg($db_pass),
|
||||
escapeshellarg($db_host),
|
||||
escapeshellarg($db_user),
|
||||
escapeshellarg($db_name),
|
||||
escapeshellarg($outputFile)
|
||||
);
|
||||
$commandDisplay = "mysqldump -h $db_host -u $db_user [비밀번호] $db_name > $outputFile";
|
||||
}
|
||||
|
||||
echo "명령 실행 중...\n";
|
||||
echo "\n";
|
||||
|
||||
exec($command, $output, $returnCode);
|
||||
|
||||
if ($returnCode === 0 && file_exists($outputFile)) {
|
||||
$fileSize = filesize($outputFile);
|
||||
echo "✅ 덤프 생성 완료!\n";
|
||||
echo "파일: $outputFile\n";
|
||||
echo "크기: " . number_format($fileSize / 1024 / 1024, 2) . " MB\n";
|
||||
echo "\n";
|
||||
echo "다음 명령으로 다운로드하세요:\n";
|
||||
echo " SFTP: Download -> $outputFile\n";
|
||||
echo " 또는: scp pro@114.203.209.83:$outputFile .\n";
|
||||
} else {
|
||||
echo "❌ 덤프 생성 실패\n";
|
||||
if (!empty($output)) {
|
||||
echo "오류 메시지:\n";
|
||||
echo implode("\n", $output) . "\n";
|
||||
}
|
||||
echo "\n";
|
||||
echo "수동으로 시도해보세요:\n";
|
||||
if ($db_host === 'localhost' || $db_host === '127.0.0.1') {
|
||||
echo " mysqldump -u $db_user -p $db_name > $outputFile\n";
|
||||
} else {
|
||||
echo " mysqldump -h $db_host -u $db_user -p $db_name > $outputFile\n";
|
||||
}
|
||||
}
|
||||
|
||||
223
etc/myoctopus.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Octopus 홍보 영상</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--brand-color: #4f46e5;
|
||||
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-blur: 20px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
background-color: #020617;
|
||||
color: #f8fafc;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: radial-gradient(circle at 0% 0%, #0f172a 0%, #020617 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
animation: float 20s infinite alternate;
|
||||
}
|
||||
.blob-1 { width: 500px; height: 500px; background: #6366f1; top: -100px; right: -100px; }
|
||||
.blob-2 { width: 400px; height: 400px; background: #db2777; bottom: 10%; left: -100px; opacity: 0.3; }
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
100% { transform: translate(40px, 60px) scale(1.1); }
|
||||
}
|
||||
|
||||
.card-glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.card-glass:hover {
|
||||
transform: translateY(-8px);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #000;
|
||||
}
|
||||
.video-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="gradient-bg min-h-screen flex flex-col items-center justify-center py-20 px-4">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
|
||||
<div class="max-w-6xl w-full relative z-10">
|
||||
<header class="text-center mb-16 animate-slide-up">
|
||||
<h1 class="text-4xl md:text-5xl font-black mb-4 tracking-tight">
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">My Octopus</span>
|
||||
<span class="text-white opacity-90">홍보 프로젝트</span>
|
||||
</h1>
|
||||
<p class="text-slate-400 text-lg">혁신적인 업무 자동화의 미래를 영상으로 확인하세요.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 animate-slide-up" style="animation-delay: 0.2s;">
|
||||
<!-- Video Card 1 -->
|
||||
<div class="card-glass rounded-3xl p-6 flex flex-col">
|
||||
<div class="video-container mb-6 shadow-2xl">
|
||||
<iframe src="https://player.vimeo.com/video/1152125085?h=0&title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white mb-2">My Octopus 홍보 영상 1</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">디지털 전환의 핵심 솔루션, My Octopus가 제시하는 업무 효율화의 새로운 기준입니다.</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-indigo-400 uppercase tracking-widest">Promotion Video #1</span>
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-indigo-500"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-indigo-500 opacity-50"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-indigo-500 opacity-25"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Card 2 -->
|
||||
<div class="card-glass rounded-3xl p-6 flex flex-col">
|
||||
<div class="video-container mb-6 shadow-2xl">
|
||||
<iframe src="https://player.vimeo.com/video/1152125432?h=0&title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white mb-2">My Octopus 홍보 영상 2</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">비즈니스 프로세스의 자동화, My Octopus와 함께라면 미래는 더 가까워집니다.</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-purple-400 uppercase tracking-widest">Promotion Video #2</span>
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-purple-500"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-purple-500 opacity-50"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-purple-500 opacity-25"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Card 3: 방화셔터 문어 캐릭터 -->
|
||||
<div class="card-glass rounded-3xl p-6 flex flex-col">
|
||||
<div class="video-container mb-6 shadow-2xl">
|
||||
<iframe src="https://player.vimeo.com/video/1152126362?h=0&title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white mb-2">My Octopus 캐릭터 홍보</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">친근한 문어 캐릭터와 함께하는 방화셔터 품질인정제도 안내 영상입니다.</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-orange-400 uppercase tracking-widest">Character Animation</span>
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-orange-500"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-orange-500 opacity-50"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-orange-500 opacity-25"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Card 4: 문어 하단 문 출연 -->
|
||||
<div class="card-glass rounded-3xl p-6 flex flex-col">
|
||||
<div class="video-container mb-6 shadow-2xl">
|
||||
<iframe src="https://player.vimeo.com/video/1152126562?h=0&title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white mb-2">My Octopus 브랜드 애니메이션</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">문어 캐릭터와 문이 결합된 상징적인 브랜드 홍보 영상입니다.</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-emerald-400 uppercase tracking-widest">Identity Animation</span>
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-emerald-500"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-emerald-500 opacity-50"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-emerald-500 opacity-25"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Card 5: 안경 쓴 문어 -->
|
||||
<div class="card-glass rounded-3xl p-6 flex flex-col">
|
||||
<div class="video-container mb-6 shadow-2xl">
|
||||
<iframe src="https://player.vimeo.com/video/1152134596?h=0&title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white mb-2">My Octopus 스마트 가이드</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">안경 쓴 스마트한 문어 캐릭터가 설명해주는 깊이 있는 비즈니스 가이드입니다.</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-rose-400 uppercase tracking-widest">Smart Octopus</span>
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-rose-500"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-rose-500 opacity-50"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-rose-500 opacity-25"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Card 6: 키보드 치는 문어 -->
|
||||
<div class="card-glass rounded-3xl p-6 flex flex-col">
|
||||
<div class="video-container mb-6 shadow-2xl">
|
||||
<iframe src="https://player.vimeo.com/video/1152139496?h=0&title=0&byline=0&portrait=0" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-white mb-2">My Octopus 업무 생산성</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">놀라운 속도로 업무를 처리하는 문어 캐릭터를 통해 비즈니스 가속화를 경험하세요.</p>
|
||||
</div>
|
||||
<div class="mt-6 pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-cyan-400 uppercase tracking-widest">Production Speed</span>
|
||||
<div class="flex gap-1">
|
||||
<span class="w-1 h-1 rounded-full bg-cyan-500"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-cyan-500 opacity-50"></span>
|
||||
<span class="w-1 h-1 rounded-full bg-cyan-500 opacity-25"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="mt-20 text-center animate-slide-up" style="animation-delay: 0.4s;">
|
||||
<a href="../index.php" class="inline-flex items-center gap-2 px-8 py-3 bg-white/5 hover:bg-white/10 text-white rounded-2xl border border-white/10 transition-all backdrop-blur-md group">
|
||||
<svg class="w-4 h-4 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
|
||||
<span>메인페이지로 돌아가기</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -209,6 +209,10 @@
|
||||
<button onclick="scrollToSection('section-process')" class="text-slate-400 hover:text-white transition">인정절차</button>
|
||||
<button onclick="scrollToSection('section-install')" class="text-slate-400 hover:text-white transition">설치기준</button>
|
||||
<button onclick="scrollToSection('section-check')" class="px-5 py-2.5 bg-white/5 hover:bg-white/10 text-white rounded-full border border-white/10 transition backdrop-blur-md">체크리스트</button>
|
||||
<a href="https://www.kict.re.kr/menu.es?mid=a10403020000" target="_blank" class="flex items-center gap-1.5 text-orange-400 hover:text-white transition bg-orange-400/5 px-4 py-2 rounded-xl border border-orange-400/20">
|
||||
<span class="font-bold">KICT</span>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||
</a>
|
||||
</div>
|
||||
<button class="md:hidden text-slate-400 hover:text-white p-2" onclick="toggleMobileMenu()">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -225,7 +229,8 @@
|
||||
<button onclick="scrollToSection('section-specs')" class="block w-full text-left py-3 text-slate-300 hover:text-orange-400 border-b border-white/5">성능기준</button>
|
||||
<button onclick="scrollToSection('section-process')" class="block w-full text-left py-3 text-slate-300 hover:text-orange-400 border-b border-white/5">인정절차/심사</button>
|
||||
<button onclick="scrollToSection('section-install')" class="block w-full text-left py-3 text-slate-300 hover:text-orange-400 border-b border-white/5">설치기준</button>
|
||||
<button onclick="scrollToSection('section-check')" class="block w-full text-left py-3 text-slate-300 hover:text-orange-400">체크리스트</button>
|
||||
<button onclick="scrollToSection('section-check')" class="block w-full text-left py-3 text-slate-300 hover:text-orange-400 border-b border-white/5">체크리스트</button>
|
||||
<a href="https://www.kict.re.kr/menu.es?mid=a10403020000" target="_blank" class="block w-full text-left py-3 text-orange-400 font-bold">🏛️ KICT 바로가기</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -247,7 +252,7 @@
|
||||
화재 시 연기와 열을 감지하여 생명을 지키는 자동 폐쇄 시스템.<br>
|
||||
<strong class="text-white font-medium">강화된 2021 품질인정제도</strong>의 모든 기준을 제안합니다.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-6">
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-6 mb-12">
|
||||
<button onclick="scrollToSection('section-process')" class="fire-gradient hover:scale-105 active:scale-95 text-white font-bold py-4 px-10 rounded-2xl transition-all shadow-2xl glow-orange">
|
||||
인정 절차 확인하기
|
||||
</button>
|
||||
@@ -255,6 +260,29 @@
|
||||
현장 체크리스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="mt-16 max-w-4xl mx-auto animate-slide-up" style="animation-delay: 0.2s;">
|
||||
<div class="card-glass rounded-3xl p-6 md:p-8 overflow-hidden border border-white/10 hover:border-orange-500/30 transition-all">
|
||||
<div class="flex items-center justify-center gap-3 mb-6">
|
||||
<div class="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||
<h3 class="text-2xl md:text-3xl font-black text-white tracking-tight">
|
||||
<span class="fire-text-gradient">품질인정제도의 현실</span>
|
||||
</h3>
|
||||
<div class="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<div class="relative w-full" style="padding-bottom: 56.25%; background: #000;">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/1151273154?title=0&byline=0&portrait=0&badge=0&autopause=0&player_id=0&app_id=58479"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
class="absolute top-0 left-0 w-full h-full rounded-2xl"
|
||||
style="border: none;">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1458,6 +1486,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- KICT Shortcut Section -->
|
||||
<section class="mt-16 mb-24">
|
||||
<div class="card-glass rounded-[2rem] p-1.5 overflow-hidden group">
|
||||
<a href="https://www.kict.re.kr/menu.es?mid=a10403020000" target="_blank" class="flex flex-col md:flex-row items-center gap-8 bg-black/40 rounded-[1.8rem] p-8 md:p-12 transition-all group-hover:bg-black/60">
|
||||
<div class="w-full md:w-1/3 flex justify-center p-6 bg-white rounded-2xl group-hover:scale-105 transition-transform shadow-2xl">
|
||||
<img src="../img/kict_logo.png" alt="KICT 한국건설기술연구원" class="h-16 md:h-20 object-contain">
|
||||
</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<div class="inline-flex items-center gap-2 bg-orange-500/10 text-orange-400 px-4 py-1.5 rounded-full text-xs font-bold mb-4 border border-orange-500/20">
|
||||
KICT 공식 서비스
|
||||
</div>
|
||||
<h3 class="text-2xl md:text-3xl font-black text-white mb-4 tracking-tight">
|
||||
한국건설기술연구원 <span class="text-orange-500">인증·인정 서비스</span>
|
||||
</h3>
|
||||
<p class="text-slate-400 text-lg mb-8 font-light leading-relaxed">
|
||||
시험 신청, 진행 현황 확인 및 관련 규정 조회를 위한<br class="hidden md:block">
|
||||
<strong class="text-white font-medium">KICT 공식 홈페이지</strong>로 직접 연결됩니다.
|
||||
</p>
|
||||
<div class="inline-flex items-center gap-3 bg-white text-black font-bold py-4 px-8 rounded-2xl hover:bg-orange-500 hover:text-white transition-all transform group-hover:translate-x-2">
|
||||
<span>인증·인정 업무 사이트 바로가기</span>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
49
fix_env.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "=== Production Environment Auto-Configurator ===\n";
|
||||
|
||||
$sourceEnv = '/home/webservice/sales_org/.env';
|
||||
$targetEnv = '/home/webservice/sales/.env';
|
||||
|
||||
if (isset($_GET['apply']) && $_GET['apply'] === 'true') {
|
||||
if (file_exists($sourceEnv)) {
|
||||
echo "Reading configuration from $sourceEnv...\n";
|
||||
$content = file_get_contents($sourceEnv);
|
||||
|
||||
// Parse source credentials
|
||||
$db_host = 'localhost';
|
||||
$db_name = 'chandj';
|
||||
$db_user = 'codebridge';
|
||||
$db_pass = '';
|
||||
|
||||
foreach (explode("\n", $content) as $line) {
|
||||
$line = trim($line);
|
||||
if (strpos($line, '=') === false) continue;
|
||||
list($key, $val) = explode('=', $line, 2);
|
||||
if ($key === 'DB_HOST') $db_host = $val;
|
||||
if ($key === 'DB_NAME') $db_name = $val;
|
||||
if ($key === 'DB_USER') $db_user = $val;
|
||||
if ($key === 'DB_PASS') $db_pass = $val;
|
||||
}
|
||||
|
||||
// Construct new .env for current server
|
||||
$newContent = "APP_URL=https://sales.codebridge-x.com/\n";
|
||||
$newContent .= "DB_HOST=$db_host\n";
|
||||
$newContent .= "DB_NAME=$db_name\n";
|
||||
$newContent .= "DB_USER=$db_user\n";
|
||||
$newContent .= "DB_PASS=$db_pass\n";
|
||||
$newContent .= "DOCUMENT_ROOT=/home/webservice/sales\n";
|
||||
|
||||
if (file_put_contents($targetEnv, $newContent)) {
|
||||
echo "SUCCESS: New .env created at $targetEnv\n";
|
||||
echo "You can now log in to the sales management system.\n";
|
||||
echo "\nIMPORTANT: Delete fix_env.php after verification!\n";
|
||||
} else {
|
||||
echo "ERROR: Failed to write to $targetEnv. Check directory permissions.\n";
|
||||
}
|
||||
} else {
|
||||
echo "ERROR: Source .env not found at $sourceEnv\n";
|
||||
}
|
||||
} else {
|
||||
echo "Usage: Append ?apply=true to execute the configuration copy.\n";
|
||||
}
|
||||
66
git-auto.ps1
Normal file
@@ -0,0 +1,66 @@
|
||||
# Git 자동화 스크립트
|
||||
# 사용법: g "커밋 메시지"
|
||||
|
||||
function g {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$message
|
||||
)
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Git 자동화 시작" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 1. git add .
|
||||
Write-Host "▶ git add . 실행 중..." -ForegroundColor Yellow
|
||||
git add .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ git add 실패" -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
Write-Host "✅ git add 완료" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# 2. git commit
|
||||
Write-Host "▶ git commit 실행 중..." -ForegroundColor Yellow
|
||||
Write-Host " 메시지: $message" -ForegroundColor Gray
|
||||
git commit -m "$message"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ git commit 실패" -ForegroundColor Red
|
||||
Write-Host " (변경사항이 없거나 이미 커밋된 상태일 수 있습니다)" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
Write-Host "✅ git commit 완료" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# 3. git push (최대 2번 재시도)
|
||||
$pushAttempt = 0
|
||||
$maxPushAttempts = 2
|
||||
$pushSuccess = $false
|
||||
|
||||
while ($pushAttempt -lt $maxPushAttempts -and -not $pushSuccess) {
|
||||
$pushAttempt++
|
||||
Write-Host "▶ git push 실행 중... (시도 $pushAttempt/$maxPushAttempts)" -ForegroundColor Yellow
|
||||
git push
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "✅ git push 완료" -ForegroundColor Green
|
||||
$pushSuccess = $true
|
||||
} else {
|
||||
if ($pushAttempt -lt $maxPushAttempts) {
|
||||
Write-Host "⚠️ git push 실패, 재시도 중... ($pushAttempt/$maxPushAttempts)" -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 2
|
||||
} else {
|
||||
Write-Host "❌ git push 실패 (최대 재시도 횟수 도달)" -ForegroundColor Red
|
||||
Write-Host " (원격 저장소 설정을 확인하세요)" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "✅ 모든 작업 완료!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
44
hotfix.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 긴급 수정 보고서 (Hotfix Report): 영업관리 서버 에러 해결
|
||||
|
||||
## 1. 장애 개요
|
||||
* **에러 메시지**: `Session check failed: SyntaxError: Unexpected token 'E', "Error:SQLS"... is not valid JSON`
|
||||
* **증상**: 로컬 개발 환경에서는 정상 작동하나, 실서버(`sales.codebridge-x.com`) 접속 시 대시보드 로딩 실패 및 세션 체크 오류 발생.
|
||||
|
||||
## 2. 원인 분석
|
||||
|
||||
### 가. 환경 설정 불일치 (주요 원인)
|
||||
* **로컬/도커 환경**: DB 호스트명을 `mysql`로 사용하도록 설정됨 (도커 컴포즈 표준).
|
||||
* **실서버 환경**: 표준 리눅스 환경으로 DB 호스트가 `localhost`로 설정되어야 함.
|
||||
* **.env 파일 누락**: 보안상 Git 추적에서 제외된 `.env` 파일이 서버에 생성되지 않음. 이로 인해 시스템이 로컬용 기본값(`mysql`)을 사용하면서 접속 실패.
|
||||
|
||||
### 나. 부적절한 에러 핸들링 (연쇄 원인)
|
||||
* `lib/mydb.php`에서 DB 접속 실패 시 `die("Error: " . $msg)`를 사용하여 프로세스를 종료함.
|
||||
* 이로 인해 API가 JSON 형식이 아닌 일반 텍스트 문자열을 반환하게 됨.
|
||||
* React 프론트엔드가 이 문자열을 JSON으로 파싱하려다 첫 글자인 'E'에서 `SyntaxError`를 일으킴.
|
||||
|
||||
### 다. DB 접속 권한 차이
|
||||
* 실서버는 보안상 `root` 계정이 아닌 전용 계정(`codebridge`)을 사용해야 하는데, 설정 파일 부재로 기본값인 `root` 인증을 시도함.
|
||||
|
||||
## 3. 해결 단계
|
||||
|
||||
### 1단계: 에러 보고 방식 개선
|
||||
`lib/mydb.php`에서 `die()` 대신 `throw new Exception()`을 사용하도록 수정. 이제 접속 실패 시에도 API가 구조화된 JSON 응답(`{success: false, error: "..."}`)을 반환하여 프론트엔드가 에러 내용을 정확히 표시할 수 있게 됨.
|
||||
|
||||
### 2단계: 자동 환경 설정 도구 배포 (`fix_env.php`)
|
||||
SSH 접속이 제한된 상황에서 서버 환경을 진단하고 복구하기 위한 스크립트 제작:
|
||||
1. 서버 내 인접 폴더(`sales_org`)에서 실제 운영 중인 `.env` 파일을 자동으로 탐색.
|
||||
2. 탐색된 정보(DB 유저명, 비밀번호 등)를 현재 프로젝트 경로에 맞춰 재구성하여 새로운 `.env` 파일 생성.
|
||||
|
||||
### 3단계: 프로덕션 환경 최적화
|
||||
* `lib/mydb.php`의 기본 호스트값을 `localhost`로 변경하여 호환성 강화.
|
||||
* `index.php` 내 로그인 입력 필드에 `autocomplete` 속성을 추가하여 브라우저 경고 해결.
|
||||
|
||||
## 4. 향후 방지 대책
|
||||
1. **환경 설정 관리**: 새로운 서버 배포 시 `.env` 설정 확인을 필수 체크리스트에 포함.
|
||||
2. **응답 구조 통일**: JSON API를 제공하는 PHP 파일에서는 절대 `die()`나 `echo`를 직접 사용하지 않고, 반드시 제어된 JSON 인코더를 통해 응답.
|
||||
3. **진단 도구 활용**: 이번에 사용된 `fix_env.php`와 같은 진단용 스크립트는 설정이 완료된 후 즉시 삭제하여 보안 유지.
|
||||
|
||||
---
|
||||
**상태**: 해결 완료
|
||||
**일자**: 2026-01-04
|
||||
**작성**: Antigravity AI
|
||||
BIN
img/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
img/kict_logo.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
111
index.php
@@ -1,12 +1,19 @@
|
||||
<?php require_once 'session.php'; ?>
|
||||
<?php
|
||||
require_once 'session.php';
|
||||
|
||||
// 환경에 따른 MNG URL 설정
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
$mngUrl = (strpos($currentHost, 'sam.kr') !== false)
|
||||
? 'https://mng.sam.kr'
|
||||
: 'https://mng.codebridge-x.com';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>영업 관리 시스템</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<?php include_once 'lib/meta_common.php'; ?>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
@@ -113,18 +120,7 @@
|
||||
display: block;
|
||||
box-shadow: 0 0 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
.fullscreen-close {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 110;
|
||||
background: white;
|
||||
border-radius: 9999px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
/* Mobile touch optimization */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
@@ -169,18 +165,57 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center gap-3 cursor-pointer" onclick="filterAssets('All')">
|
||||
<div class="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-brand-200">
|
||||
S
|
||||
<img src="img/favicon-32x32.png" alt="SAM" class="w-10 h-10 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold text-slate-800 leading-tight">SAM</span>
|
||||
<span class="text-xs text-slate-500 leading-tight">Smart Automation Management</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold tracking-tight text-slate-900">CodeBridgeX <span class="text-brand-600">SAM</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<a href="sales_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">영업 시나리오</a>
|
||||
<a href="sales_manager_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">매니저 시나리오</a>
|
||||
<a href="salesmanagement/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">영업관리</a>
|
||||
<a href="corp/kodata.php" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">기업분석</a>
|
||||
<!-- <a href="sales_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">영업 시나리오</a>
|
||||
<a href="sales_manager_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">매니저 시나리오</a> -->
|
||||
<a href="<?= $mngUrl ?>" target="_blank" class="px-4 py-2 bg-brand-600 text-white text-sm font-bold rounded-lg hover:bg-brand-700 transition-colors shadow-lg shadow-brand-200">영업관리 로그인</a>
|
||||
<a href="https://demo.codebridge-x.com/" target="_blank" class="px-4 py-2 bg-emerald-600 text-white text-sm font-bold rounded-lg hover:bg-emerald-700 transition-colors shadow-lg shadow-emerald-200">데모</a>
|
||||
<a href="https://dev.codebridge-x.com/" target="_blank" class="px-4 py-2 bg-amber-600 text-white text-sm font-bold rounded-lg hover:bg-amber-700 transition-colors shadow-lg shadow-amber-200">운영</a>
|
||||
<a href="price/index.php" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">가격정책</a>
|
||||
|
||||
<!-- Corporate Analysis Hamburger Menu -->
|
||||
<div class="relative group">
|
||||
<button class="p-2 rounded-lg text-slate-600 hover:bg-slate-100 transition-colors">
|
||||
<i data-lucide="menu" class="w-5 h-5"></i>
|
||||
</button>
|
||||
<div class="absolute right-0 top-full mt-1 w-48 bg-white rounded-xl shadow-xl border border-slate-100 py-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-top-right z-50">
|
||||
<a href="etc/myoctopus.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
문어이미지 선발
|
||||
</a>
|
||||
<a href="Requestforcorrection/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
경정청구 소개
|
||||
</a>
|
||||
<a href="barobill/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
바로빌 API 통합기획서
|
||||
</a>
|
||||
<a href="plan/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
견적서 자동기획 프로젝트
|
||||
</a>
|
||||
<a href="corp/kodata.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
기업분석 한국평가데이터
|
||||
</a>
|
||||
<a href="coocon/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
기업신용조회 쿠콘닷컴
|
||||
</a>
|
||||
<a href="creditreport/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
기업신용조회 리포트
|
||||
</a>
|
||||
<a href="company/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
기타 기업분석자료
|
||||
</a>
|
||||
<a href="debt/index.php" class="block px-4 py-2 text-sm text-slate-700 hover:bg-brand-50 hover:text-brand-600 font-medium">
|
||||
채권추심 프로세스 관리
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (isset($_SESSION['userid']) && $_SESSION['userid'] != ''): ?>
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 font-bold border border-slate-300">
|
||||
@@ -189,10 +224,6 @@
|
||||
<span class="text-sm font-medium text-slate-700"><?= $_SESSION['name'] ?>님</span>
|
||||
<a href="login/logout.php" class="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs font-bold rounded hover:bg-slate-200 transition-colors border border-slate-200">로그아웃</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<a href="login/login_form.php" class="px-4 py-2 bg-brand-600 text-white text-sm font-bold rounded-lg hover:bg-brand-700 transition-colors shadow-lg shadow-brand-200">
|
||||
로그인
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -208,7 +239,10 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="sales_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors px-2 py-2 hover:bg-slate-50 rounded-lg" onclick="closeMobileMenu()">영업 시나리오</a>
|
||||
<a href="sales_manager_scenario/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors px-2 py-2 hover:bg-slate-50 rounded-lg" onclick="closeMobileMenu()">매니저 시나리오</a>
|
||||
<a href="salesmanagement/" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors px-2 py-2 hover:bg-slate-50 rounded-lg" onclick="closeMobileMenu()">영업관리</a>
|
||||
<a href="<?= $mngUrl ?>" target="_blank" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors px-2 py-2 hover:bg-slate-50 rounded-lg" onclick="closeMobileMenu()">영업관리</a>
|
||||
<a href="https://demo.codebridge-x.com/" target="_blank" class="text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors px-2 py-2 hover:bg-emerald-50 rounded-lg" onclick="closeMobileMenu()">데모</a>
|
||||
<a href="https://dev.codebridge-x.com/" target="_blank" class="text-sm font-medium text-amber-600 hover:text-amber-700 transition-colors px-2 py-2 hover:bg-amber-50 rounded-lg" onclick="closeMobileMenu()">운영</a>
|
||||
<a href="price/index.php" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors px-2 py-2 hover:bg-slate-50 rounded-lg" onclick="closeMobileMenu()">가격정책</a>
|
||||
<a href="corp/kodata.php" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors px-2 py-2 hover:bg-slate-50 rounded-lg" onclick="closeMobileMenu()">기업분석</a>
|
||||
<?php if (isset($_SESSION['userid']) && $_SESSION['userid'] != ''): ?>
|
||||
<div class="flex items-center gap-2 px-2 py-2 border-t border-slate-200 mt-2 pt-3">
|
||||
@@ -218,10 +252,6 @@
|
||||
<span class="text-sm font-medium text-slate-700 flex-1"><?= $_SESSION['name'] ?>님</span>
|
||||
<a href="login/logout.php" class="px-3 py-1.5 bg-slate-100 text-slate-600 text-xs font-bold rounded hover:bg-slate-200 transition-colors border border-slate-200" onclick="closeMobileMenu()">로그아웃</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<a href="login/login_form.php" class="px-4 py-2 bg-brand-600 text-white text-sm font-bold rounded-lg hover:bg-brand-700 transition-colors shadow-lg shadow-brand-200 text-center mt-2" onclick="closeMobileMenu()">
|
||||
로그인
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,6 +276,7 @@
|
||||
SAM은 현장에 없어도, 현장을 다 보여줍니다. <br class="hidden sm:block"/>
|
||||
대표님 손 안의 스마트 경영 계기판, SAM<br />
|
||||
<strong>오직 CEO를 위한 시크릿 대시보드</strong>를 제안하십시오.
|
||||
https://vimeo.com/manage/videos/1151273154
|
||||
</p>
|
||||
|
||||
<!-- Main Hero Image -->
|
||||
@@ -307,10 +338,9 @@
|
||||
<!-- Modal Content injected by JS -->
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Viewer Overlay -->
|
||||
<div id="fullscreen-view" onclick="closeFullscreen()">
|
||||
<div class="fullscreen-close">
|
||||
<i data-lucide="x" class="w-6 h-6 text-slate-900"></i>
|
||||
<div class="fullscreen-close btn-close-modal group">
|
||||
<span>✕</span>
|
||||
</div>
|
||||
<img id="fullscreen-img" src="" alt="Full size view" onclick="event.stopPropagation()">
|
||||
</div>
|
||||
@@ -322,8 +352,8 @@
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-[95vw] h-[95vh] flex flex-col pointer-events-auto scale-95 opacity-0 transition-all duration-300" id="pdf-modal-card">
|
||||
<div class="sticky top-0 bg-white/90 backdrop-blur-sm p-4 border-b border-slate-100 flex justify-between items-center z-10">
|
||||
<h3 class="text-lg font-bold text-slate-900" id="pdf-modal-title">상세자료</h3>
|
||||
<button onclick="closePdfViewer()" class="w-8 h-8 rounded-full bg-slate-50 hover:bg-slate-100 flex items-center justify-center text-slate-500 transition-colors">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
<button onclick="closePdfViewer()" class="btn-close-modal group">
|
||||
<span>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
@@ -504,9 +534,10 @@
|
||||
|
||||
// Initial Render
|
||||
function init() {
|
||||
|
||||
renderFilters();
|
||||
renderGrid();
|
||||
// Initial Lucide icons are handled by DOMContentLoaded in meta_common.php
|
||||
// but we call it here for the dynamically rendered grid items
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
@@ -718,8 +749,8 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base sm:text-lg font-bold text-slate-900 break-keep leading-tight" style="word-break: keep-all; overflow-wrap: break-word;">${asset.title}</h3>
|
||||
</div>
|
||||
<button onclick="closeModal()" class="w-8 h-8 rounded-full bg-slate-50 hover:bg-slate-100 flex items-center justify-center text-slate-500 transition-colors">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
<button onclick="closeModal()" class="btn-close-modal group">
|
||||
<span>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1025,6 +1056,6 @@
|
||||
// Initialize
|
||||
init();
|
||||
</script>
|
||||
<script src="https://player.vimeo.com/api/player.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KoDATA EW 리포트 인터뷰 계획안 - SAM Project</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="../img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="../img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="../img/favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="../img/favicon.png">
|
||||
|
||||
<!-- Fonts: Pretendard -->
|
||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css" />
|
||||
|
||||
|
||||
45
lib/DotEnv.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
if (!class_exists('DotEnv')) {
|
||||
class DotEnv
|
||||
{
|
||||
/**
|
||||
* The directory where the .env file is located.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
throw new \InvalidArgumentException(sprintf('%s does not exist', $path));
|
||||
}
|
||||
$this->path = $path;
|
||||
}
|
||||
|
||||
public function load(): void
|
||||
{
|
||||
if (!is_readable($this->path)) {
|
||||
throw new \RuntimeException(sprintf('%s file is not readable', $this->path));
|
||||
}
|
||||
|
||||
$lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
|
||||
putenv(sprintf('%s=%s', $name, $value));
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
lib/func.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
session_start();
|
||||
?>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" type="text/css" href="./css/common.css">
|
||||
<link rel="stylesheet" type="text/css" href="./css/func.css">
|
||||
</head>
|
||||
|
||||
<?php
|
||||
function latest_article($table,$loop,$char_limit)
|
||||
{
|
||||
require_once("../lib/mydb.php");
|
||||
$pdo=db_connect();
|
||||
|
||||
try{
|
||||
$sql="select * from chandj.$table order by num desc limit $loop";
|
||||
$stmh=$pdo->query($sql);
|
||||
|
||||
While($row=$stmh->fetch(PDO::FETCH_ASSOC))
|
||||
{
|
||||
$num=$row["num"];
|
||||
$len_subject=strlen($row["subject"]);
|
||||
$subject=$row["subject"];
|
||||
if($len_subject>$char_limit)
|
||||
{
|
||||
$subject=mb_substr($row["subject"],0,$char_limit,'utf-8');
|
||||
$subject=$subject . "..."; // 글자수가 초과하면 ...으로 표기됨
|
||||
}
|
||||
$regist_day=substr($row["regist_day"],0,10);
|
||||
$page=1;
|
||||
|
||||
echo("<div class='col1'> <a href='./$table/view.php?num=$num&page=$page'>$subject</a>
|
||||
</div><div class='col2'>$regist_day</div>
|
||||
<div class='clear'></div>");
|
||||
|
||||
}
|
||||
} catch (PDOException $Exception) {
|
||||
print "오류: ". $Exception->getMessage();
|
||||
|
||||
}
|
||||
}
|
||||
?>
|
||||
21
lib/helper.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
function href($url)
|
||||
{
|
||||
print "
|
||||
<script>
|
||||
location.href = '$url';
|
||||
</script>
|
||||
";
|
||||
}
|
||||
|
||||
function alert($msg)
|
||||
{
|
||||
print "
|
||||
<script>
|
||||
alert('$msg');
|
||||
</script>
|
||||
";
|
||||
}
|
||||
|
||||
?>
|
||||