Compare commits
379 Commits
462f6bd9e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b6d2b9942e | |||
| 4208ca3010 | |||
| 95371fd841 | |||
| 1df34b2fa9 | |||
| 3d12687a2d | |||
| 5e4cbc7742 | |||
| 4dd38ab14d | |||
| f9cd219f67 | |||
|
|
091719e81b | ||
|
|
b06438cc52 | ||
|
|
1e5cd70081 | ||
|
|
b21c9de6eb | ||
|
|
91567c54bd | ||
|
|
88eb507426 | ||
|
|
0bf56931fa | ||
|
|
c55a4a42e6 | ||
|
|
428b2e2a12 | ||
|
|
64877869e6 | ||
|
|
92efe2e83b | ||
|
|
78eb9363f4 | ||
|
|
c611f551a6 | ||
|
|
48889a7250 | ||
|
|
18f39433ae | ||
|
|
3785d87df4 | ||
|
|
d9075e5da5 | ||
| 1d71b588cb | |||
|
|
521229adcf | ||
|
|
5ce2d2fcbf | ||
|
|
5f5b5db59f | ||
|
|
814b965748 | ||
|
|
c55380f1d2 | ||
|
|
4870b7e6eb | ||
|
|
88ef6a8490 | ||
|
|
2fd122feba | ||
|
|
5a0deddb58 | ||
| d68fd56232 | |||
|
|
d8abc57271 | ||
|
|
d7dd6cdbc5 | ||
|
|
2bb3a2872a | ||
|
|
6df1da9e42 | ||
|
|
93e94901b7 | ||
|
|
7028e27517 | ||
|
|
1d2876d90c | ||
|
|
bfb821698a | ||
|
|
b80f4a0392 | ||
|
|
2ed90dc6db | ||
|
|
347d351d9d | ||
|
|
87a8930c00 | ||
|
|
bbcb0205fe | ||
| 9bf0cc8df2 | |||
|
|
255fad99e7 | ||
|
|
08b07c724a | ||
|
|
91cdfe9917 | ||
|
|
10c09b9fea | ||
|
|
04bb990045 | ||
| 7543054df3 | |||
|
|
13ba753b7f | ||
|
|
433f3ee4ad | ||
| fcb110a7ed | |||
| bb0615693e | |||
| 0802bc172e | |||
| c4bcab07c1 | |||
| 0a461c9209 | |||
|
|
3da8a16bfc | ||
| 97f61e24bc | |||
| 3f5a942939 | |||
| e9ffd3bc38 | |||
| 6a4485134a | |||
| 6f77eb564d | |||
| cb9c905698 | |||
| dc42be5256 | |||
| f67feaf82f | |||
| 3f51447b22 | |||
| 147f25ce48 | |||
| 80bd0dcb36 | |||
| 8426bdcd73 | |||
|
|
eb6bd5e03e | ||
| 59f5253512 | |||
| d1a09935e2 | |||
| 8096514e93 | |||
| 4f4fa2dd04 | |||
| 0ded7897a4 | |||
| b49f66ed57 | |||
|
|
03a1a0ab3d | ||
|
|
240199af9d | ||
| 7eb5825d41 | |||
| 16b8dbcc6f | |||
| 7c117bb29f | |||
| 855e806e42 | |||
| b00fa0502a | |||
| 5a3d6c2243 | |||
| 9c88138de8 | |||
| 4f777d8cf9 | |||
| 25e21ee6d7 | |||
| 8be54c3b8b | |||
| ba49313ffa | |||
| f8858cf1b7 | |||
| c637dd38eb | |||
| 37424b9cef | |||
| d7ca8cfa00 | |||
| a2dbdae14b | |||
| 5cc43828d3 | |||
|
|
3ae3a1dcda | ||
|
|
ef33540940 | ||
|
|
3b116c980b | ||
|
|
cdbb825fbe | ||
|
|
295f7b7ee9 | ||
|
|
03e3e84066 | ||
|
|
83ddfabd7c | ||
|
|
961ab47bac | ||
|
|
fdea1d0244 | ||
|
|
0692eda282 | ||
|
|
b576fe97e8 | ||
|
|
1dd9057540 | ||
| 555fd196f5 | |||
| 400adb7c58 | |||
| 3ab4f24bb4 | |||
| 602702b891 | |||
| 71dc5fae68 | |||
| b0547c425f | |||
|
|
ee72af10b4 | ||
| edb81a1041 | |||
| 6ae82b7057 | |||
| 2fedc3a712 | |||
| 1429f22f11 | |||
| 89344c0755 | |||
| 74a83e6711 | |||
| 10b1b26c1b | |||
| 55270198d4 | |||
|
|
e3d5303167 | ||
|
|
7472264364 | ||
|
|
8dbe5dffe4 | ||
|
|
6f456f2cfd | ||
|
|
f1dc2d8bbe | ||
| 1f848ad291 | |||
| 59cd8cf4fe | |||
| 6ac3f0c860 | |||
|
|
cc5fe61fcd | ||
|
|
c290c99cc4 | ||
|
|
6874754b92 | ||
|
|
d97130fc3a | ||
|
|
f3ab7525e2 | ||
|
|
9f3a743988 | ||
|
|
80c1e4aeae | ||
|
|
2dacefd14f | ||
| adfccc9af0 | |||
|
|
9c19536423 | ||
|
|
6d4e5b740b | ||
|
|
cd20f8d73f | ||
|
|
ef23b9dda6 | ||
|
|
c7c9c3838d | ||
| 4ecf16d387 | |||
| f28bbc2a74 | |||
| 090a978991 | |||
| 5eaa65cc9c | |||
| df69cabdc3 | |||
| 89c19b869d | |||
| fc4fad6e75 | |||
| e4c53c7b17 | |||
| d730c2d91a | |||
| 441359f5fd | |||
|
|
a13e694174 | ||
|
|
57879f7673 | ||
| 9af48a15af | |||
| 32cbb1071d | |||
| 45dd18dbab | |||
|
|
fa6d2082c8 | ||
|
|
6958be1fd8 | ||
| 911c8a36ad | |||
| 376348a491 | |||
|
|
818f764aa5 | ||
|
|
eade587135 | ||
| 8d1ed6c096 | |||
| de10441275 | |||
| bcad646ea6 | |||
| 51aad4e522 | |||
| a3a4e18e8a | |||
| 8b78d62068 | |||
| 12053386f6 | |||
|
|
bf3ee24314 | ||
| 1d7ef66d19 | |||
| bb457d4ca8 | |||
| 2d68e5e669 | |||
|
|
d0418eeb85 | ||
| 6733a431bb | |||
| d8fd221278 | |||
|
|
ac83d0bddc | ||
|
|
6c186c91ab | ||
| e885b1ca45 | |||
| 7adfc6d536 | |||
| 072d0c0ae1 | |||
| be572678db | |||
|
|
4c02ff64f1 | ||
| 61c70b6fd1 | |||
|
|
0bd470a6f8 | ||
| 9bae7fccae | |||
| 5eaa5f036b | |||
| f9de25257f | |||
|
|
e9faac5c9d | ||
| d07bad16df | |||
| ee6794be1a | |||
|
|
b9137c93b0 | ||
|
|
f45f91967f | ||
| 78851ec04a | |||
| 6318474b6f | |||
| d4125dc4ad | |||
| 487e651845 | |||
| 6b3e5c3e87 | |||
|
|
3a62a2a6e6 | ||
| d2b0f028d4 | |||
| 874bf97b8f | |||
| 4ae7b438f1 | |||
| 6dbcb5337d | |||
| b6f1c817d8 | |||
|
|
bdf6bcc480 | ||
|
|
edbd95053c | ||
|
|
28bf445844 | ||
|
|
3406b12260 | ||
| 6bc766411b | |||
| f640a837e9 | |||
| 9f2b1cf44a | |||
|
|
70aab06364 | ||
|
|
44079f0f0e | ||
| bc23debb26 | |||
|
|
a94c68ea91 | ||
|
|
f941ca17b9 | ||
| 229ebc7483 | |||
| 83d12a8ca2 | |||
|
|
bc4a41263a | ||
|
|
49d68e3b3e | ||
|
|
98ab3dac9a | ||
| 5118c364ef | |||
|
|
ba34fb09df | ||
|
|
9626bca7eb | ||
|
|
2b5ac4c54d | ||
|
|
00470b7d30 | ||
|
|
422bad7dfc | ||
| e364239572 | |||
|
|
9c276ed8c3 | ||
|
|
343d7f6256 | ||
| fa07e5b58a | |||
| a6fc537a02 | |||
|
|
0d1b088463 | ||
|
|
cee37b3e20 | ||
|
|
0e9f8be423 | ||
|
|
c7029f9eef | ||
| a7975f7270 | |||
| d27061bbdc | |||
| af42c115ae | |||
|
|
d012be69eb | ||
| 3d20c6979d | |||
|
|
83f0f69643 | ||
|
|
529c587023 | ||
|
|
6c2e74d6ce | ||
| 2779caed6e | |||
| e35b167b63 | |||
| 60e2286cec | |||
|
|
fe4303f807 | ||
|
|
c2c19249d7 | ||
|
|
c9970d65fd | ||
| da100ed5ad | |||
| 6001df27f2 | |||
|
|
7b06644f23 | ||
|
|
502e34d88e | ||
| f701e0636e | |||
|
|
c90077bd51 | ||
|
|
28ed5d9da7 | ||
|
|
dc2dee0c0b | ||
|
|
9cc73f4688 | ||
| ce59cdfe81 | |||
| a3c7f83dfb | |||
|
|
32392ef4de | ||
|
|
fd3dbb75af | ||
|
|
3a2eeb299c | ||
|
|
6db428ccc5 | ||
| fb06975d97 | |||
| fb0155624f | |||
| 57d9ac2d7f | |||
| e5a293ab12 | |||
| 9bd585bdf3 | |||
| f4a902fceb | |||
| e9639d1011 | |||
| 86ec5c4185 | |||
| 06552ad64e | |||
| 868b765658 | |||
| 24c97cf75a | |||
| 49d163ae0c | |||
| a41bf48dd8 | |||
|
|
1ae9a29c62 | ||
| 1c3cb48c7c | |||
| c111b2b55d | |||
| 591197f29c | |||
| 5b553ea13c | |||
|
|
d412ae45b7 | ||
|
|
73dd6595d0 | ||
|
|
f30fbadc90 | ||
|
|
e407c40228 | ||
|
|
60a1d753fd | ||
| a5576a0e00 | |||
| c9e37f4338 | |||
|
|
69fa80d36e | ||
| 63afa4fc9b | |||
| 66887c9c69 | |||
| 0fbd080875 | |||
| ca2dd44567 | |||
| e300062f32 | |||
| ca51867cc2 | |||
| 3793e95662 | |||
| 4d8dac1091 | |||
| 595e3d59b4 | |||
| 6c9735581d | |||
| e882d33de1 | |||
|
|
68ffbdfa08 | ||
| c88048db67 | |||
| 4f2a329e4e | |||
|
|
f8d37f0b5e | ||
|
|
ff829ad184 | ||
| ef4a894cbc | |||
|
|
8327568a77 | ||
|
|
e7054b6633 | ||
| e1aa1d1577 | |||
| 45a15fe64f | |||
| 189b38c936 | |||
| 00a1257c63 | |||
| 99a6c89d41 | |||
| f7ad9ae36e | |||
| 847717e631 | |||
|
|
e439bfffda | ||
| e8fc42c14d | |||
| a25d267550 | |||
|
|
c7339ac7db | ||
| 7acaac8340 | |||
| aa7678c358 | |||
|
|
27a558dafb | ||
| 946e008b02 | |||
|
|
4c7cf85ef9 | ||
| 87e20e965a | |||
| a0ffeb954b | |||
|
|
6208d90244 | ||
| 90bcfaf268 | |||
| 3fce54b7d4 | |||
| e9894fef61 | |||
| 42deb60861 | |||
| d3825e4bfb | |||
| 8cf588bf05 | |||
| e06b0637fa | |||
| 9bceaab8a3 | |||
| b7f8157548 | |||
| 94612e3b50 | |||
| 2e219edf8a | |||
| bacc42da73 | |||
| ec47c26ea8 | |||
| 2bce30056d | |||
| c7b2e97189 | |||
| f76fd2f865 | |||
| a96499a66d | |||
|
|
f163373fb0 | ||
| f74767563f | |||
|
|
6e553ce3c9 | ||
|
|
a88f8a2021 | ||
|
|
3e8570ac3f | ||
|
|
4839cfcad2 | ||
| 518ae4657e | |||
|
|
9182cbc1b3 | ||
| 3917ea3831 | |||
| 51b23adcfe | |||
|
|
5de77e3b35 | ||
| 7bd296b2fa | |||
| a9cdf004e3 | |||
| dd8a744d12 | |||
|
|
4106f59cd1 | ||
| 8a1e78ec72 | |||
|
|
597d24eb19 | ||
|
|
4c22b74b27 | ||
| 22f7e9d94a | |||
| 3ff3c65ade | |||
| eeb55d1c28 | |||
| 6d05ab815f | |||
| f2da990771 |
137
.env.example
Normal file
137
.env.example
Normal file
@@ -0,0 +1,137 @@
|
||||
# ─────────────────────────────────────────────────
|
||||
# SAM API (REST API 서버) 환경 변수
|
||||
# ─────────────────────────────────────────────────
|
||||
# 이 파일을 .env로 복사한 후 실제 값을 입력하세요.
|
||||
# cp .env.example .env && php artisan key:generate
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
APP_NAME="SAM API"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://api.sam.kr/
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=ko_KR
|
||||
APP_TIMEZONE=Asia/Seoul
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# ─── Slack 로그 알림 ───
|
||||
LOG_SLACK_WEBHOOK_URL=
|
||||
LOG_SLACK_USERNAME=API_SERVER
|
||||
LOG_SLACK_EMOJI=:boom:
|
||||
|
||||
# ─── Database ───
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=samdb
|
||||
DB_USERNAME=samuser
|
||||
DB_PASSWORD=sampass
|
||||
# 도커 환경: docker-compose.yml의 환경변수로 오버라이드 (DB_HOST=sam-mysql-1)
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ─── Mail ───
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=
|
||||
MAIL_FROM_NAME="(주)코드브릿지엑스"
|
||||
|
||||
# ─── AWS (미사용) ───
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ─── Swagger ───
|
||||
L5_SWAGGER_GENERATE_ALWAYS=true
|
||||
L5_SWAGGER_CONST_HOST=https://api.sam.kr/
|
||||
L5_SWAGGER_CONST_NAME="SAM API 서버"
|
||||
|
||||
# ─── Sanctum 토큰 만료 (분) ───
|
||||
SANCTUM_ACCESS_TOKEN_EXPIRATION=120
|
||||
SANCTUM_REFRESH_TOKEN_EXPIRATION=10080
|
||||
|
||||
# ─── 내부 통신 키 (MNG ↔ API HMAC 검증) ───
|
||||
# MNG 프로젝트의 INTERNAL_EXCHANGE_SECRET과 동일한 값 사용
|
||||
INTERNAL_EXCHANGE_SECRET=
|
||||
|
||||
# ─── Firebase (FCM) ───
|
||||
FCM_PROJECT_ID=
|
||||
FCM_SA_PATH=secrets/codebridge-x-firebase-sa.json
|
||||
|
||||
# ─── 5130 Legacy DB ───
|
||||
CHANDJ_DB_HOST=sam-mysql-1
|
||||
CHANDJ_DB_DATABASE=chandj
|
||||
CHANDJ_DB_USERNAME=root
|
||||
CHANDJ_DB_PASSWORD=root
|
||||
|
||||
# ─── 바로빌 SOAP API ───
|
||||
BAROBILL_CERT_KEY_TEST=
|
||||
BAROBILL_CERT_KEY_PROD=
|
||||
BAROBILL_CORP_NUM=
|
||||
BAROBILL_TEST_MODE=true
|
||||
|
||||
# ─────────────────────────────────────────────────
|
||||
# 공유 API 키 (MNG 프로젝트와 동일한 값 사용)
|
||||
# ─────────────────────────────────────────────────
|
||||
|
||||
# ─── Google Gemini AI ───
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
GEMINI_PROJECT_ID=codebridge-chatbot
|
||||
|
||||
# ─── Claude AI ───
|
||||
CLAUDE_API_KEY=
|
||||
|
||||
# ─── Vertex AI (Veo 영상 생성) ───
|
||||
VERTEX_AI_PROJECT_ID=codebridge-chatbot
|
||||
VERTEX_AI_LOCATION=us-central1
|
||||
|
||||
# ─── Google Cloud (STT + GCS Storage) ───
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/var/www/mng/apikey/google_service_account.json
|
||||
GOOGLE_STORAGE_BUCKET=codebridge-speech-audio-files
|
||||
GOOGLE_STT_LOCATION=asia-southeast1
|
||||
|
||||
# ─── FCM (Firebase Cloud Messaging) ───
|
||||
FCM_BATCH_CHUNK_SIZE=200
|
||||
FCM_BATCH_DELAY_MS=100
|
||||
FCM_LOGGING_ENABLED=true
|
||||
FCM_LOG_CHANNEL=stack
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
!storage/.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.phpunit.result.cache
|
||||
Homestead.yaml
|
||||
Homestead.json
|
||||
@@ -153,4 +154,10 @@ _ide_helper_models.php
|
||||
!**/data/
|
||||
# 그리고 .gitkeep은 예외로 추적
|
||||
!**/data/.gitkeep
|
||||
# 시더 데이터 JSON은 추적
|
||||
!database/seeders/data/kyungdong/
|
||||
!database/seeders/data/kyungdong/**
|
||||
storage/secrets/
|
||||
|
||||
# Serena MCP memories
|
||||
.serena/
|
||||
|
||||
21
.serena/memories/db-backup-state.md
Normal file
21
.serena/memories/db-backup-state.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# DB Backup System - State
|
||||
|
||||
## 상태
|
||||
- **phase**: Phase 1 대기
|
||||
- **progress**: 0/14 (0%)
|
||||
- **next_step**: Phase 1.1 - backup.conf.example 설정 파일 생성
|
||||
- **last_decision**: 계획 확정 (5 Phase, 14 항목)
|
||||
- **last_update**: 2026-01-30
|
||||
|
||||
## Phase 구성
|
||||
- Phase 1: 백업 스크립트 (3항목) — api/scripts/backup/
|
||||
- Phase 2: Laravel 모니터링 (3항목) — api/ BackupCheckCommand
|
||||
- Phase 3: 서버 배포 & 테스트 (2항목) — 개발서버 crontab
|
||||
- Phase 4: Slack 알림 (4항목) — api/ SlackNotificationService
|
||||
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
|
||||
|
||||
## 핵심 파일
|
||||
- 계획 문서: docs/plans/db-backup-system-plan.md
|
||||
- 개발서버: 114.203.209.83 (SSH: hskwon)
|
||||
- DB: sam (메인) + sam_stat (통계)
|
||||
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)
|
||||
@@ -79,6 +79,31 @@ excluded_tools: []
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "api"
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
@@ -509,6 +509,7 @@ ### 2. Multi-tenancy & Models
|
||||
- SoftDeletes by default
|
||||
- Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required)
|
||||
- FK constraints: Created during design, minimal in production
|
||||
- **🔴 쿼리 수정 시 모델 스코프 우선**: `where('컬럼', '값')` 하드코딩 전에 반드시 모델에 정의된 스코프(scopeActive 등)를 먼저 확인하고, 스코프가 있으면 `Model::active()` 형태로 사용할 것
|
||||
|
||||
### 3. Middleware Stack
|
||||
- ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper
|
||||
|
||||
2525
CURRENT_WORKS.md
2525
CURRENT_WORKS.md
File diff suppressed because it is too large
Load Diff
133
Jenkinsfile
vendored
Normal file
133
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
environment {
|
||||
DEPLOY_USER = 'hskwon'
|
||||
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
|
||||
}
|
||||
slackSend channel: '#deploy_api', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
|
||||
// ── main → 운영서버 Stage 배포 ──
|
||||
stage('Deploy Stage') {
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
--exclude='.git' --exclude='.env' \
|
||||
--exclude='storage/app' --exclude='storage/logs' \
|
||||
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
|
||||
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage bootstrap/cache &&
|
||||
sudo chmod -R 775 storage bootstrap/cache &&
|
||||
ln -sfn /home/webservice/api-stage/shared/.env .env &&
|
||||
sudo chmod 640 /home/webservice/api-stage/shared/.env &&
|
||||
ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
php artisan config:cache &&
|
||||
php artisan route:cache &&
|
||||
php artisan view:cache &&
|
||||
php artisan migrate --force &&
|
||||
ln -sfn /home/webservice/api-stage/releases/${RELEASE_ID} /home/webservice/api-stage/current &&
|
||||
sudo systemctl reload php8.4-fpm &&
|
||||
cd /home/webservice/api-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 운영 배포 승인 (런칭 후 활성화) ──
|
||||
// stage('Production Approval') {
|
||||
// when { branch 'main' }
|
||||
// steps {
|
||||
// slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
|
||||
// message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
|
||||
// timeout(time: 24, unit: 'HOURS') {
|
||||
// input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
|
||||
// ok: '운영 배포 진행'
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// ── main → 운영서버 Production 배포 ──
|
||||
stage('Deploy Production') {
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
--exclude='.git' --exclude='.env' \
|
||||
--exclude='storage/app' --exclude='storage/logs' \
|
||||
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
|
||||
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
cd /home/webservice/api/releases/${RELEASE_ID} &&
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage bootstrap/cache &&
|
||||
sudo chmod -R 775 storage bootstrap/cache &&
|
||||
ln -sfn /home/webservice/api/shared/.env .env &&
|
||||
sudo chmod 640 /home/webservice/api/shared/.env &&
|
||||
ln -sfn /home/webservice/api/shared/storage/app storage/app &&
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
php artisan config:cache &&
|
||||
php artisan route:cache &&
|
||||
php artisan view:cache &&
|
||||
php artisan migrate --force &&
|
||||
ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
|
||||
sudo systemctl reload php8.4-fpm &&
|
||||
sudo supervisorctl restart sam-queue-worker:* &&
|
||||
cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// develop → Jenkins 관여 안함 (기존 post-update hook 유지)
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
failure {
|
||||
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
script {
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
|
||||
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
|
||||
sudo systemctl reload php8.4-fpm
|
||||
' || true
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# 논리적 데이터베이스 관계 문서
|
||||
|
||||
> **자동 생성**: 2026-01-23 15:57:29
|
||||
> **자동 생성**: 2026-03-07 02:57:21
|
||||
> **소스**: Eloquent 모델 관계 분석
|
||||
|
||||
## 📊 모델별 관계 현황
|
||||
@@ -194,6 +194,121 @@ ### model_versions
|
||||
- **model()**: belongsTo → `models`
|
||||
- **bomTemplates()**: hasMany → `bom_templates`
|
||||
|
||||
### documents
|
||||
**모델**: `App\Models\Documents\Document`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **approvals()**: hasMany → `document_approvals`
|
||||
- **data()**: hasMany → `document_data`
|
||||
- **attachments()**: hasMany → `document_attachments`
|
||||
- **linkable()**: morphTo → `(Polymorphic)`
|
||||
|
||||
### document_approvals
|
||||
**모델**: `App\Models\Documents\DocumentApproval`
|
||||
|
||||
- **document()**: belongsTo → `documents`
|
||||
- **user()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### document_attachments
|
||||
**모델**: `App\Models\Documents\DocumentAttachment`
|
||||
|
||||
- **document()**: belongsTo → `documents`
|
||||
- **file()**: belongsTo → `files`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### document_datas
|
||||
**모델**: `App\Models\Documents\DocumentData`
|
||||
|
||||
- **document()**: belongsTo → `documents`
|
||||
|
||||
### document_links
|
||||
**모델**: `App\Models\Documents\DocumentLink`
|
||||
|
||||
- **document()**: belongsTo → `documents`
|
||||
- **linkDefinition()**: belongsTo → `document_template_links`
|
||||
|
||||
### document_templates
|
||||
**모델**: `App\Models\Documents\DocumentTemplate`
|
||||
|
||||
- **approvalLines()**: hasMany → `document_template_approval_lines`
|
||||
- **basicFields()**: hasMany → `document_template_basic_fields`
|
||||
- **sections()**: hasMany → `document_template_sections`
|
||||
- **columns()**: hasMany → `document_template_columns`
|
||||
- **sectionFields()**: hasMany → `document_template_section_fields`
|
||||
- **links()**: hasMany → `document_template_links`
|
||||
|
||||
### document_template_approval_lines
|
||||
**모델**: `App\Models\Documents\DocumentTemplateApprovalLine`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
|
||||
### document_template_basic_fields
|
||||
**모델**: `App\Models\Documents\DocumentTemplateBasicField`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
|
||||
### document_template_columns
|
||||
**모델**: `App\Models\Documents\DocumentTemplateColumn`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
|
||||
### document_template_links
|
||||
**모델**: `App\Models\Documents\DocumentTemplateLink`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
- **linkValues()**: hasMany → `document_template_link_values`
|
||||
|
||||
### document_template_link_values
|
||||
**모델**: `App\Models\Documents\DocumentTemplateLinkValue`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
- **link()**: belongsTo → `document_template_links`
|
||||
|
||||
### document_template_sections
|
||||
**모델**: `App\Models\Documents\DocumentTemplateSection`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
- **items()**: hasMany → `document_template_section_items`
|
||||
|
||||
### document_template_section_fields
|
||||
**모델**: `App\Models\Documents\DocumentTemplateSectionField`
|
||||
|
||||
- **template()**: belongsTo → `document_templates`
|
||||
|
||||
### document_template_section_items
|
||||
**모델**: `App\Models\Documents\DocumentTemplateSectionItem`
|
||||
|
||||
- **section()**: belongsTo → `document_template_sections`
|
||||
|
||||
### esign_audit_logs
|
||||
**모델**: `App\Models\ESign\EsignAuditLog`
|
||||
|
||||
- **contract()**: belongsTo → `esign_contracts`
|
||||
- **signer()**: belongsTo → `esign_signers`
|
||||
|
||||
### esign_contracts
|
||||
**모델**: `App\Models\ESign\EsignContract`
|
||||
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **signers()**: hasMany → `esign_signers`
|
||||
- **signFields()**: hasMany → `esign_sign_fields`
|
||||
- **auditLogs()**: hasMany → `esign_audit_logs`
|
||||
|
||||
### esign_sign_fields
|
||||
**모델**: `App\Models\ESign\EsignSignField`
|
||||
|
||||
- **contract()**: belongsTo → `esign_contracts`
|
||||
- **signer()**: belongsTo → `esign_signers`
|
||||
|
||||
### esign_signers
|
||||
**모델**: `App\Models\ESign\EsignSigner`
|
||||
|
||||
- **contract()**: belongsTo → `esign_contracts`
|
||||
- **signFields()**: hasMany → `esign_sign_fields`
|
||||
|
||||
### estimates
|
||||
**모델**: `App\Models\Estimate\Estimate`
|
||||
|
||||
@@ -219,6 +334,36 @@ ### folders
|
||||
**모델**: `App\Models\Folder`
|
||||
|
||||
|
||||
### interview_answers
|
||||
**모델**: `App\Models\Interview\InterviewAnswer`
|
||||
|
||||
- **session()**: belongsTo → `interview_sessions`
|
||||
- **question()**: belongsTo → `interview_questions`
|
||||
- **template()**: belongsTo → `interview_templates`
|
||||
|
||||
### interview_categorys
|
||||
**모델**: `App\Models\Interview\InterviewCategory`
|
||||
|
||||
- **templates()**: hasMany → `interview_templates`
|
||||
- **sessions()**: hasMany → `interview_sessions`
|
||||
|
||||
### interview_questions
|
||||
**모델**: `App\Models\Interview\InterviewQuestion`
|
||||
|
||||
- **template()**: belongsTo → `interview_templates`
|
||||
|
||||
### interview_sessions
|
||||
**모델**: `App\Models\Interview\InterviewSession`
|
||||
|
||||
- **category()**: belongsTo → `interview_categories`
|
||||
- **answers()**: hasMany → `interview_answers`
|
||||
|
||||
### interview_templates
|
||||
**모델**: `App\Models\Interview\InterviewTemplate`
|
||||
|
||||
- **category()**: belongsTo → `interview_categories`
|
||||
- **questions()**: hasMany → `interview_questions`
|
||||
|
||||
### custom_tabs
|
||||
**모델**: `App\Models\ItemMaster\CustomTab`
|
||||
|
||||
@@ -255,6 +400,7 @@ ### items
|
||||
- **category()**: belongsTo → `categories`
|
||||
- **files()**: hasMany → `files`
|
||||
- **details()**: hasOne → `item_details`
|
||||
- **stock()**: hasOne → `stocks`
|
||||
|
||||
### item_details
|
||||
**모델**: `App\Models\Items\ItemDetail`
|
||||
@@ -379,6 +525,8 @@ ### orders
|
||||
- **item()**: belongsTo → `items`
|
||||
- **sale()**: belongsTo → `sales`
|
||||
- **items()**: hasMany → `order_items`
|
||||
- **nodes()**: hasMany → `order_nodes`
|
||||
- **rootNodes()**: hasMany → `order_nodes`
|
||||
- **histories()**: hasMany → `order_histories`
|
||||
- **versions()**: hasMany → `order_versions`
|
||||
- **workOrders()**: hasMany → `work_orders`
|
||||
@@ -394,6 +542,7 @@ ### order_items
|
||||
**모델**: `App\Models\Orders\OrderItem`
|
||||
|
||||
- **order()**: belongsTo → `orders`
|
||||
- **node()**: belongsTo → `order_nodes`
|
||||
- **item()**: belongsTo → `items`
|
||||
- **quote()**: belongsTo → `quotes`
|
||||
- **quoteItem()**: belongsTo → `quote_items`
|
||||
@@ -404,6 +553,14 @@ ### order_item_components
|
||||
|
||||
- **orderItem()**: belongsTo → `order_items`
|
||||
|
||||
### order_nodes
|
||||
**모델**: `App\Models\Orders\OrderNode`
|
||||
|
||||
- **parent()**: belongsTo → `order_nodes`
|
||||
- **order()**: belongsTo → `orders`
|
||||
- **children()**: hasMany → `order_nodes`
|
||||
- **items()**: hasMany → `order_items`
|
||||
|
||||
### order_versions
|
||||
**모델**: `App\Models\Orders\OrderVersion`
|
||||
|
||||
@@ -423,17 +580,10 @@ ### roles
|
||||
**모델**: `App\Models\Permissions\Role`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
- **menuPermissions()**: hasMany → `role_menu_permissions`
|
||||
- **userRoles()**: hasMany → `user_roles`
|
||||
- **users()**: belongsToMany → `users`
|
||||
- **permissions()**: belongsToMany → `permissions`
|
||||
|
||||
### role_menu_permissions
|
||||
**모델**: `App\Models\Permissions\RoleMenuPermission`
|
||||
|
||||
- **role()**: belongsTo → `roles`
|
||||
- **menu()**: belongsTo → `menus`
|
||||
|
||||
### popups
|
||||
**모델**: `App\Models\Popups\Popup`
|
||||
|
||||
@@ -446,6 +596,7 @@ ### process
|
||||
|
||||
- **classificationRules()**: hasMany → `process_classification_rules`
|
||||
- **processItems()**: hasMany → `process_items`
|
||||
- **steps()**: hasMany → `process_steps`
|
||||
|
||||
### process_classification_rules
|
||||
**모델**: `App\Models\ProcessClassificationRule`
|
||||
@@ -458,6 +609,11 @@ ### process_items
|
||||
- **process()**: belongsTo → `processes`
|
||||
- **item()**: belongsTo → `items`
|
||||
|
||||
### process_steps
|
||||
**모델**: `App\Models\ProcessStep`
|
||||
|
||||
- **process()**: belongsTo → `processes`
|
||||
|
||||
### work_orders
|
||||
**모델**: `App\Models\Production\WorkOrder`
|
||||
|
||||
@@ -471,8 +627,12 @@ ### work_orders
|
||||
- **primaryAssignee()**: hasMany → `work_order_assignees`
|
||||
- **items()**: hasMany → `work_order_items`
|
||||
- **issues()**: hasMany → `work_order_issues`
|
||||
- **stepProgress()**: hasMany → `work_order_step_progress`
|
||||
- **materialInputs()**: hasMany → `work_order_material_inputs`
|
||||
- **shipments()**: hasMany → `shipments`
|
||||
- **inspections()**: hasMany → `inspections`
|
||||
- **bendingDetail()**: hasOne → `work_order_bending_details`
|
||||
- **documents()**: morphMany → `documents`
|
||||
|
||||
### work_order_assignees
|
||||
**모델**: `App\Models\Production\WorkOrderAssignee`
|
||||
@@ -497,6 +657,25 @@ ### work_order_items
|
||||
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **item()**: belongsTo → `items`
|
||||
- **sourceOrderItem()**: belongsTo → `order_items`
|
||||
- **materialInputs()**: hasMany → `work_order_material_inputs`
|
||||
|
||||
### work_order_material_inputs
|
||||
**모델**: `App\Models\Production\WorkOrderMaterialInput`
|
||||
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **workOrderItem()**: belongsTo → `work_order_items`
|
||||
- **stockLot()**: belongsTo → `stock_lots`
|
||||
- **item()**: belongsTo → `items`
|
||||
- **inputBy()**: belongsTo → `users`
|
||||
|
||||
### work_order_step_progress
|
||||
**모델**: `App\Models\Production\WorkOrderStepProgress`
|
||||
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **processStep()**: belongsTo → `process_steps`
|
||||
- **workOrderItem()**: belongsTo → `work_order_items`
|
||||
- **completedByUser()**: belongsTo → `users`
|
||||
|
||||
### work_results
|
||||
**모델**: `App\Models\Production\WorkResult`
|
||||
@@ -558,6 +737,7 @@ ### push_notification_settings
|
||||
### inspections
|
||||
**모델**: `App\Models\Qualitys\Inspection`
|
||||
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **item()**: belongsTo → `items`
|
||||
- **inspector()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
@@ -573,6 +753,38 @@ ### lot_sales
|
||||
|
||||
- **lot()**: belongsTo → `lots`
|
||||
|
||||
### performance_reports
|
||||
**모델**: `App\Models\Qualitys\PerformanceReport`
|
||||
|
||||
- **qualityDocument()**: belongsTo → `quality_documents`
|
||||
- **confirmer()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### quality_documents
|
||||
**모델**: `App\Models\Qualitys\QualityDocument`
|
||||
|
||||
- **client()**: belongsTo → `clients`
|
||||
- **inspector()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **documentOrders()**: hasMany → `quality_document_orders`
|
||||
- **locations()**: hasMany → `quality_document_locations`
|
||||
- **performanceReport()**: hasOne → `performance_reports`
|
||||
|
||||
### quality_document_locations
|
||||
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
|
||||
|
||||
- **qualityDocument()**: belongsTo → `quality_documents`
|
||||
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
|
||||
- **orderItem()**: belongsTo → `order_items`
|
||||
- **document()**: belongsTo → `documents`
|
||||
|
||||
### quality_document_orders
|
||||
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
|
||||
|
||||
- **qualityDocument()**: belongsTo → `quality_documents`
|
||||
- **order()**: belongsTo → `orders`
|
||||
- **locations()**: hasMany → `quality_document_locations`
|
||||
|
||||
### quotes
|
||||
**모델**: `App\Models\Quote\Quote`
|
||||
|
||||
@@ -631,6 +843,16 @@ ### ai_reports
|
||||
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### ai_token_usages
|
||||
**모델**: `App\Models\Tenants\AiTokenUsage`
|
||||
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### ai_voice_recordings
|
||||
**모델**: `App\Models\Tenants\AiVoiceRecording`
|
||||
|
||||
- **user()**: belongsTo → `users`
|
||||
|
||||
### approvals
|
||||
**모델**: `App\Models\Tenants\Approval`
|
||||
|
||||
@@ -641,6 +863,7 @@ ### approvals
|
||||
- **steps()**: hasMany → `approval_steps`
|
||||
- **approverSteps()**: hasMany → `approval_steps`
|
||||
- **referenceSteps()**: hasMany → `approval_steps`
|
||||
- **linkable()**: morphTo → `(Polymorphic)`
|
||||
|
||||
### approval_forms
|
||||
**모델**: `App\Models\Tenants\ApprovalForm`
|
||||
@@ -738,6 +961,16 @@ ### expense_accounts
|
||||
|
||||
- **vendor()**: belongsTo → `clients`
|
||||
|
||||
### journal_entrys
|
||||
**모델**: `App\Models\Tenants\JournalEntry`
|
||||
|
||||
- **lines()**: hasMany → `journal_entry_lines`
|
||||
|
||||
### journal_entry_lines
|
||||
**모델**: `App\Models\Tenants\JournalEntryLine`
|
||||
|
||||
- **journalEntry()**: belongsTo → `journal_entries`
|
||||
|
||||
### leaves
|
||||
**모델**: `App\Models\Tenants\Leave`
|
||||
|
||||
@@ -766,7 +999,10 @@ ### leave_policys
|
||||
### loans
|
||||
**모델**: `App\Models\Tenants\Loan`
|
||||
|
||||
- **user()**: belongsTo → `users`
|
||||
- **withdrawal()**: belongsTo → `withdrawals`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
|
||||
### payments
|
||||
**모델**: `App\Models\Tenants\Payment`
|
||||
@@ -848,6 +1084,7 @@ ### shipments
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **items()**: hasMany → `shipment_items`
|
||||
- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches`
|
||||
|
||||
### shipment_items
|
||||
**모델**: `App\Models\Tenants\ShipmentItem`
|
||||
@@ -855,6 +1092,11 @@ ### shipment_items
|
||||
- **shipment()**: belongsTo → `shipments`
|
||||
- **stockLot()**: belongsTo → `stock_lots`
|
||||
|
||||
### shipment_vehicle_dispatchs
|
||||
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
|
||||
|
||||
- **shipment()**: belongsTo → `shipments`
|
||||
|
||||
### sites
|
||||
**모델**: `App\Models\Tenants\Site`
|
||||
|
||||
@@ -877,12 +1119,21 @@ ### stocks
|
||||
- **item()**: belongsTo → `items`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **lots()**: hasMany → `stock_lots`
|
||||
- **transactions()**: hasMany → `stock_transactions`
|
||||
|
||||
### stock_lots
|
||||
**모델**: `App\Models\Tenants\StockLot`
|
||||
|
||||
- **stock()**: belongsTo → `stocks`
|
||||
- **receiving()**: belongsTo → `receivings`
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### stock_transactions
|
||||
**모델**: `App\Models\Tenants\StockTransaction`
|
||||
|
||||
- **stock()**: belongsTo → `stocks`
|
||||
- **stockLot()**: belongsTo → `stock_lots`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### subscriptions
|
||||
@@ -943,6 +1194,8 @@ ### tenant_user_profiles
|
||||
### today_issues
|
||||
**모델**: `App\Models\Tenants\TodayIssue`
|
||||
|
||||
- **reader()**: belongsTo → `users`
|
||||
- **targetUser()**: belongsTo → `users`
|
||||
|
||||
### withdrawals
|
||||
**모델**: `App\Models\Tenants\Withdrawal`
|
||||
|
||||
54
app/Console/Commands/BackfillQuoteProductCodeCommand.php
Normal file
54
app/Console/Commands/BackfillQuoteProductCodeCommand.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Quote\Quote;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillQuoteProductCodeCommand extends Command
|
||||
{
|
||||
protected $signature = 'data:backfill-quote-product-code {--dry-run : 실제 저장하지 않고 결과만 출력}';
|
||||
|
||||
protected $description = 'quotes.product_code가 비어있는 레코드에 calculation_inputs.items[0].productCode 값 보정';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$quotes = Quote::whereNull('product_code')
|
||||
->whereNotNull('calculation_inputs')
|
||||
->get();
|
||||
|
||||
$this->info("대상: {$quotes->count()}건".($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($quotes as $quote) {
|
||||
$inputs = $quote->calculation_inputs;
|
||||
if (! is_array($inputs)) {
|
||||
$inputs = json_decode($inputs, true);
|
||||
}
|
||||
|
||||
$productCode = $inputs['items'][0]['productCode'] ?? null;
|
||||
|
||||
if (! $productCode) {
|
||||
$skipped++;
|
||||
$this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$quote->update(['product_code' => $productCode]);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
$this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}");
|
||||
}
|
||||
|
||||
$this->info("완료: 보정 {$updated}건, 스킵 {$skipped}건");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
115
app/Console/Commands/BackupCheckCommand.php
Normal file
115
app/Console/Commands/BackupCheckCommand.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SlackNotificationService;
|
||||
use App\Services\Stats\StatMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BackupCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'db:backup-check
|
||||
{--path= : 백업 경로 오버라이드}';
|
||||
|
||||
protected $description = 'DB 백업 상태를 확인하고 이상 시 알림을 생성합니다';
|
||||
|
||||
public function handle(StatMonitorService $monitorService): int
|
||||
{
|
||||
$this->info('DB 백업 상태 확인 시작...');
|
||||
|
||||
$statusFile = $this->option('path')
|
||||
? rtrim($this->option('path'), '/') . '/.backup_status'
|
||||
: env('BACKUP_STATUS_FILE', '/data/backup/mysql/.backup_status');
|
||||
|
||||
$errors = [];
|
||||
|
||||
// 1. 상태 파일 존재 여부
|
||||
if (! file_exists($statusFile)) {
|
||||
$errors[] = '백업 상태 파일 없음: ' . $statusFile;
|
||||
$this->reportErrors($monitorService, $errors);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$status = json_decode(file_get_contents($statusFile), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errors[] = '상태 파일 JSON 파싱 실패: ' . json_last_error_msg();
|
||||
$this->reportErrors($monitorService, $errors);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 2. last_run이 25시간 이내인지
|
||||
$lastRun = strtotime($status['last_run'] ?? '');
|
||||
if (! $lastRun || (time() - $lastRun) > 25 * 3600) {
|
||||
$lastRunStr = $status['last_run'] ?? 'unknown';
|
||||
$errors[] = "마지막 백업이 25시간 초과: {$lastRunStr}";
|
||||
}
|
||||
|
||||
// 3. status가 success인지
|
||||
if (($status['status'] ?? '') !== 'success') {
|
||||
$errors[] = '백업 상태 실패: ' . ($status['status'] ?? 'unknown');
|
||||
}
|
||||
|
||||
// 4. 각 DB 백업 파일 크기 검증
|
||||
$minSizes = [
|
||||
'sam' => (int) env('BACKUP_MIN_SIZE_SAM', 1048576),
|
||||
'sam_stat' => (int) env('BACKUP_MIN_SIZE_STAT', 102400),
|
||||
];
|
||||
|
||||
$databases = $status['databases'] ?? [];
|
||||
foreach ($minSizes as $dbName => $minSize) {
|
||||
if (! isset($databases[$dbName])) {
|
||||
$errors[] = "{$dbName} DB 백업 정보 없음";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sizeBytes = $databases[$dbName]['size_bytes'] ?? 0;
|
||||
if ($sizeBytes < $minSize) {
|
||||
$errors[] = "{$dbName} 백업 파일 크기 부족: {$sizeBytes} bytes (최소 {$minSize})";
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 처리
|
||||
if (! empty($errors)) {
|
||||
$this->reportErrors($monitorService, $errors);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('✅ DB 백업 상태 정상');
|
||||
Log::info('db:backup-check 정상', [
|
||||
'last_run' => $status['last_run'],
|
||||
'databases' => array_keys($databases),
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function reportErrors(StatMonitorService $monitorService, array $errors): void
|
||||
{
|
||||
$errorMessage = implode("\n", $errors);
|
||||
|
||||
$this->error('❌ DB 백업 이상 감지:');
|
||||
foreach ($errors as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
|
||||
// stat_alerts에 기록
|
||||
$monitorService->recordBackupFailure(
|
||||
'[backup] DB 백업 이상 감지',
|
||||
$errorMessage
|
||||
);
|
||||
|
||||
// Slack 알림 전송
|
||||
app(SlackNotificationService::class)->sendBackupAlert(
|
||||
'DB 백업 이상 감지',
|
||||
$errorMessage
|
||||
);
|
||||
|
||||
Log::error('db:backup-check 실패', ['errors' => $errors]);
|
||||
}
|
||||
}
|
||||
202
app/Console/Commands/ExportItemMasterDataCommand.php
Normal file
202
app/Console/Commands/ExportItemMasterDataCommand.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 경동기업(tenant_id=287) 품목 기준 데이터를 JSON으로 추출
|
||||
*
|
||||
* 추출 대상: item_pages, item_sections, item_fields,
|
||||
* entity_relationships, categories, items, item_details, prices
|
||||
*
|
||||
* 사용법: php artisan kyungdong:export-item-master
|
||||
*/
|
||||
class ExportItemMasterDataCommand extends Command
|
||||
{
|
||||
protected $signature = 'kyungdong:export-item-master';
|
||||
|
||||
protected $description = '경동기업(tenant_id=287) 품목 기준 데이터를 JSON 파일로 추출';
|
||||
|
||||
private const TENANT_ID = 287;
|
||||
|
||||
private string $outputPath;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->outputPath = database_path('seeders/data/kyungdong');
|
||||
|
||||
if (! is_dir($this->outputPath)) {
|
||||
mkdir($this->outputPath, 0755, true);
|
||||
}
|
||||
|
||||
$this->info('경동기업 품목 기준 데이터 추출 시작 (tenant_id=' . self::TENANT_ID . ')');
|
||||
$this->newLine();
|
||||
|
||||
$this->exportItemPages();
|
||||
$this->exportItemSections();
|
||||
$this->exportItemFields();
|
||||
$this->exportEntityRelationships();
|
||||
$this->exportCategories();
|
||||
$this->exportItems();
|
||||
$this->exportItemDetails();
|
||||
$this->exportPrices();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('추출 완료! 경로: ' . $this->outputPath);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function exportItemPages(): void
|
||||
{
|
||||
$rows = DB::table('item_pages')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('item_pages.json', $rows);
|
||||
$this->info(" item_pages: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportItemSections(): void
|
||||
{
|
||||
$rows = DB::table('item_sections')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('item_sections.json', $rows);
|
||||
$this->info(" item_sections: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportItemFields(): void
|
||||
{
|
||||
$rows = DB::table('item_fields')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('item_fields.json', $rows);
|
||||
$this->info(" item_fields: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportEntityRelationships(): void
|
||||
{
|
||||
// 참조 대상이 실제 존재하는 것만 추출
|
||||
$validPageIds = DB::table('item_pages')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
|
||||
$validSectionIds = DB::table('item_sections')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
|
||||
$validFieldIds = DB::table('item_fields')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
|
||||
$validBomIds = DB::table('item_bom_items')->where('tenant_id', self::TENANT_ID)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
$validIds = [
|
||||
'page' => $validPageIds->flip(),
|
||||
'section' => $validSectionIds->flip(),
|
||||
'field' => $validFieldIds->flip(),
|
||||
'bom' => $validBomIds->flip(),
|
||||
];
|
||||
|
||||
$rows = DB::table('entity_relationships')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->get()
|
||||
->filter(function ($row) use ($validIds) {
|
||||
$parentValid = isset($validIds[$row->parent_type][$row->parent_id]);
|
||||
$childValid = isset($validIds[$row->child_type][$row->child_id]);
|
||||
|
||||
return $parentValid && $childValid;
|
||||
})
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('entity_relationships.json', $rows);
|
||||
$this->info(" entity_relationships: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportCategories(): void
|
||||
{
|
||||
$rows = DB::table('categories')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->orderByRaw('COALESCE(parent_id, 0), sort_order, id')
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('categories.json', $rows);
|
||||
$this->info(" categories: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportItems(): void
|
||||
{
|
||||
$rows = DB::table('items')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('items.json', $rows);
|
||||
$this->info(" items: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportItemDetails(): void
|
||||
{
|
||||
$itemIds = DB::table('items')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id');
|
||||
|
||||
$rows = DB::table('item_details')
|
||||
->whereIn('item_id', $itemIds)
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('item_details.json', $rows);
|
||||
$this->info(" item_details: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
private function exportPrices(): void
|
||||
{
|
||||
$rows = DB::table('prices')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn ($row) => $this->addOriginalId($row))
|
||||
->toArray();
|
||||
|
||||
$this->writeJson('prices.json', $rows);
|
||||
$this->info(" prices: " . count($rows) . "건");
|
||||
}
|
||||
|
||||
/**
|
||||
* _original_id 추가 + id 제거
|
||||
*/
|
||||
private function addOriginalId(object $row): array
|
||||
{
|
||||
$data = (array) $row;
|
||||
$data['_original_id'] = $data['id'];
|
||||
unset($data['id']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function writeJson(string $filename, array $data): void
|
||||
{
|
||||
$path = $this->outputPath . '/' . $filename;
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class FcmSendCommand extends Command
|
||||
{--tenant= : 테넌트 ID (미지정 시 전체)}
|
||||
{--user= : 사용자 ID (미지정 시 전체)}
|
||||
{--platform= : 플랫폼 (android, ios, web)}
|
||||
{--channel=push_default : 알림 채널 (push_default, push_urgent)}
|
||||
{--channel=push_default : 알림 채널 (push_default, push_vendor_register, push_approval_request, push_income, push_sales_order, push_purchase_order, push_contract)}
|
||||
{--title= : 알림 제목 (필수)}
|
||||
{--body= : 알림 내용 (필수)}
|
||||
{--type= : 알림 타입 (invoice_failed 등)}
|
||||
|
||||
@@ -14,7 +14,7 @@ class FcmTestCommand extends Command
|
||||
*/
|
||||
protected $signature = 'fcm:test
|
||||
{--token= : FCM 디바이스 토큰 (필수)}
|
||||
{--channel=push_default : 알림 채널 ID (push_default, push_urgent)}
|
||||
{--channel=push_default : 알림 채널 ID (push_default, push_vendor_register, push_approval_request, push_income, push_sales_order, push_purchase_order, push_contract)}
|
||||
{--title=테스트 알림 : 알림 제목}
|
||||
{--body=FCM 테스트 메시지입니다. : 알림 내용}
|
||||
{--data= : 추가 데이터 (JSON 형식)}';
|
||||
@@ -38,7 +38,7 @@ public function handle(): int
|
||||
$this->line('');
|
||||
$this->line('사용법:');
|
||||
$this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN');
|
||||
$this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN --channel=push_urgent --title="긴급 알림"');
|
||||
$this->line(' php artisan fcm:test --token=YOUR_FCM_TOKEN --channel=push_vendor_register --title="신규업체 알림"');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
141
app/Console/Commands/ManageAuditPartitions.php
Normal file
141
app/Console/Commands/ManageAuditPartitions.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ManageAuditPartitions extends Command
|
||||
{
|
||||
protected $signature = 'audit:partitions
|
||||
{--add-months=3 : 미래 파티션 추가 개월 수}
|
||||
{--retention-months=13 : 보관 기간 (개월)}
|
||||
{--drop : 보관 기간 초과 파티션 삭제 실행}
|
||||
{--dry-run : 변경 없이 계획만 출력}';
|
||||
|
||||
protected $description = '트리거 감사 로그 파티션 자동 관리 (추가/삭제)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$addMonths = (int) $this->option('add-months');
|
||||
$retentionMonths = (int) $this->option('retention-months');
|
||||
$doDrop = $this->option('drop');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== 트리거 감사 로그 파티션 관리 ===');
|
||||
$this->newLine();
|
||||
|
||||
// 현재 파티션 목록 조회
|
||||
$partitions = $this->getPartitions();
|
||||
$this->info('현재 파티션: '.count($partitions).'개');
|
||||
$this->table(
|
||||
['파티션명', '상한값 (UNIX_TIMESTAMP)', '행 수'],
|
||||
collect($partitions)->map(fn ($p) => [
|
||||
$p->PARTITION_NAME,
|
||||
$p->PARTITION_DESCRIPTION === 'MAXVALUE' ? 'MAXVALUE' : Carbon::createFromTimestamp($p->PARTITION_DESCRIPTION)->format('Y-m-d'),
|
||||
number_format($p->TABLE_ROWS),
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// 1. 미래 파티션 추가
|
||||
$added = $this->addFuturePartitions($partitions, $addMonths, $dryRun);
|
||||
|
||||
// 2. 오래된 파티션 삭제
|
||||
$dropped = 0;
|
||||
if ($doDrop) {
|
||||
$dropped = $this->dropOldPartitions($partitions, $retentionMonths, $dryRun);
|
||||
} else {
|
||||
$this->warn('파티션 삭제는 --drop 옵션 필요');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("결과: 추가 {$added}개, 삭제 {$dropped}개".($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getPartitions(): array
|
||||
{
|
||||
return DB::select("
|
||||
SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS
|
||||
FROM INFORMATION_SCHEMA.PARTITIONS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs'
|
||||
AND PARTITION_NAME IS NOT NULL
|
||||
ORDER BY PARTITION_ORDINAL_POSITION
|
||||
", [config('database.connections.mysql.database')]);
|
||||
}
|
||||
|
||||
private function addFuturePartitions(array $partitions, int $addMonths, bool $dryRun): int
|
||||
{
|
||||
$existingBounds = [];
|
||||
foreach ($partitions as $p) {
|
||||
if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') {
|
||||
$existingBounds[] = (int) $p->PARTITION_DESCRIPTION;
|
||||
}
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
$now = Carbon::now();
|
||||
|
||||
for ($i = 0; $i <= $addMonths; $i++) {
|
||||
$target = $now->copy()->addMonths($i)->startOfMonth()->addMonth();
|
||||
$ts = $target->timestamp;
|
||||
$name = 'p'.$target->copy()->subMonth()->format('Ym');
|
||||
|
||||
if (in_array($ts, $existingBounds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = "ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO (
|
||||
PARTITION {$name} VALUES LESS THAN ({$ts}),
|
||||
PARTITION p_future VALUES LESS THAN MAXVALUE
|
||||
)";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY-RUN] 추가: {$name} (< {$target->format('Y-m-d')})");
|
||||
} else {
|
||||
DB::statement($sql);
|
||||
$this->info(" 추가: {$name} (< {$target->format('Y-m-d')})");
|
||||
}
|
||||
$added++;
|
||||
}
|
||||
|
||||
if ($added === 0) {
|
||||
$this->info(' 추가할 파티션 없음');
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
private function dropOldPartitions(array $partitions, int $retentionMonths, bool $dryRun): int
|
||||
{
|
||||
$cutoff = Carbon::now()->subMonths($retentionMonths)->startOfMonth()->timestamp;
|
||||
$dropped = 0;
|
||||
|
||||
foreach ($partitions as $p) {
|
||||
if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bound = (int) $p->PARTITION_DESCRIPTION;
|
||||
if ($bound <= $cutoff) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY-RUN] 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
|
||||
} else {
|
||||
DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$p->PARTITION_NAME}");
|
||||
$this->warn(" 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
|
||||
}
|
||||
$dropped++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dropped === 0) {
|
||||
$this->info(' 삭제할 파티션 없음');
|
||||
}
|
||||
|
||||
return $dropped;
|
||||
}
|
||||
}
|
||||
345
app/Console/Commands/MapItemsToProcesses.php
Normal file
345
app/Console/Commands/MapItemsToProcesses.php
Normal file
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Process;
|
||||
use App\Models\ProcessItem;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 5130 기준 품목-공정 매핑 (A+B+C 전략)
|
||||
*
|
||||
* A. 품목명 키워드 기반:
|
||||
* - "스크린용", "스크린" → P-002 스크린
|
||||
* - "철재용", "철재", "슬랫" → P-001 슬랫
|
||||
*
|
||||
* B. BD 코드 기반:
|
||||
* - BD-* → P-003 절곡
|
||||
*
|
||||
* C. 재고생산(LOT) 기반 (5130 lot 테이블 분석):
|
||||
* - PT-* 코드 → P-004 재고생산
|
||||
* - 가이드레일, 케이스, 연기차단재, L-Bar → P-004 재고생산
|
||||
*/
|
||||
class MapItemsToProcesses extends Command
|
||||
{
|
||||
protected $signature = 'items:map-to-processes
|
||||
{--tenant= : 특정 테넌트 ID (기본: 모든 테넌트)}
|
||||
{--dry-run : 실제 실행 없이 미리보기만}
|
||||
{--clear : 기존 매핑 삭제 후 새로 매핑}';
|
||||
|
||||
protected $description = '5130 기준 품목-공정 자동 매핑 (A: 키워드 + B: BD코드 + C: 재고생산)';
|
||||
|
||||
/**
|
||||
* 공정별 매핑 규칙 정의
|
||||
*
|
||||
* 5130 LOT 생산 품목 분류:
|
||||
* - R: 가이드레일-벽면형, S: 가이드레일-측면형
|
||||
* - C: 케이스 (린텔부, 전면부, 점검구, 후면코너부)
|
||||
* - B: 하단마감재-스크린, T: 하단마감재-철재
|
||||
* - G: 연기차단재
|
||||
* - L: L-Bar
|
||||
*/
|
||||
/**
|
||||
* FG(완제품), RM(원자재) 제외 - 공정별 생산 품목만 매핑
|
||||
* EST-INSPECTION(검사비), EST-MOTOR/EST-CTRL(구매품)도 제외
|
||||
*/
|
||||
private array $globalExcludes = ['FG-%', 'RM-%', 'EST-INSPECTION'];
|
||||
|
||||
private array $mappingRules = [
|
||||
'P-001' => [
|
||||
'name' => '슬랫',
|
||||
'code_patterns' => ['EST-RAW-슬랫-%'], // 슬랫 원자재 (방화/방범/조인트바)
|
||||
'name_keywords' => ['슬랫'],
|
||||
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'],
|
||||
],
|
||||
'P-002' => [
|
||||
'name' => '스크린',
|
||||
'code_patterns' => ['EST-RAW-스크린-%'], // 스크린 원자재 (실리카/와이어 등)
|
||||
'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'],
|
||||
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'],
|
||||
],
|
||||
'P-003' => [
|
||||
'name' => '절곡',
|
||||
'code_patterns' => ['BD-%'], // BD 코드는 절곡
|
||||
'name_keywords' => ['절곡', '연기차단재'], // 연기차단재는 절곡 공정에서 조립
|
||||
'name_excludes' => [],
|
||||
],
|
||||
'P-004' => [
|
||||
'name' => '재고생산',
|
||||
'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품
|
||||
'name_keywords' => ['가이드레일', '케이스', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바', '환봉', '감기샤프트', '각파이프', '앵글'],
|
||||
'name_excludes' => [],
|
||||
'code_excludes' => ['BD-%', 'EST-SMOKE-%'], // BD는 P-003, EST-SMOKE는 P-003
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = $this->option('tenant');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$clear = $this->option('clear');
|
||||
|
||||
$this->info('=== 5130 기준 품목-공정 매핑 (A+B+C 전략) ===');
|
||||
$this->info('A. 품목명 키워드: 스크린용→P-002, 철재용→P-001');
|
||||
$this->info('B. BD 코드: BD-* → P-003 절곡');
|
||||
$this->info('C. 재고생산: PT-* 또는 가이드레일/케이스/연기차단재/L-Bar → P-004');
|
||||
$this->newLine();
|
||||
|
||||
// 공정 조회
|
||||
$processQuery = Process::query();
|
||||
if ($tenantId) {
|
||||
$processQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
$processes = $processQuery->whereIn('process_code', array_keys($this->mappingRules))->get()->keyBy('process_code');
|
||||
|
||||
if ($processes->isEmpty()) {
|
||||
$this->error('매핑 대상 공정이 없습니다. (P-001, P-002, P-003, P-004)');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('대상 공정:');
|
||||
foreach ($processes as $code => $process) {
|
||||
$this->line(" - {$code}: {$process->process_name} (ID: {$process->id})");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
// 기존 매핑 삭제 (--clear 옵션)
|
||||
if ($clear) {
|
||||
$processIds = $processes->pluck('id')->toArray();
|
||||
$existingCount = ProcessItem::whereIn('process_id', $processIds)->count();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("[DRY-RUN] 기존 매핑 {$existingCount}개 삭제 예정");
|
||||
} else {
|
||||
ProcessItem::whereIn('process_id', $processIds)->delete();
|
||||
$this->warn("기존 매핑 {$existingCount}개 삭제 완료");
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// 매핑 결과 저장
|
||||
$results = [
|
||||
'P-001' => ['items' => collect(), 'process' => $processes->get('P-001')],
|
||||
'P-002' => ['items' => collect(), 'process' => $processes->get('P-002')],
|
||||
'P-003' => ['items' => collect(), 'process' => $processes->get('P-003')],
|
||||
'P-004' => ['items' => collect(), 'process' => $processes->get('P-004')],
|
||||
];
|
||||
|
||||
// 품목 조회 및 분류
|
||||
$itemQuery = Item::query();
|
||||
if ($tenantId) {
|
||||
$itemQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
$items = $itemQuery->get();
|
||||
|
||||
$this->info("전체 품목 수: {$items->count()}개");
|
||||
$this->newLine();
|
||||
|
||||
$mappedCount = 0;
|
||||
$unmappedItems = collect();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$processCode = $this->classifyItem($item);
|
||||
|
||||
if ($processCode && isset($results[$processCode])) {
|
||||
$results[$processCode]['items']->push($item);
|
||||
$mappedCount++;
|
||||
} else {
|
||||
$unmappedItems->push($item);
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
$this->info('=== 분류 결과 ===');
|
||||
$this->newLine();
|
||||
|
||||
$tableData = [];
|
||||
foreach ($results as $code => $data) {
|
||||
$count = $data['items']->count();
|
||||
$processName = $data['process']?->process_name ?? '(없음)';
|
||||
$tableData[] = [$code, $processName, $count];
|
||||
}
|
||||
$tableData[] = ['-', '미분류', $unmappedItems->count()];
|
||||
$tableData[] = ['=', '합계', $items->count()];
|
||||
|
||||
$this->table(['공정코드', '공정명', '품목 수'], $tableData);
|
||||
$this->newLine();
|
||||
|
||||
// 샘플 출력
|
||||
foreach ($results as $code => $data) {
|
||||
if ($data['items']->isNotEmpty()) {
|
||||
$this->info("[{$code} {$data['process']?->process_name}] 샘플 (최대 10개):");
|
||||
foreach ($data['items']->take(10) as $item) {
|
||||
$this->line(" - {$item->code}: {$item->name}");
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// 미분류 샘플
|
||||
if ($unmappedItems->isNotEmpty()) {
|
||||
$this->info('[미분류] 샘플 (최대 10개):');
|
||||
foreach ($unmappedItems->take(10) as $item) {
|
||||
$this->line(" - {$item->code}: {$item->name}");
|
||||
}
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
// 실제 매핑 실행
|
||||
if (! $dryRun) {
|
||||
$this->info('=== 매핑 실행 ===');
|
||||
|
||||
DB::transaction(function () use ($results) {
|
||||
foreach ($results as $code => $data) {
|
||||
$process = $data['process'];
|
||||
if (! $process) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$priority = 0;
|
||||
foreach ($data['items'] as $item) {
|
||||
// 중복 체크
|
||||
$exists = ProcessItem::where('process_id', $process->id)
|
||||
->where('item_id', $item->id)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
ProcessItem::create([
|
||||
'process_id' => $process->id,
|
||||
'item_id' => $item->id,
|
||||
'priority' => $priority++,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" {$code}: {$data['items']->count()}개 매핑 완료");
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("총 {$mappedCount}개 품목 매핑 완료!");
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->warn('[DRY-RUN] 실제 매핑은 수행되지 않았습니다.');
|
||||
$this->line('실제 실행: php artisan items:map-to-processes --clear');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목을 공정에 분류 (A+B+C 전략)
|
||||
*/
|
||||
private function classifyItem(Item $item): ?string
|
||||
{
|
||||
$code = $item->code ?? '';
|
||||
$name = $item->name ?? '';
|
||||
|
||||
// 0. 글로벌 제외 (FG 완제품, RM 원자재, EST-INSPECTION 서비스)
|
||||
foreach ($this->globalExcludes as $excludePattern) {
|
||||
$prefix = rtrim($excludePattern, '-%');
|
||||
if (str_starts_with($code, $prefix)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 코드 패턴 우선 매핑 (정확한 매칭)
|
||||
// EST-RAW-슬랫-* → P-001
|
||||
if (str_starts_with($code, 'EST-RAW-슬랫-')) {
|
||||
return 'P-001';
|
||||
}
|
||||
|
||||
// EST-RAW-스크린-* → P-002
|
||||
if (str_starts_with($code, 'EST-RAW-스크린-')) {
|
||||
return 'P-002';
|
||||
}
|
||||
|
||||
// BD-* → P-003 절곡
|
||||
if (str_starts_with($code, 'BD-')) {
|
||||
return 'P-003';
|
||||
}
|
||||
|
||||
// EST-SMOKE-* → P-003 절곡 (연기차단재는 절곡 공정에서 조립)
|
||||
if (str_starts_with($code, 'EST-SMOKE-')) {
|
||||
return 'P-003';
|
||||
}
|
||||
|
||||
// PT-* → P-004 재고생산
|
||||
if (str_starts_with($code, 'PT-')) {
|
||||
return 'P-004';
|
||||
}
|
||||
|
||||
// EST-MOTOR/EST-CTRL → 구매품, 공정 없음
|
||||
if (str_starts_with($code, 'EST-MOTOR-') || str_starts_with($code, 'EST-CTRL-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// EST-SHAFT/EST-PIPE/EST-ANGLE → P-004 재고생산 (조달 품목)
|
||||
if (str_starts_with($code, 'EST-SHAFT-') || str_starts_with($code, 'EST-PIPE-') || str_starts_with($code, 'EST-ANGLE-')) {
|
||||
return 'P-004';
|
||||
}
|
||||
|
||||
// 2. P-004 재고생산 키워드 체크
|
||||
foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
// code_excludes 체크
|
||||
$excluded = false;
|
||||
foreach ($this->mappingRules['P-004']['code_excludes'] ?? [] as $excludePattern) {
|
||||
$prefix = rtrim($excludePattern, '-%');
|
||||
if (str_starts_with($code, $prefix)) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $excluded) {
|
||||
return 'P-004';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. P-003 절곡 키워드 체크
|
||||
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
return 'P-003';
|
||||
}
|
||||
}
|
||||
|
||||
// 4. P-002 스크린 키워드 체크
|
||||
foreach ($this->mappingRules['P-002']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
$excluded = false;
|
||||
foreach ($this->mappingRules['P-002']['name_excludes'] as $exclude) {
|
||||
if (mb_stripos($name, $exclude) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $excluded) {
|
||||
return 'P-002';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. P-001 슬랫 키워드 체크
|
||||
foreach ($this->mappingRules['P-001']['name_keywords'] as $keyword) {
|
||||
if (mb_stripos($name, $keyword) !== false) {
|
||||
$excluded = false;
|
||||
foreach ($this->mappingRules['P-001']['name_excludes'] as $exclude) {
|
||||
if (mb_stripos($name, $exclude) !== false) {
|
||||
$excluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $excluded) {
|
||||
return 'P-001';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
631
app/Console/Commands/Migrate5130BendingStock.php
Normal file
631
app/Console/Commands/Migrate5130BendingStock.php
Normal file
@@ -0,0 +1,631 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 코드 생성 + BD-* 전체 품목 초기 재고 셋팅')]
|
||||
class Migrate5130BendingStock extends Command
|
||||
{
|
||||
protected $signature = 'migrate:5130-bending-stock
|
||||
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
|
||||
{--dry-run : 실제 저장 없이 시뮬레이션만 수행}
|
||||
{--min-stock=100 : 품목별 초기 재고 수량 (기본: 100)}
|
||||
{--rollback : 초기 재고 셋팅 롤백 (init_stock 소스 데이터 삭제)}';
|
||||
|
||||
private string $sourceDb = 'chandj';
|
||||
|
||||
private string $targetDb = 'mysql';
|
||||
|
||||
// 5130 prod 코드 → 한글명
|
||||
private array $prodNames = [
|
||||
'R' => '가이드레일(벽면)', 'S' => '가이드레일(측면)',
|
||||
'G' => '연기차단재', 'B' => '하단마감재(스크린)',
|
||||
'T' => '하단마감재(철재)', 'L' => 'L-Bar', 'C' => '케이스',
|
||||
];
|
||||
|
||||
// 5130 spec 코드 → 한글명
|
||||
private array $specNames = [
|
||||
'I' => '화이바원단', 'S' => 'SUS', 'U' => 'SUS2', 'E' => 'EGI',
|
||||
'A' => '스크린용', 'D' => 'D형', 'C' => 'C형', 'M' => '본체',
|
||||
'T' => '본체(철재)', 'B' => '후면코너부', 'L' => '린텔부',
|
||||
'P' => '점검구', 'F' => '전면부',
|
||||
];
|
||||
|
||||
// 5130 slength 코드 → 한글명
|
||||
private array $slengthNames = [
|
||||
'53' => 'W50×3000', '54' => 'W50×4000', '83' => 'W80×3000',
|
||||
'84' => 'W80×4000', '12' => '1219mm', '24' => '2438mm',
|
||||
'30' => '3000mm', '35' => '3500mm', '40' => '4000mm',
|
||||
'41' => '4150mm', '42' => '4200mm', '43' => '4300mm',
|
||||
];
|
||||
|
||||
private array $stats = [
|
||||
'items_found' => 0,
|
||||
'items_created_5130' => 0,
|
||||
'items_category_updated' => 0,
|
||||
'stocks_created' => 0,
|
||||
'stocks_skipped' => 0,
|
||||
'lots_created' => 0,
|
||||
'transactions_created' => 0,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$rollback = $this->option('rollback');
|
||||
$minStock = (int) $this->option('min-stock');
|
||||
|
||||
$this->info('=== BD-* 절곡품 초기 재고 셋팅 ===');
|
||||
$this->info("Tenant ID: {$tenantId}");
|
||||
$this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
|
||||
$this->info("초기 재고: {$minStock}개/품목");
|
||||
$this->newLine();
|
||||
|
||||
if ($rollback) {
|
||||
return $this->rollbackInitStock($tenantId, $dryRun);
|
||||
}
|
||||
|
||||
// 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
|
||||
$this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...');
|
||||
$this->createLegacyItems($tenantId, $dryRun);
|
||||
$this->newLine();
|
||||
|
||||
// 1. 전체 BD-* 아이템 조회 (기존 58개 + 5130 생성분)
|
||||
$this->info('📥 Step 1: BD-* 절곡품 품목 조회...');
|
||||
$items = DB::connection($this->targetDb)
|
||||
->table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', 'like', 'BD-%')
|
||||
->whereNull('deleted_at')
|
||||
->select('id', 'code', 'name', 'item_type', 'item_category', 'unit', 'options')
|
||||
->orderBy('code')
|
||||
->get();
|
||||
|
||||
$this->stats['items_found'] = $items->count();
|
||||
$this->info(" - BD-* 품목: {$items->count()}건");
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
$this->warn('BD-* 품목이 없습니다. 종료합니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 2. item_category 미설정 품목 업데이트
|
||||
$this->newLine();
|
||||
$this->info('🏷️ Step 2: item_category 업데이트...');
|
||||
$needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING');
|
||||
|
||||
if ($needsCategoryUpdate->isNotEmpty()) {
|
||||
$this->info(" - item_category 미설정/불일치: {$needsCategoryUpdate->count()}건");
|
||||
if (! $dryRun) {
|
||||
DB::connection($this->targetDb)
|
||||
->table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', 'like', 'BD-%')
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('item_category')
|
||||
->orWhere('item_category', '!=', 'BENDING');
|
||||
})
|
||||
->update(['item_category' => 'BENDING', 'updated_at' => now()]);
|
||||
}
|
||||
$this->stats['items_category_updated'] = $needsCategoryUpdate->count();
|
||||
} else {
|
||||
$this->info(' - 모든 품목 BENDING 카테고리 설정 완료');
|
||||
}
|
||||
|
||||
// 3. 현재 재고 현황 표시
|
||||
$this->newLine();
|
||||
$this->info('📊 Step 3: 현재 재고 현황...');
|
||||
$this->showCurrentStockStatus($tenantId, $items);
|
||||
|
||||
// 4. 재고 셋팅 대상 확인
|
||||
$this->newLine();
|
||||
$this->info('📦 Step 4: 재고 셋팅 대상 확인...');
|
||||
$itemsNeedingStock = $this->getItemsNeedingStock($tenantId, $items, $minStock);
|
||||
|
||||
if ($itemsNeedingStock->isEmpty()) {
|
||||
$this->info(" - 모든 품목이 이미 {$minStock}개 이상 재고 보유. 추가 작업 불필요.");
|
||||
$this->showStats();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(" - 재고 셋팅 필요: {$itemsNeedingStock->count()}건");
|
||||
$this->table(
|
||||
['코드', '품목명', '현재고', '목표', '추가수량'],
|
||||
$itemsNeedingStock->map(fn ($item) => [
|
||||
$item->code,
|
||||
mb_strlen($item->name) > 30 ? mb_substr($item->name, 0, 30).'...' : $item->name,
|
||||
number_format($item->current_qty),
|
||||
number_format($minStock),
|
||||
number_format($item->supplement_qty),
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->stats['stocks_created'] = $itemsNeedingStock->filter(fn ($i) => ! $i->has_stock)->count();
|
||||
$this->stats['lots_created'] = $itemsNeedingStock->count();
|
||||
$this->stats['transactions_created'] = $itemsNeedingStock->count();
|
||||
$this->showStats();
|
||||
$this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->confirm('초기 재고를 셋팅하시겠습니까?')) {
|
||||
$this->info('취소되었습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 5. 실행
|
||||
$this->newLine();
|
||||
$this->info('🚀 Step 5: 초기 재고 셋팅 실행...');
|
||||
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $itemsNeedingStock, $minStock) {
|
||||
$this->executeStockSetup($tenantId, $itemsNeedingStock, $minStock);
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->showStats();
|
||||
$this->info('✅ 초기 재고 셋팅 완료!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 재고 현황 표시
|
||||
*/
|
||||
private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void
|
||||
{
|
||||
$itemIds = $items->pluck('id');
|
||||
|
||||
$stocks = DB::connection($this->targetDb)
|
||||
->table('stocks')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('item_id', $itemIds)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('item_id');
|
||||
|
||||
$hasStock = 0;
|
||||
$noStock = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$stock = $stocks->get($item->id);
|
||||
if ($stock && (float) $stock->stock_qty > 0) {
|
||||
$hasStock++;
|
||||
} else {
|
||||
$noStock++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" - 재고 있음: {$hasStock}건");
|
||||
$this->info(" - 재고 없음: {$noStock}건");
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 셋팅이 필요한 품목 목록 조회
|
||||
*/
|
||||
private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection
|
||||
{
|
||||
$itemIds = $items->pluck('id');
|
||||
|
||||
$stocks = DB::connection($this->targetDb)
|
||||
->table('stocks')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('item_id', $itemIds)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('item_id');
|
||||
|
||||
$result = collect();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$stock = $stocks->get($item->id);
|
||||
$currentQty = $stock ? (float) $stock->stock_qty : 0;
|
||||
|
||||
if ($currentQty >= $minStock) {
|
||||
$this->stats['stocks_skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$supplementQty = $minStock - $currentQty;
|
||||
|
||||
$item->has_stock = (bool) $stock;
|
||||
$item->stock_id = $stock?->id;
|
||||
$item->current_qty = $currentQty;
|
||||
$item->supplement_qty = $supplementQty;
|
||||
|
||||
$result->push($item);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 초기 재고 셋팅 실행
|
||||
*/
|
||||
private function executeStockSetup(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): void
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
$stockId = $item->stock_id;
|
||||
|
||||
// Stock 레코드가 없으면 생성
|
||||
if (! $item->has_stock) {
|
||||
$stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'item_id' => $item->id,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'item_type' => 'bent_part',
|
||||
'unit' => $item->unit ?? 'EA',
|
||||
'stock_qty' => 0,
|
||||
'safety_stock' => 0,
|
||||
'reserved_qty' => 0,
|
||||
'available_qty' => 0,
|
||||
'lot_count' => 0,
|
||||
'status' => 'out',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$this->stats['stocks_created']++;
|
||||
$this->line(" + Stock 생성: {$item->code}");
|
||||
}
|
||||
|
||||
// FIFO 순서 계산
|
||||
$maxFifo = DB::connection($this->targetDb)
|
||||
->table('stock_lots')
|
||||
->where('stock_id', $stockId)
|
||||
->max('fifo_order');
|
||||
$nextFifo = ($maxFifo ?? 0) + 1;
|
||||
|
||||
// LOT 번호 생성
|
||||
$lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code);
|
||||
|
||||
// 중복 체크
|
||||
$existingLot = DB::connection($this->targetDb)
|
||||
->table('stock_lots')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('stock_id', $stockId)
|
||||
->where('lot_no', $lotNo)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existingLot) {
|
||||
$this->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$supplementQty = $item->supplement_qty;
|
||||
|
||||
// StockLot 생성
|
||||
$stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'stock_id' => $stockId,
|
||||
'lot_no' => $lotNo,
|
||||
'fifo_order' => $nextFifo,
|
||||
'receipt_date' => now()->toDateString(),
|
||||
'qty' => $supplementQty,
|
||||
'reserved_qty' => 0,
|
||||
'available_qty' => $supplementQty,
|
||||
'unit' => $item->unit ?? 'EA',
|
||||
'status' => 'available',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$this->stats['lots_created']++;
|
||||
|
||||
// StockTransaction 생성
|
||||
DB::connection($this->targetDb)->table('stock_transactions')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'stock_id' => $stockId,
|
||||
'stock_lot_id' => $stockLotId,
|
||||
'type' => 'IN',
|
||||
'qty' => $supplementQty,
|
||||
'balance_qty' => 0,
|
||||
'reference_type' => 'init_stock',
|
||||
'reference_id' => 0,
|
||||
'lot_no' => $lotNo,
|
||||
'reason' => 'receiving',
|
||||
'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})",
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$this->stats['transactions_created']++;
|
||||
|
||||
// Stock 집계 갱신
|
||||
$this->refreshStockFromLots($stockId, $tenantId);
|
||||
|
||||
$this->line(" ✅ {$item->code}: 0 → {$supplementQty} (+{$supplementQty})");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock 집계 갱신 (LOT 기반)
|
||||
*/
|
||||
private function refreshStockFromLots(int $stockId, int $tenantId): void
|
||||
{
|
||||
$lotStats = DB::connection($this->targetDb)
|
||||
->table('stock_lots')
|
||||
->where('stock_id', $stockId)
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(qty), 0) as total_qty,
|
||||
COALESCE(SUM(reserved_qty), 0) as total_reserved,
|
||||
COALESCE(SUM(available_qty), 0) as total_available,
|
||||
COUNT(*) as lot_count,
|
||||
MIN(receipt_date) as oldest_lot_date,
|
||||
MAX(receipt_date) as latest_receipt_date
|
||||
')
|
||||
->first();
|
||||
|
||||
$stockQty = (float) $lotStats->total_qty;
|
||||
|
||||
DB::connection($this->targetDb)
|
||||
->table('stocks')
|
||||
->where('id', $stockId)
|
||||
->update([
|
||||
'stock_qty' => $stockQty,
|
||||
'reserved_qty' => (float) $lotStats->total_reserved,
|
||||
'available_qty' => (float) $lotStats->total_available,
|
||||
'lot_count' => (int) $lotStats->lot_count,
|
||||
'oldest_lot_date' => $lotStats->oldest_lot_date,
|
||||
'last_receipt_date' => $lotStats->latest_receipt_date,
|
||||
'status' => $stockQty > 0 ? 'normal' : 'out',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백: init_stock 참조 데이터 삭제
|
||||
*/
|
||||
private function rollbackInitStock(int $tenantId, bool $dryRun): int
|
||||
{
|
||||
$this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.');
|
||||
|
||||
// init_stock으로 생성된 트랜잭션
|
||||
$txCount = DB::connection($this->targetDb)
|
||||
->table('stock_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('reference_type', 'init_stock')
|
||||
->count();
|
||||
|
||||
// init_stock 트랜잭션에 연결된 LOT
|
||||
$lotIds = DB::connection($this->targetDb)
|
||||
->table('stock_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('reference_type', 'init_stock')
|
||||
->whereNotNull('stock_lot_id')
|
||||
->pluck('stock_lot_id')
|
||||
->unique();
|
||||
|
||||
// 5130으로 생성된 아이템
|
||||
$legacyItemCount = DB::connection($this->targetDb)
|
||||
->table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('options->source', '5130_migration')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$this->info(' 삭제 대상:');
|
||||
$this->info(" - stock_transactions (reference_type=init_stock): {$txCount}건");
|
||||
$this->info(" - stock_lots (연결 LOT): {$lotIds->count()}건");
|
||||
$this->info(" - items (source=5130_migration): {$legacyItemCount}건");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN - 실제 삭제 없음');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) {
|
||||
// 1. 트랜잭션 삭제
|
||||
DB::connection($this->targetDb)
|
||||
->table('stock_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('reference_type', 'init_stock')
|
||||
->delete();
|
||||
|
||||
// 2. LOT에서 stock_id 목록 수집 (집계 갱신용)
|
||||
$affectedStockIds = collect();
|
||||
if ($lotIds->isNotEmpty()) {
|
||||
$affectedStockIds = DB::connection($this->targetDb)
|
||||
->table('stock_lots')
|
||||
->whereIn('id', $lotIds)
|
||||
->pluck('stock_id')
|
||||
->unique();
|
||||
|
||||
// LOT 삭제
|
||||
DB::connection($this->targetDb)
|
||||
->table('stock_lots')
|
||||
->whereIn('id', $lotIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
// 3. 영향받은 Stock 집계 갱신
|
||||
foreach ($affectedStockIds as $stockId) {
|
||||
$this->refreshStockFromLots($stockId, $tenantId);
|
||||
}
|
||||
|
||||
// 4. 5130 migration으로 생성된 아이템 + 연결 stocks 삭제
|
||||
$migrationItemIds = DB::connection($this->targetDb)
|
||||
->table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('options->source', '5130_migration')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id');
|
||||
|
||||
if ($migrationItemIds->isNotEmpty()) {
|
||||
$migrationStockIds = DB::connection($this->targetDb)
|
||||
->table('stocks')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('item_id', $migrationItemIds)
|
||||
->pluck('id');
|
||||
|
||||
if ($migrationStockIds->isNotEmpty()) {
|
||||
DB::connection($this->targetDb)
|
||||
->table('stock_lots')
|
||||
->whereIn('stock_id', $migrationStockIds)
|
||||
->delete();
|
||||
|
||||
DB::connection($this->targetDb)
|
||||
->table('stocks')
|
||||
->whereIn('id', $migrationStockIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
DB::connection($this->targetDb)
|
||||
->table('items')
|
||||
->whereIn('id', $migrationItemIds)
|
||||
->delete();
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('✅ 롤백 완료');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
|
||||
*/
|
||||
private function createLegacyItems(int $tenantId, bool $dryRun): void
|
||||
{
|
||||
// 5130 lot 테이블에서 고유 prod+spec+slength 조합 추출
|
||||
$lots = DB::connection($this->sourceDb)
|
||||
->table('lot')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('is_deleted')
|
||||
->orWhere('is_deleted', 0);
|
||||
})
|
||||
->whereNotNull('prod')
|
||||
->where('prod', '!=', '')
|
||||
->whereNotNull('surang')
|
||||
->where('surang', '>', 0)
|
||||
->select('prod', 'spec', 'slength')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
// bending_work_log 테이블에서도 추출 (lot에 없는 조합 포함)
|
||||
$workLogs = DB::connection($this->sourceDb)
|
||||
->table('bending_work_log')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('is_deleted')
|
||||
->orWhere('is_deleted', 0);
|
||||
})
|
||||
->whereNotNull('prod_code')
|
||||
->where('prod_code', '!=', '')
|
||||
->select('prod_code as prod', 'spec_code as spec', 'slength_code as slength')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
$allRecords = $lots->merge($workLogs);
|
||||
|
||||
if ($allRecords->isEmpty()) {
|
||||
$this->info(' - 5130 데이터 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 고유 제품 조합 추출
|
||||
$uniqueProducts = [];
|
||||
foreach ($allRecords as $row) {
|
||||
$key = trim($row->prod).'-'.trim($row->spec ?? '').'-'.trim($row->slength ?? '');
|
||||
if (! isset($uniqueProducts[$key])) {
|
||||
$uniqueProducts[$key] = [
|
||||
'prod' => trim($row->prod),
|
||||
'spec' => trim($row->spec ?? ''),
|
||||
'slength' => trim($row->slength ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(" - 5130 고유 제품 조합: ".count($uniqueProducts).'개');
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($uniqueProducts as $data) {
|
||||
$itemCode = "BD-{$data['prod']}{$data['spec']}-{$data['slength']}";
|
||||
$prodName = $this->prodNames[$data['prod']] ?? $data['prod'];
|
||||
$specName = $this->specNames[$data['spec']] ?? $data['spec'];
|
||||
$slengthName = $this->slengthNames[$data['slength']] ?? $data['slength'];
|
||||
$itemName = implode(' ', array_filter([$prodName, $specName, $slengthName]));
|
||||
|
||||
// 이미 존재하는지 확인
|
||||
$existing = DB::connection($this->targetDb)
|
||||
->table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $itemCode)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::connection($this->targetDb)->table('items')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $itemCode,
|
||||
'name' => $itemName,
|
||||
'item_type' => 'PT',
|
||||
'item_category' => 'BENDING',
|
||||
'unit' => 'EA',
|
||||
'options' => json_encode([
|
||||
'source' => '5130_migration',
|
||||
'lot_managed' => true,
|
||||
'consumption_method' => 'auto',
|
||||
'production_source' => 'self_produced',
|
||||
'input_tracking' => true,
|
||||
'legacy_prod' => $data['prod'],
|
||||
'legacy_spec' => $data['spec'],
|
||||
'legacy_slength' => $data['slength'],
|
||||
]),
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->stats['items_created_5130'] = $created;
|
||||
$this->info(" - 신규 생성: {$created}건, 기존 존재 (skip): {$skipped}건");
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 출력
|
||||
*/
|
||||
private function showStats(): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info('📊 실행 통계');
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info(" 5130 아이템 생성: {$this->stats['items_created_5130']}건");
|
||||
$this->info(" BD-* 품목 수 (전체): {$this->stats['items_found']}건");
|
||||
$this->info(" 카테고리 업데이트: {$this->stats['items_category_updated']}건");
|
||||
$this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건");
|
||||
$this->info(" 기존 재고 충분 (skip): {$this->stats['stocks_skipped']}건");
|
||||
$this->info(" StockLot 생성: {$this->stats['lots_created']}건");
|
||||
$this->info(" 입고 트랜잭션: {$this->stats['transactions_created']}건");
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ private function loadBomTemplates(int $tenantId): bool
|
||||
$bom = json_decode($sourceItem->bom, true);
|
||||
if (is_array($bom) && count($bom) > 0) {
|
||||
$this->bomTemplates[$category] = $sourceItem->bom;
|
||||
$this->info(" ✅ {$category}: {$sourceCode} 템플릿 로드됨 (" . count($bom) . "개 항목)");
|
||||
$this->info(" ✅ {$category}: {$sourceCode} 템플릿 로드됨 (".count($bom).'개 항목)');
|
||||
} else {
|
||||
$this->warn(" ⚠️ {$category}: {$sourceCode} BOM이 비어있음");
|
||||
}
|
||||
@@ -227,9 +227,9 @@ private function applyBomToItem(object $item, bool $dryRun): string
|
||||
|
||||
return 'success';
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ [{$item->code}] 오류: " . $e->getMessage());
|
||||
$this->error(" ❌ [{$item->code}] 오류: ".$e->getMessage());
|
||||
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
640
app/Console/Commands/MigrateBDModelsPrices.php
Normal file
640
app/Console/Commands/MigrateBDModelsPrices.php
Normal file
@@ -0,0 +1,640 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션
|
||||
*
|
||||
* 레거시 chandj DB의 BDmodels, price_motor, price_raw_materials,
|
||||
* price_shaft, price_pipe, price_angle, price_smokeban 데이터를
|
||||
* items + item_details + prices 통합 구조로 마이그레이션
|
||||
*/
|
||||
class MigrateBDModelsPrices extends Command
|
||||
{
|
||||
protected $signature = 'kd:migrate-prices
|
||||
{--dry-run : 실제 DB 변경 없이 미리보기}
|
||||
{--fresh : 기존 EST-* 항목 삭제 후 재생성}';
|
||||
|
||||
protected $description = '경동 견적 단가를 chandj 원본에서 items+item_details+prices로 마이그레이션';
|
||||
|
||||
private const TENANT_ID = 287;
|
||||
|
||||
private int $created = 0;
|
||||
|
||||
private int $updated = 0;
|
||||
|
||||
private int $skipped = 0;
|
||||
|
||||
private int $deleted = 0;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$fresh = $this->option('fresh');
|
||||
|
||||
$this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ===');
|
||||
$this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다');
|
||||
$this->newLine();
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
// --fresh: 기존 EST-* 항목 삭제
|
||||
if ($fresh) {
|
||||
$this->cleanExistingEstItems($dryRun);
|
||||
}
|
||||
|
||||
// 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철)
|
||||
$this->migrateBDModels($dryRun);
|
||||
|
||||
// 2. price_motor (모터 + 제어기)
|
||||
$this->migrateMotors($dryRun);
|
||||
|
||||
// 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등)
|
||||
$this->migrateRawMaterials($dryRun);
|
||||
|
||||
// 4. price_shaft (감기샤프트)
|
||||
$this->migrateShafts($dryRun);
|
||||
|
||||
// 5. price_pipe (각파이프)
|
||||
$this->migratePipes($dryRun);
|
||||
|
||||
// 6. price_angle (앵글)
|
||||
$this->migrateAngles($dryRun);
|
||||
|
||||
// 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완)
|
||||
$this->migrateSmokeBan($dryRun);
|
||||
|
||||
if ($dryRun) {
|
||||
DB::rollBack();
|
||||
$this->warn('[DRY RUN] 롤백 완료');
|
||||
} else {
|
||||
DB::commit();
|
||||
$this->info('커밋 완료');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}건");
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error("오류: {$e->getMessage()}");
|
||||
$this->error($e->getTraceAsString());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 EST-* 항목 삭제 (--fresh 옵션)
|
||||
*/
|
||||
private function cleanExistingEstItems(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- 기존 EST-* 항목 삭제 ---');
|
||||
|
||||
$items = DB::table('items')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->where('code', 'LIKE', 'EST-%')
|
||||
->whereNull('deleted_at')
|
||||
->get(['id', 'code']);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$this->line(" [삭제] {$item->code}");
|
||||
if (! $dryRun) {
|
||||
DB::table('prices')->where('item_id', $item->id)->delete();
|
||||
DB::table('item_details')->where('item_id', $item->id)->delete();
|
||||
DB::table('items')->where('id', $item->id)->delete();
|
||||
}
|
||||
$this->deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.BDmodels → items + item_details + prices
|
||||
*/
|
||||
private function migrateBDModels(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- BDmodels (절곡품) ---');
|
||||
|
||||
$rows = DB::connection('chandj')->select("
|
||||
SELECT model_name, seconditem, finishing_type, spec, unitprice, description
|
||||
FROM BDmodels
|
||||
WHERE is_deleted = 0
|
||||
ORDER BY model_name, seconditem, finishing_type, spec
|
||||
");
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$modelName = trim($row->model_name ?? '');
|
||||
$secondItem = trim($row->seconditem ?? '');
|
||||
$finishingType = trim($row->finishing_type ?? '');
|
||||
$spec = trim($row->spec ?? '');
|
||||
$unitPrice = (float) str_replace(',', '', $row->unitprice ?? '0');
|
||||
|
||||
// finishing_type 정규화: 'SUS마감' → 'SUS', 'EGI마감' → 'EGI'
|
||||
$finishingType = str_replace('마감', '', $finishingType);
|
||||
|
||||
if (empty($secondItem) || $unitPrice <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$codeParts = ['BD', $secondItem];
|
||||
if ($modelName) {
|
||||
$codeParts[] = $modelName;
|
||||
}
|
||||
if ($finishingType) {
|
||||
$codeParts[] = $finishingType;
|
||||
}
|
||||
if ($spec) {
|
||||
$codeParts[] = $spec;
|
||||
}
|
||||
$code = implode('-', $codeParts);
|
||||
|
||||
$nameParts = [$secondItem];
|
||||
if ($modelName) {
|
||||
$nameParts[] = $modelName;
|
||||
}
|
||||
if ($finishingType) {
|
||||
$nameParts[] = $finishingType;
|
||||
}
|
||||
if ($spec) {
|
||||
$nameParts[] = $spec;
|
||||
}
|
||||
$name = implode(' ', $nameParts);
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'bdmodels',
|
||||
partType: $secondItem,
|
||||
specification: $spec ?: null,
|
||||
attributes: array_filter([
|
||||
'model_name' => $modelName ?: null,
|
||||
'finishing_type' => $finishingType ?: null,
|
||||
'bdmodel_source' => 'BDmodels',
|
||||
'description' => $row->description ?: null,
|
||||
]),
|
||||
salesPrice: $unitPrice,
|
||||
note: 'chandj.BDmodels',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.price_motor → 모터 + 제어기
|
||||
*
|
||||
* col1: 전압 (220, 380, 제어기, 방화, 방범)
|
||||
* col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등)
|
||||
* col13: 판매가
|
||||
*/
|
||||
private function migrateMotors(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- price_motor (모터/제어기) ---');
|
||||
|
||||
$row = DB::connection('chandj')->selectOne(
|
||||
"SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
|
||||
);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범
|
||||
$name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등
|
||||
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
|
||||
|
||||
if (empty($name) || $price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 카테고리 분류
|
||||
if (in_array($category, ['220', '380'])) {
|
||||
$productCategory = 'motor';
|
||||
$code = "EST-MOTOR-{$category}V-{$name}";
|
||||
$displayName = "모터 {$name} ({$category}V)";
|
||||
$partType = $name;
|
||||
} elseif ($category === '제어기') {
|
||||
$productCategory = 'controller';
|
||||
$code = "EST-CTRL-{$name}";
|
||||
$displayName = "제어기 {$name}";
|
||||
$partType = $name;
|
||||
} else {
|
||||
// 방화, 방범 등
|
||||
$productCategory = 'controller';
|
||||
$code = "EST-CTRL-{$category}-{$name}";
|
||||
$displayName = "{$category} {$name}";
|
||||
$partType = "{$category} {$name}";
|
||||
}
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $displayName,
|
||||
productCategory: $productCategory,
|
||||
partType: $partType,
|
||||
specification: null,
|
||||
attributes: ['voltage' => $category, 'source' => 'price_motor'],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_motor',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.price_raw_materials → 원자재
|
||||
*
|
||||
* col1: 카테고리 (슬랫, 스크린)
|
||||
* col2: 품명 (방화, 실리카, 화이바, 와이어 등)
|
||||
* col13: 판매단가
|
||||
*/
|
||||
private function migrateRawMaterials(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- price_raw_materials (원자재) ---');
|
||||
|
||||
$row = DB::connection('chandj')->selectOne(
|
||||
"SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1"
|
||||
);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$category = trim($item['col1'] ?? '');
|
||||
$name = trim($item['col2'] ?? '');
|
||||
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
|
||||
|
||||
if (empty($name) || $price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = "EST-RAW-{$category}-{$name}";
|
||||
$displayName = "{$category} {$name}";
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $displayName,
|
||||
productCategory: 'raw_material',
|
||||
partType: $name,
|
||||
specification: $category,
|
||||
attributes: ['category' => $category, 'source' => 'price_raw_materials'],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_raw_materials',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.price_shaft → 감기샤프트
|
||||
*
|
||||
* col4: 인치 (3, 4, 5, 6, 8, 10, 12)
|
||||
* col10: 길이 (m)
|
||||
* col19: 판매가
|
||||
*/
|
||||
private function migrateShafts(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- price_shaft (감기샤프트) ---');
|
||||
|
||||
$row = DB::connection('chandj')->selectOne(
|
||||
"SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
|
||||
);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$inch = trim($item['col4'] ?? '');
|
||||
$lengthM = trim($item['col10'] ?? '');
|
||||
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
|
||||
|
||||
if (empty($inch) || empty($lengthM) || $price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = "EST-SHAFT-{$inch}-{$lengthM}";
|
||||
$name = "감기샤프트 {$inch}인치 {$lengthM}m";
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'shaft',
|
||||
partType: $inch,
|
||||
specification: $lengthM,
|
||||
attributes: ['source' => 'price_shaft'],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_shaft',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.price_pipe → 각파이프
|
||||
*
|
||||
* col4: 두께 (1.4, 2)
|
||||
* col2: 길이 (3,000 / 6,000)
|
||||
* col8: 판매가
|
||||
*/
|
||||
private function migratePipes(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- price_pipe (각파이프) ---');
|
||||
|
||||
$row = DB::connection('chandj')->selectOne(
|
||||
"SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1"
|
||||
);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$thickness = trim($item['col4'] ?? '');
|
||||
$length = (int) str_replace(',', '', $item['col2'] ?? '0');
|
||||
$price = (int) str_replace(',', '', $item['col8'] ?? '0');
|
||||
|
||||
if (empty($thickness) || $length <= 0 || $price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = "EST-PIPE-{$thickness}-{$length}";
|
||||
$name = "각파이프 {$thickness}T {$length}mm";
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'pipe',
|
||||
partType: $thickness,
|
||||
specification: (string) $length,
|
||||
attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_pipe',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.price_angle → 앵글 (bracket + main 분리)
|
||||
*
|
||||
* bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등)
|
||||
* - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가
|
||||
*
|
||||
* main angle (부자재용): col2가 숫자 (4 등)
|
||||
* - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가
|
||||
*/
|
||||
private function migrateAngles(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- price_angle (앵글) ---');
|
||||
|
||||
$row = DB::connection('chandj')->selectOne(
|
||||
"SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
|
||||
);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$col2 = trim($item['col2'] ?? '');
|
||||
$col3 = trim($item['col3'] ?? '');
|
||||
$col4 = trim($item['col4'] ?? '');
|
||||
$col10 = trim($item['col10'] ?? '');
|
||||
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
|
||||
|
||||
if ($price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// col2가 숫자이면 main angle, 텍스트이면 bracket angle
|
||||
if (is_numeric($col2)) {
|
||||
// Main angle (부자재용): col4=앵글3T, col10=2.5
|
||||
if (empty($col4) || empty($col10)) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = "EST-ANGLE-MAIN-{$col4}-{$col10}";
|
||||
$name = "앵글 {$col4} {$col10}m";
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'angle_main',
|
||||
partType: $col4,
|
||||
specification: $col10,
|
||||
attributes: ['source' => 'price_angle'],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_angle (main)',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
} else {
|
||||
// Bracket angle (모터 받침용): col2=스크린용, col3=380*180
|
||||
if (empty($col2)) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = "EST-ANGLE-BRACKET-{$col2}";
|
||||
$name = "모터받침 앵글 {$col2}";
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'angle_bracket',
|
||||
partType: $col2,
|
||||
specification: $col3 ?: null,
|
||||
attributes: [
|
||||
'angle_type' => $col4,
|
||||
'source' => 'price_angle',
|
||||
],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_angle (bracket)',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj.price_smokeban → 연기차단재
|
||||
*
|
||||
* col2: 용도 (레일용, 케이스용)
|
||||
* col11: 판매가
|
||||
*/
|
||||
private function migrateSmokeBan(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- price_smokeban (연기차단재) ---');
|
||||
|
||||
$row = DB::connection('chandj')->selectOne(
|
||||
"SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
|
||||
);
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$usage = trim($item['col2'] ?? '');
|
||||
$price = (int) str_replace(',', '', $item['col11'] ?? '0');
|
||||
|
||||
if (empty($usage) || $price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = "EST-SMOKE-{$usage}";
|
||||
$name = "연기차단재 {$usage}";
|
||||
|
||||
$this->upsertEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'smokeban',
|
||||
partType: $usage,
|
||||
specification: null,
|
||||
attributes: ['source' => 'price_smokeban'],
|
||||
salesPrice: (float) $price,
|
||||
note: 'chandj.price_smokeban',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 품목 생성 또는 가격 업데이트
|
||||
*/
|
||||
private function upsertEstimateItem(
|
||||
string $code,
|
||||
string $name,
|
||||
string $productCategory,
|
||||
string $partType,
|
||||
?string $specification,
|
||||
array $attributes,
|
||||
float $salesPrice,
|
||||
string $note,
|
||||
bool $dryRun
|
||||
): void {
|
||||
$existing = DB::table('items')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->where('code', $code)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// 가격 업데이트
|
||||
$currentPrice = DB::table('prices')
|
||||
->where('item_id', $existing->id)
|
||||
->where('status', 'active')
|
||||
->orderByDesc('id')
|
||||
->value('sales_price');
|
||||
|
||||
if ((float) $currentPrice === $salesPrice) {
|
||||
$this->skipped++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . " → " . number_format($salesPrice));
|
||||
|
||||
if (! $dryRun) {
|
||||
// 기존 가격 비활성화
|
||||
DB::table('prices')
|
||||
->where('item_id', $existing->id)
|
||||
->where('status', 'active')
|
||||
->update(['status' => 'inactive', 'updated_at' => now()]);
|
||||
|
||||
// 새 가격 추가
|
||||
DB::table('prices')->insert([
|
||||
'tenant_id' => self::TENANT_ID,
|
||||
'item_type_code' => 'PT',
|
||||
'item_id' => $existing->id,
|
||||
'sales_price' => $salesPrice,
|
||||
'effective_from' => now()->toDateString(),
|
||||
'status' => 'active',
|
||||
'note' => $note,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->updated++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 신규 생성
|
||||
$this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->created++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
$itemId = DB::table('items')->insertGetId([
|
||||
'tenant_id' => self::TENANT_ID,
|
||||
'item_type' => 'PT',
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'unit' => 'EA',
|
||||
'attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE),
|
||||
'is_active' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('item_details')->insert([
|
||||
'item_id' => $itemId,
|
||||
'product_category' => $productCategory,
|
||||
'part_type' => $partType,
|
||||
'specification' => $specification,
|
||||
'item_name' => $name,
|
||||
'is_purchasable' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('prices')->insert([
|
||||
'tenant_id' => self::TENANT_ID,
|
||||
'item_type_code' => 'PT',
|
||||
'item_id' => $itemId,
|
||||
'sales_price' => $salesPrice,
|
||||
'effective_from' => $now->toDateString(),
|
||||
'status' => 'active',
|
||||
'note' => $note,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->created++;
|
||||
}
|
||||
}
|
||||
253
app/Console/Commands/NormalizeDocumentData.php
Normal file
253
app/Console/Commands/NormalizeDocumentData.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Documents\DocumentData;
|
||||
use App\Models\Documents\DocumentTemplateSection;
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[AsCommand(name: 'documents:normalize-data', description: '기존 document_data의 field_key를 정규화 형식으로 변환')]
|
||||
class NormalizeDocumentData extends Command
|
||||
{
|
||||
protected $signature = 'documents:normalize-data
|
||||
{--document= : 특정 문서 ID만 처리 (쉼표 구분)}
|
||||
{--dry-run : 실제 변경 없이 시뮬레이션만 수행}
|
||||
{--force : 확인 없이 실행}';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$documentIds = $this->option('document')
|
||||
? array_map('intval', explode(',', $this->option('document')))
|
||||
: null;
|
||||
|
||||
$this->info('=== document_data 정규화 마이그레이션 ===');
|
||||
$this->info('Mode: ' . ($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
|
||||
|
||||
if ($documentIds) {
|
||||
$this->info('대상 문서: ' . implode(', ', $documentIds));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// 정규화 대상 레코드 조회: section_id가 NULL이고 field_key가 s{N}_ 패턴인 레코드
|
||||
$query = DocumentData::query()
|
||||
->whereNull('section_id')
|
||||
->where(function ($q) {
|
||||
// MNG 형식: s{sectionId}_r{rowIndex}_c{colId}...
|
||||
$q->where('field_key', 'regexp', '^s[0-9]+_r[0-9]+_c[0-9]+')
|
||||
// React 형식: {itemId}_n{N}, {itemId}_okng_n{N}, {itemId}_result
|
||||
->orWhere('field_key', 'regexp', '^[0-9]+_(n[0-9]+|okng_n[0-9]+|result)$')
|
||||
// 푸터 형식: footer_remark, footer_judgement
|
||||
->orWhereIn('field_key', ['footer_remark', 'footer_judgement']);
|
||||
});
|
||||
|
||||
if ($documentIds) {
|
||||
$query->whereIn('document_id', $documentIds);
|
||||
}
|
||||
|
||||
$records = $query->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
$this->info('정규화 대상 레코드가 없습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("대상 레코드: {$records->count()}개");
|
||||
|
||||
// 관련 문서 ID 수집 및 템플릿 정보 사전 로드
|
||||
$docIds = $records->pluck('document_id')->unique();
|
||||
$documents = Document::whereIn('id', $docIds)
|
||||
->with(['template.sections.items', 'template.columns'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// 변환 결과 집계
|
||||
$stats = ['mng' => 0, 'react' => 0, 'footer' => 0, 'skipped' => 0];
|
||||
$updates = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$doc = $documents->get($record->document_id);
|
||||
if (! $doc || ! $doc->template) {
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->normalizeRecord($record, $doc);
|
||||
if ($result) {
|
||||
$updates[] = $result;
|
||||
$stats[$result['type']]++;
|
||||
} else {
|
||||
$stats['skipped']++;
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 테이블 출력
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['유형', '건수'],
|
||||
[
|
||||
['MNG 형식 (s{}_r{}_c{})', $stats['mng']],
|
||||
['React 형식 ({itemId}_*)', $stats['react']],
|
||||
['Footer 형식', $stats['footer']],
|
||||
['건너뜀', $stats['skipped']],
|
||||
['총 변환', count($updates)],
|
||||
]
|
||||
);
|
||||
|
||||
if (empty($updates)) {
|
||||
$this->info('변환할 레코드가 없습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 변환 상세 샘플
|
||||
$this->newLine();
|
||||
$this->info('변환 샘플 (최대 10건):');
|
||||
$this->table(
|
||||
['ID', 'doc_id', '기존 field_key', '→ section_id', '→ column_id', '→ row_index', '→ field_key'],
|
||||
collect($updates)->take(10)->map(fn ($u) => [
|
||||
$u['id'],
|
||||
$u['document_id'],
|
||||
$u['old_field_key'],
|
||||
$u['section_id'] ?? 'NULL',
|
||||
$u['column_id'] ?? 'NULL',
|
||||
$u['row_index'],
|
||||
$u['field_key'],
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN 모드: 실제 변경 없음. --dry-run 제거하여 실행.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 실행 확인
|
||||
if (! $this->option('force') && ! $this->confirm(count($updates) . '건의 레코드를 정규화하시겠습니까?')) {
|
||||
$this->info('취소되었습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 트랜잭션으로 일괄 업데이트
|
||||
DB::transaction(function () use ($updates) {
|
||||
foreach ($updates as $update) {
|
||||
DocumentData::where('id', $update['id'])->update([
|
||||
'section_id' => $update['section_id'],
|
||||
'column_id' => $update['column_id'],
|
||||
'row_index' => $update['row_index'],
|
||||
'field_key' => $update['field_key'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(count($updates) . '건 정규화 완료.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 레코드 정규화
|
||||
*/
|
||||
private function normalizeRecord(DocumentData $record, Document $doc): ?array
|
||||
{
|
||||
$key = $record->field_key;
|
||||
|
||||
// 1. Footer 형식
|
||||
if ($key === 'footer_remark') {
|
||||
return $this->buildUpdate($record, null, null, 0, 'remark', 'footer');
|
||||
}
|
||||
if ($key === 'footer_judgement') {
|
||||
return $this->buildUpdate($record, null, null, 0, 'overall_result', 'footer');
|
||||
}
|
||||
|
||||
// 2. MNG 형식: s{sectionId}_r{rowIndex}_c{colId}[_suffix]
|
||||
if (preg_match('/^s(\d+)_r(\d+)_c(\d+)(?:_(.+))?$/', $key, $m)) {
|
||||
$sectionId = (int) $m[1];
|
||||
$rowIndex = (int) $m[2];
|
||||
$columnId = (int) $m[3];
|
||||
$suffix = $m[4] ?? null;
|
||||
|
||||
// suffix 정규화: n1, n1_ok, n1_ng, sub0 등은 그대로, 없으면 value
|
||||
$fieldKey = $suffix ?: 'value';
|
||||
|
||||
return $this->buildUpdate($record, $sectionId, $columnId, $rowIndex, $fieldKey, 'mng');
|
||||
}
|
||||
|
||||
// 3. React 형식: {itemId}_n{N} 또는 {itemId}_okng_n{N} 또는 {itemId}_result
|
||||
if (preg_match('/^(\d+)_(n\d+|okng_n\d+|result)$/', $key, $m)) {
|
||||
$itemId = (int) $m[1];
|
||||
$suffix = $m[2];
|
||||
|
||||
$template = $doc->template;
|
||||
$sectionId = null;
|
||||
$rowIndex = 0;
|
||||
|
||||
// 아이템 ID로 섹션과 행 인덱스 찾기
|
||||
foreach ($template->sections as $section) {
|
||||
foreach ($section->items as $idx => $item) {
|
||||
if ($item->id === $itemId) {
|
||||
$sectionId = $section->id;
|
||||
$rowIndex = $idx;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($sectionId === null) {
|
||||
return null; // 아이템을 찾을 수 없음
|
||||
}
|
||||
|
||||
// suffix → 정규화된 field_key
|
||||
if ($suffix === 'result') {
|
||||
$fieldKey = 'value';
|
||||
// 결과 컬럼 ID 찾기
|
||||
$resultCol = $template->columns
|
||||
->first(fn ($c) => in_array($c->column_type, ['select', 'check'])
|
||||
|| str_contains($c->label, '판정'));
|
||||
$columnId = $resultCol?->id;
|
||||
} elseif (str_starts_with($suffix, 'okng_')) {
|
||||
// okng_n1 → n1_ok (checked value로 저장된 경우)
|
||||
$nPart = str_replace('okng_', '', $suffix);
|
||||
$fieldKey = $nPart . '_ok';
|
||||
$complexCol = $template->columns->first(fn ($c) => $c->column_type === 'complex');
|
||||
$columnId = $complexCol?->id;
|
||||
} else {
|
||||
// n1, n2, ... 그대로
|
||||
$fieldKey = $suffix;
|
||||
$complexCol = $template->columns->first(fn ($c) => $c->column_type === 'complex');
|
||||
$columnId = $complexCol?->id;
|
||||
}
|
||||
|
||||
return $this->buildUpdate($record, $sectionId, $columnId, $rowIndex, $fieldKey, 'react');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildUpdate(
|
||||
DocumentData $record,
|
||||
?int $sectionId,
|
||||
?int $columnId,
|
||||
int $rowIndex,
|
||||
string $fieldKey,
|
||||
string $type
|
||||
): array {
|
||||
return [
|
||||
'id' => $record->id,
|
||||
'document_id' => $record->document_id,
|
||||
'old_field_key' => $record->field_key,
|
||||
'section_id' => $sectionId,
|
||||
'column_id' => $columnId,
|
||||
'row_index' => $rowIndex,
|
||||
'field_key' => $fieldKey,
|
||||
'type' => $type,
|
||||
];
|
||||
}
|
||||
}
|
||||
218
app/Console/Commands/NormalizeItemDimensions.php
Normal file
218
app/Console/Commands/NormalizeItemDimensions.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[AsCommand(name: 'items:normalize-dimensions', description: '품목 attributes에서 thickness/width/length 정규화')]
|
||||
class NormalizeItemDimensions extends Command
|
||||
{
|
||||
protected $signature = 'items:normalize-dimensions
|
||||
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
|
||||
{--dry-run : 기본 모드 - 변경 예정 목록만 출력}
|
||||
{--execute : 실제 DB 업데이트 수행}';
|
||||
|
||||
protected $description = '101_specification_1/2/3에서 thickness/width/length를 추출하여 attributes에 정규화';
|
||||
|
||||
private int $updatedCount = 0;
|
||||
|
||||
private int $skippedCount = 0;
|
||||
|
||||
private array $changes = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$execute = $this->option('execute');
|
||||
$dryRun = ! $execute;
|
||||
|
||||
$this->info('=== 품목 치수 정규화 ===');
|
||||
$this->info("Tenant ID: {$tenantId}");
|
||||
$this->info('Mode: '.($dryRun ? 'DRY-RUN (미리보기)' : 'EXECUTE (실행)'));
|
||||
$this->newLine();
|
||||
|
||||
$items = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw("JSON_EXTRACT(attributes, '$.\"101_specification_1\"') IS NOT NULL")
|
||||
->get();
|
||||
|
||||
$this->info("대상 품목: {$items->count()}건 (101_specification_1 존재)");
|
||||
$this->newLine();
|
||||
|
||||
$bar = $this->output->createProgressBar($items->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$this->processItem($item, $dryRun);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->showResults($dryRun);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function processItem(object $item, bool $dryRun): void
|
||||
{
|
||||
$attributes = json_decode($item->attributes, true) ?? [];
|
||||
|
||||
$spec1 = $attributes['101_specification_1'] ?? null;
|
||||
$spec2 = $attributes['102_specification_2'] ?? null;
|
||||
$spec3 = $attributes['103_specification_3'] ?? null;
|
||||
|
||||
$existingThickness = $attributes['thickness'] ?? null;
|
||||
$existingWidth = $attributes['width'] ?? null;
|
||||
$existingLength = $attributes['length'] ?? null;
|
||||
|
||||
$changed = false;
|
||||
$changeDetails = [];
|
||||
|
||||
// thickness 추출: spec1에서 숫자 추출 (t/T 제거)
|
||||
if ($spec1 !== null && $spec1 !== '' && $existingThickness === null) {
|
||||
$thickness = $this->extractThickness($spec1);
|
||||
if ($thickness !== null) {
|
||||
$attributes['thickness'] = $thickness;
|
||||
$changeDetails[] = "thickness: {$spec1} → {$thickness}";
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// width 추출: spec2에서 순수 숫자만
|
||||
if ($spec2 !== null && $spec2 !== '' && $existingWidth === null) {
|
||||
$width = $this->extractNumeric($spec2);
|
||||
if ($width !== null) {
|
||||
$attributes['width'] = $width;
|
||||
$changeDetails[] = "width: {$spec2} → {$width}";
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// length 추출: spec3에서 순수 숫자만 (c, P/L, 문자 포함 시 스킵)
|
||||
if ($spec3 !== null && $spec3 !== '' && $existingLength === null) {
|
||||
$length = $this->extractLength($spec3);
|
||||
if ($length !== null) {
|
||||
$attributes['length'] = $length;
|
||||
$changeDetails[] = "length: {$spec3} → {$length}";
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$this->changes[] = [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'changes' => implode(', ', $changeDetails),
|
||||
];
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::table('items')
|
||||
->where('id', $item->id)
|
||||
->update(['attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE)]);
|
||||
}
|
||||
|
||||
$this->updatedCount++;
|
||||
} else {
|
||||
$this->skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* thickness 추출: "t1.2", "T1.2", "1.2", "egi1.17" → 숫자
|
||||
* 패턴: 선행 문자(t/T/영문) 제거 후 숫자 추출
|
||||
*/
|
||||
private function extractThickness(?string $value): ?string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = trim($value);
|
||||
|
||||
// "t1.2", "T1.2" → "1.2"
|
||||
$cleaned = preg_replace('/^[tT]/', '', $cleaned);
|
||||
|
||||
// "egi1.17", "sus1.2" → 영문자 제거 후 숫자 추출
|
||||
if (preg_match('/(\d+(?:\.\d+)?)/', $cleaned, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순수 숫자만 추출 (정수/소수)
|
||||
* "1219" → "1219", "1219.5" → "1219.5"
|
||||
* "c" → null, "" → null, "P/L" → null
|
||||
*/
|
||||
private function extractNumeric(?string $value): ?string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = trim($value);
|
||||
|
||||
// 순수 숫자 (정수/소수)만 허용
|
||||
if (preg_match('/^\d+(?:\.\d+)?$/', $cleaned)) {
|
||||
return $cleaned;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* length 추출: "3000" → "3000", "3000 P/L" → "3000", "c" → null
|
||||
* 선행 숫자가 있고 뒤에 공백+문자(P/L 등)가 붙는 경우 숫자만 추출
|
||||
*/
|
||||
private function extractLength(?string $value): ?string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = trim($value);
|
||||
|
||||
// 순수 숫자
|
||||
if (preg_match('/^\d+(?:\.\d+)?$/', $cleaned)) {
|
||||
return $cleaned;
|
||||
}
|
||||
|
||||
// "3000 P/L" → "3000" (숫자로 시작하고 뒤에 공백+문자)
|
||||
if (preg_match('/^(\d+(?:\.\d+)?)\s+/', $cleaned, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// "c", "P/L" 등 숫자 없는 경우
|
||||
return null;
|
||||
}
|
||||
|
||||
private function showResults(bool $dryRun): void
|
||||
{
|
||||
$this->info('=== 결과 ===');
|
||||
$this->info("변경 대상: {$this->updatedCount}건");
|
||||
$this->info("스킵 (변경 불필요): {$this->skippedCount}건");
|
||||
$this->newLine();
|
||||
|
||||
if (! empty($this->changes)) {
|
||||
$this->table(
|
||||
['ID', '품목명', '변경 내용'],
|
||||
array_map(fn ($c) => [$c['id'], mb_substr($c['name'], 0, 30), $c['changes']], $this->changes)
|
||||
);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->warn('DRY-RUN 모드입니다. 실제 적용하려면 --execute 옵션을 사용하세요:');
|
||||
$this->line(' php artisan items:normalize-dimensions --execute');
|
||||
} else {
|
||||
$this->newLine();
|
||||
$this->info("총 {$this->updatedCount}건 업데이트 완료");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class RecordStorageUsage extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$tenants = Tenant::where('status', 'active')->get();
|
||||
$tenants = Tenant::active()->get();
|
||||
|
||||
$recorded = 0;
|
||||
foreach ($tenants as $tenant) {
|
||||
|
||||
184
app/Console/Commands/RegenerateAuditTriggers.php
Normal file
184
app/Console/Commands/RegenerateAuditTriggers.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateAuditTriggers extends Command
|
||||
{
|
||||
protected $signature = 'audit:triggers
|
||||
{--table= : 특정 테이블만 재생성}
|
||||
{--drop-only : 트리거 삭제만 (재생성 안 함)}
|
||||
{--dry-run : 변경 없이 대상 목록만 출력}';
|
||||
|
||||
protected $description = '트리거 감사 로그용 MySQL 트리거 재생성 (스키마 변경 후 사용)';
|
||||
|
||||
/** @var string[] 트리거 제외 테이블 */
|
||||
private array $excludeTables = [
|
||||
'audit_logs',
|
||||
'trigger_audit_logs',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'migrations',
|
||||
'personal_access_tokens',
|
||||
'api_request_logs',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$specificTable = $this->option('table');
|
||||
$dropOnly = $this->option('drop-only');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$dbName = config('database.connections.mysql.database');
|
||||
|
||||
$this->info('=== 트리거 감사 로그 트리거 '.($dropOnly ? '삭제' : '재생성').' ===');
|
||||
$this->newLine();
|
||||
|
||||
// 대상 테이블 목록
|
||||
$tables = $this->getTargetTables($dbName, $specificTable);
|
||||
$this->info('대상 테이블: '.count($tables).'개');
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($tables as $t) {
|
||||
$this->line(" - {$t}");
|
||||
}
|
||||
$this->newLine();
|
||||
$this->info('[DRY-RUN] 실제 변경 없음');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dropped = 0;
|
||||
$created = 0;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
// 기존 트리거 삭제
|
||||
foreach (['ai', 'au', 'ad'] as $suffix) {
|
||||
$triggerName = "trg_{$table}_{$suffix}";
|
||||
DB::unprepared("DROP TRIGGER IF EXISTS `{$triggerName}`");
|
||||
$dropped++;
|
||||
}
|
||||
|
||||
if (! $dropOnly) {
|
||||
// 트리거 재생성
|
||||
$this->createTriggersForTable($dbName, $table);
|
||||
$created += 3;
|
||||
}
|
||||
|
||||
$this->line(" {$table}: ".($dropOnly ? '삭제 완료' : '재생성 완료'));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("결과: 삭제 {$dropped}개, 생성 {$created}개");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTargetTables(string $dbName, ?string $specificTable): array
|
||||
{
|
||||
if ($specificTable) {
|
||||
if (in_array($specificTable, $this->excludeTables)) {
|
||||
$this->error("{$specificTable}은(는) 트리거 제외 테이블입니다.");
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$specificTable];
|
||||
}
|
||||
|
||||
$rows = DB::select("
|
||||
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
||||
ORDER BY TABLE_NAME
|
||||
", [$dbName]);
|
||||
|
||||
return collect($rows)
|
||||
->pluck('TABLE_NAME')
|
||||
->reject(fn ($t) => in_array($t, $this->excludeTables))
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function createTriggersForTable(string $dbName, string $table): void
|
||||
{
|
||||
// PK 컬럼
|
||||
$pkRow = DB::selectOne("
|
||||
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI'
|
||||
LIMIT 1
|
||||
", [$dbName, $table]);
|
||||
|
||||
$pkCol = $pkRow?->COLUMN_NAME ?? 'id';
|
||||
|
||||
// 컬럼 목록 (제외: created_at, updated_at, deleted_at, remember_token)
|
||||
$excludeCols = ['created_at', 'updated_at', 'deleted_at', 'remember_token'];
|
||||
$columns = DB::select('
|
||||
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION
|
||||
', [$dbName, $table]);
|
||||
|
||||
$cols = collect($columns)
|
||||
->pluck('COLUMN_NAME')
|
||||
->reject(fn ($c) => in_array($c, $excludeCols))
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($cols)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON_OBJECT 표현식
|
||||
$newJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ').')';
|
||||
$oldJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ').')';
|
||||
|
||||
// changed_columns (UPDATE용)
|
||||
$changedCols = collect($cols)->map(fn ($c) => "IF(NOT (NEW.`{$c}` <=> OLD.`{$c}`), '{$c}', NULL)")->implode(', ');
|
||||
$changeCheck = collect($cols)->map(fn ($c) => "NOT (NEW.`{$c}` <=> OLD.`{$c}`)")->implode(' OR ');
|
||||
|
||||
$tenantExpr = in_array('tenant_id', $cols) ? 'NEW.`tenant_id`' : 'NULL';
|
||||
$tenantExprOld = in_array('tenant_id', $cols) ? 'OLD.`tenant_id`' : 'NULL';
|
||||
|
||||
$guard = 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN';
|
||||
|
||||
// INSERT trigger
|
||||
DB::unprepared("
|
||||
CREATE TRIGGER `trg_{$table}_ai` AFTER INSERT ON `{$table}`
|
||||
FOR EACH ROW BEGIN
|
||||
{$guard}
|
||||
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
|
||||
VALUES ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
|
||||
END IF;
|
||||
END
|
||||
");
|
||||
|
||||
// UPDATE trigger
|
||||
DB::unprepared("
|
||||
CREATE TRIGGER `trg_{$table}_au` AFTER UPDATE ON `{$table}`
|
||||
FOR EACH ROW BEGIN
|
||||
{$guard}
|
||||
IF {$changeCheck} THEN
|
||||
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
|
||||
VALUES ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
");
|
||||
|
||||
// DELETE trigger
|
||||
DB::unprepared("
|
||||
CREATE TRIGGER `trg_{$table}_ad` AFTER DELETE ON `{$table}`
|
||||
FOR EACH ROW BEGIN
|
||||
{$guard}
|
||||
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
|
||||
VALUES ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
|
||||
END IF;
|
||||
END
|
||||
");
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/StatAggregateDailyCommand.php
Normal file
60
app/Console/Commands/StatAggregateDailyCommand.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stats\StatAggregatorService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class StatAggregateDailyCommand extends Command
|
||||
{
|
||||
protected $signature = 'stat:aggregate-daily
|
||||
{--date= : 집계 대상 날짜 (YYYY-MM-DD, 기본: 전일)}
|
||||
{--domain= : 특정 도메인만 집계 (sales, finance, production)}
|
||||
{--tenant= : 특정 테넌트만 집계}';
|
||||
|
||||
protected $description = '일간 통계 집계 (sam_stat DB)';
|
||||
|
||||
public function handle(StatAggregatorService $aggregator): int
|
||||
{
|
||||
$date = $this->option('date')
|
||||
? Carbon::parse($this->option('date'))
|
||||
: Carbon::yesterday();
|
||||
|
||||
$domain = $this->option('domain');
|
||||
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
|
||||
|
||||
$this->info("📊 일간 통계 집계 시작: {$date->format('Y-m-d')}");
|
||||
|
||||
if ($domain) {
|
||||
$this->info(" 도메인 필터: {$domain}");
|
||||
}
|
||||
if ($tenantId) {
|
||||
$this->info(" 테넌트 필터: {$tenantId}");
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
|
||||
|
||||
$this->info('✅ 일간 집계 완료:');
|
||||
$this->info(" - 처리 테넌트: {$result['tenants_processed']}");
|
||||
$this->info(" - 처리 도메인: {$result['domains_processed']}");
|
||||
$this->info(" - 소요 시간: {$result['duration_ms']}ms");
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("❌ 집계 실패: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Console/Commands/StatAggregateMonthlyCommand.php
Normal file
54
app/Console/Commands/StatAggregateMonthlyCommand.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stats\StatAggregatorService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class StatAggregateMonthlyCommand extends Command
|
||||
{
|
||||
protected $signature = 'stat:aggregate-monthly
|
||||
{--year= : 집계 대상 연도 (기본: 전월 기준)}
|
||||
{--month= : 집계 대상 월 (기본: 전월)}
|
||||
{--domain= : 특정 도메인만 집계}
|
||||
{--tenant= : 특정 테넌트만 집계}';
|
||||
|
||||
protected $description = '월간 통계 집계 (sam_stat DB)';
|
||||
|
||||
public function handle(StatAggregatorService $aggregator): int
|
||||
{
|
||||
$lastMonth = Carbon::now()->subMonth();
|
||||
$year = $this->option('year') ? (int) $this->option('year') : $lastMonth->year;
|
||||
$month = $this->option('month') ? (int) $this->option('month') : $lastMonth->month;
|
||||
|
||||
$domain = $this->option('domain');
|
||||
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
|
||||
|
||||
$this->info("📊 월간 통계 집계 시작: {$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
|
||||
|
||||
try {
|
||||
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
|
||||
|
||||
$this->info('✅ 월간 집계 완료:');
|
||||
$this->info(" - 처리 테넌트: {$result['tenants_processed']}");
|
||||
$this->info(" - 처리 도메인: {$result['domains_processed']}");
|
||||
$this->info(" - 소요 시간: {$result['duration_ms']}ms");
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("❌ 집계 실패: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/Console/Commands/StatBackfillCommand.php
Normal file
174
app/Console/Commands/StatBackfillCommand.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stats\DimensionSyncService;
|
||||
use App\Services\Stats\StatAggregatorService;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class StatBackfillCommand extends Command
|
||||
{
|
||||
protected $signature = 'stat:backfill
|
||||
{--from= : 시작 날짜 (YYYY-MM-DD, 필수)}
|
||||
{--to= : 종료 날짜 (YYYY-MM-DD, 기본: 어제)}
|
||||
{--domain= : 특정 도메인만 집계}
|
||||
{--tenant= : 특정 테넌트만 집계}
|
||||
{--skip-monthly : 월간 집계 건너뛰기}
|
||||
{--skip-dimensions : 차원 동기화 건너뛰기}';
|
||||
|
||||
protected $description = '과거 데이터 일괄 통계 집계 (백필)';
|
||||
|
||||
public function handle(StatAggregatorService $aggregator, DimensionSyncService $dimensionSync): int
|
||||
{
|
||||
$from = $this->option('from');
|
||||
if (! $from) {
|
||||
$this->error('--from 옵션은 필수입니다. 예: --from=2024-01-01');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$startDate = Carbon::parse($from);
|
||||
$endDate = $this->option('to')
|
||||
? Carbon::parse($this->option('to'))
|
||||
: Carbon::yesterday();
|
||||
|
||||
$domain = $this->option('domain');
|
||||
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
|
||||
|
||||
$totalDays = $startDate->diffInDays($endDate) + 1;
|
||||
|
||||
$this->info("📊 백필 시작: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
|
||||
if ($domain) {
|
||||
$this->info(" 도메인 필터: {$domain}");
|
||||
}
|
||||
if ($tenantId) {
|
||||
$this->info(" 테넌트 필터: {$tenantId}");
|
||||
}
|
||||
|
||||
$totalErrors = [];
|
||||
$totalDomainsProcessed = 0;
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 1. 차원 테이블 동기화 (최초 1회)
|
||||
if (! $this->option('skip-dimensions')) {
|
||||
$this->info('');
|
||||
$this->info('🔄 차원 테이블 동기화...');
|
||||
try {
|
||||
$tenants = $this->getTargetTenants($tenantId);
|
||||
foreach ($tenants as $tenant) {
|
||||
$clients = $dimensionSync->syncClients($tenant->id);
|
||||
$products = $dimensionSync->syncProducts($tenant->id);
|
||||
$this->line(" tenant={$tenant->id}: 고객 {$clients}건, 제품 {$products}건");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" 차원 동기화 실패: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 일간 집계
|
||||
$this->info('');
|
||||
$this->info('📅 일간 집계 시작...');
|
||||
$bar = $this->output->createProgressBar($totalDays);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %message%');
|
||||
$bar->setMessage('');
|
||||
|
||||
$period = CarbonPeriod::create($startDate, $endDate);
|
||||
|
||||
foreach ($period as $date) {
|
||||
$bar->setMessage($date->format('Y-m-d'));
|
||||
|
||||
try {
|
||||
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
|
||||
$totalDomainsProcessed += $result['domains_processed'];
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$totalErrors = array_merge($totalErrors, $result['errors']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$totalErrors[] = "daily {$date->format('Y-m-d')}: {$e->getMessage()}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
// 3. 월간 집계
|
||||
if (! $this->option('skip-monthly')) {
|
||||
$this->info('');
|
||||
$this->info('📆 월간 집계 시작...');
|
||||
|
||||
$months = $this->getMonthRange($startDate, $endDate);
|
||||
$monthBar = $this->output->createProgressBar(count($months));
|
||||
|
||||
foreach ($months as [$year, $month]) {
|
||||
$monthBar->setMessage("{$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
|
||||
|
||||
try {
|
||||
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
|
||||
$totalDomainsProcessed += $result['domains_processed'];
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$totalErrors = array_merge($totalErrors, $result['errors']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$totalErrors[] = "monthly {$year}-{$month}: {$e->getMessage()}";
|
||||
}
|
||||
|
||||
$monthBar->advance();
|
||||
}
|
||||
|
||||
$monthBar->finish();
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$durationSec = round(microtime(true) - $startTime, 1);
|
||||
|
||||
$this->info('');
|
||||
$this->info('✅ 백필 완료:');
|
||||
$this->info(" - 기간: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
|
||||
$this->info(" - 처리 도메인-테넌트: {$totalDomainsProcessed}건");
|
||||
$this->info(" - 소요 시간: {$durationSec}초");
|
||||
|
||||
if (! empty($totalErrors)) {
|
||||
$this->warn(' - 에러: '.count($totalErrors).'건');
|
||||
foreach (array_slice($totalErrors, 0, 20) as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
if (count($totalErrors) > 20) {
|
||||
$this->warn(' ... 외 '.(count($totalErrors) - 20).'건');
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
|
||||
if ($tenantId) {
|
||||
$query->where('id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function getMonthRange(Carbon $start, Carbon $end): array
|
||||
{
|
||||
$months = [];
|
||||
$current = $start->copy()->startOfMonth();
|
||||
$endMonth = $end->copy()->startOfMonth();
|
||||
|
||||
while ($current->lte($endMonth)) {
|
||||
$months[] = [$current->year, $current->month];
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
}
|
||||
33
app/Console/Commands/StatCheckKpiAlertsCommand.php
Normal file
33
app/Console/Commands/StatCheckKpiAlertsCommand.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stats\KpiAlertService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class StatCheckKpiAlertsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stat:check-kpi-alerts';
|
||||
|
||||
protected $description = 'KPI 목표 대비 실적을 체크하고 미달 시 알림을 생성합니다';
|
||||
|
||||
public function handle(KpiAlertService $service): int
|
||||
{
|
||||
$this->info('KPI 알림 체크 시작...');
|
||||
|
||||
$result = $service->checkKpiAlerts();
|
||||
|
||||
$this->info("알림 생성: {$result['alerts_created']}건");
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
$this->warn('오류 발생:');
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('KPI 알림 체크 완료.');
|
||||
|
||||
return empty($result['errors']) ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
211
app/Console/Commands/StatVerifyCommand.php
Normal file
211
app/Console/Commands/StatVerifyCommand.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Stats\Daily\StatFinanceDaily;
|
||||
use App\Models\Stats\Daily\StatSalesDaily;
|
||||
use App\Models\Stats\Daily\StatSystemDaily;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StatVerifyCommand extends Command
|
||||
{
|
||||
protected $signature = 'stat:verify
|
||||
{--date= : 검증 날짜 (YYYY-MM-DD, 기본: 어제)}
|
||||
{--tenant= : 특정 테넌트만 검증}
|
||||
{--domain= : 특정 도메인만 검증 (sales,finance,system)}
|
||||
{--fix : 불일치 시 자동 재집계}';
|
||||
|
||||
protected $description = '원본 DB와 sam_stat 통계 정합성 교차 검증';
|
||||
|
||||
private int $totalChecks = 0;
|
||||
|
||||
private int $passedChecks = 0;
|
||||
|
||||
private int $failedChecks = 0;
|
||||
|
||||
private array $mismatches = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$date = $this->option('date')
|
||||
? Carbon::parse($this->option('date'))
|
||||
: Carbon::yesterday();
|
||||
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
|
||||
$domain = $this->option('domain');
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
|
||||
$this->info("🔍 정합성 검증: {$dateStr}");
|
||||
|
||||
$tenants = $this->getTargetTenants($tenantId);
|
||||
|
||||
$domains = $domain
|
||||
? [$domain]
|
||||
: ['sales', 'finance', 'system'];
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$this->info('');
|
||||
$this->info("── tenant={$tenant->id} ──");
|
||||
|
||||
foreach ($domains as $d) {
|
||||
match ($d) {
|
||||
'sales' => $this->verifySales($tenant->id, $dateStr),
|
||||
'finance' => $this->verifyFinance($tenant->id, $dateStr),
|
||||
'system' => $this->verifySystem($tenant->id, $dateStr),
|
||||
default => $this->warn(" 미지원 도메인: {$d}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$this->printSummary();
|
||||
|
||||
if ($this->failedChecks > 0 && $this->option('fix')) {
|
||||
$this->info('');
|
||||
$this->info('🔧 불일치 항목 재집계...');
|
||||
$this->reAggregate($date, $tenantId, $domains);
|
||||
}
|
||||
|
||||
return $this->failedChecks > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function verifySales(int $tenantId, string $dateStr): void
|
||||
{
|
||||
$this->line(' [sales]');
|
||||
|
||||
$originOrderCount = DB::connection('mysql')
|
||||
->table('orders')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('created_at', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$originSalesAmount = (float) DB::connection('mysql')
|
||||
->table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('sale_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->sum('supply_amount');
|
||||
|
||||
$stat = StatSalesDaily::where('tenant_id', $tenantId)
|
||||
->where('stat_date', $dateStr)
|
||||
->first();
|
||||
|
||||
$this->check('수주건수', $originOrderCount, $stat?->order_count ?? 0, $tenantId, 'sales');
|
||||
$this->check('매출금액', $originSalesAmount, (float) ($stat?->sales_amount ?? 0), $tenantId, 'sales');
|
||||
}
|
||||
|
||||
private function verifyFinance(int $tenantId, string $dateStr): void
|
||||
{
|
||||
$this->line(' [finance]');
|
||||
|
||||
$originDepositAmount = (float) DB::connection('mysql')
|
||||
->table('deposits')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deposit_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
$originWithdrawalAmount = (float) DB::connection('mysql')
|
||||
->table('withdrawals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('withdrawal_date', $dateStr)
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount');
|
||||
|
||||
$stat = StatFinanceDaily::where('tenant_id', $tenantId)
|
||||
->where('stat_date', $dateStr)
|
||||
->first();
|
||||
|
||||
$this->check('입금액', $originDepositAmount, (float) ($stat?->deposit_amount ?? 0), $tenantId, 'finance');
|
||||
$this->check('출금액', $originWithdrawalAmount, (float) ($stat?->withdrawal_amount ?? 0), $tenantId, 'finance');
|
||||
}
|
||||
|
||||
private function verifySystem(int $tenantId, string $dateStr): void
|
||||
{
|
||||
$this->line(' [system]');
|
||||
|
||||
$originApiCount = DB::connection('mysql')
|
||||
->table('api_request_logs')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('created_at', $dateStr)
|
||||
->count();
|
||||
|
||||
$originAuditCount = DB::connection('mysql')
|
||||
->table('audit_logs')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereDate('created_at', $dateStr)
|
||||
->count();
|
||||
|
||||
$stat = StatSystemDaily::where('tenant_id', $tenantId)
|
||||
->where('stat_date', $dateStr)
|
||||
->first();
|
||||
|
||||
$this->check('API요청수', $originApiCount, $stat?->api_request_count ?? 0, $tenantId, 'system');
|
||||
|
||||
$statAuditTotal = ($stat?->audit_create_count ?? 0)
|
||||
+ ($stat?->audit_update_count ?? 0)
|
||||
+ ($stat?->audit_delete_count ?? 0);
|
||||
$this->check('감사로그수', $originAuditCount, $statAuditTotal, $tenantId, 'system');
|
||||
}
|
||||
|
||||
private function check(string $label, float|int $expected, float|int $actual, int $tenantId, string $domain): void
|
||||
{
|
||||
$this->totalChecks++;
|
||||
|
||||
$tolerance = is_float($expected) ? 0.01 : 0;
|
||||
$match = abs($expected - $actual) <= $tolerance;
|
||||
|
||||
if ($match) {
|
||||
$this->passedChecks++;
|
||||
$this->line(" ✅ {$label}: {$actual}");
|
||||
} else {
|
||||
$this->failedChecks++;
|
||||
$this->error(" ❌ {$label}: 원본={$expected} / 통계={$actual} (차이=".($actual - $expected).')');
|
||||
$this->mismatches[] = compact('tenantId', 'domain', 'label', 'expected', 'actual');
|
||||
}
|
||||
}
|
||||
|
||||
private function printSummary(): void
|
||||
{
|
||||
$this->info('');
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info("📊 검증 결과: {$this->totalChecks}건 검사, ✅ {$this->passedChecks}건 일치, ❌ {$this->failedChecks}건 불일치");
|
||||
|
||||
if ($this->failedChecks > 0) {
|
||||
$this->warn('');
|
||||
$this->warn('불일치 목록:');
|
||||
foreach ($this->mismatches as $m) {
|
||||
$this->warn(" - tenant={$m['tenantId']} [{$m['domain']}] {$m['label']}: 원본={$m['expected']} / 통계={$m['actual']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function reAggregate(Carbon $date, ?int $tenantId, array $domains): void
|
||||
{
|
||||
$aggregator = app(\App\Services\Stats\StatAggregatorService::class);
|
||||
|
||||
foreach ($domains as $d) {
|
||||
$result = $aggregator->aggregateDaily($date, $d, $tenantId);
|
||||
$this->line(" {$d}: 재집계 완료 ({$result['domains_processed']}건)");
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->error(" {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('✅ 재집계 완료. stat:verify를 다시 실행하여 확인하세요.');
|
||||
}
|
||||
|
||||
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
|
||||
if ($tenantId) {
|
||||
$query->where('id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
}
|
||||
137
app/Console/Commands/ValidateBendingItems.php
Normal file
137
app/Console/Commands/ValidateBendingItems.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[AsCommand(name: 'bending:validate-items', description: 'BD-* 절곡 세부품목 마스터 데이터 검증 (prefix × lengthCode 전 조합)')]
|
||||
class ValidateBendingItems extends Command
|
||||
{
|
||||
protected $signature = 'bending:validate-items
|
||||
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}';
|
||||
|
||||
/**
|
||||
* prefix별 유효 길이코드 정의
|
||||
*
|
||||
* 가이드레일: 30, 35, 40, 43 (벽면/측면 공통)
|
||||
* 하단마감재: 30, 40
|
||||
* 셔터박스: 12, 24, 30, 35, 40, 41
|
||||
* 연기차단재: 53, 54, 83, 84 (W50/W80 전용 코드)
|
||||
* XX: 12, 24, 30, 35, 40, 41, 43 (하부BASE + 셔터 상부/마구리)
|
||||
* YY: 30, 35, 40, 43 (별도 SUS 마감)
|
||||
* HH: 30, 40 (보강평철)
|
||||
*/
|
||||
private function getPrefixLengthCodes(): array
|
||||
{
|
||||
$guideRailCodes = ['30', '35', '40', '43'];
|
||||
$guideRailCodesWithExtra = ['24', '30', '35', '40', '43']; // RT/ST는 적은 종류
|
||||
$bottomBarCodes = ['30', '40'];
|
||||
$shutterBoxCodes = ['12', '24', '30', '35', '40', '41'];
|
||||
|
||||
return [
|
||||
// 가이드레일 벽면형
|
||||
'RS' => $guideRailCodes, // 벽면 SUS 마감재
|
||||
'RM' => ['24', '30', '35', '40', '42', '43'], // 벽면 본체 (EGI)
|
||||
'RC' => ['24', '30', '35', '40', '42', '43'], // 벽면 C형
|
||||
'RD' => ['24', '30', '35', '40', '42', '43'], // 벽면 D형
|
||||
'RT' => ['30', '43'], // 벽면 본체 (철재)
|
||||
|
||||
// 가이드레일 측면형
|
||||
'SS' => ['30', '35', '40'], // 측면 SUS 마감재
|
||||
'SM' => ['24', '30', '35', '40', '43'], // 측면 본체 (EGI)
|
||||
'SC' => ['24', '30', '35', '40', '43'], // 측면 C형
|
||||
'SD' => ['24', '30', '35', '40', '43'], // 측면 D형
|
||||
'ST' => ['43'], // 측면 본체 (철재)
|
||||
'SU' => ['30', '35', '40', '43'], // 측면 SUS (SUS2)
|
||||
|
||||
// 하단마감재
|
||||
'BE' => $bottomBarCodes, // EGI 마감
|
||||
'BS' => ['24', '30', '35', '40', '43'], // SUS 마감
|
||||
'TS' => ['43'], // 철재 SUS
|
||||
'LA' => $bottomBarCodes, // L-Bar
|
||||
|
||||
// 셔터박스
|
||||
'CF' => $shutterBoxCodes, // 전면부
|
||||
'CL' => $shutterBoxCodes, // 린텔부
|
||||
'CP' => $shutterBoxCodes, // 점검구
|
||||
'CB' => $shutterBoxCodes, // 후면코너부
|
||||
|
||||
// 연기차단재
|
||||
'GI' => ['53', '54', '83', '84', '30', '35', '40'], // W50/W80 + 일반
|
||||
|
||||
// 공용/기타
|
||||
'XX' => ['12', '24', '30', '35', '40', '41', '43'], // 하부BASE/셔터 상부/마구리
|
||||
'YY' => ['30', '35', '40', '43'], // 별도 SUS 마감
|
||||
'HH' => ['30', '40'], // 보강평철
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
|
||||
$this->info("=== BD-* 절곡 세부품목 마스터 검증 (tenant: {$tenantId}) ===");
|
||||
$this->newLine();
|
||||
|
||||
// DB에서 전체 BD-* 품목 조회
|
||||
$existingItems = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', 'like', 'BD-%')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('code')
|
||||
->toArray();
|
||||
|
||||
$existingSet = array_flip($existingItems);
|
||||
|
||||
$this->info('현재 등록된 BD-* 품목: '.count($existingItems).'개');
|
||||
$this->newLine();
|
||||
|
||||
$prefixMap = $this->getPrefixLengthCodes();
|
||||
$totalExpected = 0;
|
||||
$missing = [];
|
||||
$found = 0;
|
||||
|
||||
foreach ($prefixMap as $prefix => $codes) {
|
||||
$prefixMissing = [];
|
||||
foreach ($codes as $code) {
|
||||
$itemCode = "BD-{$prefix}-{$code}";
|
||||
$totalExpected++;
|
||||
|
||||
if (isset($existingSet[$itemCode])) {
|
||||
$found++;
|
||||
} else {
|
||||
$prefixMissing[] = $itemCode;
|
||||
$missing[] = $itemCode;
|
||||
}
|
||||
}
|
||||
|
||||
$status = empty($prefixMissing) ? '✅' : '❌';
|
||||
$countStr = count($codes) - count($prefixMissing).'/'.count($codes);
|
||||
$this->line(" {$status} BD-{$prefix}: {$countStr}");
|
||||
|
||||
if (! empty($prefixMissing)) {
|
||||
foreach ($prefixMissing as $m) {
|
||||
$this->line(" ⚠️ 누락: {$m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info("검증 결과: {$found}/{$totalExpected} 등록 완료");
|
||||
|
||||
if (empty($missing)) {
|
||||
$this->info('✅ All items registered — 누락 0건');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->warn('❌ 누락 항목: '.count($missing).'건');
|
||||
$this->newLine();
|
||||
$this->table(['누락 품목코드'], array_map(fn ($m) => [$m], $missing));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public function handle(FormulaEvaluatorService $formulaEvaluator): int
|
||||
$finishedGoodsCode = $this->option('finished-goods');
|
||||
$verboseMode = $this->option('verbose-mode');
|
||||
|
||||
$this->info("📥 입력 파라미터:");
|
||||
$this->info('📥 입력 파라미터:');
|
||||
$this->table(
|
||||
['항목', '값'],
|
||||
[
|
||||
@@ -87,7 +87,7 @@ public function handle(FormulaEvaluatorService $formulaEvaluator): int
|
||||
);
|
||||
|
||||
if (! $samResult['success']) {
|
||||
$this->error("SAM 계산 실패: " . ($samResult['error'] ?? '알 수 없는 오류'));
|
||||
$this->error('SAM 계산 실패: '.($samResult['error'] ?? '알 수 없는 오류'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
@@ -214,4 +214,4 @@ private function calculateSamVariables(float $W0, float $H0, string $productType
|
||||
'K' => $K,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
app/DTOs/Production/DynamicBomEntry.php
Normal file
101
app/DTOs/Production/DynamicBomEntry.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs\Production;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* dynamic_bom JSON 항목 DTO
|
||||
*
|
||||
* work_order_items.options.dynamic_bom 배열의 각 엔트리를 표현
|
||||
*/
|
||||
class DynamicBomEntry
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $child_item_id,
|
||||
public readonly string $child_item_code,
|
||||
public readonly string $lot_prefix,
|
||||
public readonly string $part_type,
|
||||
public readonly string $category,
|
||||
public readonly string $material_type,
|
||||
public readonly int $length_mm,
|
||||
public readonly int|float $qty,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 배열에서 DTO 생성
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
self::validate($data);
|
||||
|
||||
return new self(
|
||||
child_item_id: (int) $data['child_item_id'],
|
||||
child_item_code: (string) $data['child_item_code'],
|
||||
lot_prefix: (string) $data['lot_prefix'],
|
||||
part_type: (string) $data['part_type'],
|
||||
category: (string) $data['category'],
|
||||
material_type: (string) $data['material_type'],
|
||||
length_mm: (int) $data['length_mm'],
|
||||
qty: $data['qty'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO → 배열 변환 (JSON 저장용)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'child_item_id' => $this->child_item_id,
|
||||
'child_item_code' => $this->child_item_code,
|
||||
'lot_prefix' => $this->lot_prefix,
|
||||
'part_type' => $this->part_type,
|
||||
'category' => $this->category,
|
||||
'material_type' => $this->material_type,
|
||||
'length_mm' => $this->length_mm,
|
||||
'qty' => $this->qty,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 필드 검증
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function validate(array $data): bool
|
||||
{
|
||||
$required = ['child_item_id', 'child_item_code', 'lot_prefix', 'part_type', 'category', 'material_type', 'length_mm', 'qty'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (! array_key_exists($field, $data) || $data[$field] === null) {
|
||||
throw new InvalidArgumentException("DynamicBomEntry: '{$field}' is required");
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) $data['child_item_id'] <= 0) {
|
||||
throw new InvalidArgumentException('DynamicBomEntry: child_item_id must be positive');
|
||||
}
|
||||
|
||||
$validCategories = ['guideRail', 'bottomBar', 'shutterBox', 'smokeBarrier'];
|
||||
if (! in_array($data['category'], $validCategories, true)) {
|
||||
throw new InvalidArgumentException('DynamicBomEntry: category must be one of: '.implode(', ', $validCategories));
|
||||
}
|
||||
|
||||
if ($data['qty'] <= 0) {
|
||||
throw new InvalidArgumentException('DynamicBomEntry: qty must be positive');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicBomEntry 배열 → JSON 저장용 배열 변환
|
||||
*
|
||||
* @param DynamicBomEntry[] $entries
|
||||
*/
|
||||
public static function toArrayList(array $entries): array
|
||||
{
|
||||
return array_map(fn (self $e) => $e->toArray(), $entries);
|
||||
}
|
||||
}
|
||||
@@ -17,25 +17,13 @@
|
||||
class Handler extends ExceptionHandler
|
||||
{
|
||||
/**
|
||||
* 특정 IP에서 발생하는 예외를 슬랙/로그에서 무시할지 확인
|
||||
* 슬랙 알림에서 무시할 예외인지 확인
|
||||
*/
|
||||
protected function shouldIgnoreException(Throwable $e): bool
|
||||
{
|
||||
$ignoredIps = array_filter(
|
||||
array_map('trim', explode(',', env('EXCEPTION_IGNORED_IPS', '')))
|
||||
);
|
||||
|
||||
if (empty($ignoredIps)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentIp = request()?->ip();
|
||||
|
||||
// 무시할 IP 목록에 있고, '회원정보 정보 없음' 예외인 경우
|
||||
if (in_array($currentIp, $ignoredIps, true)) {
|
||||
if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') {
|
||||
return true;
|
||||
}
|
||||
// 세션 만료로 인한 인증 실패는 슬랙 알림 제외 (API Key 검증 통과 후 발생하므로 정상 케이스)
|
||||
if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle
|
||||
@@ -31,10 +32,10 @@ public function headings(): array
|
||||
return [
|
||||
['일일 일보 - '.$this->report['date']],
|
||||
[],
|
||||
['전일 잔액', number_format($this->report['previous_balance']).'원'],
|
||||
['당일 입금액', number_format($this->report['daily_deposit']).'원'],
|
||||
['당일 출금액', number_format($this->report['daily_withdrawal']).'원'],
|
||||
['당일 잔액', number_format($this->report['current_balance']).'원'],
|
||||
['전월 이월', number_format($this->report['previous_balance']).'원'],
|
||||
['당월 입금', number_format($this->report['daily_deposit']).'원'],
|
||||
['당월 출금', number_format($this->report['daily_withdrawal']).'원'],
|
||||
['잔액', number_format($this->report['current_balance']).'원'],
|
||||
[],
|
||||
['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'],
|
||||
];
|
||||
@@ -47,6 +48,7 @@ public function array(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
// ── 예금 입출금 내역 ──
|
||||
foreach ($this->report['details'] as $detail) {
|
||||
$rows[] = [
|
||||
$detail['type_label'],
|
||||
@@ -58,7 +60,7 @@ public function array(): array
|
||||
];
|
||||
}
|
||||
|
||||
// 합계 행 추가
|
||||
// 합계 행
|
||||
$rows[] = [];
|
||||
$rows[] = [
|
||||
'합계',
|
||||
@@ -69,6 +71,37 @@ public function array(): array
|
||||
'',
|
||||
];
|
||||
|
||||
// ── 어음 및 외상매출채권 현황 ──
|
||||
$noteReceivables = $this->report['note_receivables'] ?? [];
|
||||
|
||||
$rows[] = [];
|
||||
$rows[] = [];
|
||||
$rows[] = ['어음 및 외상매출채권 현황'];
|
||||
$rows[] = ['No.', '내용', '금액', '발행일', '만기일'];
|
||||
|
||||
$noteTotal = 0;
|
||||
$no = 1;
|
||||
foreach ($noteReceivables as $item) {
|
||||
$amount = $item['current_balance'] ?? 0;
|
||||
$noteTotal += $amount;
|
||||
$rows[] = [
|
||||
$no++,
|
||||
$item['content'] ?? '-',
|
||||
$amount > 0 ? number_format($amount) : '',
|
||||
$item['issue_date'] ?? '-',
|
||||
$item['due_date'] ?? '-',
|
||||
];
|
||||
}
|
||||
|
||||
// 어음 합계
|
||||
$rows[] = [
|
||||
'합계',
|
||||
'',
|
||||
number_format($noteTotal),
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
@@ -77,7 +110,7 @@ public function array(): array
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
return [
|
||||
$styles = [
|
||||
1 => ['font' => ['bold' => true, 'size' => 14]],
|
||||
3 => ['font' => ['bold' => true]],
|
||||
4 => ['font' => ['bold' => true]],
|
||||
@@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array
|
||||
8 => [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'E0E0E0'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// 어음 섹션 헤더 스타일 (동적 행 번호)
|
||||
// headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행
|
||||
$detailCount = count($this->report['details']);
|
||||
$noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행
|
||||
$noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행
|
||||
|
||||
$styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]];
|
||||
$styles[$noteHeaderRow] = [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'E0E0E0'],
|
||||
],
|
||||
];
|
||||
|
||||
// 어음 합계 행
|
||||
$noteCount = count($this->report['note_receivables'] ?? []);
|
||||
$noteTotalRow = $noteHeaderRow + $noteCount + 1;
|
||||
$styles[$noteTotalRow] = ['font' => ['bold' => true]];
|
||||
|
||||
return $styles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Exceptions\DuplicateCodeException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class ApiResponse
|
||||
@@ -245,6 +246,15 @@ public static function handle(
|
||||
return self::success($data, $responseTitle, $debug, $statusCode);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// 모든 예외를 로깅 (디버깅용)
|
||||
Log::error('API Exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'url' => request()->fullUrl(),
|
||||
'method' => request()->method(),
|
||||
]);
|
||||
|
||||
// ValidationException - 422 Unprocessable Entity
|
||||
if ($e instanceof \Illuminate\Validation\ValidationException) {
|
||||
@@ -279,9 +289,12 @@ public static function handle(
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 예외는 500으로 처리, debug 모드에서만 스택 트레이스 포함
|
||||
return self::error('서버 에러', 500, [
|
||||
// 일반 예외는 500으로 처리, debug 모드에서만 상세 정보 포함
|
||||
$errorMessage = config('app.debug') ? $e->getMessage() : '서버 에러';
|
||||
|
||||
return self::error($errorMessage, 500, [
|
||||
'details' => config('app.debug') ? $e->getTraceAsString() : null,
|
||||
'exception' => config('app.debug') ? get_class($e) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - 두께 매핑 (normalizeThickness)
|
||||
* - 면적 계산 (calculateArea)
|
||||
*
|
||||
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
|
||||
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
|
||||
*/
|
||||
class Legacy5130Calculator
|
||||
{
|
||||
@@ -506,4 +506,4 @@ public static function validateAgainstLegacy(
|
||||
'differences' => $differences,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
app/Http/Controllers/Api/V1/AccountSubjectController.php
Normal file
60
app/Http/Controllers/Api/V1/AccountSubjectController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
|
||||
use App\Services\AccountCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountSubjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountCodeService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정과목 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only(['search', 'category']);
|
||||
|
||||
$subjects = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($subjects, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 등록
|
||||
*/
|
||||
public function store(StoreAccountSubjectRequest $request)
|
||||
{
|
||||
$subject = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($subject, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 활성/비활성 토글
|
||||
*/
|
||||
public function toggleStatus(int $id, Request $request)
|
||||
{
|
||||
$isActive = (bool) $request->input('is_active', true);
|
||||
|
||||
$subject = $this->service->toggleStatus($id, $isActive);
|
||||
|
||||
return ApiResponse::success($subject, __('message.toggled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 삭제
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$this->service->destroy($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\AiReport\AiReportGenerateRequest;
|
||||
use App\Http\Requests\V1\AiReport\AiReportListRequest;
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Services\AiReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
|
||||
38
app/Http/Controllers/Api/V1/AppVersionController.php
Normal file
38
app/Http/Controllers/Api/V1/AppVersionController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AppVersionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AppVersionController extends Controller
|
||||
{
|
||||
/**
|
||||
* 최신 버전 확인
|
||||
* GET /api/v1/app/version?platform=android¤t_version_code=1
|
||||
*/
|
||||
public function latestVersion(Request $request): JsonResponse
|
||||
{
|
||||
$platform = $request->input('platform', 'android');
|
||||
$currentVersionCode = (int) $request->input('current_version_code', 0);
|
||||
|
||||
$result = AppVersionService::getLatestVersion($platform, $currentVersionCode);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* APK 다운로드
|
||||
* GET /api/v1/app/download/{id}
|
||||
*/
|
||||
public function download(int $id): StreamedResponse
|
||||
{
|
||||
return AppVersionService::downloadApk($id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Audit;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Audit\TriggerAuditLogIndexRequest;
|
||||
use App\Http\Requests\Audit\TriggerAuditRollbackRequest;
|
||||
use App\Services\Audit\AuditRollbackService;
|
||||
use App\Services\Audit\TriggerAuditLogService;
|
||||
|
||||
class TriggerAuditLogController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TriggerAuditLogService $service,
|
||||
protected AuditRollbackService $rollbackService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 트리거 감사 로그 목록 조회
|
||||
*/
|
||||
public function index(TriggerAuditLogIndexRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->paginate($request->validated());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리거 감사 로그 상세 조회
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return \App\Models\Audit\TriggerAuditLog::findOrFail($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레코드의 변경 이력
|
||||
*/
|
||||
public function recordHistory(string $tableName, string $rowId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($tableName, $rowId) {
|
||||
return $this->service->recordHistory($tableName, $rowId);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$tenantId = request()->query('tenant_id');
|
||||
|
||||
return $this->service->stats($tenantId ? (int) $tenantId : null);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백 SQL 미리보기
|
||||
*/
|
||||
public function rollbackPreview(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return [
|
||||
'audit_id' => $id,
|
||||
'rollback_sql' => $this->rollbackService->generateRollbackSQL($id),
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백 실행
|
||||
*/
|
||||
public function rollbackExecute(TriggerAuditRollbackRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->rollbackService->executeRollback($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
}
|
||||
144
app/Http/Controllers/Api/V1/BarobillController.php
Normal file
144
app/Http/Controllers/Api/V1/BarobillController.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BarobillService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BarobillController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BarobillService $barobillService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 연동 현황 조회
|
||||
*/
|
||||
public function status()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$setting = $this->barobillService->getSetting();
|
||||
|
||||
return [
|
||||
'bank_service_count' => 0,
|
||||
'account_link_count' => 0,
|
||||
'member' => $setting ? [
|
||||
'barobill_id' => $setting->barobill_id,
|
||||
'biz_no' => $setting->corp_num,
|
||||
'status' => $setting->isVerified() ? 'active' : 'inactive',
|
||||
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
|
||||
] : null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 로그인 정보 등록
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'barobill_id' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
return $this->barobillService->saveSetting([
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
]);
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 회원가입 정보 등록
|
||||
*/
|
||||
public function signup(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'business_number' => 'required|string|size:10',
|
||||
'company_name' => 'required|string',
|
||||
'ceo_name' => 'required|string',
|
||||
'business_type' => 'nullable|string',
|
||||
'business_category' => 'nullable|string',
|
||||
'address' => 'nullable|string',
|
||||
'barobill_id' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'manager_name' => 'nullable|string',
|
||||
'manager_phone' => 'nullable|string',
|
||||
'manager_email' => 'nullable|email',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
return $this->barobillService->saveSetting([
|
||||
'corp_num' => $data['business_number'],
|
||||
'corp_name' => $data['company_name'],
|
||||
'ceo_name' => $data['ceo_name'],
|
||||
'biz_type' => $data['business_type'] ?? null,
|
||||
'biz_class' => $data['business_category'] ?? null,
|
||||
'addr' => $data['address'] ?? null,
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
'contact_name' => $data['manager_name'] ?? null,
|
||||
'contact_tel' => $data['manager_phone'] ?? null,
|
||||
'contact_id' => $data['manager_email'] ?? null,
|
||||
]);
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 빠른조회 서비스 URL 조회
|
||||
*/
|
||||
public function bankServiceUrl(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/BankAccountService'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 연동 등록 URL 조회
|
||||
*/
|
||||
public function accountLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/AccountLink'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 연동 등록 URL 조회
|
||||
*/
|
||||
public function cardLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Card/CardLink'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공인인증서 등록 URL 조회
|
||||
*/
|
||||
public function certificateUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Certificate/Register'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\v1;
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
@@ -106,6 +106,22 @@ public function tenantBoards()
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 상세 조회 (코드 기반)
|
||||
*/
|
||||
public function showByCode(string $code)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($code) {
|
||||
$board = $this->boardService->getBoardByCode($code);
|
||||
|
||||
if (! $board) {
|
||||
abort(404, __('error.board.not_found'));
|
||||
}
|
||||
|
||||
return $board;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 필드 목록 조회
|
||||
*/
|
||||
|
||||
@@ -51,4 +51,56 @@ public function summary(Request $request)
|
||||
);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 등록
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'start_time' => 'nullable|date_format:H:i',
|
||||
'end_time' => 'nullable|date_format:H:i',
|
||||
'is_all_day' => 'boolean',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($validated) {
|
||||
return $this->calendarService->createSchedule($validated);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 수정
|
||||
*/
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'start_time' => 'nullable|date_format:H:i',
|
||||
'end_time' => 'nullable|date_format:H:i',
|
||||
'is_all_day' => 'boolean',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($id, $validated) {
|
||||
return $this->calendarService->updateSchedule($id, $validated);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->calendarService->deleteSchedule($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
|
||||
133
app/Http/Controllers/Api/V1/CalendarScheduleController.php
Normal file
133
app/Http/Controllers/Api/V1/CalendarScheduleController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CalendarScheduleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CalendarScheduleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CalendarScheduleService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 일정 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'year' => 'required|integer|min:2000|max:2100',
|
||||
'type' => 'nullable|string',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->list(
|
||||
(int) $request->input('year'),
|
||||
$request->input('type')
|
||||
),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'year' => 'required|integer|min:2000|max:2100',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->stats((int) $request->input('year')),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->show($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
|
||||
'is_recurring' => 'boolean',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($validated),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
|
||||
'is_recurring' => 'boolean',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->update($id, $validated),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->delete($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 등록
|
||||
*/
|
||||
public function bulkStore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'schedules' => 'required|array|min:1',
|
||||
'schedules.*.name' => 'required|string|max:100',
|
||||
'schedules.*.start_date' => 'required|date',
|
||||
'schedules.*.end_date' => 'required|date|after_or_equal:schedules.*.start_date',
|
||||
'schedules.*.type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
|
||||
'schedules.*.is_recurring' => 'boolean',
|
||||
'schedules.*.memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->bulkStore($validated['schedules']),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,17 @@
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Models\Products\CommonCode;
|
||||
use App\Models\Scopes\TenantScope;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CommonController
|
||||
{
|
||||
public static function getComeCode()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return DB::table('common_codes')
|
||||
return CommonCode::query()
|
||||
->select(['code_group', 'code', 'name', 'description', 'is_active'])
|
||||
->where('tenant_id', app('tenant_id'))
|
||||
->get();
|
||||
}, '공통코드');
|
||||
}
|
||||
@@ -36,13 +36,22 @@ public function index(Request $request, string $group)
|
||||
return ApiResponse::handle(function () use ($group) {
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
return DB::table('common_codes')
|
||||
->select(['id', 'code', 'name', 'description', 'sort_order', 'attributes'])
|
||||
// BelongsToTenant 스코프 해제 (글로벌 폴백 로직 직접 처리)
|
||||
$base = CommonCode::withoutGlobalScope(TenantScope::class)
|
||||
->where('code_group', $group)
|
||||
->where('is_active', true)
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId)
|
||||
->orWhereNull('tenant_id');
|
||||
->where('is_active', true);
|
||||
|
||||
// 테넌트 전용 데이터가 있으면 테넌트만, 없으면 글로벌 폴백
|
||||
$hasTenantData = (clone $base)->where('tenant_id', $tenantId)->exists();
|
||||
|
||||
return (clone $base)
|
||||
->select(['id', 'code', 'name', 'description', 'sort_order', 'attributes'])
|
||||
->where(function ($query) use ($tenantId, $hasTenantData) {
|
||||
if ($hasTenantData) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
} else {
|
||||
$query->whereNull('tenant_id');
|
||||
}
|
||||
})
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exports\DailyReportExport;
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\DailyReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* 일일 보고서 컨트롤러
|
||||
@@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->service->summary($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 보고서 엑셀 다운로드
|
||||
*/
|
||||
public function export(Request $request): BinaryFileResponse
|
||||
{
|
||||
$params = $request->validate([
|
||||
'date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$reportData = $this->service->exportData($params);
|
||||
$filename = '일일일보_'.$reportData['date'].'.xlsx';
|
||||
|
||||
return Excel::download(new DailyReportExport($reportData), $filename);
|
||||
}
|
||||
}
|
||||
|
||||
92
app/Http/Controllers/Api/V1/DashboardCeoController.php
Normal file
92
app/Http/Controllers/Api/V1/DashboardCeoController.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\DashboardCeoService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* CEO 대시보드 섹션별 API 컨트롤러
|
||||
*
|
||||
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
|
||||
*/
|
||||
class DashboardCeoController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DashboardCeoService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 매출 현황 요약
|
||||
* GET /api/v1/dashboard/sales/summary
|
||||
*/
|
||||
public function salesSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->salesSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 현황 요약
|
||||
* GET /api/v1/dashboard/purchases/summary
|
||||
*/
|
||||
public function purchasesSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->purchasesSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산 현황 요약
|
||||
* GET /api/v1/dashboard/production/summary
|
||||
*/
|
||||
public function productionSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->productionSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미출고 내역 요약
|
||||
* GET /api/v1/dashboard/unshipped/summary
|
||||
*/
|
||||
public function unshippedSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->unshippedSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시공 현황 요약
|
||||
* GET /api/v1/dashboard/construction/summary
|
||||
*/
|
||||
public function constructionSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->constructionSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 현황 요약
|
||||
* GET /api/v1/dashboard/attendance/summary
|
||||
*/
|
||||
public function attendanceSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->attendanceSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Dashboard\DashboardApprovalsRequest;
|
||||
use App\Http\Requests\V1\Dashboard\DashboardChartsRequest;
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
|
||||
201
app/Http/Controllers/Api/V1/Documents/DocumentController.php
Normal file
201
app/Http/Controllers/Api/V1/Documents/DocumentController.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Documents;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Document\ApproveRequest;
|
||||
use App\Http\Requests\Document\BulkCreateFqcRequest;
|
||||
use App\Http\Requests\Document\IndexRequest;
|
||||
use App\Http\Requests\Document\RejectRequest;
|
||||
use App\Http\Requests\Document\ResolveRequest;
|
||||
use App\Http\Requests\Document\StoreRequest;
|
||||
use App\Http\Requests\Document\UpdateRequest;
|
||||
use App\Http\Requests\Document\UpsertRequest;
|
||||
use App\Services\DocumentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
public function __construct(private DocumentService $service) {}
|
||||
|
||||
/**
|
||||
* 문서 목록 조회
|
||||
* GET /v1/documents
|
||||
*/
|
||||
public function index(IndexRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->list($request->validated());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 상세 조회
|
||||
* GET /v1/documents/{id}
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 생성
|
||||
* POST /v1/documents
|
||||
*/
|
||||
public function store(StoreRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->create($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 수정
|
||||
* PATCH /v1/documents/{id}
|
||||
*/
|
||||
public function update(int $id, UpdateRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 삭제
|
||||
* DELETE /v1/documents/{id}
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->destroy($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* rendered_html 스냅샷 저장 (Lazy Snapshot)
|
||||
* PATCH /v1/documents/{id}/snapshot
|
||||
*/
|
||||
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$renderedHtml = $request->validated()['rendered_html'] ?? null;
|
||||
if (! $renderedHtml) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
|
||||
}
|
||||
|
||||
return $this->service->patchSnapshot($id, $renderedHtml);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FQC 일괄생성 (제품검사)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수주 개소별 제품검사 문서 일괄생성
|
||||
* POST /v1/documents/bulk-create-fqc
|
||||
*/
|
||||
public function bulkCreateFqc(BulkCreateFqcRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->bulkCreateFqc($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 FQC 진행현황 조회
|
||||
* GET /v1/documents/fqc-status?order_id=1&template_id=65
|
||||
*/
|
||||
public function fqcStatus(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$orderId = (int) request('order_id');
|
||||
$templateId = (int) request('template_id');
|
||||
|
||||
if (! $orderId || ! $templateId) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(
|
||||
__('validation.required', ['attribute' => 'order_id, template_id'])
|
||||
);
|
||||
}
|
||||
|
||||
return $this->service->fqcStatus($orderId, $templateId);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Resolve/Upsert (React 연동용)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 문서 Resolve
|
||||
* GET /v1/documents/resolve?category=incoming_inspection&item_id=12596
|
||||
*/
|
||||
public function resolve(ResolveRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->resolve($request->validated());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 Upsert
|
||||
* POST /v1/documents/upsert
|
||||
*/
|
||||
public function upsert(UpsertRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->upsert($request->validated());
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 결재 워크플로우
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재 제출 (DRAFT → PENDING)
|
||||
* POST /v1/documents/{id}/submit
|
||||
*/
|
||||
public function submit(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->submit($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인
|
||||
* POST /v1/documents/{id}/approve
|
||||
*/
|
||||
public function approve(int $id, ApproveRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->approve($id, $request->validated()['comment'] ?? null);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려
|
||||
* POST /v1/documents/{id}/reject
|
||||
*/
|
||||
public function reject(int $id, RejectRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->reject($id, $request->validated()['comment']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 취소/회수
|
||||
* POST /v1/documents/{id}/cancel
|
||||
*/
|
||||
public function cancel(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->cancel($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Documents;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DocumentTemplate\IndexRequest;
|
||||
use App\Services\DocumentTemplateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DocumentTemplateController extends Controller
|
||||
{
|
||||
public function __construct(private DocumentTemplateService $service) {}
|
||||
|
||||
/**
|
||||
* 양식 목록 조회
|
||||
* GET /v1/document-templates
|
||||
*/
|
||||
public function index(IndexRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->list($request->validated());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 상세 조회
|
||||
* GET /v1/document-templates/{id}
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
116
app/Http/Controllers/Api/V1/ESign/EsignContractController.php
Normal file
116
app/Http/Controllers/Api/V1/ESign/EsignContractController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\ESign;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ESign\ContractStoreRequest;
|
||||
use App\Http\Requests\ESign\FieldConfigureRequest;
|
||||
use App\Services\ESign\EsignContractService;
|
||||
use App\Services\ESign\EsignPdfService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EsignContractController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EsignContractService $service,
|
||||
private EsignPdfService $pdfService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->list($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function store(ContractStoreRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->create($request->validated() + ['file' => $request->file('file')]);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
public function cancel(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->cancel($id);
|
||||
}, __('message.esign.cancelled'));
|
||||
}
|
||||
|
||||
public function configureFields(int $id, FieldConfigureRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->configureFields($id, $request->validated()['fields']);
|
||||
}, __('message.esign.fields_configured'));
|
||||
}
|
||||
|
||||
public function send(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->send($id);
|
||||
}, __('message.esign.sent'));
|
||||
}
|
||||
|
||||
public function remind(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->remind($id);
|
||||
}, __('message.esign.reminded'));
|
||||
}
|
||||
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->stats();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function download(int $id): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
|
||||
{
|
||||
try {
|
||||
$contract = $this->service->show($id);
|
||||
$filePath = $contract->signed_file_path ?? $contract->original_file_path;
|
||||
|
||||
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
|
||||
return ApiResponse::error(__('error.esign.file_not_found'), 404);
|
||||
}
|
||||
|
||||
$fileName = $contract->original_file_name ?? 'contract.pdf';
|
||||
|
||||
return Storage::disk('local')->download($filePath, $fileName);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponse::error($e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function verify(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$contract = $this->service->show($id);
|
||||
|
||||
if (! $contract->original_file_path || ! $contract->original_file_hash) {
|
||||
return ['verified' => false, 'message' => '파일 정보가 없습니다.'];
|
||||
}
|
||||
|
||||
$isValid = $this->pdfService->verifyIntegrity(
|
||||
$contract->original_file_path,
|
||||
$contract->original_file_hash
|
||||
);
|
||||
|
||||
return [
|
||||
'verified' => $isValid,
|
||||
'original_hash' => $contract->original_file_hash,
|
||||
];
|
||||
}, __('message.esign.verified'));
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Api/V1/ESign/EsignSignController.php
Normal file
75
app/Http/Controllers/Api/V1/ESign/EsignSignController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\ESign;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ESign\SignRejectRequest;
|
||||
use App\Http\Requests\ESign\SignSubmitRequest;
|
||||
use App\Services\ESign\EsignSignService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EsignSignController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EsignSignService $service,
|
||||
) {}
|
||||
|
||||
public function getContract(string $token): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($token) {
|
||||
return $this->service->getByToken($token);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function sendOtp(string $token): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($token) {
|
||||
return $this->service->sendOtp($token);
|
||||
}, __('message.esign.otp_sent'));
|
||||
}
|
||||
|
||||
public function verifyOtp(string $token, Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($token, $request) {
|
||||
$request->validate(['otp_code' => 'required|string|size:6']);
|
||||
|
||||
return $this->service->verifyOtp($token, $request->input('otp_code'));
|
||||
}, __('message.esign.otp_verified'));
|
||||
}
|
||||
|
||||
public function getDocument(string $token): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
|
||||
{
|
||||
try {
|
||||
$data = $this->service->getByToken($token);
|
||||
$contract = $data['contract'];
|
||||
$filePath = $contract->original_file_path;
|
||||
|
||||
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
|
||||
return ApiResponse::error(__('error.esign.file_not_found'), 404);
|
||||
}
|
||||
|
||||
return Storage::disk('local')->response($filePath, null, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponse::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit(string $token, SignSubmitRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($token, $request) {
|
||||
return $this->service->submitSignature($token, $request->validated());
|
||||
}, __('message.esign.signed'));
|
||||
}
|
||||
|
||||
public function reject(string $token, SignRejectRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($token, $request) {
|
||||
return $this->service->reject($token, $request->validated()['reason']);
|
||||
}, __('message.esign.rejected'));
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,6 @@ public function __construct(
|
||||
|
||||
/**
|
||||
* 접대비 현황 요약 조회
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
@@ -36,4 +33,20 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 상세 조회 (모달용)
|
||||
*/
|
||||
public function detail(Request $request): JsonResponse
|
||||
{
|
||||
$companyType = $request->query('company_type', 'medium');
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) {
|
||||
return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,13 +128,16 @@ public function summary(Request $request)
|
||||
/**
|
||||
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
|
||||
*
|
||||
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체)
|
||||
* @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
|
||||
*/
|
||||
public function dashboardDetail(Request $request)
|
||||
{
|
||||
$transactionType = $request->query('transaction_type');
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
$search = $request->query('search');
|
||||
|
||||
$data = $this->service->dashboardDetail($transactionType);
|
||||
$data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
|
||||
|
||||
return ApiResponse::success($data, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\GeneralJournalEntry\StoreManualJournalRequest;
|
||||
use App\Http\Requests\V1\GeneralJournalEntry\UpdateJournalRequest;
|
||||
use App\Services\GeneralJournalEntryService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GeneralJournalEntryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GeneralJournalEntryService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 일반전표 통합 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'start_date', 'end_date', 'search', 'page', 'per_page',
|
||||
]);
|
||||
|
||||
$result = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계
|
||||
*/
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'start_date', 'end_date', 'search',
|
||||
]);
|
||||
|
||||
$summary = $this->service->summary($params);
|
||||
|
||||
return ApiResponse::success($summary, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수기전표 등록
|
||||
*/
|
||||
public function store(StoreManualJournalRequest $request)
|
||||
{
|
||||
$entry = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($entry, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 상세 조회 (분개 수정 모달용)
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$detail = $this->service->show($id);
|
||||
|
||||
return ApiResponse::success($detail, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 수정
|
||||
*/
|
||||
public function updateJournal(int $id, UpdateJournalRequest $request)
|
||||
{
|
||||
$entry = $this->service->updateJournal($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($entry, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 삭제
|
||||
*/
|
||||
public function destroyJournal(int $id)
|
||||
{
|
||||
$this->service->destroyJournal($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ public function stats(Request $request)
|
||||
}, __('message.inspection.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캘린더 스케줄 조회
|
||||
*/
|
||||
public function calendar(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->calendar($request->all());
|
||||
}, __('message.inspection.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*/
|
||||
|
||||
@@ -98,6 +98,22 @@ public function store(int $id, Request $request)
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$item = $this->getItem($id);
|
||||
$inputItems = $request->input('items', []);
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
// child_item_id 존재 검증
|
||||
$childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray();
|
||||
if (! empty($childIds)) {
|
||||
$validIds = Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
$invalidIds = array_diff($childIds, $validIds);
|
||||
if (! empty($invalidIds)) {
|
||||
throw new \InvalidArgumentException(
|
||||
__('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$existingBom = $item->bom ?? [];
|
||||
$existingMap = collect($existingBom)->keyBy('child_item_id')->toArray();
|
||||
@@ -273,6 +289,22 @@ public function replace(int $id, Request $request)
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$item = $this->getItem($id);
|
||||
$inputItems = $request->input('items', []);
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
// child_item_id 존재 검증
|
||||
$childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray();
|
||||
if (! empty($childIds)) {
|
||||
$validIds = Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
$invalidIds = array_diff($childIds, $validIds);
|
||||
if (! empty($invalidIds)) {
|
||||
throw new \InvalidArgumentException(
|
||||
__('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$newBom = [];
|
||||
foreach ($inputItems as $inputItem) {
|
||||
|
||||
@@ -30,6 +30,8 @@ public function index(Request $request)
|
||||
'item_category' => $request->input('item_category'),
|
||||
'group_id' => $request->input('group_id'),
|
||||
'active' => $request->input('is_active') ?? $request->input('active'),
|
||||
'has_bom' => $request->input('has_bom'),
|
||||
'exclude_process_id' => $request->input('exclude_process_id'),
|
||||
];
|
||||
|
||||
return $this->service->index($params);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use App\Http\Requests\Loan\LoanUpdateRequest;
|
||||
use App\Services\LoanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LoanController extends Controller
|
||||
{
|
||||
@@ -33,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse
|
||||
*/
|
||||
public function summary(LoanIndexRequest $request): JsonResponse
|
||||
{
|
||||
$userId = $request->validated()['user_id'] ?? null;
|
||||
$result = $this->loanService->summary($userId);
|
||||
$validated = $request->validated();
|
||||
$userId = $validated['user_id'] ?? null;
|
||||
$category = $validated['category'] ?? null;
|
||||
$result = $this->loanService->summary($userId, $category);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
@@ -42,9 +45,12 @@ public function summary(LoanIndexRequest $request): JsonResponse
|
||||
/**
|
||||
* 가지급금 대시보드
|
||||
*/
|
||||
public function dashboard(): JsonResponse
|
||||
public function dashboard(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->loanService->dashboard();
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
$result = $this->loanService->dashboard($startDate, $endDate);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Order\CreateFromQuoteRequest;
|
||||
use App\Http\Requests\Order\CreateProductionOrderRequest;
|
||||
use App\Http\Requests\Order\OrderBulkDeleteRequest;
|
||||
use App\Http\Requests\Order\StoreOrderRequest;
|
||||
use App\Http\Requests\Order\UpdateOrderRequest;
|
||||
use App\Http\Requests\Order\UpdateOrderStatusRequest;
|
||||
@@ -66,6 +67,21 @@ public function update(UpdateOrderRequest $request, int $id)
|
||||
}, __('message.order.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 삭제
|
||||
*/
|
||||
public function bulkDestroy(OrderBulkDeleteRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validated();
|
||||
|
||||
return $this->service->bulkDestroy(
|
||||
$validated['ids'],
|
||||
$validated['force'] ?? false
|
||||
);
|
||||
}, __('message.order.bulk_deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
@@ -119,12 +135,25 @@ public function revertOrderConfirmation(int $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
||||
* 절곡 BOM 품목 재고 확인
|
||||
*/
|
||||
public function revertProductionOrder(int $id)
|
||||
public function checkBendingStock(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->revertProductionOrder($id);
|
||||
return $this->service->checkBendingStockForOrder($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
||||
*/
|
||||
public function revertProductionOrder(Request $request, int $id)
|
||||
{
|
||||
$force = $request->boolean('force', false);
|
||||
$reason = $request->input('reason');
|
||||
|
||||
return ApiResponse::handle(function () use ($id, $force, $reason) {
|
||||
return $this->service->revertProductionOrder($id, $force, $reason);
|
||||
}, __('message.order.production_order_reverted'));
|
||||
}
|
||||
}
|
||||
|
||||
59
app/Http/Controllers/Api/V1/PerformanceReportController.php
Normal file
59
app/Http/Controllers/Api/V1/PerformanceReportController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
|
||||
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
|
||||
use App\Services\PerformanceReportService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PerformanceReportController extends Controller
|
||||
{
|
||||
public function __construct(private PerformanceReportService $service) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function stats(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function confirm(PerformanceReportConfirmRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->confirm($request->validated()['ids']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function unconfirm(PerformanceReportConfirmRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->unconfirm($request->validated()['ids']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function updateMemo(PerformanceReportMemoRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$data = $request->validated();
|
||||
|
||||
return $this->service->updateMemo($data['ids'], $data['memo']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function missing(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->missing($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Api/V1/ProductionOrderController.php
Normal file
50
app/Http/Controllers/Api/V1/ProductionOrderController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
|
||||
use App\Services\ProductionOrderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductionOrderService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 생산지시 목록 조회
|
||||
*/
|
||||
public function index(ProductionOrderIndexRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->index($request->validated());
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 상태별 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->service->stats();
|
||||
|
||||
return ApiResponse::success($stats, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 상세 조회
|
||||
*/
|
||||
public function show(int $orderId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$detail = $this->service->show($orderId);
|
||||
|
||||
return ApiResponse::success($detail, __('message.fetched'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.order.not_found'), 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/Http/Controllers/Api/V1/QualityDocumentController.php
Normal file
127
app/Http/Controllers/Api/V1/QualityDocumentController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
|
||||
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
|
||||
use App\Services\QualityDocumentService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QualityDocumentController extends Controller
|
||||
{
|
||||
public function __construct(private QualityDocumentService $service) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function stats(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function calendar(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->calendar($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function availableOrders(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->availableOrders($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function store(QualityDocumentStoreRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
public function update(QualityDocumentUpdateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->destroy($id);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
public function complete(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->complete($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function attachOrders(Request $request, int $id)
|
||||
{
|
||||
$request->validate([
|
||||
'order_ids' => ['required', 'array', 'min:1'],
|
||||
'order_ids.*' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->attachOrders($id, $request->input('order_ids'));
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function detachOrder(int $id, int $orderId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $orderId) {
|
||||
return $this->service->detachOrder($id, $orderId);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function inspectLocation(Request $request, int $id, int $locId)
|
||||
{
|
||||
$request->validate([
|
||||
'post_width' => ['nullable', 'integer'],
|
||||
'post_height' => ['nullable', 'integer'],
|
||||
'change_reason' => ['nullable', 'string', 'max:500'],
|
||||
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id, $locId) {
|
||||
return $this->service->inspectLocation($id, $locId, $request->all());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function requestDocument(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->requestDocument($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function resultDocument(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->resultDocument($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -81,19 +81,8 @@ public function store(QuoteStoreRequest $request)
|
||||
*/
|
||||
public function update(QuoteUpdateRequest $request, int $id)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
// 🔍 디버깅: 요청 데이터 확인
|
||||
\Log::info('🔍 [QuoteController::update] 요청 수신', [
|
||||
'id' => $id,
|
||||
'raw_options_keys' => $request->input('options') ? array_keys($request->input('options')) : null,
|
||||
'raw_options_detail_items_count' => $request->input('options.detail_items') ? count($request->input('options.detail_items')) : 0,
|
||||
'validated_options_keys' => isset($validated['options']) ? array_keys($validated['options']) : null,
|
||||
'validated_options_detail_items_count' => isset($validated['options']['detail_items']) ? count($validated['options']['detail_items']) : 0,
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($validated, $id) {
|
||||
return $this->quoteService->update($id, $validated);
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->quoteService->update($id, $request->validated());
|
||||
}, __('message.quote.updated'));
|
||||
}
|
||||
|
||||
@@ -162,6 +151,16 @@ public function convertToBidding(int $id)
|
||||
}, __('message.bidding.converted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 데이터 조회 (현장명, 부호 목록)
|
||||
*/
|
||||
public function referenceData()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->quoteService->referenceData();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적번호 미리보기
|
||||
*/
|
||||
@@ -270,4 +269,22 @@ public function sendHistory(int $id)
|
||||
return $this->documentService->getSendHistory($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 단가 조회
|
||||
*
|
||||
* 품목 코드 배열을 받아 단가를 조회합니다.
|
||||
* 수동 품목 추가 시 단가를 조회하여 견적금액에 반영합니다.
|
||||
*/
|
||||
public function getItemPrices(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'item_codes' => 'required|array|min:1',
|
||||
'item_codes.*' => 'required|string',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->calculationService->getItemPrices($request->input('item_codes'));
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Http/Controllers/Api/V1/StatController.php
Normal file
64
app/Http/Controllers/Api/V1/StatController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Stat\StatAlertRequest;
|
||||
use App\Http\Requests\V1\Stat\StatDailyRequest;
|
||||
use App\Http\Requests\V1\Stat\StatMonthlyRequest;
|
||||
use App\Services\Stats\StatQueryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class StatController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StatQueryService $statQueryService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 대시보드 요약 통계 (sam_stat 기반)
|
||||
*/
|
||||
public function summary(): JsonResponse
|
||||
{
|
||||
$data = $this->statQueryService->getDashboardSummary();
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일간 통계 조회
|
||||
*/
|
||||
public function daily(StatDailyRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->statQueryService->getDailyStat(
|
||||
$request->validated('domain'),
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계 조회
|
||||
*/
|
||||
public function monthly(StatMonthlyRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->statQueryService->getMonthlyStat(
|
||||
$request->validated('domain'),
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 목록 조회
|
||||
*/
|
||||
public function alerts(StatAlertRequest $request): JsonResponse
|
||||
{
|
||||
$data = $this->statQueryService->getAlerts($request->validated());
|
||||
|
||||
return ApiResponse::handle(['data' => $data], __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,4 @@ public function summary()
|
||||
return $this->statusBoardService->summary();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@ public function index(Request $request): JsonResponse
|
||||
$params = $request->only([
|
||||
'search',
|
||||
'item_type',
|
||||
'item_category',
|
||||
'status',
|
||||
'location',
|
||||
'sort_by',
|
||||
'sort_dir',
|
||||
'per_page',
|
||||
'page',
|
||||
'start_date',
|
||||
'end_date',
|
||||
]);
|
||||
|
||||
$stocks = $this->service->index($params);
|
||||
|
||||
111
app/Http/Controllers/Api/V1/TenantSettingController.php
Normal file
111
app/Http/Controllers/Api/V1/TenantSettingController.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\TenantSetting\BulkUpdateSettingsRequest;
|
||||
use App\Http\Requests\TenantSetting\GetSettingsRequest;
|
||||
use App\Http\Requests\TenantSetting\UpdateSettingRequest;
|
||||
use App\Services\TenantSettingService;
|
||||
|
||||
class TenantSettingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private TenantSettingService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 모든 설정 조회 (그룹별)
|
||||
*/
|
||||
public function index(GetSettingsRequest $request)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
if (! empty($validated['group'])) {
|
||||
$data = $this->service->getByGroup($validated['group']);
|
||||
} else {
|
||||
$data = $this->service->getAll();
|
||||
}
|
||||
|
||||
return ApiResponse::handle(__('message.fetched'), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 설정 조회
|
||||
*/
|
||||
public function show(string $group, string $key)
|
||||
{
|
||||
$value = $this->service->get($group, $key);
|
||||
|
||||
return ApiResponse::handle(__('message.fetched'), [
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장/업데이트
|
||||
*/
|
||||
public function store(UpdateSettingRequest $request)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$setting = $this->service->set(
|
||||
$validated['group'],
|
||||
$validated['key'],
|
||||
$validated['value'],
|
||||
$validated['description'] ?? null
|
||||
);
|
||||
|
||||
return ApiResponse::handle(__('message.updated'), $setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 설정 일괄 저장
|
||||
*/
|
||||
public function bulkUpdate(BulkUpdateSettingsRequest $request)
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$settings = collect($validated['settings'])->mapWithKeys(function ($item) {
|
||||
return [$item['key'] => [
|
||||
'value' => $item['value'],
|
||||
'description' => $item['description'] ?? null,
|
||||
]];
|
||||
})->toArray();
|
||||
|
||||
$results = $this->service->setMany($validated['group'], $settings);
|
||||
|
||||
return ApiResponse::handle(__('message.bulk_upsert'), [
|
||||
'updated' => count($results),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 삭제
|
||||
*/
|
||||
public function destroy(string $group, string $key)
|
||||
{
|
||||
$deleted = $this->service->delete($group, $key);
|
||||
|
||||
if (! $deleted) {
|
||||
return ApiResponse::handle(__('error.not_found'), null, 404);
|
||||
}
|
||||
|
||||
return ApiResponse::handle(__('message.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 설정 초기화
|
||||
*/
|
||||
public function initialize()
|
||||
{
|
||||
$results = $this->service->initializeDefaults();
|
||||
|
||||
return ApiResponse::handle(__('message.created'), [
|
||||
'initialized' => count($results),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,10 @@ public function __construct(
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$limit = (int) $request->input('limit', 30);
|
||||
$date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용)
|
||||
|
||||
return ApiResponse::handle(function () use ($limit) {
|
||||
return $this->todayIssueService->summary($limit);
|
||||
return ApiResponse::handle(function () use ($limit, $date) {
|
||||
return $this->todayIssueService->summary($limit, null, $date);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ public function __construct(
|
||||
|
||||
/**
|
||||
* 부가세 현황 요약 조회
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
@@ -35,4 +32,18 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->vatService->getSummary($periodType, $year, $period);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부가세 상세 조회 (모달용)
|
||||
*/
|
||||
public function detail(Request $request): JsonResponse
|
||||
{
|
||||
$periodType = $request->query('period_type', 'quarter');
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$period = $request->query('period') ? (int) $request->query('period') : null;
|
||||
|
||||
return ApiResponse::handle(function () use ($periodType, $year, $period) {
|
||||
return $this->vatService->getDetail($periodType, $year, $period);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
74
app/Http/Controllers/Api/V1/VehicleDispatchController.php
Normal file
74
app/Http/Controllers/Api/V1/VehicleDispatchController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\VehicleDispatch\VehicleDispatchUpdateRequest;
|
||||
use App\Services\VehicleDispatchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VehicleDispatchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VehicleDispatchService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 배차차량 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->only([
|
||||
'search',
|
||||
'status',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'per_page',
|
||||
'page',
|
||||
]);
|
||||
|
||||
$dispatches = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($dispatches, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차차량 통계 조회
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->service->stats();
|
||||
|
||||
return ApiResponse::success($stats, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차차량 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$dispatch = $this->service->show($id);
|
||||
|
||||
return ApiResponse::success($dispatch, __('message.fetched'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.not_found'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차차량 수정
|
||||
*/
|
||||
public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$dispatch = $this->service->update($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($dispatch, __('message.updated'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.not_found'), 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\WorkOrder\MaterialInputForItemRequest;
|
||||
use App\Http\Requests\WorkOrder\StoreItemInspectionRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderAssignRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderIssueRequest;
|
||||
use App\Http\Requests\WorkOrder\WorkOrderStatusRequest;
|
||||
@@ -149,12 +151,211 @@ public function materials(int $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 등록
|
||||
* 자재 투입 등록 (로트별 수량 차감)
|
||||
*/
|
||||
public function registerMaterialInput(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->registerMaterialInput($id, $request->input('material_ids', []));
|
||||
return $this->service->registerMaterialInput($id, $request->input('inputs', []));
|
||||
}, __('message.work_order.material_input_registered'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 진행 현황 조회
|
||||
*/
|
||||
public function stepProgress(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getStepProgress($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 완료 토글
|
||||
*/
|
||||
public function toggleStepProgress(Request $request, int $id, int $progressId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $progressId) {
|
||||
return $this->service->toggleStepProgress($id, $progressId);
|
||||
}, __('message.work_order.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 이력 조회
|
||||
*/
|
||||
public function materialInputHistory(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getMaterialInputHistory($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 LOT 번호 조회 (stock_transactions 기반)
|
||||
*/
|
||||
public function materialInputLots(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getMaterialInputLots($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목별 중간검사 데이터 저장
|
||||
*/
|
||||
public function storeItemInspection(StoreItemInspectionRequest $request, int $workOrderId, int $itemId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $workOrderId, $itemId) {
|
||||
return $this->service->storeItemInspection($workOrderId, $itemId, $request->validated());
|
||||
}, __('message.work_order.inspection_saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 전체 품목 검사 데이터 조회
|
||||
*/
|
||||
public function inspectionData(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->getInspectionData($id, $request->all());
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 검사 성적서용 데이터 조회
|
||||
*/
|
||||
public function inspectionReport(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getInspectionReport($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
|
||||
*/
|
||||
public function inspectionConfig(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getInspectionConfig($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시의 검사용 문서 템플릿 조회
|
||||
*/
|
||||
public function inspectionTemplate(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getInspectionTemplate($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 문서 resolve (기존 문서 조회 또는 생성 정보 반환)
|
||||
*/
|
||||
public function resolveInspectionDocument(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->resolveInspectionDocument($id, $request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 완료 시 검사 문서(Document) 생성/수정
|
||||
*/
|
||||
public function createInspectionDocument(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->createInspectionDocument($id, $request->all());
|
||||
}, __('message.work_order.inspection_document_created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업일지 양식 템플릿 조회
|
||||
*/
|
||||
public function workLogTemplate(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getWorkLogTemplate($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업일지 조회 (기존 문서 + 템플릿 + 통계)
|
||||
*/
|
||||
public function workLog(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getWorkLog($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업일지 생성/수정
|
||||
*/
|
||||
public function createWorkLog(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->createWorkLog($id, $request->all());
|
||||
}, __('message.work_order.work_log_saved'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 개소별 자재 투입
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 개소별 자재 목록 조회
|
||||
*/
|
||||
public function materialsForItem(int $id, int $itemId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $itemId) {
|
||||
return $this->service->getMaterialsForItem($id, $itemId);
|
||||
}, __('message.work_order.materials_fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 개소별 자재 투입 등록
|
||||
*/
|
||||
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id, $itemId) {
|
||||
$validated = $request->validated();
|
||||
|
||||
return $this->service->registerMaterialInputForItem(
|
||||
$id,
|
||||
$itemId,
|
||||
$validated['inputs'],
|
||||
(bool) ($validated['replace'] ?? false)
|
||||
);
|
||||
}, __('message.work_order.material_input_registered'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 개소별 자재 투입 이력 조회
|
||||
*/
|
||||
public function materialInputsForItem(int $id, int $itemId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $itemId) {
|
||||
return $this->service->getMaterialInputsForItem($id, $itemId);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
public function deleteMaterialInput(int $id, int $inputId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $inputId) {
|
||||
$this->service->deleteMaterialInput($id, $inputId);
|
||||
}, __('message.work_order.deleted'));
|
||||
}
|
||||
|
||||
public function updateMaterialInput(Request $request, int $id, int $inputId)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'qty' => ['required', 'numeric', 'gt:0'],
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($id, $inputId, $data) {
|
||||
return $this->service->updateMaterialInput($id, $inputId, (float) $data['qty']);
|
||||
}, __('message.work_order.updated'));
|
||||
}
|
||||
}
|
||||
|
||||
84
app/Http/Controllers/V1/ProcessStepController.php
Normal file
84
app/Http/Controllers/V1/ProcessStepController.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\ProcessStep\ReorderProcessStepRequest;
|
||||
use App\Http\Requests\V1\ProcessStep\StoreProcessStepRequest;
|
||||
use App\Http\Requests\V1\ProcessStep\UpdateProcessStepRequest;
|
||||
use App\Services\ProcessStepService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProcessStepController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProcessStepService $processStepService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 공정 단계 목록 조회
|
||||
*/
|
||||
public function index(int $processId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->processStepService->index($processId),
|
||||
'message.fetched'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 상세 조회
|
||||
*/
|
||||
public function show(int $processId, int $stepId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->processStepService->show($processId, $stepId),
|
||||
'message.fetched'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 생성
|
||||
*/
|
||||
public function store(StoreProcessStepRequest $request, int $processId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->processStepService->store($processId, $request->validated()),
|
||||
'message.created'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 수정
|
||||
*/
|
||||
public function update(UpdateProcessStepRequest $request, int $processId, int $stepId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->processStepService->update($processId, $stepId, $request->validated()),
|
||||
'message.updated'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 삭제
|
||||
*/
|
||||
public function destroy(int $processId, int $stepId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->processStepService->destroy($processId, $stepId),
|
||||
'message.deleted'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 단계 순서 변경
|
||||
*/
|
||||
public function reorder(ReorderProcessStepRequest $request, int $processId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->processStepService->reorder($processId, $request->validated('items')),
|
||||
'message.reordered'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ public function handle(Request $request, Closure $next)
|
||||
'api/v1/debug-apikey',
|
||||
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
|
||||
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
|
||||
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
|
||||
];
|
||||
|
||||
// 현재 라우트 확인 (경로 또는 이름)
|
||||
|
||||
171
app/Http/Middleware/ApiVersionMiddleware.php
Normal file
171
app/Http/Middleware/ApiVersionMiddleware.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* API 버전 관리 미들웨어
|
||||
*
|
||||
* - 헤더 Accept-Version으로 버전 선택 (기본: v1)
|
||||
* - 요청된 버전의 라우트가 없으면 하위 버전으로 fallback
|
||||
* - 응답 헤더에 실제 사용된 버전 표시
|
||||
*/
|
||||
class ApiVersionMiddleware
|
||||
{
|
||||
/**
|
||||
* 지원하는 API 버전 목록 (우선순위 순)
|
||||
*/
|
||||
protected array $supportedVersions = ['v2', 'v1'];
|
||||
|
||||
/**
|
||||
* 기본 버전
|
||||
*/
|
||||
protected string $defaultVersion = 'v1';
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 1. 요청된 버전 확인 (헤더 > 쿼리 파라미터 > 기본값)
|
||||
$requestedVersion = $this->getRequestedVersion($request);
|
||||
|
||||
// 2. 실제 사용할 버전 결정 (fallback 적용)
|
||||
$actualVersion = $this->resolveVersion($request, $requestedVersion);
|
||||
|
||||
// 3. 요청에 버전 정보 저장 (컨트롤러에서 사용 가능)
|
||||
$request->attributes->set('api_version', $actualVersion);
|
||||
$request->attributes->set('api_version_requested', $requestedVersion);
|
||||
$request->attributes->set('api_version_fallback', $actualVersion !== $requestedVersion);
|
||||
|
||||
// 4. 요청 처리
|
||||
$response = $next($request);
|
||||
|
||||
// 5. 응답 헤더에 버전 정보 추가
|
||||
$response->headers->set('X-API-Version', $actualVersion);
|
||||
if ($actualVersion !== $requestedVersion) {
|
||||
$response->headers->set('X-API-Version-Fallback', 'true');
|
||||
$response->headers->set('X-API-Version-Requested', $requestedVersion);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에서 버전 정보 추출
|
||||
*/
|
||||
protected function getRequestedVersion(Request $request): string
|
||||
{
|
||||
// 1. Accept-Version 헤더 (권장)
|
||||
$version = $request->header('Accept-Version');
|
||||
if ($version && $this->isValidVersion($version)) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
// 2. X-API-Version 헤더 (대안)
|
||||
$version = $request->header('X-API-Version');
|
||||
if ($version && $this->isValidVersion($version)) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
// 3. 쿼리 파라미터 (테스트용)
|
||||
$version = $request->query('api_version');
|
||||
if ($version && $this->isValidVersion($version)) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
return $this->defaultVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 버전인지 확인
|
||||
*/
|
||||
protected function isValidVersion(string $version): bool
|
||||
{
|
||||
return in_array($version, $this->supportedVersions, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 사용할 버전 결정 (fallback 로직)
|
||||
*/
|
||||
protected function resolveVersion(Request $request, string $requestedVersion): string
|
||||
{
|
||||
// 요청된 버전부터 하위 버전까지 순차 확인
|
||||
$startIndex = array_search($requestedVersion, $this->supportedVersions, true);
|
||||
|
||||
if ($startIndex === false) {
|
||||
return $this->defaultVersion;
|
||||
}
|
||||
|
||||
// 요청된 버전부터 하위 버전까지 체크
|
||||
for ($i = $startIndex; $i < count($this->supportedVersions); $i++) {
|
||||
$version = $this->supportedVersions[$i];
|
||||
|
||||
if ($this->versionRouteExists($request, $version)) {
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 버전에서 라우트를 찾지 못하면 기본값 반환
|
||||
return $this->defaultVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 버전의 라우트가 존재하는지 확인
|
||||
*/
|
||||
protected function versionRouteExists(Request $request, string $version): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// URL에서 버전 부분 교체
|
||||
// /api/v1/users → /api/v2/users
|
||||
$versionedPath = preg_replace('/^api\/v\d+/', "api/{$version}", $path);
|
||||
|
||||
// 해당 경로의 라우트가 존재하는지 확인
|
||||
$routes = Route::getRoutes();
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$routeUri = $route->uri();
|
||||
|
||||
// 정확히 일치하거나 파라미터 패턴 매칭
|
||||
if ($this->matchesRoute($versionedPath, $routeUri, $request->method())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로가 라우트와 일치하는지 확인
|
||||
*/
|
||||
protected function matchesRoute(string $path, string $routeUri, string $method): bool
|
||||
{
|
||||
// 라우트 URI의 파라미터를 정규식으로 변환
|
||||
// {id} → [^/]+
|
||||
$pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $routeUri);
|
||||
$pattern = '#^'.$pattern.'$#';
|
||||
|
||||
return (bool) preg_match($pattern, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원 버전 목록 반환 (외부에서 사용)
|
||||
*/
|
||||
public function getSupportedVersions(): array
|
||||
{
|
||||
return $this->supportedVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 버전 반환
|
||||
*/
|
||||
public function getDefaultVersion(): string
|
||||
{
|
||||
return $this->defaultVersion;
|
||||
}
|
||||
}
|
||||
31
app/Http/Middleware/SetAuditSessionVariables.php
Normal file
31
app/Http/Middleware/SetAuditSessionVariables.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetAuditSessionVariables
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// 요청 단위 operation_id (인증 여부와 무관하게 항상 설정)
|
||||
DB::statement('SET @sam_operation_id = ?', [Str::uuid()->toString()]);
|
||||
|
||||
if (auth()->check()) {
|
||||
DB::statement('SET @sam_actor_id = ?', [auth()->id()]);
|
||||
DB::statement('SET @sam_session_info = ?', [
|
||||
json_encode([
|
||||
'ip' => $request->ip(),
|
||||
'ua' => mb_substr((string) $request->userAgent(), 0, 255),
|
||||
'route' => $request->route()?->getName(),
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ public function rules(): array
|
||||
'sort_dir' => 'nullable|string|in:asc,desc',
|
||||
'per_page' => 'nullable|integer|min:1',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'start_date' => 'nullable|date_format:Y-m-d',
|
||||
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php
Normal file
36
app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Audit;
|
||||
|
||||
use App\Http\Requests\Traits\HasPagination;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TriggerAuditLogIndexRequest extends FormRequest
|
||||
{
|
||||
use HasPagination;
|
||||
|
||||
protected int $maxSize = 200;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'size' => 'nullable|integer|min:1',
|
||||
'table_name' => 'nullable|string|max:64',
|
||||
'row_id' => 'nullable|string|max:64',
|
||||
'dml_type' => 'nullable|string|in:INSERT,UPDATE,DELETE',
|
||||
'tenant_id' => 'nullable|integer|min:1',
|
||||
'actor_id' => 'nullable|integer|min:1',
|
||||
'db_user' => 'nullable|string|max:100',
|
||||
'from' => 'nullable|date',
|
||||
'to' => 'nullable|date|after_or_equal:from',
|
||||
'sort' => 'nullable|string|in:created_at,id',
|
||||
'order' => 'nullable|string|in:asc,desc',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Audit/TriggerAuditRollbackRequest.php
Normal file
20
app/Http/Requests/Audit/TriggerAuditRollbackRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Audit;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TriggerAuditRollbackRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'confirm' => 'required|boolean|accepted',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public function rules(): array
|
||||
'mobile' => 'nullable|string|max:20',
|
||||
'fax' => 'nullable|string|max:20',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:500',
|
||||
// 담당자 정보
|
||||
'manager_name' => 'nullable|string|max:50',
|
||||
'manager_tel' => 'nullable|string|max:20',
|
||||
@@ -96,6 +96,7 @@ public function rules(): array
|
||||
// 기타
|
||||
'memo' => 'nullable|string',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_overdue' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public function rules(): array
|
||||
'mobile' => 'nullable|string|max:20',
|
||||
'fax' => 'nullable|string|max:20',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'address' => 'nullable|string|max:500',
|
||||
// 담당자 정보
|
||||
'manager_name' => 'nullable|string|max:50',
|
||||
'manager_tel' => 'nullable|string|max:20',
|
||||
@@ -96,6 +96,7 @@ public function rules(): array
|
||||
// 기타
|
||||
'memo' => 'nullable|string',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'is_overdue' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
20
app/Http/Requests/Document/ApproveRequest.php
Normal file
20
app/Http/Requests/Document/ApproveRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ApproveRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'comment' => 'nullable|string|max:500',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Document/BulkCreateFqcRequest.php
Normal file
31
app/Http/Requests/Document/BulkCreateFqcRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BulkCreateFqcRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'template_id' => 'required|integer|exists:document_templates,id',
|
||||
'order_id' => 'required|integer|exists:orders,id',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
|
||||
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
|
||||
'order_id.required' => __('validation.required', ['attribute' => '수주']),
|
||||
'order_id.exists' => __('validation.exists', ['attribute' => '수주']),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/Document/IndexRequest.php
Normal file
37
app/Http/Requests/Document/IndexRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$statuses = implode(',', [
|
||||
Document::STATUS_DRAFT,
|
||||
Document::STATUS_PENDING,
|
||||
Document::STATUS_APPROVED,
|
||||
Document::STATUS_REJECTED,
|
||||
Document::STATUS_CANCELLED,
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => "nullable|string|in:{$statuses}",
|
||||
'template_id' => 'nullable|integer',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'from_date' => 'nullable|date',
|
||||
'to_date' => 'nullable|date|after_or_equal:from_date',
|
||||
'sort_by' => 'nullable|string|in:created_at,document_no,title,status,submitted_at,completed_at',
|
||||
'sort_dir' => 'nullable|string|in:asc,desc',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Document/RejectRequest.php
Normal file
20
app/Http/Requests/Document/RejectRequest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RejectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'comment' => 'required|string|max:500',
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Document/ResolveRequest.php
Normal file
30
app/Http/Requests/Document/ResolveRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ResolveRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'required|string|max:50',
|
||||
'item_id' => 'required|integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'category.required' => __('validation.required', ['attribute' => '문서 분류']),
|
||||
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
|
||||
'item_id.integer' => __('validation.integer', ['attribute' => '품목 ID']),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Http/Requests/Document/StoreRequest.php
Normal file
62
app/Http/Requests/Document/StoreRequest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use App\Models\Documents\DocumentAttachment;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
|
||||
|
||||
return [
|
||||
// 기본 정보
|
||||
'template_id' => 'required|integer|exists:document_templates,id',
|
||||
'title' => 'required|string|max:255',
|
||||
'linkable_type' => 'nullable|string|max:100',
|
||||
'linkable_id' => 'nullable|integer',
|
||||
|
||||
// 결재선 (보류 상태이지만 구조는 유지)
|
||||
'approvers' => 'nullable|array',
|
||||
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
|
||||
'approvers.*.role' => 'nullable|string|max:50',
|
||||
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 문서 데이터 (EAV)
|
||||
'data' => 'nullable|array',
|
||||
'data.*.section_id' => 'nullable|integer',
|
||||
'data.*.column_id' => 'nullable|integer',
|
||||
'data.*.row_index' => 'nullable|integer|min:0',
|
||||
'data.*.field_key' => 'required_with:data|string|max:100',
|
||||
'data.*.field_value' => 'nullable|string',
|
||||
|
||||
// 첨부파일
|
||||
'attachments' => 'nullable|array',
|
||||
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
|
||||
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
|
||||
'attachments.*.description' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
|
||||
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
|
||||
'title.required' => __('validation.required', ['attribute' => '제목']),
|
||||
'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]),
|
||||
'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']),
|
||||
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
|
||||
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Http/Requests/Document/UpdateRequest.php
Normal file
58
app/Http/Requests/Document/UpdateRequest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use App\Models\Documents\DocumentAttachment;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
|
||||
|
||||
return [
|
||||
// 기본 정보
|
||||
'title' => 'nullable|string|max:255',
|
||||
'linkable_type' => 'nullable|string|max:100',
|
||||
'linkable_id' => 'nullable|integer',
|
||||
|
||||
// 결재선 (보류 상태이지만 구조는 유지)
|
||||
'approvers' => 'nullable|array',
|
||||
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
|
||||
'approvers.*.role' => 'nullable|string|max:50',
|
||||
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 문서 데이터 (EAV)
|
||||
'data' => 'nullable|array',
|
||||
'data.*.section_id' => 'nullable|integer',
|
||||
'data.*.column_id' => 'nullable|integer',
|
||||
'data.*.row_index' => 'nullable|integer|min:0',
|
||||
'data.*.field_key' => 'required_with:data|string|max:100',
|
||||
'data.*.field_value' => 'nullable|string',
|
||||
|
||||
// 첨부파일
|
||||
'attachments' => 'nullable|array',
|
||||
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
|
||||
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
|
||||
'attachments.*.description' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]),
|
||||
'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']),
|
||||
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
|
||||
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Document/UpsertRequest.php
Normal file
54
app/Http/Requests/Document/UpsertRequest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Document;
|
||||
|
||||
use App\Models\Documents\DocumentAttachment;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpsertRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
|
||||
|
||||
return [
|
||||
// 필수: 템플릿 + 품목
|
||||
'template_id' => 'required|integer|exists:document_templates,id',
|
||||
'item_id' => 'required|integer',
|
||||
'title' => 'nullable|string|max:255',
|
||||
|
||||
// 문서 데이터 (EAV)
|
||||
'data' => 'nullable|array',
|
||||
'data.*.section_id' => 'nullable|integer',
|
||||
'data.*.column_id' => 'nullable|integer',
|
||||
'data.*.row_index' => 'nullable|integer|min:0',
|
||||
'data.*.field_key' => 'required_with:data|string|max:100',
|
||||
'data.*.field_value' => 'nullable|string',
|
||||
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 첨부파일
|
||||
'attachments' => 'nullable|array',
|
||||
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
|
||||
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
|
||||
'attachments.*.description' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
|
||||
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
|
||||
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
|
||||
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
|
||||
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/DocumentTemplate/IndexRequest.php
Normal file
26
app/Http/Requests/DocumentTemplate/IndexRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\DocumentTemplate;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'nullable|boolean',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'sort_by' => 'nullable|string|in:created_at,name,category',
|
||||
'sort_dir' => 'nullable|string|in:asc,desc',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/ESign/ContractStoreRequest.php
Normal file
45
app/Http/Requests/ESign/ContractStoreRequest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ESign;
|
||||
|
||||
use App\Models\ESign\EsignContract;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContractStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:2000',
|
||||
'sign_order_type' => 'nullable|string|in:' . implode(',', EsignContract::SIGN_ORDERS),
|
||||
'file' => 'required|file|mimes:pdf|max:20480',
|
||||
'expires_at' => 'nullable|date|after:now',
|
||||
'creator_name' => 'required|string|max:100',
|
||||
'creator_email' => 'required|email|max:255',
|
||||
'creator_phone' => 'nullable|string|max:20',
|
||||
'counterpart_name' => 'required|string|max:100',
|
||||
'counterpart_email' => 'required|email|max:255',
|
||||
'counterpart_phone' => 'nullable|string|max:20',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => __('validation.required', ['attribute' => '계약 제목']),
|
||||
'file.required' => __('validation.required', ['attribute' => 'PDF 파일']),
|
||||
'file.mimes' => __('validation.mimes', ['attribute' => '파일', 'values' => 'PDF']),
|
||||
'file.max' => __('validation.max.file', ['attribute' => '파일', 'max' => '20MB']),
|
||||
'creator_name.required' => __('validation.required', ['attribute' => '작성자 이름']),
|
||||
'creator_email.required' => __('validation.required', ['attribute' => '작성자 이메일']),
|
||||
'counterpart_name.required' => __('validation.required', ['attribute' => '상대방 이름']),
|
||||
'counterpart_email.required' => __('validation.required', ['attribute' => '상대방 이메일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/ESign/FieldConfigureRequest.php
Normal file
39
app/Http/Requests/ESign/FieldConfigureRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ESign;
|
||||
|
||||
use App\Models\ESign\EsignSignField;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class FieldConfigureRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'fields' => 'required|array|min:1',
|
||||
'fields.*.signer_id' => 'required|integer|exists:esign_signers,id',
|
||||
'fields.*.page_number' => 'required|integer|min:1',
|
||||
'fields.*.position_x' => 'required|numeric|min:0|max:100',
|
||||
'fields.*.position_y' => 'required|numeric|min:0|max:100',
|
||||
'fields.*.width' => 'required|numeric|min:1|max:100',
|
||||
'fields.*.height' => 'required|numeric|min:1|max:100',
|
||||
'fields.*.field_type' => 'nullable|string|in:' . implode(',', EsignSignField::FIELD_TYPES),
|
||||
'fields.*.field_label' => 'nullable|string|max:100',
|
||||
'fields.*.is_required' => 'nullable|boolean',
|
||||
'fields.*.sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'fields.required' => __('validation.required', ['attribute' => '서명 필드']),
|
||||
'fields.min' => '최소 1개 이상의 서명 필드가 필요합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/ESign/SignRejectRequest.php
Normal file
27
app/Http/Requests/ESign/SignRejectRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ESign;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SignRejectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'reason' => 'required|string|max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'reason.required' => __('validation.required', ['attribute' => '거절 사유']),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/ESign/SignSubmitRequest.php
Normal file
27
app/Http/Requests/ESign/SignSubmitRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ESign;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SignSubmitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'signature_image' => 'required|string',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'signature_image.required' => __('validation.required', ['attribute' => '서명 이미지']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public function rules(): array
|
||||
Inspection::TYPE_FQC,
|
||||
])],
|
||||
'lot_no' => ['required', 'string', 'max:50'],
|
||||
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
|
||||
'item_name' => ['nullable', 'string', 'max:200'],
|
||||
'process_name' => ['nullable', 'string', 'max:100'],
|
||||
'quantity' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
@@ -29,6 +29,7 @@ public function rules(): array
|
||||
return [
|
||||
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
|
||||
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'search' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Requests\Loan;
|
||||
|
||||
use App\Models\Tenants\Loan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LoanStoreRequest extends FormRequest
|
||||
{
|
||||
@@ -21,12 +23,27 @@ public function authorize(): bool
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE;
|
||||
|
||||
return [
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'],
|
||||
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
|
||||
'purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
|
||||
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
|
||||
'metadata.memo' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Requests\Loan;
|
||||
|
||||
use App\Models\Tenants\Loan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LoanUpdateRequest extends FormRequest
|
||||
{
|
||||
@@ -27,6 +29,20 @@ public function rules(): array
|
||||
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
|
||||
'purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
|
||||
'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)],
|
||||
'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
|
||||
'metadata.memo' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ public function rules(): array
|
||||
'priority' => 'nullable|string',
|
||||
'process_type' => ['nullable', Rule::in(WorkOrder::PROCESS_TYPES)],
|
||||
'assignee_id' => 'nullable|integer|exists:users,id',
|
||||
'assignee_ids' => 'nullable|array',
|
||||
'assignee_ids.*' => 'integer|exists:users,id',
|
||||
'team_id' => 'nullable|integer|exists:departments,id',
|
||||
'scheduled_date' => 'nullable|date',
|
||||
'memo' => 'nullable|string',
|
||||
|
||||
22
app/Http/Requests/Order/OrderBulkDeleteRequest.php
Normal file
22
app/Http/Requests/Order/OrderBulkDeleteRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Order;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class OrderBulkDeleteRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'required|integer',
|
||||
'force' => 'sometimes|boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public function rules(): array
|
||||
Order::STATUS_CONFIRMED,
|
||||
])],
|
||||
'category_code' => 'nullable|string|max:50',
|
||||
'pair_code' => 'nullable|string|max:20',
|
||||
|
||||
// 거래처 정보
|
||||
'client_id' => 'nullable|integer|exists:clients,id',
|
||||
@@ -53,10 +54,12 @@ public function rules(): array
|
||||
'options.receiver_contact' => 'nullable|string|max:100',
|
||||
'options.shipping_address' => 'nullable|string|max:500',
|
||||
'options.shipping_address_detail' => 'nullable|string|max:500',
|
||||
'options.manager_name' => 'nullable|string|max:100',
|
||||
|
||||
// 품목 배열
|
||||
'items' => 'nullable|array',
|
||||
'items.*.item_id' => 'nullable|integer|exists:items,id',
|
||||
'items.*.item_code' => 'nullable|string|max:50',
|
||||
'items.*.item_name' => 'required|string|max:200',
|
||||
'items.*.specification' => 'nullable|string|max:500',
|
||||
'items.*.quantity' => 'required|numeric|min:0',
|
||||
@@ -65,6 +68,8 @@ public function rules(): array
|
||||
'items.*.supply_amount' => 'nullable|numeric|min:0',
|
||||
'items.*.tax_amount' => 'nullable|numeric|min:0',
|
||||
'items.*.total_amount' => 'nullable|numeric|min:0',
|
||||
'items.*.floor_code' => 'nullable|string|max:50',
|
||||
'items.*.symbol_code' => 'nullable|string|max:50',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user