Compare commits
460 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a14cfaae18 | ||
|
|
7fd6b904f6 | ||
|
|
2f1ea3b369 | ||
|
|
697560b2de | ||
|
|
fd4411b04f | ||
|
|
486724d38a | ||
|
|
253067f2b5 | ||
|
|
9b7362fa4f | ||
|
|
0e86636354 | ||
|
|
64b3ad2b59 | ||
|
|
c993826fdc | ||
|
|
0e242bdcc1 | ||
|
|
f8a00c3f8c | ||
|
|
d02c142f65 | ||
|
|
e7f81cb063 | ||
|
|
301369bb37 | ||
|
|
75dbe2910a | ||
|
|
8563d9aa2b | ||
|
|
d81c5f4a6f | ||
|
|
05af666a4b | ||
|
|
1543db684d | ||
|
|
4c06c81e4a | ||
|
|
57f53ac01e | ||
|
|
1a78f2dc72 | ||
|
|
1aa8781bfe | ||
|
|
a6779e0031 | ||
|
|
ce055542a5 | ||
|
|
d9c808b928 | ||
|
|
a38c017c63 | ||
|
|
f120273160 | ||
|
|
5e0f1a6373 | ||
|
|
280bfddbd3 | ||
|
|
dfbbd3a1a0 | ||
|
|
ac5ae6eb05 | ||
|
|
8ff84e7f94 | ||
|
|
f4131df0ce | ||
|
|
95cd217cdc | ||
|
|
ff373c719c | ||
|
|
7785dfed98 | ||
|
|
20e5ab784e | ||
|
|
08cc866afa | ||
|
|
a27d9921b1 | ||
|
|
78c8f3f876 | ||
|
|
063d8c61e4 | ||
|
|
5271072e20 | ||
|
|
056f7f99f3 | ||
|
|
1bb134020c | ||
|
|
3659bef743 | ||
|
|
6ce6c853b3 | ||
|
|
0622fc2a34 | ||
|
|
708cef2ec7 | ||
|
|
d7edb52573 | ||
|
|
94d19af290 | ||
|
|
6f11a08a9f | ||
|
|
178e4e22aa | ||
|
|
64ab20becf | ||
|
|
e38ef0f1d5 | ||
|
|
6c683c7d4e | ||
|
|
a032b1a11e | ||
|
|
0dab993508 | ||
|
|
67694b926f | ||
|
|
d9ad2e801b | ||
|
|
d2620ddd22 | ||
|
|
a7287638c4 | ||
|
|
28a9c8075d | ||
|
|
9c3b9951b0 | ||
|
|
5c900b618b | ||
|
|
ffe02b3224 | ||
|
|
8784b81f1b | ||
|
|
e272f16357 | ||
|
|
32e9f317d5 | ||
|
|
ca50f65124 | ||
| 2d820cd395 | |||
| f5bc4fcb19 | |||
| fe420a3cd7 | |||
|
|
2e97b824cd | ||
|
|
617c89a33f | ||
|
|
2a1e72a15e | ||
|
|
fc5af2734a | ||
|
|
77c1012d23 | ||
|
|
7ab410e454 | ||
|
|
9f4c79b5d2 | ||
|
|
1ccf97ce6b | ||
|
|
810c1f67dd | ||
|
|
466aafdb01 | ||
|
|
bcd35895d1 | ||
|
|
2e4cccc19e | ||
|
|
c850172f5b | ||
|
|
81157a150a | ||
|
|
8c8fd5f61f | ||
|
|
9fd72e49e2 | ||
|
|
a12ee886a5 | ||
|
|
197e6e6652 | ||
|
|
e1fc78ada1 | ||
|
|
df72d241fb | ||
|
|
399813a16f | ||
|
|
774a35e097 | ||
|
|
ebb10b5c47 | ||
|
|
f51427bcce | ||
|
|
862e980809 | ||
|
|
adb0cde573 | ||
|
|
457576f2f5 | ||
|
|
e963b5a2dc | ||
|
|
25f811bcb6 | ||
|
|
48613ecc70 | ||
|
|
8f494270d9 | ||
|
|
147274ca14 | ||
|
|
efd8d96156 | ||
|
|
ecd813c0b7 | ||
|
|
4e2893be92 | ||
|
|
94664898a5 | ||
|
|
e78aef47e5 | ||
|
|
7bbfc9dab5 | ||
|
|
b35b352f19 | ||
|
|
befba7e2ae | ||
|
|
2d327a8300 | ||
|
|
6ebaa756a6 | ||
|
|
74b37a287e | ||
|
|
4b478b4e05 | ||
|
|
71fce456b5 | ||
|
|
5d7eb57578 | ||
|
|
27790861c2 | ||
|
|
a34d23fd59 | ||
|
|
7ffa8952fe | ||
|
|
0445748b32 | ||
|
|
1fef5f16d9 | ||
|
|
2bf13cc886 | ||
|
|
7a277c6986 | ||
|
|
159a7a9331 | ||
|
|
08cd48405e | ||
|
|
cf7ffb69f5 | ||
|
|
0e34da74eb | ||
|
|
02e03b1044 | ||
|
|
b19eb8c217 | ||
|
|
bb2a3f730b | ||
|
|
03f48dfe89 | ||
|
|
945305b54b | ||
|
|
bfe1167f20 | ||
|
|
65774ab93d | ||
|
|
ac094c5833 | ||
|
|
3c75b97873 | ||
|
|
561883676e | ||
|
|
21f930a52f | ||
|
|
b16eb343a0 | ||
|
|
08d7409435 | ||
|
|
3f6dfd7251 | ||
|
|
531e9ec0ca | ||
|
|
956f57d5d6 | ||
|
|
d698996e31 | ||
|
|
e19487683c | ||
|
|
30078e5e86 | ||
|
|
4247a60aa2 | ||
|
|
8100f889f5 | ||
|
|
8239f03592 | ||
|
|
579a6caf39 | ||
|
|
a112ace148 | ||
|
|
d59a651fb9 | ||
|
|
404342f750 | ||
|
|
5adedb35bb | ||
|
|
778961c9f0 | ||
|
|
8671b218d1 | ||
|
|
301afcfc95 | ||
|
|
be35f7ba49 | ||
|
|
5f81e5f356 | ||
|
|
c255bb001a | ||
|
|
56ab5d86b6 | ||
|
|
4cf208e2d8 | ||
|
|
b04b30f076 | ||
|
|
b21f1bc0c0 | ||
|
|
975dd84564 | ||
|
|
69f837ef99 | ||
|
|
3464787a4c | ||
|
|
d328055f83 | ||
|
|
121fec76e0 | ||
| 8298b4271e | |||
|
|
d48a38eaf6 | ||
|
|
c734a23b30 | ||
|
|
24f8bfeb94 | ||
|
|
35080c252c | ||
|
|
0e3eb24dd0 | ||
|
|
31ac46fe21 | ||
| fd017a9e34 | |||
|
|
491426fc3e | ||
|
|
e1299d5f25 | ||
|
|
adc54ffeba | ||
|
|
c653618ecc | ||
|
|
ac8f16de59 | ||
|
|
0011681683 | ||
|
|
cfae574a35 | ||
|
|
b083d1561f | ||
|
|
7c38790801 | ||
|
|
8cda77ea17 | ||
|
|
f2556aae61 | ||
|
|
aa1153f652 | ||
|
|
f506f68df5 | ||
|
|
f4c08de0e4 | ||
|
|
bfb7302f9c | ||
|
|
8a52cd198f | ||
|
|
622fb92a92 | ||
|
|
b791b7d764 | ||
|
|
e3efc4f2ee | ||
|
|
1e5ebcb6b1 | ||
|
|
18c44f3a1c | ||
|
|
c314715008 | ||
|
|
c5720e8c16 | ||
|
|
50bfaf160f | ||
|
|
85410ab760 | ||
|
|
c52b73696e | ||
|
|
94674a2dac | ||
|
|
42650000c4 | ||
|
|
46bb3f190b | ||
|
|
fe892d81ec | ||
|
|
35696400a2 | ||
|
|
c0f606a949 | ||
|
|
caf549b2a0 | ||
|
|
2813f31f7b | ||
|
|
a6cc2fd2b4 | ||
|
|
1dee6d0de8 | ||
|
|
2a2b3bb6ee | ||
|
|
1c8d06eb99 | ||
|
|
fa0740bb17 | ||
|
|
23c6eede44 | ||
|
|
91cbc9559f | ||
|
|
f8f9df98ec | ||
|
|
18dcec312f | ||
|
|
5bd74c1094 | ||
|
|
ec6b72937c | ||
|
|
56cbd7ca21 | ||
|
|
af1bbe05dd | ||
|
|
b4f0329113 | ||
|
|
6f03a8d12c | ||
|
|
266040a008 | ||
|
|
af325d1cab | ||
|
|
6cdcc293cf | ||
|
|
896446f388 | ||
|
|
ff7947d5bd | ||
|
|
d55e34357d | ||
|
|
4513e51e50 | ||
|
|
2277d94cac | ||
|
|
7885c1581d | ||
|
|
650f0ee3a7 | ||
|
|
a276e8b2de | ||
|
|
a605e62360 | ||
|
|
1b50e3bb2f | ||
|
|
98e086a6e2 | ||
|
|
bd42adad55 | ||
|
|
9b989c5190 | ||
|
|
ec6e33699e | ||
| 17ba5c8dd0 | |||
| b1914434d8 | |||
|
|
193cd2666f | ||
|
|
3c37050b30 | ||
|
|
e5ab358a76 | ||
|
|
f55e576277 | ||
|
|
eb45fc608e | ||
|
|
896c84475c | ||
|
|
0aa432eb39 | ||
|
|
2803e4a53a | ||
|
|
5c98c0be93 | ||
|
|
a3afa1a405 | ||
|
|
1299543f4d | ||
|
|
b927612c58 | ||
|
|
60ec2408ca | ||
|
|
64b005b697 | ||
|
|
61df5f104a | ||
|
|
f3f1416004 | ||
|
|
458e5f890a | ||
|
|
411f4a596c | ||
|
|
1faa23ebc5 | ||
|
|
5e592b2f3d | ||
|
|
c3284a6dca | ||
|
|
f051dadabb | ||
|
|
1e96a1287c | ||
|
|
33a0b43d6d | ||
|
|
f5ed38abbb | ||
|
|
8413dd1c88 | ||
|
|
5863e3148b | ||
|
|
f152d1537f | ||
|
|
5a0bb45b51 | ||
|
|
b2226341ee | ||
|
|
2a45b6bfe8 | ||
|
|
9823945807 | ||
|
|
baf1fb5ddf | ||
|
|
18fb810f81 | ||
|
|
74400cd6e2 | ||
|
|
7ba438b41b | ||
|
|
86cc18020a | ||
|
|
8b55bef385 | ||
|
|
2aea6962ef | ||
|
|
3443fd7b05 | ||
|
|
cb0f72e36c | ||
|
|
d697f80340 | ||
|
|
c36539f2bd | ||
|
|
f372791ba9 | ||
|
|
90d7639884 | ||
|
|
744acca395 | ||
|
|
9f0e038ffe | ||
|
|
272a4842e8 | ||
|
|
05845b5311 | ||
|
|
4d375d2725 | ||
|
|
50c0c9ce50 | ||
|
|
9a69af98f0 | ||
|
|
3d8606f4d5 | ||
|
|
bdc1b2d3e0 | ||
|
|
775b654a26 | ||
|
|
7568fabc18 | ||
|
|
472a1e5c54 | ||
|
|
19eea07041 | ||
|
|
fa086147de | ||
|
|
7a1b502f5c | ||
|
|
a844dcb0ac | ||
|
|
367a7bbe56 | ||
|
|
55865155de | ||
|
|
f5090b48b0 | ||
|
|
1a3ec05d6d | ||
|
|
779ba7246e | ||
|
|
c20670f165 | ||
|
|
8ccac1535e | ||
|
|
e9277c695f | ||
|
|
8a6ee9f2fe | ||
|
|
1c8c08b078 | ||
|
|
d729e29996 | ||
|
|
beecf0851e | ||
|
|
0aab609dcc | ||
|
|
2b09857637 | ||
|
|
fce8b7011e | ||
|
|
317beb1b5e | ||
|
|
4856eedb09 | ||
|
|
49951d70c0 | ||
|
|
5ebca1402d | ||
|
|
83f10552df | ||
|
|
8ba619d659 | ||
|
|
0b5429838c | ||
|
|
a5abd950f2 | ||
|
|
0ee6b9f77a | ||
|
|
ac3b72cac6 | ||
|
|
d5b1f05256 | ||
|
|
bcfbcc3a1e | ||
|
|
28458488d4 | ||
|
|
f87f1afde0 | ||
|
|
1aa0c50c6d | ||
|
|
8c574088f4 | ||
|
|
f922646b7b | ||
|
|
958a9302b0 | ||
|
|
48dc94c0b0 | ||
|
|
22e6cacced | ||
|
|
d55d1c3405 | ||
|
|
f81436c26f | ||
|
|
2a1cbdff15 | ||
|
|
78615ec6ee | ||
|
|
1b38071fd1 | ||
|
|
3603a06c62 | ||
|
|
810e170644 | ||
|
|
f6b2f0d499 | ||
|
|
5553ccf493 | ||
|
|
0c7f6b19ae | ||
|
|
0faf4e4d4e | ||
|
|
cc3aed004c | ||
|
|
66ceb06b4b | ||
|
|
6d19b4bd39 | ||
|
|
dfc207668a | ||
|
|
743ab6da34 | ||
|
|
f02e96d4fd | ||
|
|
41693d1888 | ||
|
|
95de6cbd87 | ||
|
|
d99fdcc2ec | ||
|
|
3d295e1ca7 | ||
|
|
c1b097b7fe | ||
|
|
5fde7855bb | ||
|
|
df1e83af1b | ||
|
|
efebd1e1f8 | ||
|
|
efc133bd78 | ||
|
|
c2edef2253 | ||
|
|
e45df999aa | ||
|
|
e6eb1d7691 | ||
|
|
bbdad75468 | ||
|
|
5e61d20231 | ||
|
|
bd85a902ad | ||
|
|
c8cea0b67f | ||
|
|
9c14f1df25 | ||
|
|
11b2c0ec17 | ||
|
|
8d78a1ee69 | ||
|
|
af17880246 | ||
|
|
1f81e6672d | ||
|
|
43917fe486 | ||
|
|
e9454d2232 | ||
|
|
fc1efba9fd | ||
|
|
3c3e0f8141 | ||
|
|
3f1c5ead73 | ||
|
|
fbbc4ba385 | ||
|
|
9676f0409e | ||
|
|
50c43b52b0 | ||
|
|
c26ede01b5 | ||
|
|
0b19728fef | ||
|
|
06fb6b42be | ||
|
|
a70df1cc2d | ||
|
|
fe739431ca | ||
|
|
684bba105f | ||
|
|
b17979f412 | ||
|
|
7f5bb43372 | ||
|
|
1bc77f94ff | ||
|
|
4398d5e27c | ||
|
|
7b2300e1be | ||
|
|
2550c16894 | ||
|
|
94000d965d | ||
|
|
474165ff67 | ||
|
|
fb92348a9d | ||
|
|
c5d5d0c3ab | ||
|
|
84985ceab6 | ||
|
|
4fa163397a | ||
|
|
e8d38953d0 | ||
|
|
b9a4a6b835 | ||
|
|
b6220810cf | ||
|
|
a1ca8b7e46 | ||
|
|
442533e7c8 | ||
|
|
9623256386 | ||
|
|
446b8787de | ||
|
|
e8d4803590 | ||
|
|
8d0dee2bb2 | ||
|
|
ba24034020 | ||
|
|
f58436a4dc | ||
|
|
bb9193bcad | ||
|
|
6b66172af7 | ||
|
|
2a8d29be8d | ||
| 9782082d01 | |||
| c6ddc78bc7 | |||
|
|
b8b2e7e023 | ||
|
|
84fe893a5b | ||
|
|
894364098d | ||
|
|
af542c0f41 | ||
|
|
4bbabde383 | ||
|
|
b137e637a1 | ||
|
|
8e2b3ffbc8 | ||
|
|
7b5235f2aa | ||
|
|
7404aa68cb | ||
|
|
b5da40c051 | ||
|
|
394dd258cd | ||
|
|
7044030ba8 | ||
|
|
524aaab115 | ||
|
|
76c60f6a92 | ||
|
|
370d001818 | ||
|
|
5a299ad20f | ||
|
|
2f6e796e3f | ||
|
|
38d7e137de | ||
|
|
d46f6d8b19 | ||
|
|
d9261e7969 | ||
|
|
0b3ab8d07b | ||
|
|
b62521213a | ||
|
|
bd8176b426 | ||
|
|
651ee2ef61 | ||
|
|
58af12f08d | ||
|
|
308462dd69 | ||
|
|
e291b29bd7 | ||
|
|
7f1327bfea | ||
|
|
a3668354d9 | ||
|
|
4115bbd7db | ||
| 31246e3317 | |||
|
|
5e1a093476 | ||
|
|
70e63edfa8 | ||
|
|
1416b4600c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -162,3 +162,6 @@ package-lock.json
|
||||
|
||||
# 아카데미 SVG 일러스트는 프로젝트 자산으로 추적
|
||||
!public/images/academy/**/*.svg
|
||||
|
||||
# 다운로드용 PPTX 파일은 프로젝트 자산으로 추적
|
||||
!public/downloads/*.pptx
|
||||
|
||||
@@ -12,4 +12,4 @@ ## 최근 커밋 이력 (참고용)
|
||||
|
||||
## 다음 단계 (필요 시)
|
||||
- API Explorer Phase 2-5 (API 실행, 즐겨찾기, 히스토리, UX 개선)
|
||||
- MNG 견적수식 관리 UI 개발 (`docs/plans/mng-quote-formula-development-plan.md`)
|
||||
- MNG 견적수식 관리 UI 개발 (`docs/dev/dev_plans/mng-quote-formula-development-plan.md`)
|
||||
|
||||
8
Jenkinsfile
vendored
8
Jenkinsfile
vendored
@@ -17,7 +17,7 @@ pipeline {
|
||||
script {
|
||||
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
|
||||
}
|
||||
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_mng', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
message: "🚀 *mng* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,9 @@ pipeline {
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage/logs &&
|
||||
ln -sfn /home/webservice/mng/shared/.env .env &&
|
||||
sudo chmod 640 /home/webservice/mng/shared/.env &&
|
||||
ln -sfn /home/webservice/mng/shared/storage/app storage/app &&
|
||||
ln -sfn /home/webservice/mng/shared/storage/credentials storage/credentials &&
|
||||
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||
npm install --prefer-offline &&
|
||||
npm run build &&
|
||||
@@ -65,11 +67,11 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_mng', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *mng* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
failure {
|
||||
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_mng', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *mng* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
script {
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
|
||||
30
app/Console/Commands/MarkAbsentEmployees.php
Normal file
30
app/Console/Commands/MarkAbsentEmployees.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MarkAbsentEmployees extends Command
|
||||
{
|
||||
protected $signature = 'attendance:mark-absent {--date= : 대상 날짜 (YYYY-MM-DD), 기본값: 오늘}';
|
||||
|
||||
protected $description = '영업일에 출근 기록이 없는 사원을 자동 결근 처리';
|
||||
|
||||
public function handle(AttendanceService $service): int
|
||||
{
|
||||
$date = $this->option('date') ?: now()->toDateString();
|
||||
|
||||
$this->info("자동 결근 처리 시작: {$date}");
|
||||
|
||||
$count = $service->markAbsentees($date);
|
||||
|
||||
if ($count > 0) {
|
||||
$this->info("{$count}명 결근 처리 완료");
|
||||
} else {
|
||||
$this->info('결근 처리 대상이 없습니다 (주말이거나 모든 사원에 기록이 있음)');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
266
app/Enums/InspectionCycle.php
Normal file
266
app/Enums/InspectionCycle.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class InspectionCycle
|
||||
{
|
||||
const DAILY = 'daily';
|
||||
|
||||
const WEEKLY = 'weekly';
|
||||
|
||||
const MONTHLY = 'monthly';
|
||||
|
||||
const BIMONTHLY = 'bimonthly';
|
||||
|
||||
const QUARTERLY = 'quarterly';
|
||||
|
||||
const SEMIANNUAL = 'semiannual';
|
||||
|
||||
/**
|
||||
* 전체 주기 목록 (코드 → 라벨)
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::DAILY => '일일',
|
||||
self::WEEKLY => '주간',
|
||||
self::MONTHLY => '월간',
|
||||
self::BIMONTHLY => '2개월',
|
||||
self::QUARTERLY => '분기',
|
||||
self::SEMIANNUAL => '반년',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 주기별 라벨 반환
|
||||
*/
|
||||
public static function label(string $cycle): string
|
||||
{
|
||||
return self::all()[$cycle] ?? $cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주기별 기간 필터 타입 반환
|
||||
*/
|
||||
public static function periodType(string $cycle): string
|
||||
{
|
||||
return $cycle === self::DAILY ? 'month' : 'year';
|
||||
}
|
||||
|
||||
/**
|
||||
* 주기별 그리드 열 라벨 반환
|
||||
*
|
||||
* @return array<int, string> [colIndex => label]
|
||||
*/
|
||||
public static function columnLabels(string $cycle, ?string $period = null): array
|
||||
{
|
||||
return match ($cycle) {
|
||||
self::DAILY => self::dailyLabels($period),
|
||||
self::WEEKLY => self::weeklyLabels(),
|
||||
self::MONTHLY => self::monthlyLabels(),
|
||||
self::BIMONTHLY => self::bimonthlyLabels(),
|
||||
self::QUARTERLY => self::quarterlyLabels(),
|
||||
self::SEMIANNUAL => self::semiannualLabels(),
|
||||
default => self::dailyLabels($period),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 인덱스 → check_date 변환
|
||||
*/
|
||||
public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string
|
||||
{
|
||||
return match ($cycle) {
|
||||
self::DAILY => self::dailyCheckDate($period, $colIndex),
|
||||
self::WEEKLY => self::weeklyCheckDate($period, $colIndex),
|
||||
self::MONTHLY => self::monthlyCheckDate($period, $colIndex),
|
||||
self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex),
|
||||
self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex),
|
||||
self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex),
|
||||
default => self::dailyCheckDate($period, $colIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* check_date → year_month(period) 역산
|
||||
*/
|
||||
public static function resolvePeriod(string $cycle, string $checkDate): string
|
||||
{
|
||||
$date = Carbon::parse($checkDate);
|
||||
|
||||
return match ($cycle) {
|
||||
self::DAILY => $date->format('Y-m'),
|
||||
self::WEEKLY => (string) $date->isoWeekYear,
|
||||
default => $date->format('Y'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 주기별 그리드 열 수
|
||||
*/
|
||||
public static function columnCount(string $cycle, ?string $period = null): int
|
||||
{
|
||||
return count(self::columnLabels($cycle, $period));
|
||||
}
|
||||
|
||||
/**
|
||||
* 주말 여부 (daily 전용)
|
||||
*/
|
||||
public static function isWeekend(string $period, int $colIndex): bool
|
||||
{
|
||||
$date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1);
|
||||
|
||||
return in_array($date->dayOfWeek, [0, 6]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 기간의 휴일 날짜 목록 반환 (date string Set)
|
||||
*/
|
||||
public static function getHolidayDates(string $cycle, string $period): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
if ($cycle === self::DAILY) {
|
||||
$start = Carbon::createFromFormat('Y-m', $period)->startOfMonth();
|
||||
$end = $start->copy()->endOfMonth();
|
||||
} else {
|
||||
$start = Carbon::create((int) $period, 1, 1);
|
||||
$end = Carbon::create((int) $period, 12, 31);
|
||||
}
|
||||
|
||||
$holidays = \App\Models\System\Holiday::where('tenant_id', $tenantId)
|
||||
->where('start_date', '<=', $end->toDateString())
|
||||
->where('end_date', '>=', $start->toDateString())
|
||||
->get();
|
||||
|
||||
$dates = [];
|
||||
foreach ($holidays as $holiday) {
|
||||
$hStart = $holiday->start_date->copy()->max($start);
|
||||
$hEnd = $holiday->end_date->copy()->min($end);
|
||||
$current = $hStart->copy();
|
||||
while ($current->lte($hEnd)) {
|
||||
$dates[$current->format('Y-m-d')] = true;
|
||||
$current->addDay();
|
||||
}
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비근무일 여부 (주말 또는 휴일)
|
||||
*/
|
||||
public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool
|
||||
{
|
||||
$date = Carbon::parse($checkDate);
|
||||
|
||||
return $date->isWeekend() || isset($holidayDates[$checkDate]);
|
||||
}
|
||||
|
||||
// --- Daily ---
|
||||
private static function dailyLabels(?string $period): array
|
||||
{
|
||||
$date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m'));
|
||||
$days = $date->daysInMonth;
|
||||
$labels = [];
|
||||
for ($d = 1; $d <= $days; $d++) {
|
||||
$labels[$d] = (string) $d;
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
private static function dailyCheckDate(string $period, int $colIndex): string
|
||||
{
|
||||
return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// --- Weekly ---
|
||||
private static function weeklyLabels(): array
|
||||
{
|
||||
$labels = [];
|
||||
for ($w = 1; $w <= 52; $w++) {
|
||||
$labels[$w] = $w.'주';
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
private static function weeklyCheckDate(string $year, int $colIndex): string
|
||||
{
|
||||
// ISO 주차의 월요일
|
||||
return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// --- Monthly ---
|
||||
private static function monthlyLabels(): array
|
||||
{
|
||||
$labels = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$labels[$m] = $m.'월';
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
private static function monthlyCheckDate(string $year, int $colIndex): string
|
||||
{
|
||||
return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// --- Bimonthly ---
|
||||
private static function bimonthlyLabels(): array
|
||||
{
|
||||
return [
|
||||
1 => '1~2월',
|
||||
2 => '3~4월',
|
||||
3 => '5~6월',
|
||||
4 => '7~8월',
|
||||
5 => '9~10월',
|
||||
6 => '11~12월',
|
||||
];
|
||||
}
|
||||
|
||||
private static function bimonthlyCheckDate(string $year, int $colIndex): string
|
||||
{
|
||||
$month = ($colIndex - 1) * 2 + 1;
|
||||
|
||||
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// --- Quarterly ---
|
||||
private static function quarterlyLabels(): array
|
||||
{
|
||||
return [
|
||||
1 => '1분기',
|
||||
2 => '2분기',
|
||||
3 => '3분기',
|
||||
4 => '4분기',
|
||||
];
|
||||
}
|
||||
|
||||
private static function quarterlyCheckDate(string $year, int $colIndex): string
|
||||
{
|
||||
$month = ($colIndex - 1) * 3 + 1;
|
||||
|
||||
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
|
||||
}
|
||||
|
||||
// --- Semiannual ---
|
||||
private static function semiannualLabels(): array
|
||||
{
|
||||
return [
|
||||
1 => '상반기',
|
||||
2 => '하반기',
|
||||
];
|
||||
}
|
||||
|
||||
private static function semiannualCheckDate(string $year, int $colIndex): string
|
||||
{
|
||||
$month = $colIndex === 1 ? 1 : 7;
|
||||
|
||||
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -133,4 +133,13 @@ public function pm2Guide(Request $request): View|Response
|
||||
|
||||
return view('academy.pm2-guide');
|
||||
}
|
||||
|
||||
public function taxCorrection(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('academy.tax-correction'));
|
||||
}
|
||||
|
||||
return view('academy.tax-correction');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class PptxController extends Controller
|
||||
'/var/www/docs/rules' => ['label' => '정책/규칙', 'source' => 'docs'],
|
||||
'/var/www/docs/guides' => ['label' => '가이드', 'source' => 'docs'],
|
||||
'/var/www/docs/projects' => ['label' => '프로젝트', 'source' => 'docs'],
|
||||
'/var/www/docs/plans' => ['label' => '계획', 'source' => 'docs'],
|
||||
'/var/www/docs/dev_plans' => ['label' => '계획', 'source' => 'docs'],
|
||||
'/var/www/mng/docs/pptx-output' => ['label' => '산출물', 'source' => 'mng'],
|
||||
'/var/www/mng/docs' => ['label' => '교육/문서', 'source' => 'mng'],
|
||||
'/var/www/mng/public/docs' => ['label' => '공개 문서', 'source' => 'mng'],
|
||||
|
||||
841
app/Http/Controllers/Api/Admin/ApprovalApiController.php
Normal file
841
app/Http/Controllers/Api/Admin/ApprovalApiController.php
Normal file
@@ -0,0 +1,841 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\AppointmentCertService;
|
||||
use App\Services\ApprovalService;
|
||||
use App\Services\CareerCertService;
|
||||
use App\Services\EmploymentCertService;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use App\Services\ResignationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApprovalApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApprovalService $service
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// 목록
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 기안함
|
||||
*/
|
||||
public function drafts(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getMyDrafts(
|
||||
$request->only(['search', 'status', 'is_urgent', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 대기함
|
||||
*/
|
||||
public function pending(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getPendingForMe(
|
||||
auth()->id(),
|
||||
$request->only(['search', 'is_urgent', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 완료함
|
||||
*/
|
||||
public function completed(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getCompletedByMe(
|
||||
auth()->id(),
|
||||
$request->only(['search', 'status', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조함
|
||||
*/
|
||||
public function references(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getReferencesForMe(
|
||||
auth()->id(),
|
||||
$request->only(['search', 'date_from', 'date_to', 'is_read']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $approval]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 (임시저장)
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'form_id' => 'required|exists:approval_forms,id',
|
||||
'title' => 'required|string|max:200',
|
||||
'body' => 'nullable|string',
|
||||
'content' => 'nullable|array',
|
||||
'is_urgent' => 'boolean',
|
||||
'steps' => 'nullable|array',
|
||||
'steps.*.user_id' => 'required_with:steps|exists:users,id',
|
||||
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
|
||||
'attachment_file_ids' => 'nullable|array',
|
||||
'attachment_file_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$approval = $this->service->createApproval($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 저장되었습니다.',
|
||||
'data' => $approval,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'sometimes|string|max:200',
|
||||
'body' => 'nullable|string',
|
||||
'content' => 'nullable|array',
|
||||
'is_urgent' => 'boolean',
|
||||
'steps' => 'nullable|array',
|
||||
'steps.*.user_id' => 'required_with:steps|exists:users,id',
|
||||
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
|
||||
'attachment_file_ids' => 'nullable|array',
|
||||
'attachment_file_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$approval = $this->service->updateApproval($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 수정되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = auth()->user();
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
if (! $approval->isDeletableBy($user)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '삭제 권한이 없습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$this->service->deleteApproval($id, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영구삭제 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function forceDestroy(int $id): JsonResponse
|
||||
{
|
||||
if (! auth()->user()->isSuperAdmin()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '슈퍼관리자만 영구삭제할 수 있습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->forceDeleteApproval($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 영구삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '영구삭제에 실패했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택삭제 (기안자 본인 문서만)
|
||||
*/
|
||||
public function bulkDestroy(Request $request): JsonResponse
|
||||
{
|
||||
$ids = $request->input('ids', []);
|
||||
if (empty($ids) || ! is_array($ids)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '삭제할 문서를 선택하세요.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
$approval = $this->service->getApproval($id);
|
||||
if ($approval->isDeletableBy($user)) {
|
||||
$this->service->deleteApproval($id, $user);
|
||||
$deleted++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$message = "{$deleted}건 삭제 완료";
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed}건 삭제 불가)";
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'deleted' => $deleted,
|
||||
'failed' => $failed,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 재직증명서
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 사원 재직증명서 정보 조회
|
||||
*/
|
||||
public function certInfo(int $userId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$service = app(EmploymentCertService::class);
|
||||
$data = $service->getCertInfo($userId, $tenantId);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 불러올 수 없습니다.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재직증명서 PDF 다운로드 (content JSON 기반 HTML→PDF)
|
||||
*/
|
||||
public function certPdf(int $id)
|
||||
{
|
||||
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
|
||||
->findOrFail($id);
|
||||
|
||||
$content = $approval->content ?? [];
|
||||
$service = app(EmploymentCertService::class);
|
||||
|
||||
return $service->generatePdfResponse($content);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 경력증명서
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 사원 경력증명서 정보 조회
|
||||
*/
|
||||
public function careerCertInfo(int $userId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$service = app(CareerCertService::class);
|
||||
$data = $service->getCertInfo($userId, $tenantId);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 불러올 수 없습니다.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 경력증명서 PDF 다운로드
|
||||
*/
|
||||
public function careerCertPdf(int $id)
|
||||
{
|
||||
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
|
||||
->findOrFail($id);
|
||||
|
||||
$content = $approval->content ?? [];
|
||||
$service = app(CareerCertService::class);
|
||||
|
||||
return $service->generatePdfResponse($content);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 위촉증명서
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 사원 위촉증명서 정보 조회
|
||||
*/
|
||||
public function appointmentCertInfo(int $userId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$service = app(AppointmentCertService::class);
|
||||
$data = $service->getCertInfo($userId, $tenantId);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 불러올 수 없습니다.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 위촉증명서 PDF 다운로드
|
||||
*/
|
||||
public function appointmentCertPdf(int $id)
|
||||
{
|
||||
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
|
||||
->findOrFail($id);
|
||||
|
||||
$content = $approval->content ?? [];
|
||||
$service = app(AppointmentCertService::class);
|
||||
|
||||
return $service->generatePdfResponse($content);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 사직서
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 사원 사직서 정보 조회
|
||||
*/
|
||||
public function resignationInfo(int $userId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$service = app(ResignationService::class);
|
||||
$data = $service->getCertInfo($userId, $tenantId);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 불러올 수 없습니다.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사직서 PDF 다운로드
|
||||
*/
|
||||
public function resignationPdf(int $id)
|
||||
{
|
||||
$approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id'))
|
||||
->findOrFail($id);
|
||||
|
||||
$content = $approval->content ?? [];
|
||||
$service = app(ResignationService::class);
|
||||
|
||||
return $service->generatePdfResponse($content);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 워크플로우
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상신
|
||||
*/
|
||||
public function submit(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->submit($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재가 상신되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->approve($id, $request->get('comment'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'comment' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$approval = $this->service->reject($id, $request->get('comment'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '반려되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회수
|
||||
*/
|
||||
public function cancel(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->cancel($id, $request->get('recall_reason'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재가 회수되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류
|
||||
*/
|
||||
public function hold(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'comment' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$approval = $this->service->hold($id, $request->get('comment'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '보류되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보류 해제
|
||||
*/
|
||||
public function releaseHold(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->releaseHold($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '보류가 해제되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전결
|
||||
*/
|
||||
public function preDecide(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->preDecide($id, $request->get('comment'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '전결 처리되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 재기안
|
||||
*/
|
||||
public function copyForRedraft(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->copyForRedraft($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서가 복사되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 열람 추적
|
||||
*/
|
||||
public function markAsRead(int $id): JsonResponse
|
||||
{
|
||||
$this->service->markAsRead($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '열람 처리되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 유틸
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재선 템플릿 목록
|
||||
*/
|
||||
public function lines(): JsonResponse
|
||||
{
|
||||
$lines = $this->service->getApprovalLines();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $lines]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 템플릿 생성
|
||||
*/
|
||||
public function storeLine(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'steps' => 'required|array|min:1',
|
||||
'steps.*.user_id' => 'required|exists:users,id',
|
||||
'steps.*.step_type' => 'required|in:approval,agreement,reference',
|
||||
'is_default' => 'boolean',
|
||||
]);
|
||||
|
||||
$line = $this->service->createLine($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재선이 저장되었습니다.',
|
||||
'data' => $line,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 템플릿 수정
|
||||
*/
|
||||
public function updateLine(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'steps' => 'required|array|min:1',
|
||||
'steps.*.user_id' => 'required|exists:users,id',
|
||||
'steps.*.step_type' => 'required|in:approval,agreement,reference',
|
||||
'is_default' => 'boolean',
|
||||
]);
|
||||
|
||||
$line = $this->service->updateLine($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재선이 수정되었습니다.',
|
||||
'data' => $line,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 템플릿 삭제
|
||||
*/
|
||||
public function destroyLine(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteLine($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재선이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지출결의서 이력 (불러오기용)
|
||||
*/
|
||||
public function expenseHistory(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$approvals = \App\Models\Approvals\Approval::where('tenant_id', $tenantId)
|
||||
->where('drafter_id', auth()->id())
|
||||
->whereHas('form', fn ($q) => $q->where('code', 'expense'))
|
||||
->whereIn('status', ['draft', 'pending', 'approved', 'rejected', 'cancelled'])
|
||||
->whereNotNull('content')
|
||||
->orderByDesc('created_at')
|
||||
->limit(30)
|
||||
->get(['id', 'title', 'content', 'status', 'created_at']);
|
||||
|
||||
$data = $approvals->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'status' => $a->status,
|
||||
'status_label' => $a->status_label,
|
||||
'total_amount' => $a->content['total_amount'] ?? 0,
|
||||
'expense_type' => $a->content['expense_type'] ?? '',
|
||||
'created_at' => $a->created_at->format('Y-m-d'),
|
||||
'content' => $a->content,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 목록
|
||||
*/
|
||||
public function forms(): JsonResponse
|
||||
{
|
||||
$forms = $this->service->getApprovalForms();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $forms]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미처리 건수
|
||||
*/
|
||||
public function badgeCounts(): JsonResponse
|
||||
{
|
||||
$counts = $this->service->getBadgeCounts(auth()->id());
|
||||
|
||||
return response()->json(['success' => true, 'data' => $counts]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함 읽음 처리 (일괄)
|
||||
*/
|
||||
public function markCompletedAsRead(): JsonResponse
|
||||
{
|
||||
$count = $this->service->markCompletedAsRead(auth()->id());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $count > 0 ? "{$count}건 읽음 처리되었습니다." : '새로운 완료 건이 없습니다.',
|
||||
'data' => ['marked_count' => $count],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 문서 기안자 읽음 처리
|
||||
*/
|
||||
public function markReadSingle(int $id): JsonResponse
|
||||
{
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
if ($approval->drafter_id === auth()->id() && ! $approval->drafter_read_at) {
|
||||
$approval->update(['drafter_read_at' => now()]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 첨부파일
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|max:20480',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
|
||||
$storagePath = "approvals/{$tenantId}/{$storedName}";
|
||||
|
||||
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
|
||||
|
||||
$gcsUri = null;
|
||||
$gcsObjectName = null;
|
||||
if ($gcs->isAvailable()) {
|
||||
$gcsObjectName = $storagePath;
|
||||
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
|
||||
}
|
||||
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => 'approval_attachment',
|
||||
'display_name' => $file->getClientOriginalName(),
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $storagePath,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => strtolower($file->getClientOriginalExtension()),
|
||||
'gcs_object_name' => $gcsObjectName,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'is_temp' => true,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $fileRecord->id,
|
||||
'name' => $fileRecord->original_name,
|
||||
'size' => $fileRecord->file_size,
|
||||
'mime_type' => $fileRecord->mime_type,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $fileId): JsonResponse
|
||||
{
|
||||
$file = File::where('id', $fileId)
|
||||
->where('uploaded_by', auth()->id())
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
if ($file->existsInStorage()) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
}
|
||||
|
||||
$file->forceDelete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $fileId)
|
||||
{
|
||||
$file = File::findOrFail($fileId);
|
||||
|
||||
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||
return Storage::disk('tenant')->download($file->file_path, $file->original_name);
|
||||
}
|
||||
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Barobill;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Services\Barobill\BarobillService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BarobillSmsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BarobillService $barobillService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 회원사 조회 및 서버 모드 전환 헬퍼
|
||||
*/
|
||||
private function resolveMember(): BarobillMember|JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$member = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
|
||||
if (! $member) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '바로빌 회원사가 등록되어 있지 않습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->barobillService->setServerMode($member->server_mode ?? 'test');
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS 발송
|
||||
*/
|
||||
public function sendSms(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from_number' => 'required|string',
|
||||
'to_name' => 'required|string',
|
||||
'to_number' => 'required|string',
|
||||
'contents' => 'required|string',
|
||||
'send_dt' => 'nullable|string',
|
||||
'ref_key' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$member = $this->resolveMember();
|
||||
if ($member instanceof JsonResponse) {
|
||||
return $member;
|
||||
}
|
||||
|
||||
$result = $this->barobillService->sendSMSMessage(
|
||||
corpNum: $member->biz_no,
|
||||
senderId: $member->barobill_id,
|
||||
fromNumber: $validated['from_number'],
|
||||
toName: $validated['to_name'],
|
||||
toNumber: $validated['to_number'],
|
||||
contents: $validated['contents'],
|
||||
sendDT: $validated['send_dt'] ?? '',
|
||||
refKey: $validated['ref_key'] ?? ''
|
||||
);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json($result, 422);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 발신번호 목록 조회
|
||||
*/
|
||||
public function getFromNumbers(): JsonResponse
|
||||
{
|
||||
$member = $this->resolveMember();
|
||||
if ($member instanceof JsonResponse) {
|
||||
return $member;
|
||||
}
|
||||
|
||||
$result = $this->barobillService->getSMSFromNumbers($member->biz_no);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json($result, 422);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발신번호 등록 여부 확인
|
||||
*/
|
||||
public function checkFromNumber(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from_number' => 'required|string',
|
||||
]);
|
||||
|
||||
$member = $this->resolveMember();
|
||||
if ($member instanceof JsonResponse) {
|
||||
return $member;
|
||||
}
|
||||
|
||||
$result = $this->barobillService->checkSMSFromNumber(
|
||||
$member->biz_no,
|
||||
$validated['from_number']
|
||||
);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json($result, 422);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS 전송 상태 조회
|
||||
*/
|
||||
public function getSendState(string $sendKey): JsonResponse
|
||||
{
|
||||
$member = $this->resolveMember();
|
||||
if ($member instanceof JsonResponse) {
|
||||
return $member;
|
||||
}
|
||||
|
||||
$result = $this->barobillService->getSMSSendState($member->biz_no, $sendKey);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json($result, 422);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ public function store(Request $request): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'builder_type' => 'nullable|string|in:legacy,block',
|
||||
'title' => 'nullable|string|max:200',
|
||||
'company_name' => 'nullable|string|max:100',
|
||||
'company_address' => 'nullable|string|max:255',
|
||||
@@ -134,6 +135,8 @@ public function store(Request $request): JsonResponse
|
||||
'footer_remark_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_options' => 'nullable|array',
|
||||
'schema' => 'nullable|array',
|
||||
'page_config' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
'linked_item_ids' => 'nullable|array',
|
||||
'linked_item_ids.*' => 'integer',
|
||||
@@ -162,6 +165,7 @@ public function store(Request $request): JsonResponse
|
||||
'tenant_id' => session('selected_tenant_id'),
|
||||
'name' => $validated['name'],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'builder_type' => $validated['builder_type'] ?? 'legacy',
|
||||
'title' => $validated['title'] ?? null,
|
||||
'company_name' => $validated['company_name'] ?? '경동기업',
|
||||
'company_address' => $validated['company_address'] ?? null,
|
||||
@@ -169,6 +173,8 @@ public function store(Request $request): JsonResponse
|
||||
'footer_remark_label' => $validated['footer_remark_label'] ?? '부적합 내용',
|
||||
'footer_judgement_label' => $validated['footer_judgement_label'] ?? '종합판정',
|
||||
'footer_judgement_options' => $validated['footer_judgement_options'] ?? ['적합', '부적합'],
|
||||
'schema' => $validated['schema'] ?? null,
|
||||
'page_config' => $validated['page_config'] ?? null,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
'linked_item_ids' => $validated['linked_item_ids'] ?? null,
|
||||
'linked_process_id' => $validated['linked_process_id'] ?? null,
|
||||
@@ -204,6 +210,7 @@ public function update(Request $request, int $id): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'builder_type' => 'nullable|string|in:legacy,block',
|
||||
'title' => 'nullable|string|max:200',
|
||||
'company_name' => 'nullable|string|max:100',
|
||||
'company_address' => 'nullable|string|max:255',
|
||||
@@ -211,6 +218,8 @@ public function update(Request $request, int $id): JsonResponse
|
||||
'footer_remark_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_options' => 'nullable|array',
|
||||
'schema' => 'nullable|array',
|
||||
'page_config' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
'linked_item_ids' => 'nullable|array',
|
||||
'linked_item_ids.*' => 'integer',
|
||||
@@ -235,7 +244,7 @@ public function update(Request $request, int $id): JsonResponse
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$template->update([
|
||||
$updateData = [
|
||||
'name' => $validated['name'],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'title' => $validated['title'] ?? null,
|
||||
@@ -248,7 +257,20 @@ public function update(Request $request, int $id): JsonResponse
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
'linked_item_ids' => $validated['linked_item_ids'] ?? null,
|
||||
'linked_process_id' => $validated['linked_process_id'] ?? null,
|
||||
]);
|
||||
];
|
||||
|
||||
// 블록 빌더 전용 필드
|
||||
if (isset($validated['builder_type'])) {
|
||||
$updateData['builder_type'] = $validated['builder_type'];
|
||||
}
|
||||
if (array_key_exists('schema', $validated)) {
|
||||
$updateData['schema'] = $validated['schema'];
|
||||
}
|
||||
if (array_key_exists('page_config', $validated)) {
|
||||
$updateData['page_config'] = $validated['page_config'];
|
||||
}
|
||||
|
||||
$template->update($updateData);
|
||||
|
||||
// 관계 데이터 저장 (기존 데이터 삭제 후 재생성)
|
||||
$this->saveRelations($template, $validated, true);
|
||||
@@ -396,6 +418,7 @@ public function duplicate(Request $request, int $id): JsonResponse
|
||||
'tenant_id' => $source->tenant_id,
|
||||
'name' => $newName,
|
||||
'category' => $source->category,
|
||||
'builder_type' => $source->builder_type ?? 'legacy',
|
||||
'title' => $source->title,
|
||||
'company_name' => $source->company_name,
|
||||
'company_address' => $source->company_address,
|
||||
@@ -403,6 +426,8 @@ public function duplicate(Request $request, int $id): JsonResponse
|
||||
'footer_remark_label' => $source->footer_remark_label,
|
||||
'footer_judgement_label' => $source->footer_judgement_label,
|
||||
'footer_judgement_options' => $source->footer_judgement_options,
|
||||
'schema' => $source->schema,
|
||||
'page_config' => $source->page_config,
|
||||
'is_active' => false,
|
||||
'linked_item_ids' => null, // 연결품목은 복사하지 않음 (중복 방지)
|
||||
'linked_process_id' => null,
|
||||
|
||||
265
app/Http/Controllers/Api/Admin/EquipmentController.php
Normal file
265
app/Http/Controllers/Api/Admin/EquipmentController.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEquipmentRequest;
|
||||
use App\Http\Requests\UpdateEquipmentRequest;
|
||||
use App\Services\EquipmentImportService;
|
||||
use App\Services\EquipmentPhotoService;
|
||||
use App\Services\EquipmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EquipmentService $equipmentService,
|
||||
private EquipmentPhotoService $photoService,
|
||||
private EquipmentImportService $importService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$equipments = $this->equipmentService->getEquipments(
|
||||
$request->all(),
|
||||
$request->input('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('equipment.partials.table', compact('equipments'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $equipments->items(),
|
||||
'meta' => [
|
||||
'current_page' => $equipments->currentPage(),
|
||||
'total' => $equipments->total(),
|
||||
'per_page' => $equipments->perPage(),
|
||||
'last_page' => $equipments->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreEquipmentRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$equipment = $this->equipmentService->createEquipment($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '설비가 등록되었습니다.',
|
||||
'data' => $equipment,
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '설비를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $equipment,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateEquipmentRequest $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$equipment = $this->equipmentService->updateEquipment($id, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '설비 정보가 수정되었습니다.',
|
||||
'data' => $equipment,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->equipmentService->deleteEquipment($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '설비가 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->equipmentService->restoreEquipment($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '설비가 복원되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function templates(int $id): JsonResponse
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $equipment->inspectionTemplates,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 사진 관리
|
||||
// =========================================================================
|
||||
|
||||
public function uploadPhotos(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'photos' => 'required|array|min:1|max:10',
|
||||
'photos.*' => 'required|image|max:10240',
|
||||
]);
|
||||
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$result = $this->photoService->uploadPhotos($equipment, $request->file('photos'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$result['uploaded']}장 업로드 완료",
|
||||
'data' => [
|
||||
'uploaded' => $result['uploaded'],
|
||||
'errors' => $result['errors'],
|
||||
'photos' => $this->photoService->getPhotoUrls($equipment),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function photos(int $id): JsonResponse
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->photoService->getPhotoUrls($equipment),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deletePhoto(int $id, int $fileId): JsonResponse
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$deleted = $this->photoService->deletePhoto($equipment, $fileId);
|
||||
|
||||
if (! $deleted) {
|
||||
return response()->json(['success' => false, 'message' => '사진을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사진이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 엑셀 Import
|
||||
// =========================================================================
|
||||
|
||||
public function importPreview(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->importService->preview($request->file('file')->getRealPath());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function importExecute(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
'duplicate_action' => 'in:skip,overwrite',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->importService->import(
|
||||
$request->file('file')->getRealPath(),
|
||||
['duplicate_action' => $request->input('duplicate_action', 'skip')]
|
||||
);
|
||||
|
||||
$photoMsg = '';
|
||||
if (! empty($result['photos_uploaded'])) {
|
||||
$photoMsg = ", 사진 {$result['photos_uploaded']}장 업로드";
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Import 완료: 성공 {$result['success']}건, 실패 {$result['failed']}건, 건너뜀 {$result['skipped']}건{$photoMsg}",
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
303
app/Http/Controllers/Api/Admin/EquipmentInspectionController.php
Normal file
303
app/Http/Controllers/Api/Admin/EquipmentInspectionController.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Enums\InspectionCycle;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EquipmentInspectionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EquipmentInspectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EquipmentInspectionService $inspectionService
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$cycle = $request->input('cycle', InspectionCycle::DAILY);
|
||||
$period = $request->input('period') ?? $request->input('year_month', now()->format('Y-m'));
|
||||
|
||||
// daily가 아닌 주기에서 period가 없으면 현재 연도
|
||||
if ($cycle !== InspectionCycle::DAILY && ! $request->input('period')) {
|
||||
$period = now()->format('Y');
|
||||
}
|
||||
|
||||
$productionLine = $request->input('production_line');
|
||||
$equipmentId = $request->input('equipment_id');
|
||||
|
||||
$inspections = $this->inspectionService->getInspections(
|
||||
$cycle,
|
||||
$period,
|
||||
$productionLine,
|
||||
$equipmentId ? (int) $equipmentId : null
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period);
|
||||
|
||||
return view('equipment.partials.inspection-grid', [
|
||||
'inspections' => $inspections,
|
||||
'cycle' => $cycle,
|
||||
'period' => $period,
|
||||
'holidayDates' => $holidayDates,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $inspections,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleDetail(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'equipment_id' => 'required|integer',
|
||||
'template_item_id' => 'required|integer',
|
||||
'check_date' => 'required|date',
|
||||
'cycle' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->inspectionService->toggleDetail(
|
||||
$request->input('equipment_id'),
|
||||
$request->input('template_item_id'),
|
||||
$request->input('check_date'),
|
||||
$request->input('cycle', InspectionCycle::DAILY)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
|
||||
public function setResult(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'equipment_id' => 'required|integer',
|
||||
'template_item_id' => 'required|integer',
|
||||
'check_date' => 'required|date',
|
||||
'cycle' => 'nullable|string',
|
||||
'result' => 'nullable|in:good,bad,repaired',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->inspectionService->setResult(
|
||||
$request->input('equipment_id'),
|
||||
$request->input('template_item_id'),
|
||||
$request->input('check_date'),
|
||||
$request->input('cycle', InspectionCycle::DAILY),
|
||||
$request->input('result')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateNotes(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'equipment_id' => 'required|integer',
|
||||
'year_month' => 'required|string',
|
||||
'cycle' => 'nullable|string',
|
||||
'overall_judgment' => 'nullable|in:OK,NG',
|
||||
'repair_note' => 'nullable|string',
|
||||
'issue_note' => 'nullable|string',
|
||||
'inspector_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$inspection = $this->inspectionService->updateInspectionNotes(
|
||||
$request->input('equipment_id'),
|
||||
$request->input('year_month'),
|
||||
$request->only(['overall_judgment', 'repair_note', 'issue_note', 'inspector_id']),
|
||||
$request->input('cycle', InspectionCycle::DAILY)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '점검 정보가 저장되었습니다.',
|
||||
'data' => $inspection,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetInspection(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'equipment_id' => 'required|integer',
|
||||
'cycle' => 'required|string',
|
||||
'period' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$deleted = $this->inspectionService->resetEquipmentInspection(
|
||||
$request->input('equipment_id'),
|
||||
$request->input('cycle'),
|
||||
$request->input('period')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "점검 데이터 {$deleted}건이 초기화되었습니다.",
|
||||
'data' => ['deleted' => $deleted],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$status = $e->getMessage() === '점검 권한이 없습니다.' ? 403 : 400;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetAllInspections(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'cycle' => 'required|string',
|
||||
'period' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$deleted = $this->inspectionService->resetAllInspections(
|
||||
$request->input('cycle'),
|
||||
$request->input('period')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "전체 점검 데이터 {$deleted}건이 초기화되었습니다.",
|
||||
'data' => ['deleted' => $deleted],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function storeTemplate(Request $request, int $equipmentId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'item_no' => 'required|integer',
|
||||
'inspection_cycle' => 'nullable|string',
|
||||
'check_point' => 'required|string|max:50',
|
||||
'check_item' => 'required|string|max:100',
|
||||
'check_timing' => 'nullable|in:operating,stopped',
|
||||
'check_frequency' => 'nullable|string|max:50',
|
||||
'check_method' => 'nullable|string',
|
||||
'sort_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$data = $request->all();
|
||||
if (empty($data['inspection_cycle'])) {
|
||||
$data['inspection_cycle'] = InspectionCycle::DAILY;
|
||||
}
|
||||
|
||||
$template = $this->inspectionService->saveTemplate($equipmentId, $data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '점검항목이 추가되었습니다.',
|
||||
'data' => $template,
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateTemplate(Request $request, int $templateId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$template = $this->inspectionService->updateTemplate($templateId, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '점검항목이 수정되었습니다.',
|
||||
'data' => $template,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function copyTemplates(Request $request, int $equipmentId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'source_cycle' => 'required|string',
|
||||
'target_cycles' => 'required|array|min:1',
|
||||
'target_cycles.*' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->inspectionService->copyTemplatesToCycles(
|
||||
$equipmentId,
|
||||
$request->input('source_cycle'),
|
||||
$request->input('target_cycles')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$result['copied']}개 항목이 복사되었습니다.".($result['skipped'] > 0 ? " (중복 {$result['skipped']}개 건너뜀)" : ''),
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteTemplate(int $templateId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->inspectionService->deleteTemplate($templateId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '점검항목이 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Api/Admin/EquipmentRepairController.php
Normal file
92
app/Http/Controllers/Api/Admin/EquipmentRepairController.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEquipmentRepairRequest;
|
||||
use App\Services\EquipmentRepairService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EquipmentRepairController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EquipmentRepairService $repairService
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$repairs = $this->repairService->getRepairs(
|
||||
$request->all(),
|
||||
$request->input('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('equipment.partials.repair-table', compact('repairs'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $repairs->items(),
|
||||
'meta' => [
|
||||
'current_page' => $repairs->currentPage(),
|
||||
'total' => $repairs->total(),
|
||||
'per_page' => $repairs->perPage(),
|
||||
'last_page' => $repairs->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreEquipmentRepairRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$repair = $this->repairService->createRepair($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수리이력이 등록되었습니다.',
|
||||
'data' => $repair,
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$repair = $this->repairService->updateRepair($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수리이력이 수정되었습니다.',
|
||||
'data' => $repair,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->repairService->deleteRepair($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수리이력이 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
380
app/Http/Controllers/Api/Admin/HR/AttendanceController.php
Normal file
380
app/Http/Controllers/Api/Admin/HR/AttendanceController.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceService $attendanceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 근태 목록 조회 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$attendances = $this->attendanceService->getAttendances(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$viewName = $request->input('view') === 'manage'
|
||||
? 'hr.attendances.partials.table-manage'
|
||||
: 'hr.attendances.partials.table';
|
||||
|
||||
return response(view($viewName, compact('attendances')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $attendances->items(),
|
||||
'meta' => [
|
||||
'current_page' => $attendances->currentPage(),
|
||||
'last_page' => $attendances->lastPage(),
|
||||
'per_page' => $attendances->perPage(),
|
||||
'total' => $attendances->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse|Response
|
||||
{
|
||||
$stats = $this->attendanceService->getMonthlyStats(
|
||||
$request->integer('year') ?: null,
|
||||
$request->integer('month') ?: null
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.stats', compact('stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 캘린더 (HTMX → HTML)
|
||||
*/
|
||||
public function calendar(Request $request): JsonResponse|Response
|
||||
{
|
||||
$year = $request->integer('year') ?: now()->year;
|
||||
$month = $request->integer('month') ?: now()->month;
|
||||
$userId = $request->integer('user_id') ?: null;
|
||||
|
||||
$attendances = $this->attendanceService->getMonthlyCalendarData($year, $month, $userId);
|
||||
$calendarData = $attendances->groupBy(fn ($att) => $att->base_date->format('Y-m-d'));
|
||||
$employees = $this->attendanceService->getActiveEmployees();
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.calendar', compact(
|
||||
'year', 'month', 'calendarData', 'employees'
|
||||
))->with('selectedUserId', $userId));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $calendarData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원별 월간 요약 (HTMX → HTML)
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse|Response
|
||||
{
|
||||
$year = $request->integer('year') ?: now()->year;
|
||||
$month = $request->integer('month') ?: now()->month;
|
||||
|
||||
$summary = $this->attendanceService->getEmployeeMonthlySummary($year, $month);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.summary', compact('summary', 'year', 'month')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $summary,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 초과근무 알림 (HTMX → HTML)
|
||||
*/
|
||||
public function overtimeAlerts(Request $request): JsonResponse|Response
|
||||
{
|
||||
$alerts = $this->attendanceService->getOvertimeAlerts();
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.overtime-alerts', compact('alerts')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $alerts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여 연차 조회
|
||||
*/
|
||||
public function leaveBalance(Request $request, int $userId): JsonResponse
|
||||
{
|
||||
$balance = $this->attendanceService->getLeaveBalance($userId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'total' => $balance?->total ?? 0,
|
||||
'used' => $balance?->used ?? 0,
|
||||
'remaining' => $balance?->remaining ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀(CSV) 내보내기
|
||||
*/
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$attendances = $this->attendanceService->getExportData($request->all());
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$filename = '근태현황_'.now()->format('Ymd').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($attendances) {
|
||||
$file = fopen('php://output', 'w');
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
||||
|
||||
fputcsv($file, ['날짜', '사원명', '부서', '상태', '출근', '퇴근', '비고']);
|
||||
|
||||
foreach ($attendances as $att) {
|
||||
$profile = $att->user?->tenantProfiles?->first();
|
||||
$displayName = $profile?->display_name ?? $att->user?->name ?? '-';
|
||||
$department = $profile?->department?->name ?? '-';
|
||||
$statusLabel = Attendance::STATUS_MAP[$att->status] ?? $att->status;
|
||||
$checkIn = $att->check_in ? substr($att->check_in, 0, 5) : '';
|
||||
$checkOut = $att->check_out ? substr($att->check_out, 0, 5) : '';
|
||||
|
||||
fputcsv($file, [
|
||||
$att->base_date->format('Y-m-d'),
|
||||
$displayName,
|
||||
$department,
|
||||
$statusLabel,
|
||||
$checkIn,
|
||||
$checkOut,
|
||||
$att->remarks ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 삭제
|
||||
*/
|
||||
public function bulkDestroy(Request $request): JsonResponse|Response
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$count = $this->attendanceService->bulkDelete($validated['ids']);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$attendances = $this->attendanceService->getAttendances(
|
||||
$request->except('ids'),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
return response(view('hr.attendances.partials.table', compact('attendances')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$count}건의 근태가 삭제되었습니다.",
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '일괄 삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'base_date' => 'required|date',
|
||||
'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
|
||||
'check_in' => 'nullable|date_format:H:i',
|
||||
'check_out' => 'nullable|date_format:H:i',
|
||||
'remarks' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$attendance = $this->attendanceService->storeAttendance($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '근태가 등록되었습니다.',
|
||||
'data' => $attendance,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '근태 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 등록
|
||||
*/
|
||||
public function bulkStore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_ids' => 'required|array|min:1',
|
||||
'user_ids.*' => 'integer|exists:users,id',
|
||||
'base_date' => 'required|date',
|
||||
'status' => 'required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
|
||||
'check_in' => 'nullable|date_format:H:i',
|
||||
'check_out' => 'nullable|date_format:H:i',
|
||||
'remarks' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->attendanceService->bulkStore($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "신규 {$result['created']}건, 수정 {$result['updated']}건 처리되었습니다.",
|
||||
'data' => $result,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '일괄 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'sometimes|required|string|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote',
|
||||
'check_in' => 'nullable|date_format:H:i',
|
||||
'check_out' => 'nullable|date_format:H:i',
|
||||
'remarks' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$attendance = $this->attendanceService->updateAttendance($id, $validated);
|
||||
|
||||
if (! $attendance) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '근태 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '근태가 수정되었습니다.',
|
||||
'data' => $attendance,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '근태 수정 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 삭제
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse|Response
|
||||
{
|
||||
try {
|
||||
$force = $request->boolean('force');
|
||||
|
||||
if ($force) {
|
||||
if (! auth()->user()->isSuperAdmin()) {
|
||||
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
|
||||
}
|
||||
$result = $this->attendanceService->forceDeleteAttendance($id);
|
||||
$message = '근태가 영구 삭제되었습니다.';
|
||||
} else {
|
||||
$result = $this->attendanceService->deleteAttendance($id);
|
||||
$message = '근태가 삭제되었습니다.';
|
||||
}
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '근태 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$attendances = $this->attendanceService->getAttendances(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
return response(view('hr.attendances.partials.table', compact('attendances')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '근태 삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use App\Services\HR\BusinessIncomeEarnerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BusinessIncomeEarnerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BusinessIncomeEarnerService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사업소득자 목록 조회 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$earners = $this->service->getBusinessIncomeEarners(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.business-income-earners.partials.table', compact('earners')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $earners->items(),
|
||||
'meta' => [
|
||||
'current_page' => $earners->currentPage(),
|
||||
'last_page' => $earners->lastPage(),
|
||||
'per_page' => $earners->perPage(),
|
||||
'total' => $earners->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 사용자 검색 (사업소득자 미등록, 테넌트 소속)
|
||||
*/
|
||||
public function searchUsers(Request $request): JsonResponse
|
||||
{
|
||||
$users = $this->service->searchTenantUsers($request->get('q') ?? '');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$rules = [
|
||||
'existing_user_id' => 'nullable|integer|exists:users,id',
|
||||
'name' => 'required|string|max:50',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:50',
|
||||
'job_title_key' => 'nullable|string|max:50',
|
||||
'work_location_key' => 'nullable|string|max:50',
|
||||
'employment_type_key' => 'nullable|string|max:50',
|
||||
'employee_status' => 'nullable|string|in:active,leave,resigned',
|
||||
'manager_user_id' => 'nullable|integer|exists:users,id',
|
||||
'display_name' => 'nullable|string|max:50',
|
||||
'hire_date' => 'nullable|date',
|
||||
'resign_date' => 'nullable|date',
|
||||
'address' => 'nullable|string|max:200',
|
||||
'emergency_contact' => 'nullable|string|max:100',
|
||||
'resident_number' => 'nullable|string|max:14',
|
||||
'bank_account.bank_code' => 'nullable|string|max:20',
|
||||
'bank_account.bank_name' => 'nullable|string|max:50',
|
||||
'bank_account.account_holder' => 'nullable|string|max:50',
|
||||
'bank_account.account_number' => 'nullable|string|max:30',
|
||||
'dependents' => 'nullable|array',
|
||||
'dependents.*.name' => 'required_with:dependents|string|max:50',
|
||||
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
|
||||
'dependents.*.resident_number' => 'nullable|string|max:14',
|
||||
'dependents.*.relationship' => 'nullable|string|max:20',
|
||||
'dependents.*.is_disabled' => 'nullable|boolean',
|
||||
'dependents.*.is_dependent' => 'nullable|boolean',
|
||||
// 사업장등록정보
|
||||
'business_registration_number' => 'nullable|string|max:12',
|
||||
'business_name' => 'nullable|string|max:100',
|
||||
'business_representative' => 'nullable|string|max:50',
|
||||
'business_type' => 'nullable|string|max:50',
|
||||
'business_category' => 'nullable|string|max:50',
|
||||
'business_address' => 'nullable|string|max:200',
|
||||
];
|
||||
|
||||
if (! $request->filled('existing_user_id')) {
|
||||
$rules['email'] = 'nullable|email|max:100|unique:users,email';
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
try {
|
||||
$earner = $this->service->create($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사업소득자가 등록되었습니다.',
|
||||
'data' => $earner,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
|
||||
if (! $earner) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $earner,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:50',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:50',
|
||||
'job_title_key' => 'nullable|string|max:50',
|
||||
'work_location_key' => 'nullable|string|max:50',
|
||||
'employment_type_key' => 'nullable|string|max:50',
|
||||
'employee_status' => 'nullable|string|in:active,leave,resigned',
|
||||
'manager_user_id' => 'nullable|integer|exists:users,id',
|
||||
'display_name' => 'nullable|string|max:50',
|
||||
'hire_date' => 'nullable|date',
|
||||
'resign_date' => 'nullable|date',
|
||||
'address' => 'nullable|string|max:200',
|
||||
'emergency_contact' => 'nullable|string|max:100',
|
||||
'resident_number' => 'nullable|string|max:14',
|
||||
'bank_account.bank_code' => 'nullable|string|max:20',
|
||||
'bank_account.bank_name' => 'nullable|string|max:50',
|
||||
'bank_account.account_holder' => 'nullable|string|max:50',
|
||||
'bank_account.account_number' => 'nullable|string|max:30',
|
||||
'dependents' => 'nullable|array',
|
||||
'dependents.*.name' => 'required_with:dependents|string|max:50',
|
||||
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
|
||||
'dependents.*.resident_number' => 'nullable|string|max:14',
|
||||
'dependents.*.relationship' => 'nullable|string|max:20',
|
||||
'dependents.*.is_disabled' => 'nullable|boolean',
|
||||
'dependents.*.is_dependent' => 'nullable|boolean',
|
||||
// 사업장등록정보
|
||||
'business_registration_number' => 'nullable|string|max:12',
|
||||
'business_name' => 'nullable|string|max:100',
|
||||
'business_representative' => 'nullable|string|max:50',
|
||||
'business_type' => 'nullable|string|max:50',
|
||||
'business_category' => 'nullable|string|max:50',
|
||||
'business_address' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) {
|
||||
$validated['dependents'] = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$earner = $this->service->update($id, $validated);
|
||||
|
||||
if (! $earner) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사업소득자 정보가 수정되었습니다.',
|
||||
'data' => $earner,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 수정 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득자 삭제 (퇴직 처리)
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse|Response
|
||||
{
|
||||
try {
|
||||
$result = $this->service->delete($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사업소득자 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$earners = $this->service->getBusinessIncomeEarners($request->all(), $request->integer('per_page', 20));
|
||||
|
||||
return response(view('hr.business-income-earners.partials.table', compact('earners')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '퇴직 처리되었습니다.',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '퇴직 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
if (! $earner) {
|
||||
return response()->json(['success' => false, 'message' => '사업소득자 정보를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'files' => 'required|array|max:10',
|
||||
'files.*' => 'file|max:20480',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$uploaded = [];
|
||||
|
||||
foreach ($request->file('files') as $file) {
|
||||
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
|
||||
$storagePath = "business-income-earners/{$tenantId}/{$earner->id}/{$storedName}";
|
||||
|
||||
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
|
||||
|
||||
$gcsUri = null;
|
||||
$gcsObjectName = null;
|
||||
if ($gcs->isAvailable()) {
|
||||
$gcsObjectName = $storagePath;
|
||||
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
|
||||
}
|
||||
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_id' => $earner->id,
|
||||
'document_type' => 'business_income_earner_profile',
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $storagePath,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => strtolower($file->getClientOriginalExtension()),
|
||||
'gcs_object_name' => $gcsObjectName,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$uploaded[] = $fileRecord;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => count($uploaded).'개 파일이 업로드되었습니다.',
|
||||
'data' => $uploaded,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $id, int $fileId, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$file = File::where('id', $fileId)
|
||||
->where('document_id', $id)
|
||||
->where('document_type', 'business_income_earner_profile')
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
if ($gcs->isAvailable() && $file->gcs_object_name) {
|
||||
$gcs->delete($file->gcs_object_name);
|
||||
}
|
||||
|
||||
if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
}
|
||||
|
||||
$file->deleted_by = auth()->id();
|
||||
$file->save();
|
||||
$file->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gcs)
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$file = File::where('id', $fileId)
|
||||
->where('document_id', $id)
|
||||
->where('document_type', 'business_income_earner_profile')
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if ($gcs->isAvailable() && $file->gcs_object_name) {
|
||||
$signedUrl = $gcs->getSignedUrl($file->gcs_object_name, 60);
|
||||
if ($signedUrl) {
|
||||
return redirect($signedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
$disk = Storage::disk('tenant');
|
||||
if ($file->file_path && $disk->exists($file->file_path)) {
|
||||
return $disk->download($file->file_path, $file->original_name);
|
||||
}
|
||||
|
||||
abort(404, '파일이 서버에 존재하지 않습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\BusinessIncomePaymentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Color;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class BusinessIncomePaymentController extends Controller
|
||||
{
|
||||
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
|
||||
|
||||
public function __construct(
|
||||
private BusinessIncomePaymentService $service
|
||||
) {}
|
||||
|
||||
private function checkPayrollAccess(): ?JsonResponse
|
||||
{
|
||||
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '급여관리는 관계자만 볼 수 있습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업소득 지급 목록 (HTMX → 스프레드시트 파셜)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
if ($denied = $this->checkPayrollAccess()) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$year = $request->integer('year') ?: now()->year;
|
||||
$month = $request->integer('month') ?: now()->month;
|
||||
|
||||
$earners = $this->service->getActiveEarners();
|
||||
$payments = $this->service->getPayments($year, $month);
|
||||
$stats = $this->service->getMonthlyStats($year, $month);
|
||||
|
||||
$earnersForJs = $earners->map(fn ($e) => [
|
||||
'user_id' => $e->user_id,
|
||||
'business_name' => $e->business_name ?? ($e->user?->name ?? ''),
|
||||
'user_name' => $e->user?->name ?? '',
|
||||
'business_reg_number' => $e->business_registration_number ?? '',
|
||||
])->values();
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(
|
||||
view('hr.business-income-payments.partials.stats', compact('stats')).
|
||||
'<!-- SPLIT -->'.
|
||||
view('hr.business-income-payments.partials.spreadsheet', compact('payments', 'earnersForJs', 'year', 'month'))
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $payments,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 저장
|
||||
*/
|
||||
public function bulkSave(Request $request): JsonResponse
|
||||
{
|
||||
if ($denied = $this->checkPayrollAccess()) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'year' => 'required|integer|min:2020|max:2100',
|
||||
'month' => 'required|integer|min:1|max:12',
|
||||
'items' => 'present|array',
|
||||
'items.*.payment_id' => 'nullable|integer',
|
||||
'items.*.user_id' => 'nullable|integer',
|
||||
'items.*.display_name' => 'required|string|max:100',
|
||||
'items.*.business_reg_number' => 'nullable|string|max:20',
|
||||
'items.*.gross_amount' => 'required|numeric|min:0',
|
||||
'items.*.service_content' => 'nullable|string|max:200',
|
||||
'items.*.payment_date' => 'nullable|date',
|
||||
'items.*.note' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$result = $this->service->bulkSave(
|
||||
$validated['year'],
|
||||
$validated['month'],
|
||||
$validated['items']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "저장 {$result['saved']}건, 삭제 {$result['deleted']}건, 건너뜀 {$result['skipped']}건",
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* XLSX 내보내기 (스타일링 포함)
|
||||
*/
|
||||
public function export(Request $request): StreamedResponse|JsonResponse
|
||||
{
|
||||
if ($denied = $this->checkPayrollAccess()) {
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$year = $request->integer('year') ?: now()->year;
|
||||
$month = $request->integer('month') ?: now()->month;
|
||||
|
||||
$payments = $this->service->getExportData($year, $month);
|
||||
$filename = "사업소득자임금대장_{$year}년{$month}월_".now()->format('Ymd').'.xlsx';
|
||||
|
||||
$spreadsheet = new Spreadsheet;
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('사업소득자 임금대장');
|
||||
|
||||
$lastCol = 'K';
|
||||
$headers = ['구분', '상호/성명', "사업자등록번호\n/주민등록번호", '용역내용', '지급총액', "소득세\n(3%)", "지방소득세\n(0.3%)", '공제합계', '실지급액', '지급일자', '비고'];
|
||||
|
||||
// ── Row 1: 제목 ──
|
||||
$sheet->mergeCells("A1:{$lastCol}1");
|
||||
$sheet->setCellValue('A1', "< {$year}년도 {$month}월 사업소득자 임금대장 >");
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 14],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(30);
|
||||
|
||||
// ── Row 2: 헤더 ──
|
||||
foreach ($headers as $colIdx => $header) {
|
||||
$cell = chr(65 + $colIdx).'2';
|
||||
$sheet->setCellValue($cell, $header);
|
||||
}
|
||||
$sheet->getStyle("A2:{$lastCol}2")->applyFromArray([
|
||||
'font' => ['bold' => true, 'size' => 10, 'color' => ['argb' => 'FFFFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF1F3864']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
|
||||
]);
|
||||
$sheet->getRowDimension(2)->setRowHeight(36);
|
||||
|
||||
// ── Row 3~: 데이터 ──
|
||||
$dataStartRow = 3;
|
||||
$row = $dataStartRow;
|
||||
$moneyColumns = ['E', 'F', 'G', 'H', 'I'];
|
||||
|
||||
foreach ($payments as $idx => $payment) {
|
||||
$name = $payment->display_name ?: ($payment->user?->name ?? '-');
|
||||
$regNumber = $payment->business_reg_number ?? '';
|
||||
|
||||
$sheet->setCellValue("A{$row}", $idx + 1);
|
||||
$sheet->setCellValue("B{$row}", $name);
|
||||
$sheet->setCellValueExplicit("C{$row}", $regNumber, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
|
||||
$sheet->setCellValue("D{$row}", $payment->service_content ?? '');
|
||||
$sheet->setCellValue("E{$row}", (int) $payment->gross_amount);
|
||||
$sheet->setCellValue("F{$row}", (int) $payment->income_tax);
|
||||
$sheet->setCellValue("G{$row}", (int) $payment->local_income_tax);
|
||||
$sheet->setCellValue("H{$row}", (int) $payment->total_deductions);
|
||||
$sheet->setCellValue("I{$row}", (int) $payment->net_amount);
|
||||
$sheet->setCellValue("J{$row}", $payment->payment_date?->format('Y-m-d') ?? '');
|
||||
$sheet->setCellValue("K{$row}", $payment->note ?? '');
|
||||
|
||||
// 지급일자 빨간색
|
||||
if ($payment->payment_date) {
|
||||
$sheet->getStyle("J{$row}")->getFont()->setColor(new Color('FF0000'));
|
||||
}
|
||||
|
||||
$row++;
|
||||
}
|
||||
|
||||
// 빈 행 채움 (최소 10행)
|
||||
$minEndRow = $dataStartRow + 9;
|
||||
while ($row <= $minEndRow) {
|
||||
$row++;
|
||||
}
|
||||
$lastDataRow = $row - 1;
|
||||
|
||||
// ── 데이터 영역 스타일 ──
|
||||
$dataRange = "A{$dataStartRow}:{$lastCol}{$lastDataRow}";
|
||||
$sheet->getStyle($dataRange)->applyFromArray([
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
'font' => ['size' => 10],
|
||||
]);
|
||||
|
||||
// 가운데 정렬: 구분, 용역내용, 지급일자, 비고
|
||||
foreach (['A', 'D', 'J', 'K'] as $col) {
|
||||
$sheet->getStyle("{$col}{$dataStartRow}:{$col}{$lastDataRow}")
|
||||
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
}
|
||||
|
||||
// 금액 서식: #,##0 + 오른쪽 정렬
|
||||
foreach ($moneyColumns as $col) {
|
||||
$range = "{$col}{$dataStartRow}:{$col}{$lastDataRow}";
|
||||
$sheet->getStyle($range)->getNumberFormat()->setFormatCode('#,##0');
|
||||
$sheet->getStyle($range)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
}
|
||||
|
||||
// ── 열 너비 ──
|
||||
$widths = ['A' => 8, 'B' => 14, 'C' => 22, 'D' => 14, 'E' => 14, 'F' => 14, 'G' => 14, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14];
|
||||
foreach ($widths as $col => $width) {
|
||||
$sheet->getColumnDimension($col)->setWidth($width);
|
||||
}
|
||||
|
||||
// ── 응답 반환 ──
|
||||
return response()->streamDownload(function () use ($spreadsheet) {
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'max-age=0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
597
app/Http/Controllers/Api/Admin/HR/EmployeeController.php
Normal file
597
app/Http/Controllers/Api/Admin/HR/EmployeeController.php
Normal file
@@ -0,0 +1,597 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use App\Services\HR\EmployeeService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class EmployeeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeService $employeeService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사원 목록 조회 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$employees = $this->employeeService->getEmployees(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.employees.partials.table', compact('employees')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $employees->items(),
|
||||
'meta' => [
|
||||
'current_page' => $employees->currentPage(),
|
||||
'last_page' => $employees->lastPage(),
|
||||
'per_page' => $employees->perPage(),
|
||||
'total' => $employees->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 사용자 검색 (사원 미등록, 테넌트 소속)
|
||||
*/
|
||||
public function searchUsers(Request $request): JsonResponse
|
||||
{
|
||||
$users = $this->employeeService->searchTenantUsers($request->get('q') ?? '');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->employeeService->getStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$rules = [
|
||||
'existing_user_id' => 'nullable|integer|exists:users,id',
|
||||
'name' => 'required|string|max:50',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:50',
|
||||
'job_title_key' => 'nullable|string|max:50',
|
||||
'work_location_key' => 'nullable|string|max:50',
|
||||
'employment_type_key' => 'nullable|string|max:50',
|
||||
'employee_status' => 'nullable|string|in:active,leave,resigned',
|
||||
'manager_user_id' => 'nullable|integer|exists:users,id',
|
||||
'display_name' => 'nullable|string|max:50',
|
||||
'hire_date' => 'nullable|date',
|
||||
'resign_date' => 'nullable|date',
|
||||
'address' => 'nullable|string|max:200',
|
||||
'emergency_contact' => 'nullable|string|max:100',
|
||||
'resident_number' => 'nullable|string|max:14',
|
||||
'bank_account.bank_code' => 'nullable|string|max:20',
|
||||
'bank_account.bank_name' => 'nullable|string|max:50',
|
||||
'bank_account.account_holder' => 'nullable|string|max:50',
|
||||
'bank_account.account_number' => 'nullable|string|max:30',
|
||||
'dependents' => 'nullable|array',
|
||||
'dependents.*.name' => 'required_with:dependents|string|max:50',
|
||||
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
|
||||
'dependents.*.resident_number' => 'nullable|string|max:14',
|
||||
'dependents.*.relationship' => 'nullable|string|max:20',
|
||||
'dependents.*.is_disabled' => 'nullable|boolean',
|
||||
'dependents.*.is_dependent' => 'nullable|boolean',
|
||||
];
|
||||
|
||||
// 신규 사용자일 때만 이메일 unique 검증
|
||||
if (! $request->filled('existing_user_id')) {
|
||||
$rules['email'] = 'nullable|email|max:100|unique:users,email';
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
try {
|
||||
$employee = $this->employeeService->createEmployee($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사원이 등록되었습니다.',
|
||||
'data' => $employee,
|
||||
], 201);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$employee = $this->employeeService->getEmployeeById($id);
|
||||
|
||||
if (! $employee) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $employee,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:50',
|
||||
'email' => 'nullable|email|max:100',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:50',
|
||||
'job_title_key' => 'nullable|string|max:50',
|
||||
'work_location_key' => 'nullable|string|max:50',
|
||||
'employment_type_key' => 'nullable|string|max:50',
|
||||
'employee_status' => 'nullable|string|in:active,leave,resigned',
|
||||
'manager_user_id' => 'nullable|integer|exists:users,id',
|
||||
'display_name' => 'nullable|string|max:50',
|
||||
'hire_date' => 'nullable|date',
|
||||
'resign_date' => 'nullable|date',
|
||||
'address' => 'nullable|string|max:200',
|
||||
'emergency_contact' => 'nullable|string|max:100',
|
||||
'resident_number' => 'nullable|string|max:14',
|
||||
'bank_account.bank_code' => 'nullable|string|max:20',
|
||||
'bank_account.bank_name' => 'nullable|string|max:50',
|
||||
'bank_account.account_holder' => 'nullable|string|max:50',
|
||||
'bank_account.account_number' => 'nullable|string|max:30',
|
||||
'dependents' => 'nullable|array',
|
||||
'dependents.*.name' => 'required_with:dependents|string|max:50',
|
||||
'dependents.*.nationality' => 'nullable|string|in:korean,foreigner',
|
||||
'dependents.*.resident_number' => 'nullable|string|max:14',
|
||||
'dependents.*.relationship' => 'nullable|string|max:20',
|
||||
'dependents.*.is_disabled' => 'nullable|boolean',
|
||||
'dependents.*.is_dependent' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 부양가족 섹션이 포함된 폼인데 dependents 데이터가 없으면 → 전체 삭제
|
||||
if ($request->has('dependents_submitted') && ! array_key_exists('dependents', $validated)) {
|
||||
$validated['dependents'] = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$employee = $this->employeeService->updateEmployee($id, $validated);
|
||||
|
||||
if (! $employee) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사원 정보가 수정되었습니다.',
|
||||
'data' => $employee,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 수정 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 삭제 (퇴직 처리)
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse|Response
|
||||
{
|
||||
try {
|
||||
$result = $this->employeeService->deleteEmployee($id);
|
||||
|
||||
if (! $result) {
|
||||
if ($request->header('HX-Request')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
|
||||
|
||||
return response(view('hr.employees.partials.table', compact('employees')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '퇴직 처리되었습니다.',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '퇴직 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 영구삭제 (퇴직자만, 슈퍼관리자 전용)
|
||||
*/
|
||||
public function forceDestroy(Request $request, int $id): JsonResponse|Response
|
||||
{
|
||||
if (! auth()->user()?->is_super_admin) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '슈퍼관리자만 영구삭제할 수 있습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->employeeService->forceDeleteEmployee($id);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json($result, 422);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
|
||||
|
||||
return response(view('hr.employees.partials.table', compact('employees')));
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '영구삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 제외/복원 토글
|
||||
*/
|
||||
public function toggleExclude(Request $request, int $id): JsonResponse|Response
|
||||
{
|
||||
$employee = $this->employeeService->toggleExclude($id);
|
||||
|
||||
if (! $employee) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '사원 정보를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$isExcluded = $employee->getJsonExtraValue('is_excluded', false);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$employees = $this->employeeService->getEmployees(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
return response(view('hr.employees.partials.table', compact('employees')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $isExcluded ? '사원이 목록에서 제외되었습니다.' : '사원이 목록에 복원되었습니다.',
|
||||
'is_excluded' => $isExcluded,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 첨부파일 업로드 (로컬 + GCS 듀얼 저장)
|
||||
*/
|
||||
public function uploadFile(Request $request, int $id, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$employee = $this->employeeService->getEmployeeById($id);
|
||||
if (! $employee) {
|
||||
return response()->json(['success' => false, 'message' => '사원 정보를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'files' => 'required|array|max:10',
|
||||
'files.*' => 'file|max:20480',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$uploaded = [];
|
||||
|
||||
foreach ($request->file('files') as $file) {
|
||||
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
|
||||
$storagePath = "employees/{$tenantId}/{$employee->id}/{$storedName}";
|
||||
|
||||
// 로컬 저장
|
||||
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
|
||||
|
||||
// GCS 업로드
|
||||
$gcsUri = null;
|
||||
$gcsObjectName = null;
|
||||
if ($gcs->isAvailable()) {
|
||||
$gcsObjectName = $storagePath;
|
||||
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
|
||||
}
|
||||
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_id' => $employee->id,
|
||||
'document_type' => 'employee_profile',
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $storagePath,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => strtolower($file->getClientOriginalExtension()),
|
||||
'gcs_object_name' => $gcsObjectName,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$uploaded[] = $fileRecord;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => count($uploaded).'개 파일이 업로드되었습니다.',
|
||||
'data' => $uploaded,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 첨부파일 삭제 (GCS + 로컬 모두 삭제)
|
||||
*/
|
||||
public function deleteFile(int $id, int $fileId, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$file = File::where('id', $fileId)
|
||||
->where('document_id', $id)
|
||||
->where('document_type', 'employee_profile')
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
// GCS 삭제
|
||||
if ($gcs->isAvailable() && $file->gcs_object_name) {
|
||||
$gcs->delete($file->gcs_object_name);
|
||||
}
|
||||
|
||||
// 로컬 삭제
|
||||
if ($file->file_path && Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
}
|
||||
|
||||
$file->deleted_by = auth()->id();
|
||||
$file->save();
|
||||
$file->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 첨부파일 다운로드 (GCS Signed URL 우선, 로컬 폴백)
|
||||
*/
|
||||
public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gcs)
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$file = File::where('id', $fileId)
|
||||
->where('document_id', $id)
|
||||
->where('document_type', 'employee_profile')
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// GCS Signed URL로 리다이렉트
|
||||
if ($gcs->isAvailable() && $file->gcs_object_name) {
|
||||
$signedUrl = $gcs->getSignedUrl($file->gcs_object_name, 60);
|
||||
if ($signedUrl) {
|
||||
return redirect($signedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 폴백
|
||||
$disk = Storage::disk('tenant');
|
||||
if ($file->file_path && $disk->exists($file->file_path)) {
|
||||
return $disk->download($file->file_path, $file->original_name);
|
||||
}
|
||||
|
||||
abort(404, '파일이 서버에 존재하지 않습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 목록 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function tenure(Request $request): JsonResponse|Response
|
||||
{
|
||||
$employees = $this->employeeService->getEmployeeTenure(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 50)
|
||||
);
|
||||
|
||||
// 근속기간 계산 추가
|
||||
$employees->getCollection()->each(function ($employee) {
|
||||
$hireDate = $employee->hire_date;
|
||||
if ($hireDate) {
|
||||
$hire = Carbon::parse($hireDate);
|
||||
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
|
||||
$tenureDays = $hire->diffInDays($end);
|
||||
$diff = $hire->diff($end);
|
||||
|
||||
$employee->tenure_days = $tenureDays;
|
||||
$employee->tenure_label = $this->formatTenureLabel($diff);
|
||||
} else {
|
||||
$employee->tenure_days = 0;
|
||||
$employee->tenure_label = '-';
|
||||
}
|
||||
});
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$stats = $this->employeeService->getTenureStats();
|
||||
|
||||
return response(view('hr.employee-tenure.partials.table', compact('employees', 'stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $employees->items(),
|
||||
'meta' => [
|
||||
'current_page' => $employees->currentPage(),
|
||||
'last_page' => $employees->lastPage(),
|
||||
'per_page' => $employees->perPage(),
|
||||
'total' => $employees->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 CSV 내보내기
|
||||
*/
|
||||
public function tenureExport(Request $request): StreamedResponse
|
||||
{
|
||||
$employees = $this->employeeService->getTenureExportData($request->all());
|
||||
|
||||
$filename = '입퇴사자현황_'.now()->format('Ymd').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($employees) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// BOM for Excel UTF-8
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
// 헤더
|
||||
fputcsv($handle, ['No.', '사원명', '부서', '직책', '상태', '입사일', '퇴사일', '근속기간', '근속일수']);
|
||||
|
||||
$index = 1;
|
||||
foreach ($employees as $employee) {
|
||||
$hireDate = $employee->hire_date;
|
||||
$tenureDays = 0;
|
||||
$tenureLabel = '-';
|
||||
|
||||
if ($hireDate) {
|
||||
$hire = Carbon::parse($hireDate);
|
||||
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
|
||||
$tenureDays = $hire->diffInDays($end);
|
||||
$tenureLabel = $this->formatTenureLabel($hire->diff($end));
|
||||
}
|
||||
|
||||
$statusMap = ['active' => '재직', 'leave' => '휴직', 'resigned' => '퇴직'];
|
||||
|
||||
fputcsv($handle, [
|
||||
$index++,
|
||||
$employee->display_name ?? $employee->user?->name ?? '-',
|
||||
$employee->department?->name ?? '-',
|
||||
$employee->position_label ?? '-',
|
||||
$statusMap[$employee->employee_status] ?? $employee->employee_status,
|
||||
$employee->hire_date ?? '-',
|
||||
$employee->resign_date ?? '-',
|
||||
$tenureLabel,
|
||||
$tenureDays,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatTenureLabel(\DateInterval $diff): string
|
||||
{
|
||||
$parts = [];
|
||||
if ($diff->y > 0) {
|
||||
$parts[] = "{$diff->y}년";
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
$parts[] = "{$diff->m}개월";
|
||||
}
|
||||
if ($diff->d > 0 || empty($parts)) {
|
||||
$parts[] = "{$diff->d}일";
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 추가
|
||||
*/
|
||||
public function storePosition(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|string|in:rank,title',
|
||||
'name' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$position = $this->employeeService->createPosition($validated['type'], $validated['name']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => ($validated['type'] === 'rank' ? '직급' : '직책').'이 추가되었습니다.',
|
||||
'data' => [
|
||||
'id' => $position->id,
|
||||
'key' => $position->key,
|
||||
'name' => $position->name,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
319
app/Http/Controllers/Api/Admin/HR/LeaveController.php
Normal file
319
app/Http/Controllers/Api/Admin/HR/LeaveController.php
Normal file
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class LeaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 휴가 목록 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$leaves = $this->leaveService->getLeaves(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.leaves.partials.table', compact('leaves')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $leaves->items(),
|
||||
'meta' => [
|
||||
'current_page' => $leaves->currentPage(),
|
||||
'last_page' => $leaves->lastPage(),
|
||||
'per_page' => $leaves->perPage(),
|
||||
'total' => $leaves->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental,business_trip,remote,field_work,early_leave,late_reason,absent_reason',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'reason' => 'nullable|string|max:1000',
|
||||
'approval_line_id' => 'nullable|integer|exists:approval_lines,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
$leave = $this->leaveService->storeLeave($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '휴가 신청이 등록되었습니다.',
|
||||
'data' => $leave,
|
||||
], 201);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '휴가 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$leave = $this->leaveService->approve($id);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인 처리되었습니다.',
|
||||
'data' => $leave,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'reject_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$leave = $this->leaveService->reject($id, $validated['reject_reason'] ?? null);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '반려 처리되었습니다.',
|
||||
'data' => $leave,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '반려 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소
|
||||
*/
|
||||
public function cancel(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$leave = $this->leaveService->cancel($id);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인된 휴가 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '취소 처리되었습니다. 연차가 복원되었습니다.',
|
||||
'data' => $leave,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '취소 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 삭제 (일반: pending만 / 슈퍼관리자: 모든 상태, force 영구삭제)
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$force = $request->boolean('force');
|
||||
$isSuperAdmin = auth()->user()->isSuperAdmin();
|
||||
|
||||
if ($force) {
|
||||
if (! $isSuperAdmin) {
|
||||
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
|
||||
}
|
||||
$leave = $this->leaveService->forceDeleteLeave($id);
|
||||
$message = '신청이 영구 삭제되었습니다.';
|
||||
} elseif ($isSuperAdmin) {
|
||||
$leave = $this->leaveService->deleteLeave($id);
|
||||
$message = '신청이 삭제되었습니다.';
|
||||
} else {
|
||||
$leave = $this->leaveService->deletePendingLeave($id);
|
||||
$message = '신청이 삭제되었습니다.';
|
||||
}
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '삭제 가능한 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여연차 목록 (HTMX → HTML)
|
||||
*/
|
||||
public function balance(Request $request): JsonResponse|Response
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$sort = $request->input('sort', 'hire_date');
|
||||
$direction = $request->input('direction', 'asc');
|
||||
$empStatus = $request->input('emp_status');
|
||||
$balances = $this->leaveService->getBalanceSummary($year, $sort, $direction, $empStatus);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.leaves.partials.balance', compact('balances', 'year', 'sort', 'direction', 'empStatus')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $balances,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 사원 잔여연차 (JSON)
|
||||
*/
|
||||
public function userBalance(Request $request, int $userId): JsonResponse
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$balance = $this->leaveService->getUserBalance($userId, $year);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $balance ? [
|
||||
'total_days' => $balance->total_days,
|
||||
'used_days' => $balance->used_days,
|
||||
'remaining_days' => $balance->remaining,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용현황 통계 (HTMX → HTML)
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse|Response
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$stats = $this->leaveService->getUsageStats($year);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.leaves.partials.stats', compact('stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 내보내기
|
||||
*/
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$filters = $request->all();
|
||||
$leaves = $this->leaveService->getExportData($filters);
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="leaves_'.now()->format('Ymd_His').'.csv"',
|
||||
];
|
||||
|
||||
return response()->stream(function () use ($leaves) {
|
||||
$output = fopen('php://output', 'w');
|
||||
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF)); // BOM
|
||||
|
||||
fputcsv($output, ['사원', '부서', '유형', '시작일', '종료일', '일수', '사유', '상태', '승인자', '신청일']);
|
||||
|
||||
foreach ($leaves as $leave) {
|
||||
$profile = $leave->user?->tenantProfiles?->first();
|
||||
fputcsv($output, [
|
||||
$leave->user?->name ?? '-',
|
||||
$profile?->department?->name ?? '-',
|
||||
$leave->type_label,
|
||||
$leave->start_date->format('Y-m-d'),
|
||||
$leave->end_date->format('Y-m-d'),
|
||||
$leave->days,
|
||||
$leave->reason ?? '-',
|
||||
$leave->status_label,
|
||||
$leave->approver?->name ?? '-',
|
||||
$leave->created_at->format('Y-m-d H:i'),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
1013
app/Http/Controllers/Api/Admin/HR/PayrollController.php
Normal file
1013
app/Http/Controllers/Api/Admin/HR/PayrollController.php
Normal file
File diff suppressed because it is too large
Load Diff
136
app/Http/Controllers/Api/Admin/Rd/AiQuotationController.php
Normal file
136
app/Http/Controllers/Api/Admin/Rd/AiQuotationController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Rd;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Rd\StoreAiQuotationRequest;
|
||||
use App\Services\Rd\AiQuotationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AiQuotationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiQuotationService $quotationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 목록 (HTMX partial 또는 JSON)
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
{
|
||||
$params = $request->only(['status', 'search', 'per_page']);
|
||||
$quotations = $this->quotationService->getList($params);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('rd.ai-quotation.partials.table', compact('quotations'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $quotations,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 생성 + AI 분석 실행
|
||||
*/
|
||||
public function store(StoreAiQuotationRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->quotationService->createAndAnalyze($request->validated());
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'AI 분석이 완료되었습니다.',
|
||||
'data' => $result['quotation'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 분석에 실패했습니다.',
|
||||
'error' => $result['error'],
|
||||
'data' => $result['quotation'] ?? null,
|
||||
], 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 견적을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $quotation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 재분석
|
||||
*/
|
||||
public function analyze(int $id): JsonResponse
|
||||
{
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 견적을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 제조 모드는 제조용 분석 실행
|
||||
if ($quotation->isManufacture()) {
|
||||
$result = $this->quotationService->runManufactureAnalysis($quotation);
|
||||
} else {
|
||||
$result = $this->quotationService->runAnalysis($quotation);
|
||||
}
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'AI 재분석이 완료되었습니다.',
|
||||
'data' => $result['quotation'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 재분석에 실패했습니다.',
|
||||
'error' => $result['error'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 편집 저장
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$result = $this->quotationService->updateQuotation($id, $request->all());
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '견적이 저장되었습니다.',
|
||||
'data' => $result['quotation'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '견적 저장에 실패했습니다.',
|
||||
'error' => $result['error'] ?? null,
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Roadmap;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Roadmap\StoreMilestoneRequest;
|
||||
use App\Http\Requests\Roadmap\UpdateMilestoneRequest;
|
||||
use App\Services\Roadmap\RoadmapMilestoneService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class RoadmapMilestoneController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RoadmapMilestoneService $milestoneService
|
||||
) {}
|
||||
|
||||
public function byPlan(int $planId): JsonResponse
|
||||
{
|
||||
$milestones = $this->milestoneService->getMilestonesByPlan($planId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $milestones,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreMilestoneRequest $request): JsonResponse
|
||||
{
|
||||
$milestone = $this->milestoneService->createMilestone($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '마일스톤이 추가되었습니다.',
|
||||
'data' => $milestone,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateMilestoneRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->milestoneService->updateMilestone($id, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '마일스톤이 수정되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$this->milestoneService->deleteMilestone($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '마일스톤이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggle(int $id): JsonResponse
|
||||
{
|
||||
$milestone = $this->milestoneService->toggleStatus($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $milestone->status === 'completed' ? '마일스톤이 완료 처리되었습니다.' : '마일스톤이 미완료로 변경되었습니다.',
|
||||
'data' => $milestone,
|
||||
]);
|
||||
}
|
||||
}
|
||||
130
app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php
Normal file
130
app/Http/Controllers/Api/Admin/Roadmap/RoadmapPlanController.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Roadmap;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Roadmap\StorePlanRequest;
|
||||
use App\Http\Requests\Roadmap\UpdatePlanRequest;
|
||||
use App\Services\Roadmap\RoadmapPlanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RoadmapPlanController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RoadmapPlanService $planService
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View|JsonResponse
|
||||
{
|
||||
$filters = $request->only([
|
||||
'search', 'status', 'category', 'priority', 'phase',
|
||||
'trashed', 'sort_by', 'sort_direction',
|
||||
]);
|
||||
$plans = $this->planService->getPlans($filters, 15);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('roadmap.plans.partials.table', compact('plans'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $plans,
|
||||
]);
|
||||
}
|
||||
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->planService->getStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function timeline(Request $request): JsonResponse
|
||||
{
|
||||
$phase = $request->input('phase');
|
||||
$timeline = $this->planService->getTimelineData($phase);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $timeline,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$plan = $this->planService->getPlanById($id, true);
|
||||
|
||||
if (! $plan) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '계획을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $plan,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePlanRequest $request): JsonResponse
|
||||
{
|
||||
$plan = $this->planService->createPlan($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계획이 생성되었습니다.',
|
||||
'data' => $plan,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdatePlanRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->planService->updatePlan($id, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계획이 수정되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$this->planService->deletePlan($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계획이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
$this->planService->restorePlan($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계획이 복원되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:planned,in_progress,completed,delayed,cancelled',
|
||||
]);
|
||||
|
||||
$plan = $this->planService->changeStatus($id, $validated['status']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상태가 변경되었습니다.',
|
||||
'data' => $plan,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreTenantRequest;
|
||||
use App\Http\Requests\UpdateTenantRequest;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use App\Services\TenantService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -104,6 +105,22 @@ public function update(UpdateTenantRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->tenantService->updateTenant($id, $request->validated());
|
||||
|
||||
// 인쇄용 회사 표시명 저장 (tenant_settings)
|
||||
if ($request->has('display_company_name')) {
|
||||
TenantSetting::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $id,
|
||||
'setting_group' => 'company',
|
||||
'setting_key' => 'display_company_name',
|
||||
],
|
||||
[
|
||||
'setting_value' => trim($request->input('display_company_name', '')),
|
||||
'description' => '문서에 인쇄되는 회사 표시명',
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// HTMX 요청 시 성공 메시지와 리다이렉트 헤더 반환
|
||||
if ($request->header('HX-Request')) {
|
||||
return response()->json([
|
||||
|
||||
@@ -23,18 +23,20 @@ public function search(Request $request): JsonResponse
|
||||
->where('user_tenants.tenant_id', $tenantId)
|
||||
->where('user_tenants.is_active', true);
|
||||
})
|
||||
->leftJoin('departments', function ($join) {
|
||||
$join->on('departments.id', '=', DB::raw('(
|
||||
SELECT du.department_id FROM department_user du
|
||||
WHERE du.user_id = users.id AND du.is_primary = 1
|
||||
LIMIT 1
|
||||
)'));
|
||||
->leftJoin('tenant_user_profiles as tp', function ($join) use ($tenantId) {
|
||||
$join->on('tp.user_id', '=', 'users.id')
|
||||
->where('tp.tenant_id', $tenantId);
|
||||
})
|
||||
->leftJoin('departments', 'departments.id', '=', 'tp.department_id')
|
||||
->whereNull('users.deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('tp.employee_status')
|
||||
->orWhere('tp.employee_status', '!=', 'resigned');
|
||||
})
|
||||
->when($query, function ($q) use ($query) {
|
||||
$q->where(function ($sub) use ($query) {
|
||||
$sub->where('users.name', 'like', "%{$query}%")
|
||||
->orWhere('users.email', 'like', "%{$query}%");
|
||||
->orWhere('departments.name', 'like', "%{$query}%");
|
||||
});
|
||||
})
|
||||
->orderBy('users.name')
|
||||
@@ -42,7 +44,8 @@ public function search(Request $request): JsonResponse
|
||||
->select([
|
||||
'users.id',
|
||||
'users.name',
|
||||
'departments.name as department_name',
|
||||
'departments.name as department',
|
||||
'tp.position_key as position',
|
||||
])
|
||||
->get();
|
||||
|
||||
@@ -51,4 +54,89 @@ public function search(Request $request): JsonResponse
|
||||
'data' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트 전체 인원 목록 (부서별 그룹핑, 결재선 에디터용)
|
||||
*/
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$users = DB::table('users')
|
||||
->join('user_tenants', function ($join) use ($tenantId) {
|
||||
$join->on('users.id', '=', 'user_tenants.user_id')
|
||||
->where('user_tenants.tenant_id', $tenantId)
|
||||
->where('user_tenants.is_active', true);
|
||||
})
|
||||
->leftJoin('tenant_user_profiles as tp', function ($join) use ($tenantId) {
|
||||
$join->on('tp.user_id', '=', 'users.id')
|
||||
->where('tp.tenant_id', $tenantId);
|
||||
})
|
||||
->leftJoin('departments', 'departments.id', '=', 'tp.department_id')
|
||||
->leftJoin('positions as pos_rank', function ($join) use ($tenantId) {
|
||||
$join->on('pos_rank.tenant_id', '=', DB::raw($tenantId))
|
||||
->where('pos_rank.type', 'rank')
|
||||
->whereRaw('pos_rank.`key` COLLATE utf8mb4_unicode_ci = tp.position_key COLLATE utf8mb4_unicode_ci');
|
||||
})
|
||||
->leftJoin('positions as pos_title', function ($join) use ($tenantId) {
|
||||
$join->on('pos_title.tenant_id', '=', DB::raw($tenantId))
|
||||
->where('pos_title.type', 'title')
|
||||
->whereRaw('pos_title.`key` COLLATE utf8mb4_unicode_ci = tp.job_title_key COLLATE utf8mb4_unicode_ci');
|
||||
})
|
||||
->whereNull('users.deleted_at')
|
||||
->whereNotNull('tp.department_id')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('tp.employee_status')
|
||||
->orWhere('tp.employee_status', '!=', 'resigned');
|
||||
})
|
||||
->when($tenantId == 1, function ($q) {
|
||||
$q->where('departments.name', '!=', '영업팀');
|
||||
})
|
||||
->orderBy('departments.name')
|
||||
->orderByRaw('COALESCE(pos_rank.sort_order, pos_title.sort_order, 9999) ASC')
|
||||
->orderBy('users.name')
|
||||
->select([
|
||||
'users.id',
|
||||
'users.name',
|
||||
'tp.department_id',
|
||||
'departments.name as department_name',
|
||||
DB::raw('COALESCE(pos_rank.name, tp.position_key, \'\') as position'),
|
||||
DB::raw('COALESCE(pos_title.name, tp.job_title_key, \'\') as job_title'),
|
||||
])
|
||||
->get();
|
||||
|
||||
$grouped = $users->groupBy(fn ($u) => $u->department_id ?? 'none');
|
||||
|
||||
$data = [];
|
||||
foreach ($grouped as $deptId => $deptUsers) {
|
||||
$first = $deptUsers->first();
|
||||
$data[] = [
|
||||
'department_id' => $deptId === 'none' ? null : (int) $deptId,
|
||||
'department_name' => $deptId === 'none' ? '미배정' : $first->department_name,
|
||||
'users' => $deptUsers->map(fn ($u) => [
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'position' => $u->position,
|
||||
'job_title' => $u->job_title,
|
||||
])->values()->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
// 미배정 그룹을 마지막으로
|
||||
usort($data, function ($a, $b) {
|
||||
if ($a['department_id'] === null) {
|
||||
return 1;
|
||||
}
|
||||
if ($b['department_id'] === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return strcmp($a['department_name'], $b['department_name']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
44
app/Http/Controllers/Api/MenuFavoriteController.php
Normal file
44
app/Http/Controllers/Api/MenuFavoriteController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\SidebarMenuService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MenuFavoriteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SidebarMenuService $sidebarMenuService
|
||||
) {}
|
||||
|
||||
public function toggle(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'menu_id' => 'required|integer|exists:menus,id',
|
||||
]);
|
||||
|
||||
$result = $this->sidebarMenuService->toggleFavorite(
|
||||
auth()->id(),
|
||||
$request->integer('menu_id')
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'menu_ids' => 'required|array',
|
||||
'menu_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$this->sidebarMenuService->reorderFavorites(
|
||||
auth()->id(),
|
||||
$request->input('menu_ids')
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
168
app/Http/Controllers/ApprovalController.php
Normal file
168
app/Http/Controllers/ApprovalController.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Finance\BankAccount;
|
||||
use App\Models\Finance\CorporateCard;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use App\Services\ApprovalService;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ApprovalController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApprovalService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 기안함
|
||||
*/
|
||||
public function drafts(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.drafts'));
|
||||
}
|
||||
|
||||
return view('approvals.drafts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안 작성
|
||||
*/
|
||||
public function create(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.create'));
|
||||
}
|
||||
|
||||
$forms = $this->service->getApprovalForms();
|
||||
$lines = $this->service->getApprovalLines();
|
||||
[$cards, $accounts] = $this->getCardAndAccountData();
|
||||
$employees = app(LeaveService::class)->getActiveEmployees();
|
||||
$tenantInfo = $this->getTenantInfo();
|
||||
|
||||
return view('approvals.create', compact('forms', 'lines', 'cards', 'accounts', 'employees', 'tenantInfo'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안 수정
|
||||
*/
|
||||
public function edit(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.edit', $id));
|
||||
}
|
||||
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
if (! $approval->isEditable() || $approval->drafter_id !== auth()->id()) {
|
||||
abort(403, '수정할 수 없습니다.');
|
||||
}
|
||||
|
||||
$forms = $this->service->getApprovalForms();
|
||||
$lines = $this->service->getApprovalLines();
|
||||
[$cards, $accounts] = $this->getCardAndAccountData();
|
||||
$employees = app(LeaveService::class)->getActiveEmployees();
|
||||
$tenantInfo = $this->getTenantInfo();
|
||||
|
||||
return view('approvals.edit', compact('approval', 'forms', 'lines', 'cards', 'accounts', 'employees', 'tenantInfo'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 상세
|
||||
*/
|
||||
public function show(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.show', $id));
|
||||
}
|
||||
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
return view('approvals.show', compact('approval'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 대기함
|
||||
*/
|
||||
public function pending(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.pending'));
|
||||
}
|
||||
|
||||
return view('approvals.pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조함
|
||||
*/
|
||||
public function references(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.references'));
|
||||
}
|
||||
|
||||
return view('approvals.references');
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함
|
||||
*/
|
||||
public function completed(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.completed'));
|
||||
}
|
||||
|
||||
return view('approvals.completed');
|
||||
}
|
||||
|
||||
private function getTenantInfo(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = Tenant::find($tenantId);
|
||||
|
||||
if (! $tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$displaySetting = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'company')
|
||||
->where('setting_key', 'display_company_name')
|
||||
->first();
|
||||
$displayName = $displaySetting?->setting_value ?? '';
|
||||
|
||||
return [
|
||||
'company_name' => ! empty($displayName) ? $displayName : ($tenant->company_name ?? ''),
|
||||
'business_num' => $tenant->business_num ?? '',
|
||||
'ceo_name' => $tenant->ceo_name ?? '',
|
||||
'address' => $tenant->address ?? '',
|
||||
'phone' => $tenant->phone ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function getCardAndAccountData(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$cards = CorporateCard::forTenant($tenantId)
|
||||
->active()
|
||||
->where('card_name', 'not like', '%하이패스%')
|
||||
->select('id', 'card_name', 'card_company', 'card_number', 'card_holder_name')
|
||||
->get();
|
||||
|
||||
$accounts = BankAccount::where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->ordered()
|
||||
->select('id', 'bank_name', 'account_number', 'account_holder', 'is_primary')
|
||||
->get();
|
||||
|
||||
return [$cards, $accounts];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,18 @@
|
||||
*/
|
||||
class BarobillController extends Controller
|
||||
{
|
||||
/**
|
||||
* 바로빌 개발문서 페이지
|
||||
*/
|
||||
public function devGuide(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('barobill.dev-guide.index'));
|
||||
}
|
||||
|
||||
return view('barobill.dev-guide');
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 설정 페이지
|
||||
* HTMX 요청 시 전체 페이지 리로드 (스크립트 로딩을 위해)
|
||||
|
||||
@@ -92,7 +92,7 @@ private function initSoapClient(): void
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'cache_wsdl' => WSDL_CACHE_BOTH,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 계좌 SOAP 클라이언트 생성 실패: '.$e->getMessage());
|
||||
@@ -316,6 +316,28 @@ public function latestBalances(Request $request): JsonResponse
|
||||
*/
|
||||
public function transactions(Request $request): JsonResponse
|
||||
{
|
||||
// SOAP API 호출이 여러 건 발생할 수 있으므로 타임아웃 연장
|
||||
if (function_exists('set_time_limit') && ! in_array('set_time_limit', explode(',', ini_get('disable_functions')))) {
|
||||
@set_time_limit(120);
|
||||
}
|
||||
|
||||
// SOAP 호출 시 소켓 타임아웃도 연장
|
||||
$originalSocketTimeout = ini_get('default_socket_timeout');
|
||||
@ini_set('default_socket_timeout', '120');
|
||||
|
||||
// PHP 프로세스 크래시 감지용 shutdown handler
|
||||
register_shutdown_function(function () {
|
||||
$error = error_get_last();
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR])) {
|
||||
Log::error('[Eaccount] PHP Fatal Error 감지', [
|
||||
'type' => $error['type'],
|
||||
'message' => $error['message'],
|
||||
'file' => $error['file'],
|
||||
'line' => $error['line'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$startDate = $request->input('startDate', date('Ymd'));
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
@@ -426,12 +448,21 @@ public function transactions(Request $request): JsonResponse
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('입출금내역 조회 오류: '.$e->getMessage());
|
||||
Log::error('입출금내역 조회 오류: '.$e->getMessage(), [
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: '.$e->getMessage(),
|
||||
'error' => '서버 오류: '.$e->getMessage().' ('.$e->getFile().':'.$e->getLine().')',
|
||||
]);
|
||||
} finally {
|
||||
// 소켓 타임아웃 복원
|
||||
if (isset($originalSocketTimeout)) {
|
||||
@ini_set('default_socket_timeout', $originalSocketTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,8 +593,10 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
|
||||
$withdraw = (int) floatval($log->Withdraw ?? 0);
|
||||
$balance = (int) floatval($log->Balance ?? 0);
|
||||
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
|
||||
$remark2 = $log->TransRemark2 ?? '';
|
||||
$cleanSummary = BankTransaction::cleanSummary($summary, $remark2);
|
||||
|
||||
$uniqueKey = implode('|', [$bankAccountNum, $transDT, $deposit, $withdraw, $balance, $summary]);
|
||||
$uniqueKey = implode('|', [$bankAccountNum, $transDT, $deposit, $withdraw, $balance, $cleanSummary]);
|
||||
$uniqueKeys[] = $uniqueKey;
|
||||
}
|
||||
|
||||
@@ -596,15 +629,18 @@ private function parseTransactionLogs($resultData, string $defaultBankName = '',
|
||||
$remark2 = $log->TransRemark2 ?? '';
|
||||
$transType = $log->TransType ?? '';
|
||||
|
||||
// ★ 적요에서 상대계좌예금주명(remark2) 중복 제거
|
||||
$cleanSummary = BankTransaction::cleanSummary($summary, $remark2);
|
||||
|
||||
$bankAccountNum = $log->BankAccountNum ?? '';
|
||||
|
||||
// 고유 키 생성하여 저장된 데이터와 매칭 (숫자는 정수로 변환하여 형식 통일)
|
||||
$uniqueKey = implode('|', [$bankAccountNum, $transDT, (int) $deposit, (int) $withdraw, (int) $balance, $summary]);
|
||||
$uniqueKey = implode('|', [$bankAccountNum, $transDT, (int) $deposit, (int) $withdraw, (int) $balance, $cleanSummary]);
|
||||
$savedItem = $savedData?->get($uniqueKey);
|
||||
$override = $overrides->get($uniqueKey);
|
||||
|
||||
// 원본 적요/내용 (remark2를 합산하지 않음 - 상대계좌예금주명 컬럼에서 별도 표시)
|
||||
$originalSummary = $summary;
|
||||
$originalSummary = $cleanSummary;
|
||||
$originalCast = $savedItem?->cast ?? $remark2;
|
||||
|
||||
// 오버라이드 적용 (수정된 값이 있으면 사용)
|
||||
@@ -712,8 +748,8 @@ private function splitDateRangeMonthly(string $startDate, string $endDate): arra
|
||||
'end' => $chunkEnd->format('Ymd'),
|
||||
];
|
||||
|
||||
// 다음 월 1일로 이동
|
||||
$cursor = $chunkEnd->copy()->addDay()->startOfMonth();
|
||||
// 다음 월 1일로 이동 (부분 월에서도 정상 작동)
|
||||
$cursor = $chunkStart->copy()->addMonth()->startOfMonth();
|
||||
}
|
||||
|
||||
return $chunks;
|
||||
@@ -850,6 +886,9 @@ private function cacheApiTransactions(int $tenantId, string $accNum, string $ban
|
||||
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
|
||||
$remark2 = $log->TransRemark2 ?? '';
|
||||
|
||||
// ★ 적요에서 상대계좌예금주명(remark2) 중복 제거
|
||||
$cleanSummary = BankTransaction::cleanSummary($summary, $remark2);
|
||||
|
||||
$rows[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'bank_account_num' => $log->BankAccountNum ?? $accNum,
|
||||
@@ -861,7 +900,7 @@ private function cacheApiTransactions(int $tenantId, string $accNum, string $ban
|
||||
'deposit' => $deposit,
|
||||
'withdraw' => $withdraw,
|
||||
'balance' => $balance,
|
||||
'summary' => $summary,
|
||||
'summary' => $cleanSummary,
|
||||
'cast' => $remark2,
|
||||
'memo' => $log->Memo ?? '',
|
||||
'trans_office' => $log->TransOffice ?? '',
|
||||
@@ -2032,11 +2071,15 @@ private function callSoap(string $method, array $params = []): array
|
||||
}
|
||||
|
||||
try {
|
||||
Log::info("바로빌 계좌 API 호출 - Method: {$method}, CorpNum: {$this->corpNum}");
|
||||
Log::info("바로빌 계좌 API 호출 시작 - Method: {$method}, CorpNum: {$this->corpNum}");
|
||||
$soapStartTime = microtime(true);
|
||||
|
||||
$result = $this->soapClient->$method($params);
|
||||
$resultProperty = $method.'Result';
|
||||
|
||||
$elapsed = round((microtime(true) - $soapStartTime) * 1000);
|
||||
Log::info("바로빌 계좌 API 완료 - Method: {$method}, 소요시간: {$elapsed}ms");
|
||||
|
||||
if (isset($result->$resultProperty)) {
|
||||
$resultData = $result->$resultProperty;
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ private function initSoapClient(): void
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'cache_wsdl' => WSDL_CACHE_BOTH,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 카드 SOAP 클라이언트 생성 실패: '.$e->getMessage());
|
||||
@@ -1006,6 +1006,12 @@ public function save(Request $request): JsonResponse
|
||||
'modified_supply_amount' => $data['modified_supply_amount'],
|
||||
'modified_tax' => $data['modified_tax'],
|
||||
]);
|
||||
|
||||
// 금액 변경 시 기존 분개 자료의 차변/대변 금액도 자동 갱신
|
||||
if ($amountChanged) {
|
||||
$this->syncJournalAmounts($tenantId, $uniqueKey, $newSupply, $newTax, $data['deduction_type']);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
} else {
|
||||
CardTransaction::create($data);
|
||||
@@ -1846,6 +1852,132 @@ public function hiddenTransactions(Request $request): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 금액 변경 시 기존 분개 자료의 차변/대변 금액 자동 갱신
|
||||
*/
|
||||
private function syncJournalAmounts(int $tenantId, string $uniqueKey, float $newSupply, float $newTax, ?string $deductionType): void
|
||||
{
|
||||
$journal = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $uniqueKey);
|
||||
if (! $journal) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = $journal->lines()->get();
|
||||
if ($lines->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isDeductible = ($deductionType ?? 'non_deductible') === 'deductible';
|
||||
$totalAmount = (int) round($newSupply + $newTax);
|
||||
$supplyInt = (int) round($newSupply);
|
||||
$taxInt = (int) round($newTax);
|
||||
|
||||
// 기존 라인의 계정과목/적요를 보존하면서 금액만 갱신
|
||||
$debitLines = $lines->where('dc_type', 'debit')->values();
|
||||
$creditLines = $lines->where('dc_type', 'credit')->values();
|
||||
|
||||
// 기존 라인 삭제 후 재생성 (금액 갱신)
|
||||
$journal->lines()->delete();
|
||||
$lineNo = 1;
|
||||
|
||||
if ($isDeductible && $debitLines->count() >= 2) {
|
||||
// 공제: 비용 계정(공급가액) + 부가세대급금(세액)
|
||||
$expenseLine = $debitLines->first(fn ($l) => $l->account_code !== '135') ?? $debitLines[0];
|
||||
$taxLine = $debitLines->first(fn ($l) => $l->account_code === '135') ?? $debitLines[1];
|
||||
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $journal->id,
|
||||
'line_no' => $lineNo++,
|
||||
'dc_type' => 'debit',
|
||||
'account_code' => $expenseLine->account_code,
|
||||
'account_name' => $expenseLine->account_name,
|
||||
'debit_amount' => $supplyInt,
|
||||
'credit_amount' => 0,
|
||||
'trading_partner_id' => $expenseLine->trading_partner_id,
|
||||
'trading_partner_name' => $expenseLine->trading_partner_name,
|
||||
'description' => $expenseLine->description,
|
||||
]);
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $journal->id,
|
||||
'line_no' => $lineNo++,
|
||||
'dc_type' => 'debit',
|
||||
'account_code' => $taxLine->account_code,
|
||||
'account_name' => $taxLine->account_name,
|
||||
'debit_amount' => $taxInt,
|
||||
'credit_amount' => 0,
|
||||
'trading_partner_id' => $taxLine->trading_partner_id,
|
||||
'trading_partner_name' => $taxLine->trading_partner_name,
|
||||
'description' => $taxLine->description,
|
||||
]);
|
||||
} elseif ($isDeductible) {
|
||||
// 공제인데 기존 라인이 1개뿐이면 기본 구조로 생성
|
||||
$expenseAccount = $debitLines->first();
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $journal->id,
|
||||
'line_no' => $lineNo++,
|
||||
'dc_type' => 'debit',
|
||||
'account_code' => $expenseAccount?->account_code ?? '826',
|
||||
'account_name' => $expenseAccount?->account_name ?? '잡비',
|
||||
'debit_amount' => $supplyInt,
|
||||
'credit_amount' => 0,
|
||||
'trading_partner_id' => $expenseAccount?->trading_partner_id,
|
||||
'trading_partner_name' => $expenseAccount?->trading_partner_name,
|
||||
'description' => $expenseAccount?->description,
|
||||
]);
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $journal->id,
|
||||
'line_no' => $lineNo++,
|
||||
'dc_type' => 'debit',
|
||||
'account_code' => '135',
|
||||
'account_name' => '부가세대급금',
|
||||
'debit_amount' => $taxInt,
|
||||
'credit_amount' => 0,
|
||||
]);
|
||||
} else {
|
||||
// 불공제: 비용 계정 = 공급가액 + 세액
|
||||
$expenseAccount = $debitLines->first();
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $journal->id,
|
||||
'line_no' => $lineNo++,
|
||||
'dc_type' => 'debit',
|
||||
'account_code' => $expenseAccount?->account_code ?? '826',
|
||||
'account_name' => $expenseAccount?->account_name ?? '잡비',
|
||||
'debit_amount' => $totalAmount,
|
||||
'credit_amount' => 0,
|
||||
'trading_partner_id' => $expenseAccount?->trading_partner_id,
|
||||
'trading_partner_name' => $expenseAccount?->trading_partner_name,
|
||||
'description' => $expenseAccount?->description,
|
||||
]);
|
||||
}
|
||||
|
||||
// 대변: 미지급비용 (기존 대변 라인의 계정 보존)
|
||||
$creditAccount = $creditLines->first();
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $journal->id,
|
||||
'line_no' => $lineNo,
|
||||
'dc_type' => 'credit',
|
||||
'account_code' => $creditAccount?->account_code ?? '205',
|
||||
'account_name' => $creditAccount?->account_name ?? '미지급비용',
|
||||
'debit_amount' => 0,
|
||||
'credit_amount' => $totalAmount,
|
||||
'trading_partner_id' => $creditAccount?->trading_partner_id,
|
||||
'trading_partner_name' => $creditAccount?->trading_partner_name,
|
||||
'description' => $creditAccount?->description,
|
||||
]);
|
||||
|
||||
// 분개 헤더 합계 갱신
|
||||
$journal->update([
|
||||
'total_debit' => $totalAmount,
|
||||
'total_credit' => $totalAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 카드거래 복식부기 분개 API (journal_entries 통합)
|
||||
// ================================================================
|
||||
|
||||
@@ -77,7 +77,7 @@ private function initSoapClient(): void
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'cache_wsdl' => WSDL_CACHE_BOTH,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 SOAP 클라이언트 생성 실패: '.$e->getMessage());
|
||||
|
||||
@@ -91,7 +91,7 @@ private function initSoapClient(): void
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30,
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE,
|
||||
'cache_wsdl' => WSDL_CACHE_BOTH,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('바로빌 홈택스 SOAP 클라이언트 생성 실패: '.$e->getMessage());
|
||||
|
||||
29
app/Http/Controllers/Barobill/SmsController.php
Normal file
29
app/Http/Controllers/Barobill/SmsController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Barobill;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SmsController extends Controller
|
||||
{
|
||||
/**
|
||||
* SMS 발송 테스트
|
||||
*/
|
||||
public function send(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('barobill.sms.send'));
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$currentTenant = Tenant::find($tenantId);
|
||||
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
|
||||
return view('barobill.sms.send.index', compact('currentTenant', 'barobillMember'));
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public function export(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
@@ -125,7 +125,7 @@ public function import(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
|
||||
26
app/Http/Controllers/ChinaTech/BigTechController.php
Normal file
26
app/Http/Controllers/ChinaTech/BigTechController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ChinaTech;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 중국의 기술도약 > 5대 신흥빅테크 컨트롤러
|
||||
*/
|
||||
class BigTechController extends Controller
|
||||
{
|
||||
/**
|
||||
* 5대 신흥빅테크 메인 페이지 (탭 UI)
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('china-tech.big-tech.index'));
|
||||
}
|
||||
|
||||
return view('china-tech.big-tech.index');
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/ChinaTech/ChinaAiController.php
Normal file
26
app/Http/Controllers/ChinaTech/ChinaAiController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ChinaTech;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 중국의 기술도약 > 중국 AI기술 컨트롤러
|
||||
*/
|
||||
class ChinaAiController extends Controller
|
||||
{
|
||||
/**
|
||||
* 중국 AI기술 발전과정 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('china-tech.ai.index'));
|
||||
}
|
||||
|
||||
return view('china-tech.ai.index');
|
||||
}
|
||||
}
|
||||
20
app/Http/Controllers/ClaudeCode/CoworkController.php
Normal file
20
app/Http/Controllers/ClaudeCode/CoworkController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ClaudeCode;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CoworkController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('claude-code.cowork.index'));
|
||||
}
|
||||
|
||||
return view('claude-code.cowork.index');
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/ClaudeCode/HistoryController.php
Normal file
23
app/Http/Controllers/ClaudeCode/HistoryController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ClaudeCode;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Claude Code > 발전과정 컨트롤러
|
||||
*/
|
||||
class HistoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('claude-code.history.index'));
|
||||
}
|
||||
|
||||
return view('claude-code.history.index');
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/ClaudeCode/NewsController.php
Normal file
42
app/Http/Controllers/ClaudeCode/NewsController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ClaudeCode;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ClaudeCodeNewsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ClaudeCodeNewsService $newsService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Claude Code 뉴스 (GitHub Releases) 목록
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('claude-code.news.index'));
|
||||
}
|
||||
|
||||
$releases = $this->newsService->getReleases();
|
||||
|
||||
return view('claude-code.news.index', compact('releases'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 새로고침
|
||||
*/
|
||||
public function refreshCache(): RedirectResponse
|
||||
{
|
||||
$this->newsService->clearCache();
|
||||
|
||||
return redirect()->route('claude-code.news.index')
|
||||
->with('success', '캐시가 새로고침되었습니다.');
|
||||
}
|
||||
}
|
||||
30
app/Http/Controllers/ClaudeCode/PricingController.php
Normal file
30
app/Http/Controllers/ClaudeCode/PricingController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ClaudeCode;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class PricingController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('claude-code.pricing.index'));
|
||||
}
|
||||
|
||||
return view('claude-code.pricing.index');
|
||||
}
|
||||
|
||||
public function download(): BinaryFileResponse
|
||||
{
|
||||
$path = public_path('downloads/claude-code-pricing.pptx');
|
||||
|
||||
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
|
||||
|
||||
return response()->download($path, 'Claude_Code_요금정책_비교분석.pptx');
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/ClaudeCode/UsagePlanController.php
Normal file
29
app/Http/Controllers/ClaudeCode/UsagePlanController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ClaudeCode;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class UsagePlanController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('claude-code.usage-plan.index'));
|
||||
}
|
||||
|
||||
return view('claude-code.usage-plan.index');
|
||||
}
|
||||
|
||||
public function download(): BinaryFileResponse
|
||||
{
|
||||
$path = public_path('downloads/sam-usage-plan.pptx');
|
||||
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
|
||||
|
||||
return response()->download($path, 'SAM_활용방안.pptx');
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public function export(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
@@ -124,7 +124,7 @@ public function import(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
*/
|
||||
class CreditController extends Controller
|
||||
{
|
||||
/**
|
||||
* 신용평가 개발문서 페이지
|
||||
*/
|
||||
public function devGuide(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('credit.dev-guide.index'));
|
||||
}
|
||||
|
||||
return view('credit.dev-guide');
|
||||
}
|
||||
|
||||
/**
|
||||
* 신용평가 조회 이력 목록
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Models\Documents\DocumentData;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Items\Item;
|
||||
use App\Services\BlockRendererService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -80,6 +81,7 @@ public function create(Request $request): View|Response
|
||||
'templates' => $templates,
|
||||
'isCreate' => true,
|
||||
'linkedItemSpecs' => $template ? $this->getLinkedItemSpecs($template) : [],
|
||||
'blockHtml' => $template ? $this->renderBlockHtml($template, null) : '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,6 +125,7 @@ public function edit(int $id): View|Response
|
||||
'templates' => $templates,
|
||||
'isCreate' => false,
|
||||
'linkedItemSpecs' => $this->getLinkedItemSpecs($document->template),
|
||||
'blockHtml' => $this->renderBlockHtml($document->template, $document),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -165,6 +168,7 @@ public function print(int $id): View
|
||||
return view('documents.print', [
|
||||
'document' => $document,
|
||||
'workOrderItems' => $workOrderItems,
|
||||
'blockHtml' => $this->renderBlockHtml($document->template, $document, 'print'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -267,6 +271,22 @@ public function show(int $id): View
|
||||
// 기본정보 bf_ 자동 backfill
|
||||
$this->resolveAndBackfillBasicFields($document);
|
||||
|
||||
// 절곡 작업일지용: bending_info 추출
|
||||
$bendingInfo = null;
|
||||
if ($workOrder) {
|
||||
$woOptions = json_decode($workOrder->options ?? '{}', true);
|
||||
$bendingInfo = $woOptions['bending_info'] ?? null;
|
||||
}
|
||||
|
||||
// 절곡 중간검사용: inspection_data 스냅샷 추출 (work_order_items.options.inspection_data)
|
||||
$inspectionData = null;
|
||||
foreach ($workOrderItems as $item) {
|
||||
if (! empty($item->options['inspection_data'])) {
|
||||
$inspectionData = $item->options['inspection_data'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return view('documents.show', [
|
||||
'document' => $document,
|
||||
'workOrderItems' => $workOrderItems,
|
||||
@@ -274,6 +294,9 @@ public function show(int $id): View
|
||||
'salesOrder' => $salesOrder,
|
||||
'materialInputLots' => $materialInputLots,
|
||||
'itemLotMap' => $itemLotMap ?? collect(),
|
||||
'blockHtml' => $this->renderBlockHtml($document->template, $document, 'view'),
|
||||
'bendingInfo' => $bendingInfo,
|
||||
'inspectionData' => $inspectionData,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -290,7 +313,7 @@ private function resolveAndBackfillBasicFields(Document $document): void
|
||||
}
|
||||
|
||||
// bf_ 레코드가 하나라도 있으면 이미 저장된 것 → skip
|
||||
$existingBfCount = $document->data
|
||||
$existingBfCount = ($document->data ?? collect())
|
||||
->filter(fn ($d) => str_starts_with($d->field_key, 'bf_'))
|
||||
->count();
|
||||
if ($existingBfCount > 0) {
|
||||
@@ -416,6 +439,30 @@ private function buildInspectionResolveMap(object $workOrder, $workOrderItems, ?
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 빌더 서식의 HTML 렌더링
|
||||
*/
|
||||
private function renderBlockHtml(DocumentTemplate $template, ?Document $document, string $mode = 'edit'): string
|
||||
{
|
||||
if (! $template->isBlockBuilder() || empty($template->schema)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$schema = $template->schema;
|
||||
|
||||
// document_data에서 field_key => field_value 맵 생성
|
||||
$data = [];
|
||||
if ($document && $document->data) {
|
||||
foreach ($document->data as $d) {
|
||||
$data[$d->field_key] = $d->field_value;
|
||||
}
|
||||
}
|
||||
|
||||
$renderer = new BlockRendererService;
|
||||
|
||||
return $renderer->render($schema, $mode, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿에 연결된 품목들의 규격 정보 (thickness, width, length) 조회
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,11 @@ public function edit(int $id): View
|
||||
'links.linkValues',
|
||||
])->findOrFail($id);
|
||||
|
||||
// 블록 빌더 타입이면 block-editor로 리다이렉트
|
||||
if ($template->isBlockBuilder()) {
|
||||
return $this->blockEdit($id);
|
||||
}
|
||||
|
||||
// JavaScript용 데이터 변환
|
||||
$templateData = $this->prepareTemplateData($template);
|
||||
|
||||
@@ -72,6 +77,56 @@ public function edit(int $id): View
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 빌더 - 새 양식 생성
|
||||
*/
|
||||
public function blockCreate(Request $request): View
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('document-templates.block-create'));
|
||||
}
|
||||
|
||||
return view('document-templates.block-editor', [
|
||||
'template' => null,
|
||||
'templateId' => 0,
|
||||
'isCreate' => true,
|
||||
'categories' => $this->getCategories(),
|
||||
'initialSchema' => [
|
||||
'_name' => '새 문서양식',
|
||||
'_category' => '',
|
||||
'version' => '1.0',
|
||||
'page' => ['size' => 'A4', 'orientation' => 'portrait', 'margin' => [20, 15, 20, 15]],
|
||||
'blocks' => [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 빌더 - 양식 수정
|
||||
*/
|
||||
public function blockEdit(int $id): View
|
||||
{
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
|
||||
$schema = $template->schema ?? [
|
||||
'version' => '1.0',
|
||||
'page' => $template->page_config ?? ['size' => 'A4', 'orientation' => 'portrait', 'margin' => [20, 15, 20, 15]],
|
||||
'blocks' => [],
|
||||
];
|
||||
|
||||
// 뷰에서 사용할 메타 정보 주입
|
||||
$schema['_name'] = $template->name;
|
||||
$schema['_category'] = $template->category ?? '';
|
||||
|
||||
return view('document-templates.block-editor', [
|
||||
'template' => $template,
|
||||
'templateId' => $template->id,
|
||||
'isCreate' => false,
|
||||
'categories' => $this->getCategories(),
|
||||
'initialSchema' => $schema,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 선택된 테넌트 조회
|
||||
*/
|
||||
|
||||
@@ -467,29 +467,36 @@ public function store(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
// 법인도장 자동 적용: GCS에서 다운로드 → 로컬 저장 → signer에 설정
|
||||
// 법인도장 자동 적용: 설정에서 도장 이미지를 읽어 signer에 복사
|
||||
$stampSetting = TenantSetting::where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'esign')
|
||||
->where('setting_key', 'company_stamp')
|
||||
->first();
|
||||
|
||||
if ($stampSetting && ! empty($stampSetting->setting_value['gcs_object'])) {
|
||||
if ($stampSetting) {
|
||||
$creatorSigner = EsignSigner::withoutGlobalScopes()
|
||||
->where('contract_id', $contract->id)
|
||||
->where('role', 'creator')
|
||||
->first();
|
||||
|
||||
if ($creatorSigner) {
|
||||
$gcs = app(GoogleCloudStorageService::class);
|
||||
$signedUrl = $gcs->getSignedUrl($stampSetting->setting_value['gcs_object'], 5);
|
||||
$val = $stampSetting->setting_value;
|
||||
$imageData = null;
|
||||
|
||||
if ($signedUrl) {
|
||||
$imageData = @file_get_contents($signedUrl);
|
||||
if ($imageData) {
|
||||
$localPath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
|
||||
Storage::disk('local')->put($localPath, $imageData);
|
||||
$creatorSigner->update(['signature_image_path' => $localPath]);
|
||||
if (! empty($val['gcs_object'])) {
|
||||
$gcs = app(GoogleCloudStorageService::class);
|
||||
$signedUrl = $gcs->getSignedUrl($val['gcs_object'], 5);
|
||||
if ($signedUrl) {
|
||||
$imageData = @file_get_contents($signedUrl);
|
||||
}
|
||||
} elseif (! empty($val['local_path']) && Storage::disk('local')->exists($val['local_path'])) {
|
||||
$imageData = Storage::disk('local')->get($val['local_path']);
|
||||
}
|
||||
|
||||
if ($imageData) {
|
||||
$localPath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
|
||||
Storage::disk('local')->put($localPath, $imageData);
|
||||
$creatorSigner->update(['signature_image_path' => $localPath]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,11 +826,14 @@ public function send(Request $request, int $id): JsonResponse
|
||||
|
||||
$sendMethod = $request->input('send_method', 'email');
|
||||
$smsFallback = $request->boolean('sms_fallback', true);
|
||||
$templateName = $request->input('template_name');
|
||||
$completionTemplateName = $request->input('completion_template_name');
|
||||
|
||||
$contract->update([
|
||||
'status' => 'pending',
|
||||
'send_method' => $sendMethod,
|
||||
'sms_fallback' => $smsFallback,
|
||||
'completion_template_name' => $completionTemplateName,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
@@ -838,7 +848,7 @@ public function send(Request $request, int $id): JsonResponse
|
||||
$notificationResults = [];
|
||||
foreach ($targetSigners as $signer) {
|
||||
$signer->update(['status' => 'notified']);
|
||||
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback);
|
||||
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback, templateName: $templateName);
|
||||
$notificationResults[] = [
|
||||
'signer_id' => $signer->id,
|
||||
'signer_name' => $signer->name,
|
||||
@@ -966,24 +976,28 @@ private function dispatchNotification(
|
||||
string $sendMethod,
|
||||
bool $smsFallback,
|
||||
bool $isReminder = false,
|
||||
?string $templateName = null,
|
||||
): array {
|
||||
$results = [];
|
||||
$alimtalkFailed = false;
|
||||
$isCounterpart = $signer->role === EsignSigner::ROLE_COUNTERPART;
|
||||
|
||||
// 알림톡 발송
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) {
|
||||
$alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder);
|
||||
// 알림톡 발송: 상대방(counterpart)에게만 카카오톡 발송, 본사(creator)는 이메일
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $isCounterpart && $signer->phone) {
|
||||
$alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder, $templateName);
|
||||
$results[] = $alimtalkResult;
|
||||
$alimtalkFailed = ! ($alimtalkResult['success'] ?? false);
|
||||
}
|
||||
|
||||
// 이메일 발송 조건:
|
||||
// 1) email/both 선택 시
|
||||
// 2) alimtalk인데 번호 없으면 폴백
|
||||
// 3) alimtalk 발송 실패 시 이메일 자동 폴백
|
||||
// 2) 본사(creator)는 항상 이메일
|
||||
// 3) 상대방이지만 전화번호 없으면 이메일 폴백
|
||||
// 4) 알림톡 발송 실패 시 이메일 자동 폴백
|
||||
$shouldSendEmail = in_array($sendMethod, ['email', 'both'])
|
||||
|| ($sendMethod === 'alimtalk' && ! $signer->phone)
|
||||
|| ($sendMethod === 'alimtalk' && $alimtalkFailed);
|
||||
|| ! $isCounterpart
|
||||
|| ($sendMethod === 'alimtalk' && $isCounterpart && ! $signer->phone)
|
||||
|| ($sendMethod === 'alimtalk' && $isCounterpart && $alimtalkFailed);
|
||||
|
||||
if ($shouldSendEmail && $signer->email) {
|
||||
try {
|
||||
@@ -1010,6 +1024,7 @@ private function sendAlimtalk(
|
||||
EsignSigner $signer,
|
||||
bool $smsFallback = true,
|
||||
bool $isReminder = false,
|
||||
?string $templateName = null,
|
||||
): array {
|
||||
try {
|
||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||
@@ -1033,7 +1048,9 @@ private function sendAlimtalk(
|
||||
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
|
||||
$expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
|
||||
|
||||
$templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청';
|
||||
if (! $templateName) {
|
||||
$templateName = $this->resolveTemplateName($isReminder ? '전자계약_리마인드' : '전자계약_서명요청');
|
||||
}
|
||||
|
||||
// 등록된 템플릿 본문 + 버튼 정보 조회 (정확한 포맷 유지)
|
||||
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName);
|
||||
@@ -1056,12 +1073,39 @@ private function sendAlimtalk(
|
||||
: " 안녕하세요, {$signer->name}님. \n 전자계약 서명 요청이 도착했습니다.\n\n ■ 계약명: {$contract->title}\n ■ 서명 기한: {$expires}\n\n 아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.";
|
||||
}
|
||||
|
||||
// 등록된 버튼 URL을 그대로 사용 (동적 URL 사용 시 템플릿 불일치 오류)
|
||||
// 버튼: 템플릿에서 가져온 URL의 #{토큰}만 치환 (도메인은 템플릿 등록값 유지 — 카카오 검증)
|
||||
$buttons = ! empty($templateButtons) ? $templateButtons : [
|
||||
['Name' => '계약서 확인하기', 'ButtonType' => 'WL',
|
||||
'Url1' => 'https://mng.codebridge-x.com', 'Url2' => 'https://mng.codebridge-x.com'],
|
||||
'Url1' => $signUrl, 'Url2' => $signUrl],
|
||||
];
|
||||
|
||||
foreach ($buttons as &$btn) {
|
||||
foreach (['Url1', 'Url2'] as $urlKey) {
|
||||
if (! empty($btn[$urlKey])) {
|
||||
$btn[$urlKey] = str_replace(
|
||||
['#{토큰}', '#{%ED%86%A0%ED%81%B0}'],
|
||||
[$signer->access_token, $signer->access_token],
|
||||
urldecode($btn[$urlKey])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($btn);
|
||||
|
||||
// 버튼 URL 도메인을 현재 환경의 도메인으로 치환
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
foreach ($buttons as &$btn) {
|
||||
foreach (['Url1', 'Url2'] as $urlKey) {
|
||||
if (! empty($btn[$urlKey])) {
|
||||
$parsed = parse_url($btn[$urlKey]);
|
||||
if (isset($parsed['host']) && $parsed['host'] !== $appHost) {
|
||||
$btn[$urlKey] = str_replace($parsed['host'], $appHost, $btn[$urlKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($btn);
|
||||
|
||||
$receiverNum = preg_replace('/[^0-9]/', '', $signer->phone);
|
||||
|
||||
\Log::info('E-Sign 알림톡 발송 시도', [
|
||||
@@ -1180,6 +1224,14 @@ private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo)
|
||||
*
|
||||
* @return array{content: string|null, buttons: array}
|
||||
*/
|
||||
/**
|
||||
* 환경별 알림톡 템플릿명 반환 (운영: 원본, 개발: _DEV 접미사)
|
||||
*/
|
||||
private function resolveTemplateName(string $baseName): string
|
||||
{
|
||||
return $baseName.(app()->environment('production') ? '' : '_DEV');
|
||||
}
|
||||
|
||||
private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array
|
||||
{
|
||||
$empty = ['content' => null, 'buttons' => []];
|
||||
@@ -1228,6 +1280,99 @@ private function getTemplateData(BarobillService $barobill, string $bizNo, strin
|
||||
return $empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 등록 알림톡 템플릿 목록 조회 (승인 완료된 것만)
|
||||
*/
|
||||
public function getAlimtalkTemplates(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$member = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||
|
||||
if (! $member || ! $member->biz_no) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '바로빌 회원 정보 또는 사업자번호가 설정되지 않았습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
$barobill = app(BarobillService::class);
|
||||
$barobill->setServerMode($member->server_mode ?? 'production');
|
||||
|
||||
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
|
||||
if (! $channelId) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '등록된 카카오톡 채널이 없습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $barobill->getKakaotalkTemplates($member->biz_no, $channelId);
|
||||
if (! ($result['success'] ?? false) || empty($result['data'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '템플릿 목록을 조회할 수 없습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$items = [];
|
||||
if (is_object($data) && isset($data->KakaotalkTemplate)) {
|
||||
$items = is_array($data->KakaotalkTemplate)
|
||||
? $data->KakaotalkTemplate
|
||||
: [$data->KakaotalkTemplate];
|
||||
}
|
||||
|
||||
// 승인(Status=3)된 템플릿만 필터링
|
||||
$templates = [];
|
||||
foreach ($items as $tpl) {
|
||||
$status = $tpl->Status ?? null;
|
||||
if ($status != 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$buttons = [];
|
||||
$btnData = $tpl->Buttons ?? null;
|
||||
if ($btnData) {
|
||||
$btnList = $btnData->KakaotalkButton ?? null;
|
||||
if ($btnList) {
|
||||
$btnList = is_array($btnList) ? $btnList : [$btnList];
|
||||
foreach ($btnList as $btn) {
|
||||
$buttons[] = [
|
||||
'Name' => $btn->Name ?? '',
|
||||
'ButtonType' => $btn->ButtonType ?? 'WL',
|
||||
'Url1' => $btn->Url1 ?? '',
|
||||
'Url2' => $btn->Url2 ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$templates[] = [
|
||||
'name' => $tpl->TemplateName ?? '',
|
||||
'content' => $tpl->TemplateContent ?? '',
|
||||
'status' => $status,
|
||||
'buttons' => $buttons,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'channel_id' => $channelId,
|
||||
'templates' => $templates,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('알림톡 템플릿 목록 조회 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '템플릿 목록 조회 중 오류: '.$e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 다운로드
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use App\Models\ESign\EsignAuditLog;
|
||||
use App\Models\ESign\EsignContract;
|
||||
use App\Models\ESign\EsignSigner;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use App\Services\Barobill\BarobillService;
|
||||
use App\Services\ESign\PdfSignatureService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -127,11 +128,13 @@ public function getContract(string $token): JsonResponse
|
||||
'id' => $signer->id,
|
||||
'name' => $signer->name,
|
||||
'email' => $signer->email,
|
||||
'phone' => $signer->phone,
|
||||
'role' => $signer->role,
|
||||
'status' => $signer->status,
|
||||
'has_stamp' => (bool) $signer->signature_image_path,
|
||||
'has_stamp' => (bool) $signer->signature_image_path || ($signer->role === 'creator' && $this->hasCompanyStamp($contract->tenant_id)),
|
||||
'signed_at' => $signer->signed_at,
|
||||
],
|
||||
'send_method' => $contract->tenant_id == 1 ? 'email' : ($contract->send_method ?? 'email'),
|
||||
'is_signable' => $isSignable,
|
||||
'status_message' => $statusMessage,
|
||||
],
|
||||
@@ -161,10 +164,28 @@ public function sendOtp(string $token): JsonResponse
|
||||
'otp_attempts' => 0,
|
||||
]);
|
||||
|
||||
// OTP 이메일 발송
|
||||
\Illuminate\Support\Facades\Mail::to($signer->email)->send(
|
||||
new \App\Mail\EsignOtpMail($signer->name, $otpCode)
|
||||
);
|
||||
$sendMethod = $contract->send_method ?? 'email';
|
||||
$channel = 'email';
|
||||
|
||||
// 알림톡/both 방식이고 전화번호가 있으면 SMS로 발송 (상대방만 SMS, 본사(creator)는 이메일 유지)
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone && $signer->role === EsignSigner::ROLE_COUNTERPART) {
|
||||
$smsSent = $this->sendOtpViaSms($contract, $signer, $otpCode);
|
||||
if ($smsSent) {
|
||||
$channel = 'sms';
|
||||
} else {
|
||||
// SMS 실패 시 이메일 폴백
|
||||
if ($signer->email) {
|
||||
Mail::to($signer->email)->send(new \App\Mail\EsignOtpMail($signer->name, $otpCode));
|
||||
$channel = 'email';
|
||||
} else {
|
||||
return response()->json(['success' => false, 'message' => 'OTP 발송에 실패했습니다.'], 500);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 이메일 방식 또는 전화번호 없음
|
||||
Mail::to($signer->email)->send(new \App\Mail\EsignOtpMail($signer->name, $otpCode));
|
||||
$channel = 'email';
|
||||
}
|
||||
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
@@ -173,11 +194,19 @@ public function sendOtp(string $token): JsonResponse
|
||||
'action' => 'otp_sent',
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'metadata' => ['email' => $signer->email],
|
||||
'metadata' => [
|
||||
'channel' => $channel,
|
||||
'email' => $channel === 'email' ? $signer->email : null,
|
||||
'phone' => $channel === 'sms' ? $signer->phone : null,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'message' => '인증 코드가 발송되었습니다.']);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '인증 코드가 발송되었습니다.',
|
||||
'channel' => $channel,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +296,15 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
Storage::disk('local')->put($imagePath, $imageData);
|
||||
$signer->update(['signature_image_path' => $imagePath]);
|
||||
} elseif (! $signer->signature_image_path) {
|
||||
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
|
||||
// 기존 계약: tenant_settings에서 법인도장 가져오기
|
||||
if ($signer->role === 'creator') {
|
||||
$stampPath = $this->applyCompanyStamp($signer, $contract->tenant_id);
|
||||
if (! $stampPath) {
|
||||
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
|
||||
}
|
||||
} else {
|
||||
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 직접 서명: signature_image 필수
|
||||
@@ -324,6 +361,7 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
Log::error('PDF 서명 합성 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -344,9 +382,36 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
foreach ($allSigners as $completedSigner) {
|
||||
$completionResults = [];
|
||||
try {
|
||||
// 이메일 발송
|
||||
if (in_array($sendMethod, ['email', 'both']) || ! $completedSigner->phone) {
|
||||
if ($completedSigner->email) {
|
||||
// 본사(creator): 이메일로 완료 알림
|
||||
// 상대방(counterpart): 알림톡(카카오톡) + PDF 다운로드 링크
|
||||
$isCounterpart = $completedSigner->role === EsignSigner::ROLE_COUNTERPART;
|
||||
|
||||
// 이메일 발송 조건:
|
||||
// 1) email/both 선택 시
|
||||
// 2) 본사(creator)는 항상 이메일
|
||||
// 3) 상대방이지만 전화번호 없으면 이메일 폴백
|
||||
$shouldSendEmail = in_array($sendMethod, ['email', 'both'])
|
||||
|| ! $isCounterpart
|
||||
|| ($isCounterpart && ! $completedSigner->phone);
|
||||
|
||||
if ($shouldSendEmail && $completedSigner->email) {
|
||||
try {
|
||||
Mail::to($completedSigner->email)->send(
|
||||
new EsignCompletedMail($contract, $completedSigner, $allSigners)
|
||||
);
|
||||
$completionResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
$completionResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// 알림톡 발송: 상대방(counterpart)에게 카카오톡으로 서명 완료 PDF 전달
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $isCounterpart && $completedSigner->phone) {
|
||||
$alimtalkResult = $this->sendCompletionAlimtalk($contract, $completedSigner);
|
||||
$completionResults[] = $alimtalkResult;
|
||||
|
||||
// 알림톡 실패 시 이메일 폴백 (아직 이메일 안 보낸 경우)
|
||||
if (! ($alimtalkResult['success'] ?? false) && ! $shouldSendEmail && $completedSigner->email) {
|
||||
try {
|
||||
Mail::to($completedSigner->email)->send(
|
||||
new EsignCompletedMail($contract, $completedSigner, $allSigners)
|
||||
@@ -358,11 +423,6 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
// 알림톡 발송
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) {
|
||||
$completionResults[] = $this->sendCompletionAlimtalk($contract, $completedSigner);
|
||||
}
|
||||
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
'contract_id' => $contract->id,
|
||||
@@ -372,6 +432,7 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => [
|
||||
'send_method' => $sendMethod,
|
||||
'signer_role' => $completedSigner->role,
|
||||
'notification_results' => [[
|
||||
'signer_id' => $completedSigner->id,
|
||||
'signer_name' => $completedSigner->name,
|
||||
@@ -402,87 +463,103 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
$nextSigner->update(['status' => 'notified']);
|
||||
$nextSendMethod = $contract->send_method ?? 'alimtalk';
|
||||
$nextSmsFallback = $contract->sms_fallback ?? true;
|
||||
$nextIsCounterpart = $nextSigner->role === EsignSigner::ROLE_COUNTERPART;
|
||||
|
||||
$notificationResults = [];
|
||||
$alimtalkFailed = false;
|
||||
|
||||
// 알림톡 발송
|
||||
if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextSigner->phone) {
|
||||
// 알림톡 발송: 상대방(counterpart)에게만 카카오톡 발송
|
||||
if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextIsCounterpart && $nextSigner->phone) {
|
||||
try {
|
||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||
if ($member) {
|
||||
if ($member && $member->biz_no) {
|
||||
$barobill = app(BarobillService::class);
|
||||
$barobill->setServerMode($member->server_mode ?? 'production');
|
||||
$nextSignUrl = config('app.url').'/esign/sign/'.$nextSigner->access_token;
|
||||
$nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
|
||||
|
||||
// 채널 ID 조회
|
||||
$channelResult = $barobill->getKakaotalkChannels($member->biz_no);
|
||||
$yellowId = '';
|
||||
if ($channelResult['success'] ?? false) {
|
||||
$chData = $channelResult['data'];
|
||||
if (is_object($chData) && isset($chData->KakaotalkChannel)) {
|
||||
$ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel;
|
||||
$yellowId = $ch->ChannelId ?? '';
|
||||
}
|
||||
}
|
||||
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
|
||||
|
||||
// 템플릿 본문 조회하여 변수 치환
|
||||
$tplResult = $barobill->getKakaotalkTemplates($member->biz_no, $yellowId);
|
||||
$tplMessage = null;
|
||||
if ($tplResult['success'] ?? false) {
|
||||
$tplData = $tplResult['data'];
|
||||
$tplItems = [];
|
||||
if (is_object($tplData) && isset($tplData->KakaotalkTemplate)) {
|
||||
$tplItems = is_array($tplData->KakaotalkTemplate) ? $tplData->KakaotalkTemplate : [$tplData->KakaotalkTemplate];
|
||||
}
|
||||
foreach ($tplItems as $t) {
|
||||
if (($t->TemplateName ?? '') === '전자계약_서명요청') {
|
||||
$tplMessage = str_replace(
|
||||
['#{이름}', '#{계약명}', '#{기한}'],
|
||||
[$nextSigner->name, $contract->title, $nextExpires],
|
||||
$t->TemplateContent
|
||||
);
|
||||
break;
|
||||
if ($channelId) {
|
||||
// 템플릿 본문 + 버튼 조회
|
||||
$nextTemplateName = $this->resolveTemplateName('전자계약_서명요청');
|
||||
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $nextTemplateName);
|
||||
$tplMessage = $tplData['content']
|
||||
? str_replace(
|
||||
['#{이름}', '#{계약명}', '#{기한}'],
|
||||
[$nextSigner->name, $contract->title, $nextExpires],
|
||||
$tplData['content']
|
||||
)
|
||||
: null;
|
||||
|
||||
// 버튼: 템플릿에서 가져온 URL의 #{토큰} 치환
|
||||
$buttons = ! empty($tplData['buttons']) ? $tplData['buttons'] : [
|
||||
['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl],
|
||||
];
|
||||
foreach ($buttons as &$btn) {
|
||||
foreach (['Url1', 'Url2'] as $urlKey) {
|
||||
if (! empty($btn[$urlKey])) {
|
||||
$btn[$urlKey] = str_replace(
|
||||
['#{토큰}', '#{%ED%86%A0%ED%81%B0}'],
|
||||
[$nextSigner->access_token, $nextSigner->access_token],
|
||||
urldecode($btn[$urlKey])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($btn);
|
||||
|
||||
$atResult = $barobill->sendATKakaotalkEx(
|
||||
corpNum: $member->biz_no,
|
||||
senderId: $member->barobill_id,
|
||||
yellowId: $yellowId,
|
||||
templateName: '전자계약_서명요청',
|
||||
receiverName: $nextSigner->name,
|
||||
receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone),
|
||||
title: '',
|
||||
message: $tplMessage ?? "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.",
|
||||
buttons: [['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl]],
|
||||
smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '',
|
||||
);
|
||||
$notificationResults[] = [
|
||||
'success' => $atResult['success'] ?? false,
|
||||
'channel' => 'alimtalk',
|
||||
'error' => $atResult['error'] ?? null,
|
||||
];
|
||||
$atResult = $barobill->sendATKakaotalkEx(
|
||||
corpNum: $member->biz_no,
|
||||
senderId: $member->barobill_id,
|
||||
yellowId: $channelId,
|
||||
templateName: $nextTemplateName,
|
||||
receiverName: $nextSigner->name,
|
||||
receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone),
|
||||
title: '',
|
||||
message: $tplMessage ?? "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.",
|
||||
buttons: $buttons,
|
||||
smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '',
|
||||
);
|
||||
$alimtalkFailed = ! ($atResult['success'] ?? false);
|
||||
$notificationResults[] = [
|
||||
'success' => $atResult['success'] ?? false,
|
||||
'channel' => 'alimtalk',
|
||||
'error' => $atResult['error'] ?? null,
|
||||
];
|
||||
} else {
|
||||
$alimtalkFailed = true;
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널 없음'];
|
||||
}
|
||||
} else {
|
||||
$alimtalkFailed = true;
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]);
|
||||
$alimtalkFailed = true;
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// 이메일 발송
|
||||
if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && ! $nextSigner->phone)) {
|
||||
if ($nextSigner->email) {
|
||||
try {
|
||||
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
|
||||
$notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]);
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
||||
}
|
||||
// 이메일 발송 조건:
|
||||
// 1) email/both 선택 시
|
||||
// 2) 본사(creator)는 항상 이메일
|
||||
// 3) 상대방이지만 전화번호 없으면 이메일 폴백
|
||||
// 4) 알림톡 발송 실패 시 이메일 자동 폴백
|
||||
$shouldSendEmail = in_array($nextSendMethod, ['email', 'both'])
|
||||
|| ! $nextIsCounterpart
|
||||
|| ($nextSendMethod === 'alimtalk' && $nextIsCounterpart && ! $nextSigner->phone)
|
||||
|| ($nextSendMethod === 'alimtalk' && $nextIsCounterpart && $alimtalkFailed);
|
||||
|
||||
if ($shouldSendEmail && $nextSigner->email) {
|
||||
try {
|
||||
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
|
||||
$notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]);
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +572,7 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => [
|
||||
'triggered_by' => 'auto_after_sign',
|
||||
'signer_role' => $nextSigner->role,
|
||||
'notification_results' => [[
|
||||
'signer_id' => $nextSigner->id,
|
||||
'signer_name' => $nextSigner->name,
|
||||
@@ -561,13 +639,37 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse
|
||||
// 서명 완료된 PDF가 있으면 우선 제공
|
||||
if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) {
|
||||
$filePath = $contract->signed_file_path;
|
||||
} elseif ($contract->status === 'completed') {
|
||||
// 계약 완료 상태인데 서명 PDF가 없으면 재생성 시도
|
||||
try {
|
||||
$pdfService = new PdfSignatureService;
|
||||
$filePath = $pdfService->mergeSignatures($contract);
|
||||
Log::info('서명 PDF 재생성 성공', ['contract_id' => $contract->id, 'path' => $filePath]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('서명 PDF 재생성 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
// 재생성 실패 시 미리보기 PDF 폴백 (서명 제외, 텍스트/날짜/체크박스만)
|
||||
try {
|
||||
$filePath = $pdfService->generatePreview($contract);
|
||||
} catch (\Throwable $e2) {
|
||||
Log::warning('미리보기 PDF 생성도 실패, 원본 제공', ['error' => $e2->getMessage()]);
|
||||
$filePath = $contract->original_file_path;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 서명 전: 텍스트/날짜/체크박스 필드가 합성된 미리보기 PDF 생성
|
||||
try {
|
||||
$pdfService = new PdfSignatureService;
|
||||
$filePath = $pdfService->generatePreview($contract);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('미리보기 PDF 생성 실패, 원본 제공', ['error' => $e->getMessage()]);
|
||||
Log::error('미리보기 PDF 생성 실패, 원본 제공', [
|
||||
'contract_id' => $contract->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$filePath = $contract->original_file_path;
|
||||
}
|
||||
}
|
||||
@@ -585,11 +687,65 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse
|
||||
|
||||
// ─── Private ───
|
||||
|
||||
/**
|
||||
* SMS로 OTP 발송 (바로빌 독립 SMS API 사용)
|
||||
*/
|
||||
private function sendOtpViaSms(EsignContract $contract, EsignSigner $signer, string $otpCode): bool
|
||||
{
|
||||
try {
|
||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||
if (! $member || ! $member->manager_hp) {
|
||||
Log::warning('OTP SMS 발송 실패: 바로빌 회원 또는 발신번호 없음', [
|
||||
'contract_id' => $contract->id,
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$barobill = app(BarobillService::class);
|
||||
$barobill->setServerMode($member->server_mode ?? 'production');
|
||||
|
||||
$fromNumber = preg_replace('/[^0-9]/', '', $member->manager_hp);
|
||||
$toNumber = preg_replace('/[^0-9]/', '', $signer->phone);
|
||||
$smsText = "[SAM] 전자계약 인증코드: {$otpCode} (5분 이내 입력)";
|
||||
|
||||
$result = $barobill->sendSMSMessage(
|
||||
corpNum: $member->biz_no,
|
||||
senderId: $member->barobill_id,
|
||||
fromNumber: $fromNumber,
|
||||
toName: $signer->name,
|
||||
toNumber: $toNumber,
|
||||
contents: $smsText,
|
||||
);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('OTP SMS 발송 API 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'error' => $result['error'] ?? 'Unknown',
|
||||
]);
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('OTP SMS 발송 예외', [
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): array
|
||||
{
|
||||
try {
|
||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||
if (! $member) {
|
||||
if (! $member || ! $member->biz_no) {
|
||||
return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
|
||||
}
|
||||
|
||||
@@ -597,41 +753,136 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si
|
||||
$barobill->setServerMode($member->server_mode ?? 'production');
|
||||
|
||||
// 채널 ID 조회
|
||||
$channelResult = $barobill->getKakaotalkChannels($member->biz_no);
|
||||
$yellowId = '';
|
||||
if ($channelResult['success'] ?? false) {
|
||||
$chData = $channelResult['data'];
|
||||
if (is_object($chData) && isset($chData->KakaotalkChannel)) {
|
||||
$ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel;
|
||||
$yellowId = $ch->ChannelId ?? '';
|
||||
}
|
||||
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
|
||||
if (! $channelId) {
|
||||
return ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널이 없습니다'];
|
||||
}
|
||||
|
||||
$templateName = $contract->completion_template_name
|
||||
?: $this->resolveTemplateName('전자계약_완료');
|
||||
$documentUrl = config('app.url').'/esign/sign/'.$signer->access_token.'/api/document';
|
||||
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
|
||||
$completedAt = $contract->completed_at?->format('Y-m-d H:i') ?? now()->format('Y-m-d H:i');
|
||||
|
||||
// 등록된 템플릿 본문 + 버튼 조회
|
||||
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName);
|
||||
$templateContent = $tplData['content'];
|
||||
$templateButtons = $tplData['buttons'];
|
||||
|
||||
if ($templateContent) {
|
||||
$message = str_replace(
|
||||
['#{이름}', '#{계약명}', '#{완료일}'],
|
||||
[$signer->name, $contract->title, $completedAt],
|
||||
$templateContent
|
||||
);
|
||||
} else {
|
||||
Log::warning('E-Sign 완료 알림톡: 템플릿 내용 조회 실패, 하드코딩 폴백 사용', [
|
||||
'template_name' => $templateName,
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
$message = "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인하고 다운로드할 수 있습니다.";
|
||||
}
|
||||
|
||||
// 버튼: 템플릿에서 가져온 URL의 #{토큰}만 치환
|
||||
$buttons = ! empty($templateButtons) ? $templateButtons : [
|
||||
[
|
||||
'Name' => '계약서 다운로드',
|
||||
'ButtonType' => 'WL',
|
||||
'Url1' => $documentUrl,
|
||||
'Url2' => $documentUrl,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($buttons as &$btn) {
|
||||
foreach (['Url1', 'Url2'] as $urlKey) {
|
||||
if (! empty($btn[$urlKey])) {
|
||||
$btn[$urlKey] = str_replace(
|
||||
['#{토큰}', '#{%ED%86%A0%ED%81%B0}'],
|
||||
[$signer->access_token, $signer->access_token],
|
||||
urldecode($btn[$urlKey])
|
||||
);
|
||||
// 완료 알림톡: 버튼 URL을 문서 다운로드 엔드포인트로 강제 변경
|
||||
// 템플릿 버튼 URL이 서명 페이지(/esign/sign/{token})를 가리키므로
|
||||
// 완료된 계약서 PDF 다운로드(/esign/sign/{token}/api/document)로 교체
|
||||
if (str_contains($btn[$urlKey], '/esign/sign/') && ! str_contains($btn[$urlKey], '/api/document')) {
|
||||
$btn[$urlKey] = $documentUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($btn);
|
||||
|
||||
// 버튼 URL 도메인을 현재 환경의 도메인으로 치환
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
foreach ($buttons as &$btn) {
|
||||
foreach (['Url1', 'Url2'] as $urlKey) {
|
||||
if (! empty($btn[$urlKey])) {
|
||||
$parsed = parse_url($btn[$urlKey]);
|
||||
if (isset($parsed['host']) && $parsed['host'] !== $appHost) {
|
||||
$btn[$urlKey] = str_replace($parsed['host'], $appHost, $btn[$urlKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($btn);
|
||||
|
||||
$receiverNum = preg_replace('/[^0-9]/', '', $signer->phone);
|
||||
|
||||
Log::info('E-Sign 완료 알림톡 발송 시도', [
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'signer_role' => $signer->role,
|
||||
'template_name' => $templateName,
|
||||
'template_from_api' => (bool) $templateContent,
|
||||
'buttons_from_api' => ! empty($templateButtons),
|
||||
'receiver_num' => $receiverNum,
|
||||
]);
|
||||
|
||||
$result = $barobill->sendATKakaotalkEx(
|
||||
corpNum: $member->biz_no,
|
||||
senderId: $member->barobill_id,
|
||||
yellowId: $yellowId,
|
||||
templateName: '전자계약_완료',
|
||||
yellowId: $channelId,
|
||||
templateName: $templateName,
|
||||
receiverName: $signer->name,
|
||||
receiverNum: preg_replace('/[^0-9]/', '', $signer->phone),
|
||||
receiverNum: $receiverNum,
|
||||
title: '',
|
||||
message: "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인할 수 있습니다.",
|
||||
buttons: [
|
||||
[
|
||||
'Name' => '계약서 확인하기',
|
||||
'ButtonType' => 'WL',
|
||||
'Url1' => $signUrl,
|
||||
'Url2' => $signUrl,
|
||||
],
|
||||
],
|
||||
message: $message,
|
||||
buttons: $buttons,
|
||||
smsMessage: ($contract->sms_fallback ?? true)
|
||||
? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}"
|
||||
? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. 계약서 다운로드: {$documentUrl}"
|
||||
: '',
|
||||
);
|
||||
|
||||
// 발송 접수 후 결과 확인
|
||||
if (($result['success'] ?? false) && ! empty($result['data']) && is_string($result['data'])) {
|
||||
$sendKey = $result['data'];
|
||||
Log::info('E-Sign 완료 알림톡 접수 성공', [
|
||||
'contract_id' => $contract->id,
|
||||
'send_key' => $sendKey,
|
||||
]);
|
||||
|
||||
sleep(3);
|
||||
$sendResult = $barobill->getSendKakaotalk($member->biz_no, $sendKey);
|
||||
$resultData = $sendResult['data'] ?? null;
|
||||
$resultCode = is_object($resultData) ? ($resultData->ResultCode ?? null) : ($resultData['ResultCode'] ?? null);
|
||||
$resultMsg = is_object($resultData) ? ($resultData->ResultMessage ?? null) : ($resultData['ResultMessage'] ?? null);
|
||||
|
||||
Log::info('E-Sign 완료 알림톡 전달 결과', [
|
||||
'contract_id' => $contract->id,
|
||||
'send_key' => $sendKey,
|
||||
'result_code' => $resultCode,
|
||||
'result_message' => $resultMsg,
|
||||
]);
|
||||
|
||||
if ($resultCode !== null && $resultCode != 1) {
|
||||
return [
|
||||
'success' => false,
|
||||
'channel' => 'alimtalk',
|
||||
'error' => "카카오톡 전달 실패: {$resultMsg} (코드: {$resultCode})",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
Log::warning('E-Sign 완료 알림톡 발송 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
@@ -654,6 +905,103 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카카오톡 채널 ID 조회 (바로빌 API)
|
||||
*/
|
||||
private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo): ?string
|
||||
{
|
||||
$result = $barobill->getKakaotalkChannels($bizNo);
|
||||
|
||||
if (! ($result['success'] ?? false) || empty($result['data'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
|
||||
if (is_object($data) && isset($data->KakaotalkChannel)) {
|
||||
$channels = is_array($data->KakaotalkChannel)
|
||||
? $data->KakaotalkChannel
|
||||
: [$data->KakaotalkChannel];
|
||||
} elseif (is_array($data) && isset($data['KakaotalkChannel'])) {
|
||||
$channels = is_array($data['KakaotalkChannel'])
|
||||
? $data['KakaotalkChannel']
|
||||
: [$data['KakaotalkChannel']];
|
||||
} else {
|
||||
$channels = is_array($data) ? $data : [$data];
|
||||
}
|
||||
|
||||
$channel = $channels[0] ?? null;
|
||||
|
||||
if (! $channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($channel)
|
||||
? ($channel['ChannelId'] ?? null)
|
||||
: ($channel->ChannelId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 등록 템플릿의 본문 내용 및 버튼 정보 조회
|
||||
*
|
||||
* @return array{content: string|null, buttons: array}
|
||||
*/
|
||||
private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array
|
||||
{
|
||||
$empty = ['content' => null, 'buttons' => []];
|
||||
|
||||
$result = $barobill->getKakaotalkTemplates($bizNo, $channelId);
|
||||
|
||||
if (! ($result['success'] ?? false) || empty($result['data'])) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$items = [];
|
||||
|
||||
if (is_object($data) && isset($data->KakaotalkTemplate)) {
|
||||
$items = is_array($data->KakaotalkTemplate)
|
||||
? $data->KakaotalkTemplate
|
||||
: [$data->KakaotalkTemplate];
|
||||
}
|
||||
|
||||
foreach ($items as $tpl) {
|
||||
if (($tpl->TemplateName ?? '') === $templateName) {
|
||||
$buttons = [];
|
||||
$btnData = $tpl->Buttons ?? null;
|
||||
if ($btnData) {
|
||||
$btnList = $btnData->KakaotalkButton ?? null;
|
||||
if ($btnList) {
|
||||
$btnList = is_array($btnList) ? $btnList : [$btnList];
|
||||
foreach ($btnList as $btn) {
|
||||
$buttons[] = [
|
||||
'Name' => $btn->Name ?? '',
|
||||
'ButtonType' => $btn->ButtonType ?? 'WL',
|
||||
'Url1' => $btn->Url1 ?? '',
|
||||
'Url2' => $btn->Url2 ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $tpl->TemplateContent ?? null,
|
||||
'buttons' => $buttons,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경별 알림톡 템플릿명 반환 (운영: 원본, 개발: _DEV 접미사)
|
||||
*/
|
||||
private function resolveTemplateName(string $baseName): string
|
||||
{
|
||||
return $baseName.(app()->environment('production') ? '' : '_DEV');
|
||||
}
|
||||
|
||||
private function findSigner(string $token): ?EsignSigner
|
||||
{
|
||||
$signer = EsignSigner::withoutGlobalScopes()
|
||||
@@ -666,4 +1014,55 @@ private function findSigner(string $token): ?EsignSigner
|
||||
|
||||
return $signer;
|
||||
}
|
||||
|
||||
private function hasCompanyStamp(int $tenantId): bool
|
||||
{
|
||||
$stamp = TenantSetting::where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'esign')
|
||||
->where('setting_key', 'company_stamp')
|
||||
->first();
|
||||
|
||||
if (! $stamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$val = $stamp->setting_value;
|
||||
|
||||
return ! empty($val['gcs_object']) || ! empty($val['local_path']);
|
||||
}
|
||||
|
||||
private function applyCompanyStamp(EsignSigner $signer, int $tenantId): ?string
|
||||
{
|
||||
$stamp = TenantSetting::where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'esign')
|
||||
->where('setting_key', 'company_stamp')
|
||||
->first();
|
||||
|
||||
if (! $stamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$val = $stamp->setting_value;
|
||||
$imageData = null;
|
||||
|
||||
if (! empty($val['gcs_object'])) {
|
||||
$gcs = app(\App\Services\GoogleCloudStorageService::class);
|
||||
$signedUrl = $gcs->getSignedUrl($val['gcs_object'], 5);
|
||||
if ($signedUrl) {
|
||||
$imageData = @file_get_contents($signedUrl);
|
||||
}
|
||||
} elseif (! empty($val['local_path']) && Storage::disk('local')->exists($val['local_path'])) {
|
||||
$imageData = Storage::disk('local')->get($val['local_path']);
|
||||
}
|
||||
|
||||
if (! $imageData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$localPath = "esign/{$tenantId}/signatures/{$signer->contract_id}_{$signer->id}_stamp.png";
|
||||
Storage::disk('local')->put($localPath, $imageData);
|
||||
$signer->update(['signature_image_path' => $localPath]);
|
||||
|
||||
return $localPath;
|
||||
}
|
||||
}
|
||||
|
||||
117
app/Http/Controllers/EquipmentController.php
Normal file
117
app/Http/Controllers/EquipmentController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\InspectionCycle;
|
||||
use App\Services\EquipmentInspectionService;
|
||||
use App\Services\EquipmentRepairService;
|
||||
use App\Services\EquipmentService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EquipmentService $equipmentService,
|
||||
private EquipmentInspectionService $inspectionService,
|
||||
private EquipmentRepairService $repairService
|
||||
) {}
|
||||
|
||||
public function dashboard(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('equipment.dashboard'));
|
||||
}
|
||||
|
||||
$stats = $this->equipmentService->getDashboardStats();
|
||||
$typeStats = $this->equipmentService->getTypeStats();
|
||||
$inspectionStats = $this->inspectionService->getMonthlyStats(now()->format('Y-m'));
|
||||
$recentRepairs = $this->repairService->getRecentRepairs(5);
|
||||
|
||||
return view('equipment.dashboard', compact('stats', 'typeStats', 'inspectionStats', 'recentRepairs'));
|
||||
}
|
||||
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('equipment.index'));
|
||||
}
|
||||
|
||||
return view('equipment.index');
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return view('equipment.create', compact('users'));
|
||||
}
|
||||
|
||||
public function show(int $id): View
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
abort(404, '설비를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return view('equipment.show', compact('equipment'));
|
||||
}
|
||||
|
||||
public function edit(int $id): View
|
||||
{
|
||||
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return view('equipment.edit', compact('id', 'users'));
|
||||
}
|
||||
|
||||
public function inspections(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('equipment.inspections'));
|
||||
}
|
||||
|
||||
$equipmentList = $this->equipmentService->getEquipmentList();
|
||||
$cycles = InspectionCycle::all();
|
||||
|
||||
return view('equipment.inspections.index', compact('equipmentList', 'cycles'));
|
||||
}
|
||||
|
||||
public function repairs(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('equipment.repairs'));
|
||||
}
|
||||
|
||||
$equipmentList = $this->equipmentService->getEquipmentList();
|
||||
|
||||
return view('equipment.repairs.index', compact('equipmentList'));
|
||||
}
|
||||
|
||||
public function repairCreate(): View
|
||||
{
|
||||
$equipmentList = $this->equipmentService->getEquipmentList();
|
||||
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
|
||||
|
||||
return view('equipment.repairs.create', compact('equipmentList', 'users'));
|
||||
}
|
||||
|
||||
public function import(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('equipment.import'));
|
||||
}
|
||||
|
||||
return view('equipment.import');
|
||||
}
|
||||
|
||||
public function guide(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('equipment.guide'));
|
||||
}
|
||||
|
||||
return view('equipment.guide');
|
||||
}
|
||||
}
|
||||
170
app/Http/Controllers/Finance/CondolenceExpenseController.php
Normal file
170
app/Http/Controllers/Finance/CondolenceExpenseController.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Finance\CondolenceExpense;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CondolenceExpenseController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('finance.condolence-expenses'));
|
||||
}
|
||||
|
||||
return view('finance.condolence-expenses');
|
||||
}
|
||||
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$query = CondolenceExpense::forTenant($tenantId);
|
||||
|
||||
if ($year = $request->input('year')) {
|
||||
$query->whereYear('event_date', $year);
|
||||
}
|
||||
|
||||
if ($category = $request->input('category')) {
|
||||
if ($category !== 'all') {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('partner_name', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%")
|
||||
->orWhere('memo', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$records = $query->orderBy('event_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'event_date' => $item->event_date?->format('Y-m-d'),
|
||||
'expense_date' => $item->expense_date?->format('Y-m-d'),
|
||||
'partner_name' => $item->partner_name,
|
||||
'description' => $item->description,
|
||||
'category' => $item->category,
|
||||
'has_cash' => $item->has_cash,
|
||||
'cash_method' => $item->cash_method,
|
||||
'cash_amount' => $item->cash_amount,
|
||||
'has_gift' => $item->has_gift,
|
||||
'gift_type' => $item->gift_type,
|
||||
'gift_amount' => $item->gift_amount,
|
||||
'total_amount' => $item->total_amount,
|
||||
'memo' => $item->memo,
|
||||
]);
|
||||
|
||||
$all = CondolenceExpense::forTenant($tenantId);
|
||||
if ($year) {
|
||||
$all = $all->whereYear('event_date', $year);
|
||||
}
|
||||
$all = $all->get();
|
||||
|
||||
$stats = [
|
||||
'totalCount' => $all->count(),
|
||||
'totalAmount' => $all->sum('total_amount'),
|
||||
'cashTotal' => $all->sum('cash_amount'),
|
||||
'giftTotal' => $all->sum('gift_amount'),
|
||||
'congratulationCount' => $all->where('category', 'congratulation')->count(),
|
||||
'condolenceCount' => $all->where('category', 'condolence')->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $records,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'partner_name' => 'required|string|max:100',
|
||||
'category' => 'required|in:congratulation,condolence',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$cashAmount = (int) $request->input('cash_amount', 0);
|
||||
$giftAmount = (int) $request->input('gift_amount', 0);
|
||||
|
||||
CondolenceExpense::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'event_date' => $request->input('event_date'),
|
||||
'expense_date' => $request->input('expense_date'),
|
||||
'partner_name' => $request->input('partner_name'),
|
||||
'description' => $request->input('description'),
|
||||
'category' => $request->input('category'),
|
||||
'has_cash' => $request->boolean('has_cash'),
|
||||
'cash_method' => $request->input('cash_method'),
|
||||
'cash_amount' => $cashAmount,
|
||||
'has_gift' => $request->boolean('has_gift'),
|
||||
'gift_type' => $request->input('gift_type'),
|
||||
'gift_amount' => $giftAmount,
|
||||
'total_amount' => $cashAmount + $giftAmount,
|
||||
'memo' => $request->input('memo'),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '경조사비가 등록되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$item = CondolenceExpense::forTenant($tenantId)->findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'partner_name' => 'required|string|max:100',
|
||||
'category' => 'required|in:congratulation,condolence',
|
||||
]);
|
||||
|
||||
$cashAmount = (int) $request->input('cash_amount', 0);
|
||||
$giftAmount = (int) $request->input('gift_amount', 0);
|
||||
|
||||
$item->update([
|
||||
'event_date' => $request->input('event_date'),
|
||||
'expense_date' => $request->input('expense_date'),
|
||||
'partner_name' => $request->input('partner_name'),
|
||||
'description' => $request->input('description'),
|
||||
'category' => $request->input('category'),
|
||||
'has_cash' => $request->boolean('has_cash'),
|
||||
'cash_method' => $request->input('cash_method'),
|
||||
'cash_amount' => $cashAmount,
|
||||
'has_gift' => $request->boolean('has_gift'),
|
||||
'gift_type' => $request->input('gift_type'),
|
||||
'gift_amount' => $giftAmount,
|
||||
'total_amount' => $cashAmount + $giftAmount,
|
||||
'memo' => $request->input('memo'),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '경조사비가 수정되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$item = CondolenceExpense::forTenant($tenantId)->findOrFail($id);
|
||||
$item->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '경조사비가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,8 @@ public function updatePrepayment(Request $request): JsonResponse
|
||||
'items.*.date' => 'required|date',
|
||||
'items.*.amount' => 'required|integer|min:0',
|
||||
'items.*.description' => 'nullable|string|max:200',
|
||||
'items.*.card_splits' => 'nullable|array',
|
||||
'items.*.card_splits.*' => 'integer|min:0',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\AccountCode;
|
||||
use App\Models\Barobill\BankTransaction;
|
||||
use App\Models\Barobill\CardTransaction;
|
||||
use App\Models\Barobill\CardTransactionHide;
|
||||
use App\Models\Finance\JournalEntry;
|
||||
use App\Models\Finance\JournalEntryLine;
|
||||
use App\Models\Finance\TradingPartner;
|
||||
@@ -111,6 +113,7 @@ public function show(int $id): JsonResponse
|
||||
'total_debit' => $entry->total_debit,
|
||||
'total_credit' => $entry->total_credit,
|
||||
'status' => $entry->status,
|
||||
'source_type' => $entry->source_type,
|
||||
'created_by_name' => $entry->created_by_name,
|
||||
'attachment_note' => $entry->attachment_note,
|
||||
'lines' => $entry->lines->map(function ($line) {
|
||||
@@ -233,6 +236,19 @@ public function store(Request $request): JsonResponse
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$entry = JournalEntry::forTenant($tenantId)->findOrFail($id);
|
||||
|
||||
// 출처 연결 전표 수정 제한 (카드/홈택스는 원본에서 수정, 계좌는 허용)
|
||||
if ($entry->source_type && ! in_array($entry->source_type, ['manual', 'bank_transaction'])) {
|
||||
$sourceLabel = $entry->source_type === 'ecard_transaction' ? '카드사용내역' : '홈택스 매출/매입';
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "이 전표는 {$sourceLabel}에서 수정해주세요.",
|
||||
], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'entry_date' => 'required|date',
|
||||
'description' => 'nullable|string|max:500',
|
||||
@@ -248,7 +264,6 @@ public function update(Request $request, int $id): JsonResponse
|
||||
'lines.*.description' => 'nullable|string|max:300',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$lines = $request->lines;
|
||||
|
||||
$totalDebit = collect($lines)->sum('debit_amount');
|
||||
@@ -261,9 +276,7 @@ public function update(Request $request, int $id): JsonResponse
|
||||
], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $id, $request, $lines, $totalDebit, $totalCredit) {
|
||||
$entry = JournalEntry::forTenant($tenantId)->findOrFail($id);
|
||||
|
||||
DB::transaction(function () use ($tenantId, $entry, $request, $lines, $totalDebit, $totalCredit) {
|
||||
$entry->update([
|
||||
'entry_date' => $request->entry_date,
|
||||
'description' => $request->description,
|
||||
@@ -861,4 +874,322 @@ public function accountCodeDestroy(int $id): JsonResponse
|
||||
'message' => '계정과목이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 카드거래 기반 분개 API
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* 카드거래 목록 조회 (DB 직접 조회 + 분개상태 병합)
|
||||
*/
|
||||
public function cardTransactions(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$startDate = $request->input('startDate', date('Ymd'));
|
||||
$endDate = $request->input('endDate', date('Ymd'));
|
||||
$cardNum = $request->input('cardNum', '');
|
||||
|
||||
$query = CardTransaction::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate]);
|
||||
|
||||
if (! empty($cardNum)) {
|
||||
$query->where('card_num', $cardNum);
|
||||
}
|
||||
|
||||
$transactions = $query->orderBy('use_date', 'desc')
|
||||
->orderBy('use_time', 'desc')
|
||||
->get();
|
||||
|
||||
// 숨김 처리된 거래 제외
|
||||
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate);
|
||||
$hiddenKeysMap = $hiddenKeys->flip();
|
||||
|
||||
$transactions = $transactions->filter(function ($tx) use ($hiddenKeysMap) {
|
||||
return ! $hiddenKeysMap->has($tx->unique_key);
|
||||
});
|
||||
|
||||
// 로그 데이터 변환
|
||||
$logs = [];
|
||||
foreach ($transactions as $tx) {
|
||||
$supplyAmount = $tx->modified_supply_amount !== null
|
||||
? (int) $tx->modified_supply_amount
|
||||
: (int) $tx->approval_amount - (int) $tx->tax;
|
||||
$taxAmount = $tx->modified_tax !== null
|
||||
? (int) $tx->modified_tax
|
||||
: (int) $tx->tax;
|
||||
|
||||
$logs[] = [
|
||||
'uniqueKey' => $tx->unique_key,
|
||||
'useDate' => $tx->use_date,
|
||||
'useTime' => $tx->use_time,
|
||||
'cardNum' => $tx->card_num,
|
||||
'cardCompanyName' => $tx->card_company_name,
|
||||
'approvalNum' => $tx->approval_num,
|
||||
'approvalType' => $tx->approval_type,
|
||||
'approvalAmount' => (int) $tx->approval_amount,
|
||||
'supplyAmount' => $supplyAmount,
|
||||
'taxAmount' => $taxAmount,
|
||||
'merchantName' => $tx->merchant_name,
|
||||
'merchantBizNum' => $tx->merchant_biz_num,
|
||||
'deductionType' => $tx->deduction_type,
|
||||
'accountCode' => $tx->account_code,
|
||||
'accountName' => $tx->account_name,
|
||||
'memo' => $tx->memo,
|
||||
'description' => $tx->description,
|
||||
];
|
||||
}
|
||||
|
||||
// 각 거래의 uniqueKey 수집
|
||||
$uniqueKeys = array_column($logs, 'uniqueKey');
|
||||
|
||||
// 분개 완료된 source_key 조회
|
||||
$journaledKeys = JournalEntry::getJournaledSourceKeys($tenantId, 'ecard_transaction', $uniqueKeys);
|
||||
$journaledKeysMap = array_flip($journaledKeys);
|
||||
|
||||
// 분개된 전표 ID 조회
|
||||
$journalMap = [];
|
||||
if (! empty($journaledKeys)) {
|
||||
$journals = JournalEntry::where('tenant_id', $tenantId)
|
||||
->where('source_type', 'ecard_transaction')
|
||||
->whereIn('source_key', $journaledKeys)
|
||||
->select('id', 'source_key', 'entry_no')
|
||||
->get();
|
||||
foreach ($journals as $j) {
|
||||
$journalMap[$j->source_key] = ['id' => $j->id, 'entry_no' => $j->entry_no];
|
||||
}
|
||||
}
|
||||
|
||||
// 각 거래에 분개 상태 추가
|
||||
foreach ($logs as &$log) {
|
||||
$key = $log['uniqueKey'] ?? '';
|
||||
$log['hasJournal'] = isset($journaledKeysMap[$key]);
|
||||
$log['journalId'] = $journalMap[$key]['id'] ?? null;
|
||||
$log['journalEntryNo'] = $journalMap[$key]['entry_no'] ?? null;
|
||||
}
|
||||
unset($log);
|
||||
|
||||
// 통계
|
||||
$totalCount = count($logs);
|
||||
$totalAmount = array_sum(array_column($logs, 'approvalAmount'));
|
||||
$deductibleSum = 0;
|
||||
$nonDeductibleSum = 0;
|
||||
foreach ($logs as $log) {
|
||||
if ($log['deductionType'] === 'non_deductible') {
|
||||
$nonDeductibleSum += $log['approvalAmount'];
|
||||
} else {
|
||||
$deductibleSum += $log['approvalAmount'];
|
||||
}
|
||||
}
|
||||
$journaledCount = count($journaledKeys);
|
||||
|
||||
// 카드 목록 (드롭다운용)
|
||||
$cards = CardTransaction::where('tenant_id', $tenantId)
|
||||
->select('card_num', 'card_company_name')
|
||||
->distinct()
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logs' => $logs,
|
||||
'cards' => $cards,
|
||||
'summary' => [
|
||||
'totalCount' => $totalCount,
|
||||
'totalAmount' => $totalAmount,
|
||||
'deductibleSum' => $deductibleSum,
|
||||
'nonDeductibleSum' => $nonDeductibleSum,
|
||||
],
|
||||
'journalStats' => [
|
||||
'journaledCount' => $journaledCount,
|
||||
'unjournaledCount' => $totalCount - $journaledCount,
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('카드거래 목록 조회 오류: '.$e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '카드거래 목록 조회 실패: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드거래 기반 전표 생성
|
||||
*/
|
||||
public function storeFromCard(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'source_key' => 'required|string|max:255',
|
||||
'entry_date' => 'required|date',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'lines' => 'required|array|min:2',
|
||||
'lines.*.dc_type' => 'required|in:debit,credit',
|
||||
'lines.*.account_code' => 'required|string|max:10',
|
||||
'lines.*.account_name' => 'required|string|max:100',
|
||||
'lines.*.trading_partner_id' => 'nullable|integer',
|
||||
'lines.*.trading_partner_name' => 'nullable|string|max:100',
|
||||
'lines.*.debit_amount' => 'required|integer|min:0',
|
||||
'lines.*.credit_amount' => 'required|integer|min:0',
|
||||
'lines.*.description' => 'nullable|string|max:300',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$lines = $request->lines;
|
||||
|
||||
$totalDebit = collect($lines)->sum('debit_amount');
|
||||
$totalCredit = collect($lines)->sum('credit_amount');
|
||||
|
||||
if ($totalDebit !== $totalCredit || $totalDebit === 0) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '차변합계와 대변합계가 일치해야 하며 0보다 커야 합니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 중복 분개 체크
|
||||
$existing = JournalEntry::getJournalBySourceKey($tenantId, 'ecard_transaction', $request->source_key);
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 분개가 완료된 거래입니다. (전표번호: '.$existing->entry_no.')',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$maxRetries = 3;
|
||||
$lastError = null;
|
||||
|
||||
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
|
||||
try {
|
||||
$entry = DB::transaction(function () use ($tenantId, $request, $lines, $totalDebit, $totalCredit) {
|
||||
$entryNo = JournalEntry::generateEntryNo($tenantId, $request->entry_date);
|
||||
|
||||
$entry = JournalEntry::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'entry_no' => $entryNo,
|
||||
'entry_date' => $request->entry_date,
|
||||
'description' => $request->description,
|
||||
'total_debit' => $totalDebit,
|
||||
'total_credit' => $totalCredit,
|
||||
'status' => 'draft',
|
||||
'source_type' => 'ecard_transaction',
|
||||
'source_key' => $request->source_key,
|
||||
'created_by_name' => auth()->user()?->name ?? '시스템',
|
||||
]);
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
JournalEntryLine::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'journal_entry_id' => $entry->id,
|
||||
'line_no' => $i + 1,
|
||||
'dc_type' => $line['dc_type'],
|
||||
'account_code' => $line['account_code'],
|
||||
'account_name' => $line['account_name'],
|
||||
'trading_partner_id' => $line['trading_partner_id'] ?? null,
|
||||
'trading_partner_name' => $line['trading_partner_name'] ?? null,
|
||||
'debit_amount' => $line['debit_amount'],
|
||||
'credit_amount' => $line['credit_amount'],
|
||||
'description' => $line['description'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $entry;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '분개가 저장되었습니다.',
|
||||
'data' => ['id' => $entry->id, 'entry_no' => $entry->entry_no],
|
||||
]);
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
$lastError = $e;
|
||||
if ($e->errorInfo[1] === 1062) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = $e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Log::error('카드거래 분개 저장 오류: '.$lastError->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '분개 저장 실패: '.$lastError->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 카드거래의 기존 분개 조회
|
||||
*/
|
||||
public function cardJournals(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$sourceKey = $request->get('source_key');
|
||||
|
||||
if (! $sourceKey) {
|
||||
return response()->json(['success' => false, 'message' => 'source_key가 필요합니다.'], 422);
|
||||
}
|
||||
|
||||
$entry = JournalEntry::forTenant($tenantId)
|
||||
->where('source_type', 'ecard_transaction')
|
||||
->where('source_key', $sourceKey)
|
||||
->with('lines')
|
||||
->first();
|
||||
|
||||
if (! $entry) {
|
||||
return response()->json(['success' => true, 'data' => null]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $entry->id,
|
||||
'entry_no' => $entry->entry_no,
|
||||
'entry_date' => $entry->entry_date->format('Y-m-d'),
|
||||
'description' => $entry->description,
|
||||
'total_debit' => $entry->total_debit,
|
||||
'total_credit' => $entry->total_credit,
|
||||
'status' => $entry->status,
|
||||
'lines' => $entry->lines->map(function ($line) {
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'line_no' => $line->line_no,
|
||||
'dc_type' => $line->dc_type,
|
||||
'account_code' => $line->account_code,
|
||||
'account_name' => $line->account_name,
|
||||
'trading_partner_id' => $line->trading_partner_id,
|
||||
'trading_partner_name' => $line->trading_partner_name,
|
||||
'debit_amount' => $line->debit_amount,
|
||||
'credit_amount' => $line->credit_amount,
|
||||
'description' => $line->description,
|
||||
];
|
||||
}),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드거래 분개 삭제 (soft delete)
|
||||
*/
|
||||
public function deleteCardJournal(int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$entry = JournalEntry::forTenant($tenantId)
|
||||
->where('source_type', 'ecard_transaction')
|
||||
->findOrFail($id);
|
||||
|
||||
$entry->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '분개가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\HometaxInvoiceJournal;
|
||||
use App\Models\Finance\JournalEntry;
|
||||
use App\Models\Finance\JournalEntryLine;
|
||||
use App\Models\Finance\Payable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -313,6 +314,7 @@ public function integrated(Request $request): JsonResponse
|
||||
$journalDetails = (clone $journalQuery)
|
||||
->select(
|
||||
'journal_entry_lines.id',
|
||||
'journal_entry_lines.journal_entry_id',
|
||||
'journal_entry_lines.trading_partner_name',
|
||||
'journal_entry_lines.account_code',
|
||||
'journal_entry_lines.account_name',
|
||||
@@ -505,4 +507,36 @@ public function journalPayables(Request $request): JsonResponse
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미지급금 관련 전표 강제 삭제 (soft delete)
|
||||
*/
|
||||
public function deleteJournalEntry(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$entry = JournalEntry::where('tenant_id', $tenantId)->find($id);
|
||||
|
||||
if (! $entry) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "전표(ID: {$id})를 찾을 수 없습니다. (tenant: {$tenantId})",
|
||||
], 404);
|
||||
}
|
||||
|
||||
$entryNo = $entry->entry_no;
|
||||
$entry->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "전표 {$entryNo}이(가) 삭제되었습니다.",
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '삭제 오류: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +523,7 @@ public function summary(Request $request): JsonResponse
|
||||
'totalCredit' => $totalCredit,
|
||||
'balance' => $priorBalance + $totalDebit - $totalCredit,
|
||||
'lastTransactionDate' => $group->pluck('date')->filter()->sort()->last(),
|
||||
'transactionCount' => $group->count(),
|
||||
'transactionCount' => $group->where('debitAmount', '>', 0)->count(),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -611,7 +611,7 @@ private function getPartnerSettlement(\Closure $baseQuery): \Illuminate\Support\
|
||||
->select([
|
||||
'id', 'partner_id', 'management_id', 'payment_type',
|
||||
'partner_commission', 'manager_commission', 'referrer_commission',
|
||||
'status', 'scheduled_payment_date',
|
||||
'status', 'scheduled_payment_date', 'manager_user_id',
|
||||
])
|
||||
->orderBy('partner_id')
|
||||
->orderBy('scheduled_payment_date')
|
||||
|
||||
@@ -229,11 +229,6 @@ public function index(Request $request): JsonResponse
|
||||
$cardPurchaseSupply = $cardRecords->sum('supplyAmount');
|
||||
$cardPurchaseVat = $cardRecords->sum('vatAmount');
|
||||
|
||||
// 수동입력 매출 종이세금계산서 (과세+영세)
|
||||
$manualSalesTaxable = $manualRecords->where('type', 'sales')->whereIn('taxType', ['taxable', 'zero_rated']);
|
||||
$manualSalesSupply = $manualSalesTaxable->sum('supplyAmount');
|
||||
$manualSalesVat = $manualSalesTaxable->sum('vatAmount');
|
||||
|
||||
// 수동입력 매입 종이세금계산서 (과세+영세)
|
||||
$manualPurchaseTaxable = $manualRecords->where('type', 'purchase')->whereIn('taxType', ['taxable', 'zero_rated']);
|
||||
$manualPurchaseSupply = $manualPurchaseTaxable->sum('supplyAmount');
|
||||
@@ -247,14 +242,12 @@ public function index(Request $request): JsonResponse
|
||||
$exemptSupply = $exemptSalesSupply + $exemptPurchaseSupply + $manualExemptSalesSupply + $manualExemptPurchaseSupply;
|
||||
|
||||
$stats = [
|
||||
'salesSupply' => $hometaxSalesSupply + $manualSalesSupply,
|
||||
'salesVat' => $hometaxSalesVat + $manualSalesVat,
|
||||
'salesSupply' => $hometaxSalesSupply,
|
||||
'salesVat' => $hometaxSalesVat,
|
||||
'purchaseSupply' => $hometaxPurchaseSupply + $cardPurchaseSupply + $manualPurchaseSupply,
|
||||
'purchaseVat' => $hometaxPurchaseVat + $cardPurchaseVat + $manualPurchaseVat,
|
||||
'hometaxSalesSupply' => $hometaxSalesSupply,
|
||||
'hometaxSalesVat' => $hometaxSalesVat,
|
||||
'manualSalesSupply' => $manualSalesSupply,
|
||||
'manualSalesVat' => $manualSalesVat,
|
||||
'hometaxPurchaseSupply' => $hometaxPurchaseSupply,
|
||||
'hometaxPurchaseVat' => $hometaxPurchaseVat,
|
||||
'manualPurchaseSupply' => $manualPurchaseSupply,
|
||||
|
||||
30
app/Http/Controllers/GoogleCloud/AiGuideController.php
Normal file
30
app/Http/Controllers/GoogleCloud/AiGuideController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\GoogleCloud;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class AiGuideController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('google-cloud.ai-guide.index'));
|
||||
}
|
||||
|
||||
return view('google-cloud.ai-guide.index');
|
||||
}
|
||||
|
||||
public function download(): BinaryFileResponse
|
||||
{
|
||||
$path = public_path('downloads/google-cloud-ai-guide.pptx');
|
||||
|
||||
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
|
||||
|
||||
return response()->download($path, 'Google_Cloud_AI_활용가이드.pptx');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\GoogleCloud;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class CloudApiPricingController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('google-cloud.cloud-api-pricing.index'));
|
||||
}
|
||||
|
||||
return view('google-cloud.cloud-api-pricing.index');
|
||||
}
|
||||
|
||||
public function download(): BinaryFileResponse
|
||||
{
|
||||
$path = public_path('downloads/google-cloud-api-pricing.pptx');
|
||||
|
||||
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
|
||||
|
||||
return response()->download($path, 'Google_Cloud_API_요금표.pptx');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\GoogleCloud;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WorkspacePolicyController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('google-cloud.workspace-policy.index'));
|
||||
}
|
||||
|
||||
return view('google-cloud.workspace-policy.index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\GoogleCloud;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class WorkspacePricingController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('google-cloud.workspace-pricing.index'));
|
||||
}
|
||||
|
||||
return view('google-cloud.workspace-pricing.index');
|
||||
}
|
||||
|
||||
public function download(): BinaryFileResponse
|
||||
{
|
||||
$path = public_path('downloads/google-workspace-pricing.pptx');
|
||||
|
||||
abort_unless(file_exists($path), 404, 'PPTX 파일을 찾을 수 없습니다.');
|
||||
|
||||
return response()->download($path, 'Google_Workspace_요금정책.pptx');
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/HR/AttendanceController.php
Normal file
57
app/Http/Controllers/HR/AttendanceController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceService $attendanceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 근태현황 목록 페이지 (조회 전용)
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.attendances.index'));
|
||||
}
|
||||
|
||||
$stats = $this->attendanceService->getMonthlyStats();
|
||||
$departments = $this->attendanceService->getDepartments();
|
||||
$statusMap = Attendance::STATUS_MAP;
|
||||
|
||||
return view('hr.attendances.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
'statusMap' => $statusMap,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태관리 페이지 (등록/수정/삭제/승인)
|
||||
*/
|
||||
public function manage(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.attendances.manage'));
|
||||
}
|
||||
|
||||
$departments = $this->attendanceService->getDepartments();
|
||||
$employees = $this->attendanceService->getActiveEmployees();
|
||||
$statusMap = Attendance::STATUS_MAP;
|
||||
|
||||
return view('hr.attendances.manage', [
|
||||
'departments' => $departments,
|
||||
'employees' => $employees,
|
||||
'statusMap' => $statusMap,
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/HR/AttendanceIntegratedController.php
Normal file
54
app/Http/Controllers/HR/AttendanceIntegratedController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\Leave;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AttendanceIntegratedController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private AttendanceService $attendanceService,
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 근태관리 통합 화면
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// JS 필요 페이지 → HTMX 시 전체 리로드
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.attendance.index'));
|
||||
}
|
||||
|
||||
$stats = $this->attendanceService->getMonthlyStats();
|
||||
$departments = $this->leaveService->getDepartments();
|
||||
$employees = $this->leaveService->getActiveEmployees();
|
||||
$statusMap = Attendance::STATUS_MAP;
|
||||
$leaveTypeMap = Leave::TYPE_MAP;
|
||||
$leaveStatusMap = Leave::STATUS_MAP;
|
||||
|
||||
// 결재선 목록
|
||||
$approvalLines = \App\Models\Approvals\ApprovalLine::query()
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'is_default']);
|
||||
|
||||
return view('hr.attendance-integrated.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
'employees' => $employees,
|
||||
'statusMap' => $statusMap,
|
||||
'leaveTypeMap' => $leaveTypeMap,
|
||||
'leaveStatusMap' => $leaveStatusMap,
|
||||
'approvalLines' => $approvalLines,
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
app/Http/Controllers/HR/BusinessIncomeEarnerController.php
Normal file
88
app/Http/Controllers/HR/BusinessIncomeEarnerController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\HR\BusinessIncomeEarnerService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class BusinessIncomeEarnerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BusinessIncomeEarnerService $service
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$stats = $this->service->getStats();
|
||||
$departments = $this->service->getDepartments();
|
||||
|
||||
return view('hr.business-income-earners.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$departments = $this->service->getDepartments();
|
||||
$ranks = $this->service->getPositions('rank');
|
||||
$titles = $this->service->getPositions('title');
|
||||
|
||||
return view('hr.business-income-earners.create', [
|
||||
'departments' => $departments,
|
||||
'ranks' => $ranks,
|
||||
'titles' => $titles,
|
||||
'banks' => config('banks', []),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): View
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
|
||||
if (! $earner) {
|
||||
abort(404, '사업소득자 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$files = File::where('document_type', 'business_income_earner_profile')
|
||||
->where('document_id', $earner->id)
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('hr.business-income-earners.show', [
|
||||
'earner' => $earner,
|
||||
'files' => $files,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(int $id): View
|
||||
{
|
||||
$earner = $this->service->getById($id);
|
||||
|
||||
if (! $earner) {
|
||||
abort(404, '사업소득자 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$departments = $this->service->getDepartments();
|
||||
$ranks = $this->service->getPositions('rank');
|
||||
$titles = $this->service->getPositions('title');
|
||||
|
||||
$files = File::where('document_type', 'business_income_earner_profile')
|
||||
->where('document_id', $earner->id)
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('hr.business-income-earners.edit', [
|
||||
'earner' => $earner,
|
||||
'departments' => $departments,
|
||||
'ranks' => $ranks,
|
||||
'titles' => $titles,
|
||||
'banks' => config('banks', []),
|
||||
'files' => $files,
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/HR/BusinessIncomePaymentController.php
Normal file
54
app/Http/Controllers/HR/BusinessIncomePaymentController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\BusinessIncomePaymentService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class BusinessIncomePaymentController extends Controller
|
||||
{
|
||||
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
|
||||
|
||||
public function __construct(
|
||||
private BusinessIncomePaymentService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사업소득자 임금대장 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.business-income-payments.index'));
|
||||
}
|
||||
|
||||
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
|
||||
return view('hr.payrolls.restricted');
|
||||
}
|
||||
|
||||
$year = $request->integer('year') ?: now()->year;
|
||||
$month = $request->integer('month') ?: now()->month;
|
||||
|
||||
$earners = $this->service->getActiveEarners();
|
||||
$payments = $this->service->getPayments($year, $month);
|
||||
$stats = $this->service->getMonthlyStats($year, $month);
|
||||
|
||||
$earnersForJs = $earners->map(fn ($e) => [
|
||||
'user_id' => $e->user_id,
|
||||
'business_name' => $e->business_name ?? ($e->user?->name ?? ''),
|
||||
'user_name' => $e->user?->name ?? '',
|
||||
'business_reg_number' => $e->business_registration_number ?? '',
|
||||
])->values();
|
||||
|
||||
return view('hr.business-income-payments.index', [
|
||||
'payments' => $payments,
|
||||
'earnersForJs' => $earnersForJs,
|
||||
'stats' => $stats,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
]);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/HR/EmployeeController.php
Normal file
101
app/Http/Controllers/HR/EmployeeController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\HR\EmployeeService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class EmployeeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeService $employeeService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사원 목록 페이지
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$showExcluded = request()->boolean('show_excluded');
|
||||
$stats = $this->employeeService->getStats($showExcluded);
|
||||
$departments = $this->employeeService->getDepartments();
|
||||
|
||||
return view('hr.employees.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 등록 폼
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
$departments = $this->employeeService->getDepartments();
|
||||
$ranks = $this->employeeService->getPositions('rank');
|
||||
$titles = $this->employeeService->getPositions('title');
|
||||
|
||||
return view('hr.employees.create', [
|
||||
'departments' => $departments,
|
||||
'ranks' => $ranks,
|
||||
'titles' => $titles,
|
||||
'banks' => config('banks', []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 상세 페이지
|
||||
*/
|
||||
public function show(int $id): View
|
||||
{
|
||||
$employee = $this->employeeService->getEmployeeById($id);
|
||||
|
||||
if (! $employee) {
|
||||
abort(404, '사원 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$files = File::where('document_type', 'employee_profile')
|
||||
->where('document_id', $employee->id)
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('hr.employees.show', [
|
||||
'employee' => $employee,
|
||||
'files' => $files,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 수정 폼
|
||||
*/
|
||||
public function edit(int $id): View
|
||||
{
|
||||
$employee = $this->employeeService->getEmployeeById($id);
|
||||
|
||||
if (! $employee) {
|
||||
abort(404, '사원 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$departments = $this->employeeService->getDepartments();
|
||||
$ranks = $this->employeeService->getPositions('rank');
|
||||
$titles = $this->employeeService->getPositions('title');
|
||||
|
||||
$files = File::where('document_type', 'employee_profile')
|
||||
->where('document_id', $employee->id)
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return view('hr.employees.edit', [
|
||||
'employee' => $employee,
|
||||
'departments' => $departments,
|
||||
'ranks' => $ranks,
|
||||
'titles' => $titles,
|
||||
'banks' => config('banks', []),
|
||||
'files' => $files,
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/HR/EmployeeTenureController.php
Normal file
34
app/Http/Controllers/HR/EmployeeTenureController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\EmployeeService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class EmployeeTenureController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeService $employeeService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.employee-tenure'));
|
||||
}
|
||||
|
||||
$stats = $this->employeeService->getTenureStats();
|
||||
$departments = $this->employeeService->getDepartments();
|
||||
|
||||
return view('hr.employee-tenure.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/HR/LeaveController.php
Normal file
61
app/Http/Controllers/HR/LeaveController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Approvals\ApprovalLine;
|
||||
use App\Models\HR\Leave;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LeaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
public function index(\Illuminate\Http\Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.leaves.index'));
|
||||
}
|
||||
|
||||
$employees = $this->leaveService->getActiveEmployees();
|
||||
$departments = $this->leaveService->getDepartments();
|
||||
$typeMap = Leave::TYPE_MAP;
|
||||
$statusMap = Leave::STATUS_MAP;
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$approvalLines = ApprovalLine::where('tenant_id', $tenantId)
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'steps', 'is_default']);
|
||||
|
||||
return view('hr.leaves.index', [
|
||||
'employees' => $employees,
|
||||
'departments' => $departments,
|
||||
'typeMap' => $typeMap,
|
||||
'statusMap' => $statusMap,
|
||||
'approvalLines' => $approvalLines,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가관리 가이드 도움말 모달
|
||||
*/
|
||||
public function helpGuide(): View
|
||||
{
|
||||
$guidePath = resource_path('markdown/휴가관리가이드.md');
|
||||
|
||||
if (file_exists($guidePath)) {
|
||||
$markdown = file_get_contents($guidePath);
|
||||
$htmlContent = Str::markdown($markdown);
|
||||
} else {
|
||||
$htmlContent = '<p class="text-gray-500">가이드를 찾을 수 없습니다.</p>';
|
||||
}
|
||||
|
||||
return view('hr.leaves.partials.help-modal', compact('htmlContent'));
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/HR/LeavePromotionController.php
Normal file
70
app/Http/Controllers/HR/LeavePromotionController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class LeavePromotionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
public function index(Request $request): \Illuminate\Contracts\View\View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.leave-promotions.index'));
|
||||
}
|
||||
|
||||
$year = (int) ($request->get('year', now()->year));
|
||||
|
||||
$candidates = $this->leaveService->getPromotionCandidates($year);
|
||||
|
||||
$stats = [
|
||||
'total' => $candidates->count(),
|
||||
'not_sent' => $candidates->where('promotion_status', 'not_sent')->count(),
|
||||
'first_sent' => $candidates->where('promotion_status', 'first_sent')->count(),
|
||||
'completed' => $candidates->where('promotion_status', 'completed')->count(),
|
||||
];
|
||||
|
||||
return view('hr.leave-promotions.index', [
|
||||
'candidates' => $candidates,
|
||||
'stats' => $stats,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'employee_ids' => 'required|array|min:1',
|
||||
'employee_ids.*' => 'integer',
|
||||
'notice_type' => 'required|in:1st,2nd',
|
||||
'deadline' => 'required_if:notice_type,1st|date',
|
||||
'designated_dates' => 'nullable|array',
|
||||
'designated_dates.*' => 'date',
|
||||
]);
|
||||
|
||||
$year = (int) ($request->get('year', now()->year));
|
||||
$noticeType = $request->get('notice_type');
|
||||
$employeeIds = $request->get('employee_ids');
|
||||
|
||||
$result = $this->leaveService->sendPromotionNotices(
|
||||
employeeIds: $employeeIds,
|
||||
noticeType: $noticeType,
|
||||
year: $year,
|
||||
deadline: $request->get('deadline'),
|
||||
designatedDates: $request->get('designated_dates', []),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => count($result['created']).'건의 연차촉진 통지서가 생성되었습니다.',
|
||||
'created' => $result['created'],
|
||||
'skipped' => $result['skipped'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/HR/PayrollController.php
Normal file
47
app/Http/Controllers/HR/PayrollController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Payroll;
|
||||
use App\Services\HR\PayrollService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
|
||||
|
||||
public function __construct(
|
||||
private PayrollService $payrollService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 급여관리 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.payrolls.index'));
|
||||
}
|
||||
|
||||
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
|
||||
return view('hr.payrolls.restricted');
|
||||
}
|
||||
|
||||
$stats = $this->payrollService->getMonthlyStats();
|
||||
$departments = $this->payrollService->getDepartments();
|
||||
$employees = $this->payrollService->getActiveEmployees();
|
||||
$settings = $this->payrollService->getSettings();
|
||||
$statusMap = Payroll::STATUS_MAP;
|
||||
|
||||
return view('hr.payrolls.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
'employees' => $employees,
|
||||
'settings' => $settings,
|
||||
'statusMap' => $statusMap,
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Help/AccountingGuideController.php
Normal file
23
app/Http/Controllers/Help/AccountingGuideController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Help;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 도움말 > 회계동작원리 컨트롤러
|
||||
*/
|
||||
class AccountingGuideController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('help.accounting.index'));
|
||||
}
|
||||
|
||||
return view('help.accounting.index');
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Help/AttendanceGuideController.php
Normal file
23
app/Http/Controllers/Help/AttendanceGuideController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Help;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 도움말 > 연차휴가/근태관리 컨트롤러
|
||||
*/
|
||||
class AttendanceGuideController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('help.attendance.index'));
|
||||
}
|
||||
|
||||
return view('help.attendance.index');
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Help/BarobillGuideController.php
Normal file
23
app/Http/Controllers/Help/BarobillGuideController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Help;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 도움말 > 바로빌 연동 가이드 컨트롤러
|
||||
*/
|
||||
class BarobillGuideController extends Controller
|
||||
{
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('help.barobill.index'));
|
||||
}
|
||||
|
||||
return view('help.barobill.index');
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,13 @@ public function project(Request $request): View|Response
|
||||
|
||||
return view('juil.project');
|
||||
}
|
||||
|
||||
public function workflow(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('juil.workflow'));
|
||||
}
|
||||
|
||||
return view('juil.workflow');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ public function index(Request $request): View|Response
|
||||
'diff' => $diff,
|
||||
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
|
||||
'remoteTenantName' => $this->remoteTenantName,
|
||||
'hasOrderSnapshot' => session()->has("menu_order_snapshot_{$selectedEnv}"),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -122,7 +123,7 @@ public function export(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
@@ -150,7 +151,7 @@ public function import(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
@@ -159,7 +160,7 @@ public function import(Request $request): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'menus' => 'required|array',
|
||||
'menus.*.name' => 'required|string|max:100',
|
||||
'menus.*.url' => 'required|string|max:255',
|
||||
'menus.*.url' => 'nullable|string|max:255',
|
||||
'menus.*.icon' => 'nullable|string|max:50',
|
||||
'menus.*.sort_order' => 'nullable|integer',
|
||||
'menus.*.options' => 'nullable|array',
|
||||
@@ -216,7 +217,7 @@ public function push(Request $request): JsonResponse
|
||||
|
||||
return [
|
||||
'name' => $menu->name,
|
||||
'url' => $menu->url,
|
||||
'url' => $menu->url ?? '',
|
||||
'icon' => $menu->icon,
|
||||
'sort_order' => $menu->sort_order,
|
||||
'options' => $menu->options,
|
||||
@@ -230,7 +231,7 @@ public function push(Request $request): JsonResponse
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->post(rtrim($env['url'], '/').'/menu-sync/import', [
|
||||
])->timeout(15)->post(rtrim($env['url'], '/').'/menu-sync/import', [
|
||||
'menus' => $menuData,
|
||||
]);
|
||||
|
||||
@@ -241,9 +242,11 @@ public function push(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
$errorMsg = $response->json('message') ?? $response->json('error') ?? '원격 서버 오류 (HTTP '.$response->status().')';
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
'error' => $errorMsg,
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
|
||||
}
|
||||
@@ -379,7 +382,7 @@ private function getChildrenData(int $parentId): array
|
||||
return $children->map(function ($menu) {
|
||||
return [
|
||||
'name' => $menu->name,
|
||||
'url' => $menu->url,
|
||||
'url' => $menu->url ?? '',
|
||||
'icon' => $menu->icon,
|
||||
'sort_order' => $menu->sort_order,
|
||||
'options' => $menu->options,
|
||||
@@ -470,6 +473,213 @@ private function filterMenusByName(array $menus, array $names, ?string $parentNa
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 동기화 Push (로컬 순서 → 원격 서버)
|
||||
*/
|
||||
public function pushOrder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 원격 서버 현재 순서 스냅샷 저장 (되돌리기용)
|
||||
$remoteMenus = $this->fetchRemoteMenus($env);
|
||||
$snapshot = $this->buildOrderMap($remoteMenus);
|
||||
session()->put("menu_order_snapshot_{$validated['env']}", $snapshot);
|
||||
|
||||
// 2. 로컬 메뉴 트리에서 순서 매핑 생성
|
||||
$localMenus = $this->getMenuTree();
|
||||
$orderMap = $this->buildOrderMap($localMenus);
|
||||
|
||||
// 3. 원격 서버에 순서 업데이트 전송
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [
|
||||
'menus' => $orderMap,
|
||||
'tenant_id' => $this->getTenantId(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $response->json('message', '순서 동기화 완료'),
|
||||
'hasSnapshot' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// 실패 시 스냅샷 삭제
|
||||
session()->forget("menu_order_snapshot_{$validated['env']}");
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
session()->forget("menu_order_snapshot_{$validated['env']}");
|
||||
|
||||
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 동기화 되돌리기
|
||||
*/
|
||||
public function undoOrder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
$snapshot = session("menu_order_snapshot_{$validated['env']}");
|
||||
if (empty($snapshot)) {
|
||||
return response()->json(['error' => '되돌릴 스냅샷이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(30)->post(rtrim($env['url'], '/').'/menu-sync/reorder', [
|
||||
'menus' => $snapshot,
|
||||
'tenant_id' => $this->getTenantId(),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
session()->forget("menu_order_snapshot_{$validated['env']}");
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 이전 상태로 복원되었습니다.',
|
||||
'hasSnapshot' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 순서 재정렬 API (외부 서버에서 호출)
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key');
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'menus' => 'required|array',
|
||||
'tenant_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = $validated['tenant_id'] ?? $this->getTenantId();
|
||||
$updated = $this->applyOrder($validated['menus'], $tenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$updated}개 메뉴 순서가 업데이트되었습니다.",
|
||||
'updated' => $updated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 트리를 순서 매핑 배열로 변환
|
||||
*/
|
||||
private function buildOrderMap(array $menus, ?string $parentName = null): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($menus as $menu) {
|
||||
$item = [
|
||||
'name' => $menu['name'],
|
||||
'parent_name' => $parentName,
|
||||
'sort_order' => $menu['sort_order'] ?? 0,
|
||||
'children' => [],
|
||||
];
|
||||
|
||||
if (! empty($menu['children'])) {
|
||||
$item['children'] = $this->buildOrderMap($menu['children'], $menu['name']);
|
||||
}
|
||||
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 매핑을 DB에 적용 (재귀)
|
||||
*/
|
||||
private function applyOrder(array $orderMap, int $tenantId, ?int $parentId = null): int
|
||||
{
|
||||
$updated = 0;
|
||||
|
||||
foreach ($orderMap as $item) {
|
||||
$query = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', $item['name']);
|
||||
|
||||
// parent_name으로 부모 매칭
|
||||
if (! empty($item['parent_name'])) {
|
||||
$parent = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', $item['parent_name'])
|
||||
->first();
|
||||
$resolvedParentId = $parent?->id;
|
||||
} else {
|
||||
$resolvedParentId = null;
|
||||
}
|
||||
|
||||
// parentId 파라미터가 있으면 우선 사용 (재귀 호출 시)
|
||||
$targetParentId = $parentId ?? $resolvedParentId;
|
||||
$query->where('parent_id', $targetParentId);
|
||||
|
||||
$menu = $query->first();
|
||||
if ($menu) {
|
||||
$changes = [];
|
||||
if ($menu->sort_order !== ($item['sort_order'] ?? 0)) {
|
||||
$changes['sort_order'] = $item['sort_order'] ?? 0;
|
||||
}
|
||||
if ($menu->parent_id !== $targetParentId) {
|
||||
$changes['parent_id'] = $targetParentId;
|
||||
}
|
||||
|
||||
if (! empty($changes)) {
|
||||
$menu->update($changes);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
// 자식 메뉴 처리
|
||||
if (! empty($item['children'])) {
|
||||
$updated += $this->applyOrder($item['children'], $tenantId, $menu->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 Import
|
||||
*/
|
||||
@@ -492,7 +702,7 @@ private function importMenu(array $data, ?int $parentId = null): void
|
||||
'parent_id' => $parentId,
|
||||
],
|
||||
[
|
||||
'url' => $data['url'],
|
||||
'url' => ! empty($data['url']) ? $data['url'] : null,
|
||||
'icon' => $data['icon'] ?? null,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
'options' => $data['options'] ?? null,
|
||||
|
||||
62
app/Http/Controllers/Mobile/MobileInspectionController.php
Normal file
62
app/Http/Controllers/Mobile/MobileInspectionController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Mobile;
|
||||
|
||||
use App\Enums\InspectionCycle;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Equipment\Equipment;
|
||||
use App\Models\Equipment\EquipmentInspection;
|
||||
use App\Models\Equipment\EquipmentInspectionDetail;
|
||||
use App\Models\Equipment\EquipmentInspectionTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MobileInspectionController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id): View
|
||||
{
|
||||
$equipment = Equipment::with(['manager', 'subManager'])->findOrFail($id);
|
||||
|
||||
$cycle = $request->input('cycle', InspectionCycle::DAILY);
|
||||
$today = now()->format('Y-m-d');
|
||||
$period = InspectionCycle::resolvePeriod($cycle, $today);
|
||||
|
||||
$activeCycles = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
|
||||
->where('is_active', true)
|
||||
->distinct()
|
||||
->pluck('inspection_cycle')
|
||||
->toArray();
|
||||
|
||||
$templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
|
||||
->where('inspection_cycle', $cycle)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
$inspection = EquipmentInspection::where('equipment_id', $equipment->id)
|
||||
->where('inspection_cycle', $cycle)
|
||||
->where('year_month', $period)
|
||||
->first();
|
||||
|
||||
$details = collect();
|
||||
if ($inspection) {
|
||||
$details = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
|
||||
->where('check_date', $today)
|
||||
->get()
|
||||
->keyBy('template_item_id');
|
||||
}
|
||||
|
||||
$canInspect = $equipment->canInspect();
|
||||
|
||||
return view('mobile.inspection.show', compact(
|
||||
'equipment',
|
||||
'cycle',
|
||||
'today',
|
||||
'period',
|
||||
'activeCycles',
|
||||
'templates',
|
||||
'details',
|
||||
'canInspect',
|
||||
));
|
||||
}
|
||||
}
|
||||
322
app/Http/Controllers/Rd/CmSongController.php
Normal file
322
app/Http/Controllers/Rd/CmSongController.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Rd;
|
||||
|
||||
use App\Helpers\AiTokenHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Rd\CmSong;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CmSongController extends Controller
|
||||
{
|
||||
private string $baseUrl;
|
||||
|
||||
private string $apiKey;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('services.gemini.base_url');
|
||||
$this->apiKey = config('services.gemini.api_key');
|
||||
}
|
||||
|
||||
/**
|
||||
* 나레이션 목록
|
||||
*/
|
||||
public function index(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.cm-song.index'));
|
||||
}
|
||||
|
||||
$songs = CmSong::with('user')
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('rd.cm-song.index', compact('songs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 나레이션 제작 페이지
|
||||
*/
|
||||
public function create(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.cm-song.create'));
|
||||
}
|
||||
|
||||
return view('rd.cm-song.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* 나레이션 상세
|
||||
*/
|
||||
public function show(Request $request, int $id): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.cm-song.show', $id));
|
||||
}
|
||||
|
||||
$song = CmSong::with('user')->findOrFail($id);
|
||||
|
||||
return view('rd.cm-song.show', compact('song'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 나레이션 가사 생성 (Gemini API)
|
||||
*/
|
||||
public function generateLyrics(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'company_name' => 'required|string|max:100',
|
||||
'industry' => 'required|string|max:200',
|
||||
'mood' => 'required|string|max:50',
|
||||
'duration' => 'required|integer|min:10|max:60',
|
||||
]);
|
||||
|
||||
$duration = $request->duration;
|
||||
$lines = match (true) {
|
||||
$duration <= 15 => '3~4줄',
|
||||
$duration <= 30 => '6~8줄',
|
||||
$duration <= 45 => '10~12줄',
|
||||
default => '14~16줄',
|
||||
};
|
||||
|
||||
$prompt = "당신은 전문 나레이션 작사가입니다. 다음 정보를 바탕으로 기억에 남는 {$duration}초 분량의 라디오 나레이션 가사를 작성해주세요.
|
||||
|
||||
회사명: {$request->company_name}
|
||||
업종/제품: {$request->industry}
|
||||
분위기: {$request->mood}
|
||||
|
||||
조건:
|
||||
- {$lines}로 작성 ({$duration}초 분량)
|
||||
- 운율을 살려서 작성
|
||||
- 지시문(예: (음악 소리), (밝은 목소리로)) 없이 오직 읽을 수 있는 가사 텍스트만 출력할 것.";
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->post(
|
||||
"{$this->baseUrl}/models/gemini-2.5-flash:generateContent?key={$this->apiKey}",
|
||||
[
|
||||
'contents' => [
|
||||
['parts' => [['text' => $prompt]]],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '가사 생성에 실패했습니다: '.$response->status(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 토큰 사용량 기록
|
||||
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash', '나레이션-가사생성');
|
||||
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'lyrics' => trim($text),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '가사 생성 중 오류: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTS 음성 생성 (Gemini TTS API)
|
||||
*/
|
||||
public function generateAudio(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'lyrics' => 'required|string|max:2000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(60)->post(
|
||||
"{$this->baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$this->apiKey}",
|
||||
[
|
||||
'contents' => [
|
||||
['parts' => [['text' => $request->lyrics]]],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'responseModalities' => ['AUDIO'],
|
||||
'speechConfig' => [
|
||||
'voiceConfig' => [
|
||||
'prebuiltVoiceConfig' => [
|
||||
'voiceName' => 'Kore',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '음성 생성에 실패했습니다: '.$response->status(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 토큰 사용량 기록
|
||||
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash-preview-tts', '나레이션-TTS');
|
||||
|
||||
$inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null;
|
||||
|
||||
if (! $inlineData || empty($inlineData['data'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '음성 데이터를 받지 못했습니다.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'audio_data' => $inlineData['data'],
|
||||
'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => '음성 생성 중 오류: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 나레이션 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'company_name' => 'required|string|max:100',
|
||||
'industry' => 'required|string|max:200',
|
||||
'mood' => 'required|string|max:50',
|
||||
'duration' => 'required|integer|min:10|max:60',
|
||||
'lyrics' => 'required|string|max:2000',
|
||||
'audio_data' => 'nullable|string',
|
||||
'audio_mime_type' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$userId = Auth::id();
|
||||
$audioPath = null;
|
||||
|
||||
// 오디오 데이터가 있으면 WAV 파일로 저장
|
||||
if ($request->audio_data) {
|
||||
$mimeType = $request->audio_mime_type ?? 'audio/L16;rate=24000';
|
||||
$audioBytes = base64_decode($request->audio_data);
|
||||
|
||||
if (str_contains($mimeType, 'L16') || str_contains($mimeType, 'pcm')) {
|
||||
$sampleRate = 24000;
|
||||
if (preg_match('/rate=(\d+)/', $mimeType, $m)) {
|
||||
$sampleRate = (int) $m[1];
|
||||
}
|
||||
$audioBytes = $this->pcmToWav($audioBytes, $sampleRate);
|
||||
}
|
||||
|
||||
$filename = 'cm-song-'.date('Ymd-His').'-'.uniqid().'.wav';
|
||||
$dir = "cm-songs/{$tenantId}";
|
||||
Storage::disk('tenant')->makeDirectory($dir);
|
||||
Storage::disk('tenant')->put("{$dir}/{$filename}", $audioBytes);
|
||||
$audioPath = "{$dir}/{$filename}";
|
||||
}
|
||||
|
||||
$song = CmSong::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'company_name' => $request->company_name,
|
||||
'industry' => $request->industry,
|
||||
'lyrics' => $request->lyrics,
|
||||
'audio_path' => $audioPath,
|
||||
'options' => [
|
||||
'mood' => $request->mood,
|
||||
'duration' => $request->duration,
|
||||
],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'id' => $song->id,
|
||||
'message' => '나레이션이 저장되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 파일 다운로드
|
||||
*/
|
||||
public function download(int $id)
|
||||
{
|
||||
$song = CmSong::findOrFail($id);
|
||||
|
||||
if (! $song->audio_path || ! Storage::disk('tenant')->exists($song->audio_path)) {
|
||||
abort(404, '음성 파일이 없습니다.');
|
||||
}
|
||||
|
||||
$filename = "나레이션_{$song->company_name}_".date('Ymd', strtotime($song->created_at)).'.wav';
|
||||
|
||||
return Storage::disk('tenant')->download($song->audio_path, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 나레이션 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$song = CmSong::findOrFail($id);
|
||||
|
||||
if ($song->audio_path && Storage::disk('tenant')->exists($song->audio_path)) {
|
||||
Storage::disk('tenant')->delete($song->audio_path);
|
||||
}
|
||||
|
||||
$song->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '나레이션이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PCM → WAV 변환 (서버사이드)
|
||||
*/
|
||||
private function pcmToWav(string $pcmData, int $sampleRate): string
|
||||
{
|
||||
$numChannels = 1;
|
||||
$bitsPerSample = 16;
|
||||
$byteRate = $sampleRate * $numChannels * $bitsPerSample / 8;
|
||||
$blockAlign = $numChannels * $bitsPerSample / 8;
|
||||
$dataSize = strlen($pcmData);
|
||||
|
||||
$header = pack('A4VVA4', 'RIFF', 36 + $dataSize, 0x45564157, 'WAVEfmt ');
|
||||
// 'WAVE' as little-endian is 0x45564157... actually let me write it properly
|
||||
$header = 'RIFF';
|
||||
$header .= pack('V', 36 + $dataSize);
|
||||
$header .= 'WAVE';
|
||||
$header .= 'fmt ';
|
||||
$header .= pack('V', 16); // SubChunk1Size
|
||||
$header .= pack('v', 1); // AudioFormat (PCM)
|
||||
$header .= pack('v', $numChannels);
|
||||
$header .= pack('V', $sampleRate);
|
||||
$header .= pack('V', $byteRate);
|
||||
$header .= pack('v', $blockAlign);
|
||||
$header .= pack('v', $bitsPerSample);
|
||||
$header .= 'data';
|
||||
$header .= pack('V', $dataSize);
|
||||
|
||||
return $header.$pcmData;
|
||||
}
|
||||
}
|
||||
592
app/Http/Controllers/RdController.php
Normal file
592
app/Http/Controllers/RdController.php
Normal file
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\AiTokenHelper;
|
||||
use App\Models\HR\Employee;
|
||||
use App\Models\Rd\AiQuotation;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\Rd\AiQuotationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RdController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiQuotationService $quotationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* R&D 대시보드
|
||||
*/
|
||||
public function index(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.index'));
|
||||
}
|
||||
|
||||
$dashboard = $this->quotationService->getDashboardStats();
|
||||
$statuses = AiQuotation::getStatuses();
|
||||
|
||||
return view('rd.index', compact('dashboard', 'statuses'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 조직도 관리
|
||||
*/
|
||||
public function orgChart(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.org-chart'));
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
// 부서 트리 (parent_id=null이 최상위)
|
||||
$departments = Department::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// 전체 직원 (활성 상태)
|
||||
$rawEmployees = Employee::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('employee_status', 'active')
|
||||
->with(['user', 'department'])
|
||||
->orderBy('display_name')
|
||||
->get();
|
||||
|
||||
// Blade @json 호환을 위해 미리 배열로 변환
|
||||
$employees = $rawEmployees->map(function ($e) {
|
||||
return [
|
||||
'id' => $e->id,
|
||||
'user_id' => $e->user_id,
|
||||
'department_id' => $e->department_id,
|
||||
'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)',
|
||||
'position_label' => $e->position_label,
|
||||
];
|
||||
})->values();
|
||||
|
||||
// 회사 정보 (조직도 최상단)
|
||||
$tenant = Tenant::find($tenantId);
|
||||
$companyName = $tenant->company_name ?? 'SAM';
|
||||
$ceoName = $tenant->ceo_name ?? '';
|
||||
|
||||
return view('rd.org-chart', compact('departments', 'employees', 'companyName', 'ceoName'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 조직도 - 직원 부서 배치
|
||||
*/
|
||||
public function orgChartAssign(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'employee_id' => 'required|integer',
|
||||
'department_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$employee = Employee::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $request->employee_id)
|
||||
->first();
|
||||
|
||||
if (! $employee) {
|
||||
return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$employee->department_id = $request->department_id;
|
||||
$employee->save();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조직도 - 직원 부서 해제 (미배치로 이동)
|
||||
*/
|
||||
public function orgChartUnassign(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'employee_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$employee = Employee::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $request->employee_id)
|
||||
->first();
|
||||
|
||||
if (! $employee) {
|
||||
return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$employee->department_id = null;
|
||||
$employee->save();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조직도 - 부서 내 직원 순서/이동 일괄 처리
|
||||
*/
|
||||
public function orgChartReorder(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'moves' => 'required|array',
|
||||
'moves.*.employee_id' => 'required|integer',
|
||||
'moves.*.department_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
foreach ($request->moves as $move) {
|
||||
Employee::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $move['employee_id'])
|
||||
->update(['department_id' => $move['department_id']]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조직도 - 부서 순서 변경 (드래그 앤 드롭)
|
||||
*/
|
||||
public function orgChartReorderDepts(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'orders' => 'required|array',
|
||||
'orders.*.id' => 'required|integer',
|
||||
'orders.*.parent_id' => 'nullable|integer',
|
||||
'orders.*.sort_order' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
foreach ($request->orders as $order) {
|
||||
Department::where('tenant_id', $tenantId)
|
||||
->where('id', $order['id'])
|
||||
->update([
|
||||
'parent_id' => $order['parent_id'],
|
||||
'sort_order' => $order['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조직도 - 부서 숨기기/표시 토글
|
||||
*/
|
||||
public function orgChartToggleHide(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'department_id' => 'required|integer',
|
||||
'hidden' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$dept = Department::where('tenant_id', $tenantId)
|
||||
->where('id', $request->department_id)
|
||||
->first();
|
||||
|
||||
if (! $dept) {
|
||||
return response()->json(['success' => false, 'message' => '부서를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$options = $dept->options ?? [];
|
||||
$options['orgchart_hidden'] = $request->hidden;
|
||||
$dept->options = $options;
|
||||
$dept->save();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중대재해처벌법 실무 점검
|
||||
*/
|
||||
public function safetyAudit(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.safety-audit'));
|
||||
}
|
||||
|
||||
return view('rd.safety-audit');
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 목록
|
||||
*/
|
||||
public function quotations(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.index'));
|
||||
}
|
||||
|
||||
$statuses = AiQuotation::getStatuses();
|
||||
|
||||
return view('rd.ai-quotation.index', compact('statuses'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 생성 폼
|
||||
*/
|
||||
public function createQuotation(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.create'));
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 문서 (인쇄용 견적서)
|
||||
*/
|
||||
public function documentQuotation(Request $request, int $id): View
|
||||
{
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation || ! $quotation->isCompleted()) {
|
||||
abort(404, '완료된 견적만 문서로 조회할 수 있습니다.');
|
||||
}
|
||||
|
||||
$template = $request->query('template', 'classic');
|
||||
$allowed = ['classic', 'modern', 'blue', 'dark', 'colorful'];
|
||||
if (! in_array($template, $allowed)) {
|
||||
$template = 'classic';
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.document', compact('quotation', 'template'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 상세
|
||||
*/
|
||||
public function showQuotation(Request $request, int $id): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.show', $id));
|
||||
}
|
||||
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
abort(404, 'AI 견적을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.show', compact('quotation'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 편집 (제조 모드)
|
||||
*/
|
||||
public function editQuotation(Request $request, int $id): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.edit', $id));
|
||||
}
|
||||
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
abort(404, 'AI 견적을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if (! $quotation->isCompleted()) {
|
||||
abort(403, '완료된 견적만 편집할 수 있습니다.');
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.edit', compact('quotation'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기획디자인 - 플래닝 캔버스
|
||||
*/
|
||||
public function planningDesign(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.planning-design'));
|
||||
}
|
||||
|
||||
return view('rd.planning-design.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 디자인 인사이트 - UI/UX 연구 도구
|
||||
*/
|
||||
public function designInsight(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.design-insight'));
|
||||
}
|
||||
|
||||
return view('rd.design-insight.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사운드 로고 생성기
|
||||
*/
|
||||
public function soundLogo(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.sound-logo'));
|
||||
}
|
||||
|
||||
return view('rd.sound-logo.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lyria RealTime 접속용 API 설정 반환
|
||||
*/
|
||||
public function soundLogoLyriaConfig(): JsonResponse
|
||||
{
|
||||
$apiKey = config('services.gemini.api_key');
|
||||
|
||||
if (! $apiKey) {
|
||||
return response()->json(['success' => false, 'error' => 'API 키가 설정되지 않았습니다.'], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'api_key' => $apiKey,
|
||||
'ws_url' => 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateMusic',
|
||||
'model' => 'models/lyria-realtime-exp',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사운드 로고 AI 생성 (Gemini API)
|
||||
*/
|
||||
public function soundLogoGenerate(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'prompt' => 'required|string|max:500',
|
||||
'category' => 'nullable|string',
|
||||
'duration' => 'nullable|numeric|min:0.3|max:5',
|
||||
]);
|
||||
|
||||
$apiKey = config('services.gemini.api_key');
|
||||
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
|
||||
$model = config('services.gemini.model', 'gemini-2.5-flash');
|
||||
|
||||
if (! $apiKey) {
|
||||
return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
|
||||
}
|
||||
|
||||
$category = $request->category ?? '기업 시그널';
|
||||
$duration = $request->duration ?? 1.5;
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
당신은 사운드 디자인 전문가입니다. 사용자의 요청에 맞는 사운드 로고(짧은 시그니처 사운드)를 Web Audio API 음표 시퀀스로 설계해주세요.
|
||||
|
||||
## 사용자 요청
|
||||
- 설명: {$request->prompt}
|
||||
- 카테고리: {$category}
|
||||
- 목표 길이: {$duration}초
|
||||
|
||||
## 사용 가능한 음표
|
||||
C3, C#3, D3, D#3, E3, F3, F#3, G3, G#3, A3, A#3, B3,
|
||||
C4, C#4, D4, D#4, E4, F4, F#4, G4, G#4, A4, A#4, B4,
|
||||
C5, C#5, D5, D#5, E5, F5, F#5, G5, G#5, A5, A#5, B5, C6
|
||||
|
||||
## 음표 타입
|
||||
- note: 단일 음 (note 필드 필수)
|
||||
- chord: 화음 (chord 배열 필수, 2~4개 음)
|
||||
- rest: 쉼표 (duration만 필요)
|
||||
|
||||
## 신스 타입
|
||||
- sine: 부드러움 (기업 로고, 알림에 적합)
|
||||
- triangle: 따뜻함 (성공, 게임에 적합)
|
||||
- square: 8bit/디지털 (게임, UI에 적합)
|
||||
- sawtooth: 날카로움 (록, 긴급 알림에 적합)
|
||||
|
||||
## 반드시 아래 JSON 형식으로만 응답하세요
|
||||
{
|
||||
"name": "사운드 이름",
|
||||
"desc": "사운드 설명 (한줄)",
|
||||
"synth": "sine",
|
||||
"adsr": { "attack": 10, "decay": 80, "sustain": 0.6, "release": 400 },
|
||||
"volume": 0.8,
|
||||
"reverb": 0.3,
|
||||
"notes": [
|
||||
{ "type": "note", "note": "C5", "duration": 0.20, "velocity": 0.8 },
|
||||
{ "type": "rest", "duration": 0.10 },
|
||||
{ "type": "chord", "chord": ["C4", "E4", "G4"], "duration": 0.50, "velocity": 1.0 }
|
||||
]
|
||||
}
|
||||
|
||||
## 설계 원칙
|
||||
- 음표의 duration 합계가 목표 길이({$duration}초)에 근접하도록 설계
|
||||
- velocity: 0.3~1.0 (음의 강약으로 표현력 추가)
|
||||
- ADSR: attack(1~500ms), decay(10~1000ms), sustain(0~1.0), release(10~3000ms)
|
||||
- 카테고리 특성에 맞는 synth와 ADSR 선택
|
||||
- 음악적으로 조화롭고 기억에 남는 멜로디 설계
|
||||
- 최소 2개, 최대 12개 음표 사용
|
||||
- name은 10자 이내, desc는 30자 이내로 간결하게 작성
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->post(
|
||||
"{$baseUrl}/models/{$model}:generateContent?key={$apiKey}",
|
||||
[
|
||||
'contents' => [
|
||||
['parts' => [['text' => $prompt]]],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.7,
|
||||
'maxOutputTokens' => 4096,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SoundLogo AI 생성 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'AI 서버 연결 실패'], 500);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('SoundLogo AI API 오류', ['status' => $response->status(), 'body' => $response->body()]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'AI 생성 실패: '.$response->status()], 500);
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 토큰 사용량 기록
|
||||
AiTokenHelper::saveGeminiUsage($data, $model, '사운드로고-AI생성');
|
||||
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
|
||||
// JSON 파싱 (코드블록 제거)
|
||||
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
|
||||
$text = preg_replace('/```\s*$/m', '', $text);
|
||||
$result = json_decode(trim($text), true);
|
||||
|
||||
if (! $result || ! isset($result['notes'])) {
|
||||
Log::warning('SoundLogo AI 응답 파싱 실패', ['text' => substr($text, 0, 500)]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'AI 응답을 파싱할 수 없습니다.'], 500);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사운드 로고 TTS 음성 생성 (Gemini TTS API)
|
||||
*/
|
||||
public function soundLogoTts(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'text' => 'required|string|max:200',
|
||||
'voice_name' => 'nullable|string|max:30',
|
||||
'voice_category' => 'nullable|string|in:female,male,child',
|
||||
'voice_style' => 'nullable|string|max:100',
|
||||
'voice_speed' => 'nullable|integer|min:1|max:5',
|
||||
]);
|
||||
|
||||
$apiKey = config('services.gemini.api_key');
|
||||
$baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta');
|
||||
|
||||
if (! $apiKey) {
|
||||
return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500);
|
||||
}
|
||||
|
||||
$voiceName = $request->voice_name ?: 'Kore';
|
||||
$voiceCategory = $request->voice_category ?: 'female';
|
||||
$voiceStyle = $request->voice_style ?: '';
|
||||
$voiceSpeed = $request->voice_speed ?: 3;
|
||||
|
||||
// 속도 지시문 매핑
|
||||
$speedDirectives = [
|
||||
1 => '아주 천천히 또박또박 말해주세요.',
|
||||
2 => '조금 느린 속도로 말해주세요.',
|
||||
3 => '',
|
||||
4 => '조금 빠른 속도로 말해주세요.',
|
||||
5 => '아주 빠른 속도로 말해주세요.',
|
||||
];
|
||||
|
||||
// TTS 프롬프트 구성 — Director's Note 형식
|
||||
$ttsText = $request->text;
|
||||
$notes = [];
|
||||
|
||||
// 아이 카테고리: 높은 톤으로 어린이처럼 연기하도록 강한 지시문
|
||||
if ($voiceCategory === 'child') {
|
||||
$notes[] = 'Speak as a young child with a high-pitched, innocent voice';
|
||||
}
|
||||
|
||||
if ($voiceStyle) {
|
||||
$notes[] = $voiceStyle;
|
||||
}
|
||||
|
||||
if (! empty($speedDirectives[$voiceSpeed])) {
|
||||
$notes[] = $speedDirectives[$voiceSpeed];
|
||||
}
|
||||
|
||||
if (! empty($notes)) {
|
||||
$direction = implode('. ', $notes);
|
||||
$ttsText = "[{$direction}]\n\n{$ttsText}";
|
||||
}
|
||||
|
||||
// 짧은 텍스트는 TTS 모델이 텍스트 생성으로 인식할 수 있으므로 발화 컨텍스트 추가
|
||||
if (mb_strlen($ttsText) < 4) {
|
||||
$ttsText = "'{$ttsText}' ";
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->post(
|
||||
"{$baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$apiKey}",
|
||||
[
|
||||
'contents' => [
|
||||
['parts' => [['text' => $ttsText]]],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'responseModalities' => ['AUDIO'],
|
||||
'speechConfig' => [
|
||||
'voiceConfig' => [
|
||||
'prebuiltVoiceConfig' => [
|
||||
'voiceName' => $voiceName,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SoundLogo TTS 생성 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'TTS 서버 연결 실패'], 500);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$body = $response->json();
|
||||
$msg = $body['error']['message'] ?? ('TTS 생성 실패: '.$response->status());
|
||||
Log::warning('SoundLogo TTS API 에러', ['status' => $response->status(), 'body' => $msg]);
|
||||
|
||||
return response()->json(['success' => false, 'error' => $msg], $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 토큰 사용량 기록
|
||||
AiTokenHelper::saveGeminiUsage($data, 'gemini-2.5-flash-preview-tts', '사운드로고-TTS');
|
||||
|
||||
$inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null;
|
||||
|
||||
if (! $inlineData || empty($inlineData['data'])) {
|
||||
return response()->json(['success' => false, 'error' => '음성 데이터를 받지 못했습니다.'], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'audio_data' => $inlineData['data'],
|
||||
'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000',
|
||||
]);
|
||||
}
|
||||
}
|
||||
242
app/Http/Controllers/RoadmapController.php
Normal file
242
app/Http/Controllers/RoadmapController.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Admin\AdminRoadmapPlan;
|
||||
use App\Services\Roadmap\RoadmapPlanService;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RoadmapController extends Controller
|
||||
{
|
||||
/** @deprecated config('roadmap.docs_base')로 대체 */
|
||||
private const DOCS_BASE = __DIR__.'/../../../../docs';
|
||||
|
||||
private const DOCUMENT_REGISTRY = [
|
||||
[
|
||||
'category' => 'vision',
|
||||
'category_label' => '비전 & 전략',
|
||||
'icon' => 'ri-lightbulb-line',
|
||||
'color' => 'indigo',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'ai-automation-vision',
|
||||
'title' => 'SAM AI 자동화 비전',
|
||||
'description' => 'SAM의 장기 비전과 AI 자동화 전략. 영업에서 출고까지 End-to-End 자동화 로드맵.',
|
||||
'path' => 'system/ai-automation-vision.md',
|
||||
'date' => '2026-03-02',
|
||||
'badge' => '설계 확정',
|
||||
],
|
||||
[
|
||||
'slug' => 'scaling-roadmap',
|
||||
'title' => '10,000 테넌트 스케일링 로드맵',
|
||||
'description' => '현재 아키텍처 진단부터 5단계 스케일링 계획까지. 세계 수준 엔지니어링 시나리오.',
|
||||
'path' => 'system/scaling-roadmap.md',
|
||||
'date' => '2026-02-22',
|
||||
'badge' => '가상 시나리오',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'category' => 'launch',
|
||||
'category_label' => '프로젝트 런칭',
|
||||
'icon' => 'ri-rocket-line',
|
||||
'color' => 'blue',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'project-launch-roadmap',
|
||||
'title' => 'SAM 프로젝트 런칭 로드맵',
|
||||
'description' => '전체 시스템 구성, MVP 범위, 마일스톤(MS1~MS3), 개발 완료율 현황.',
|
||||
'path' => 'guides/project-launch-roadmap.md',
|
||||
'date' => '2025-12-02',
|
||||
'badge' => '진행중',
|
||||
],
|
||||
[
|
||||
'slug' => 'production-deployment-plan',
|
||||
'title' => '운영 환경 배포 계획서',
|
||||
'description' => 'MS3 정식 런칭 배포 계획. 무중단 전환, 롤백, Jenkins CI/CD 자동화.',
|
||||
'path' => 'plans/production-deployment-plan.md',
|
||||
'date' => '2026-02-22',
|
||||
'badge' => '계획 수립',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'category' => 'product',
|
||||
'category_label' => '제품 설계',
|
||||
'icon' => 'ri-draft-line',
|
||||
'color' => 'green',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'erp-storyboard',
|
||||
'title' => 'SAM ERP 스토리보드 D1.4',
|
||||
'description' => '전체 ERP 메뉴 구조와 화면 설계. 대시보드, MES, HR, 전자결재, 회계, 구독 관리.',
|
||||
'path' => 'plans/SAM_ERP_Storyboard_D1.4.md',
|
||||
'date' => '2026-01-16',
|
||||
'badge' => 'D1.4',
|
||||
],
|
||||
[
|
||||
'slug' => 'erp-accounting-storyboard',
|
||||
'title' => 'SAM ERP 회계관리 스토리보드 D1.6',
|
||||
'description' => '세금계산서, 계좌 입출금, OCR, 일일 보고서, 건설/생산 대시보드.',
|
||||
'path' => 'plans/SAM_ERP_회계관리_Storyboard_D1.6.md',
|
||||
'date' => '2026-02-20',
|
||||
'badge' => 'D1.6',
|
||||
],
|
||||
[
|
||||
'slug' => 'integrated-master-plan',
|
||||
'title' => '통합 개선 마스터 플랜',
|
||||
'description' => '제품코드 추적성 + 검사 단위 구조 통합 개선. 7단계 Phase 로드맵.',
|
||||
'path' => 'plans/integrated-master-plan.md',
|
||||
'date' => '2026-02-27',
|
||||
'badge' => 'Phase 0~3 완료',
|
||||
],
|
||||
[
|
||||
'slug' => 'ai-quotation-engine-plan',
|
||||
'title' => 'AI 견적서 자동생성 엔진 개발 계획',
|
||||
'description' => '인터뷰 내용을 AI가 분석하여 SAM 표준 견적서로 자동 변환하는 엔진. Claude API 기반.',
|
||||
'path' => 'plans/ai-quotation-engine-plan.md',
|
||||
'date' => '2026-03-02',
|
||||
'badge' => '기획 초안',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'category' => 'system',
|
||||
'category_label' => '시스템 개요',
|
||||
'icon' => 'ri-server-line',
|
||||
'color' => 'gray',
|
||||
'items' => [
|
||||
[
|
||||
'slug' => 'system-overview',
|
||||
'title' => 'SAM 시스템 개요',
|
||||
'description' => '프로젝트 아키텍처, 기술 스택, 멀티테넌시, 레거시 마이그레이션 현황.',
|
||||
'path' => 'system/overview.md',
|
||||
'date' => '2026-02-27',
|
||||
'badge' => '최신',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly RoadmapPlanService $planService
|
||||
) {}
|
||||
|
||||
private function getDocsBasePath(): string
|
||||
{
|
||||
$path = config('roadmap.docs_base', self::DOCS_BASE);
|
||||
|
||||
return realpath($path) ?: $path;
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$summary = $this->planService->getDashboardSummary();
|
||||
$statuses = AdminRoadmapPlan::getStatuses();
|
||||
$categories = AdminRoadmapPlan::getCategories();
|
||||
$priorities = AdminRoadmapPlan::getPriorities();
|
||||
$phases = AdminRoadmapPlan::getPhases();
|
||||
|
||||
return view('roadmap.index', compact(
|
||||
'summary', 'statuses', 'categories', 'priorities', 'phases'
|
||||
));
|
||||
}
|
||||
|
||||
public function plans(): View
|
||||
{
|
||||
$statuses = AdminRoadmapPlan::getStatuses();
|
||||
$categories = AdminRoadmapPlan::getCategories();
|
||||
$priorities = AdminRoadmapPlan::getPriorities();
|
||||
$phases = AdminRoadmapPlan::getPhases();
|
||||
|
||||
return view('roadmap.plans.index', compact('statuses', 'categories', 'priorities', 'phases'));
|
||||
}
|
||||
|
||||
public function createPlan(): View
|
||||
{
|
||||
$statuses = AdminRoadmapPlan::getStatuses();
|
||||
$categories = AdminRoadmapPlan::getCategories();
|
||||
$priorities = AdminRoadmapPlan::getPriorities();
|
||||
$phases = AdminRoadmapPlan::getPhases();
|
||||
|
||||
return view('roadmap.plans.create', compact('statuses', 'categories', 'priorities', 'phases'));
|
||||
}
|
||||
|
||||
public function showPlan(int $id): View
|
||||
{
|
||||
$plan = $this->planService->getPlanById($id, true);
|
||||
if (! $plan) {
|
||||
abort(404, '계획을 찾을 수 없습니다.');
|
||||
}
|
||||
$statuses = AdminRoadmapPlan::getStatuses();
|
||||
$categories = AdminRoadmapPlan::getCategories();
|
||||
$priorities = AdminRoadmapPlan::getPriorities();
|
||||
$phases = AdminRoadmapPlan::getPhases();
|
||||
|
||||
return view('roadmap.plans.show', compact(
|
||||
'plan', 'statuses', 'categories', 'priorities', 'phases'
|
||||
));
|
||||
}
|
||||
|
||||
public function editPlan(int $id): View
|
||||
{
|
||||
$plan = $this->planService->getPlanById($id, true);
|
||||
if (! $plan) {
|
||||
abort(404, '계획을 찾을 수 없습니다.');
|
||||
}
|
||||
$statuses = AdminRoadmapPlan::getStatuses();
|
||||
$categories = AdminRoadmapPlan::getCategories();
|
||||
$priorities = AdminRoadmapPlan::getPriorities();
|
||||
$phases = AdminRoadmapPlan::getPhases();
|
||||
|
||||
return view('roadmap.plans.edit', compact(
|
||||
'plan', 'statuses', 'categories', 'priorities', 'phases'
|
||||
));
|
||||
}
|
||||
|
||||
public function documents(): View
|
||||
{
|
||||
$registry = self::DOCUMENT_REGISTRY;
|
||||
|
||||
// 각 문서의 파일 존재 여부 확인
|
||||
$docsBase = $this->getDocsBasePath();
|
||||
foreach ($registry as &$group) {
|
||||
foreach ($group['items'] as &$item) {
|
||||
$item['exists'] = file_exists($docsBase.'/'.$item['path']);
|
||||
}
|
||||
}
|
||||
|
||||
return view('roadmap.documents.index', compact('registry'));
|
||||
}
|
||||
|
||||
public function showDocument(string $slug): View
|
||||
{
|
||||
$document = null;
|
||||
foreach (self::DOCUMENT_REGISTRY as $group) {
|
||||
foreach ($group['items'] as $item) {
|
||||
if ($item['slug'] === $slug) {
|
||||
$document = $item;
|
||||
$document['category_label'] = $group['category_label'];
|
||||
$document['color'] = $group['color'];
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $document) {
|
||||
abort(404, '문서를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$docsBase = $this->getDocsBasePath();
|
||||
$filePath = $docsBase.'/'.$document['path'];
|
||||
$content = null;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
$markdown = file_get_contents($filePath);
|
||||
$content = Str::markdown($markdown);
|
||||
}
|
||||
|
||||
return view('roadmap.documents.show', compact('document', 'content'));
|
||||
}
|
||||
}
|
||||
@@ -124,4 +124,26 @@ public function process(Request $request, int $id)
|
||||
return redirect()->route('sales.business-cards.manage')
|
||||
->with('success', '처리가 완료되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리완료 건 삭제 (관리자 전용)
|
||||
*/
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
abort(403, '관리자만 삭제할 수 있습니다.');
|
||||
}
|
||||
|
||||
$this->service->delete($id);
|
||||
|
||||
if ($request->expectsJson() || $request->header('Accept') === 'application/json') {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('sales.business-cards.manage')
|
||||
->with('success', '삭제되었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ public function storeCategory(Request $request): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'parent_id' => 'nullable|integer|exists:interview_categories,id',
|
||||
]);
|
||||
|
||||
$category = $this->service->createCategory($validated);
|
||||
@@ -122,7 +123,12 @@ public function storeQuestion(Request $request): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'interview_template_id' => 'required|integer|exists:interview_templates,id',
|
||||
'question_text' => 'required|string|max:500',
|
||||
'question_type' => 'nullable|string|in:checkbox,text',
|
||||
'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram',
|
||||
'options' => 'nullable|array',
|
||||
'ai_hint' => 'nullable|string',
|
||||
'expected_format' => 'nullable|string|max:100',
|
||||
'depends_on' => 'nullable|array',
|
||||
'domain' => 'nullable|string|max:50',
|
||||
'is_required' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
@@ -135,7 +141,7 @@ public function updateQuestion(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'question_text' => 'required|string|max:500',
|
||||
'question_type' => 'nullable|string|in:checkbox,text',
|
||||
'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram',
|
||||
'is_required' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
@@ -224,4 +230,180 @@ public function completeSession(int $id): JsonResponse
|
||||
|
||||
return response()->json($session);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 프로젝트 API
|
||||
// ============================================================
|
||||
|
||||
public function projects(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->only(['status', 'search']);
|
||||
|
||||
return response()->json($this->service->getProjects($filters));
|
||||
}
|
||||
|
||||
public function showProject(int $id): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->getProject($id));
|
||||
}
|
||||
|
||||
public function storeProject(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:200',
|
||||
'company_type' => 'nullable|string|max:100',
|
||||
'contact_person' => 'nullable|string|max:100',
|
||||
'contact_info' => 'nullable|string|max:200',
|
||||
'product_categories' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$project = $this->service->createProject($validated);
|
||||
|
||||
return response()->json($project, 201);
|
||||
}
|
||||
|
||||
public function updateProject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'sometimes|string|max:200',
|
||||
'company_type' => 'nullable|string|max:100',
|
||||
'contact_person' => 'nullable|string|max:100',
|
||||
'contact_info' => 'nullable|string|max:200',
|
||||
'status' => 'nullable|string|in:draft,interviewing,analyzing,code_generated,deployed',
|
||||
'product_categories' => 'nullable|array',
|
||||
'summary' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$project = $this->service->updateProject($id, $validated);
|
||||
|
||||
return response()->json($project);
|
||||
}
|
||||
|
||||
public function destroyProject(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteProject($id);
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
public function projectTree(int $id): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->getProjectTree($id));
|
||||
}
|
||||
|
||||
public function projectProgress(int $id): JsonResponse
|
||||
{
|
||||
$project = $this->service->updateProjectProgress($id);
|
||||
|
||||
return response()->json($project);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 첨부파일 API
|
||||
// ============================================================
|
||||
|
||||
public function attachments(int $projectId): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->getAttachments($projectId));
|
||||
}
|
||||
|
||||
public function uploadAttachment(Request $request, int $projectId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|max:51200',
|
||||
'file_type' => 'nullable|string|in:excel_template,pdf_quote,sample_bom,price_list,photo,voice,other',
|
||||
'description' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$attachment = $this->service->uploadAttachment(
|
||||
$projectId,
|
||||
$request->only(['file_type', 'description']),
|
||||
$request->file('file')
|
||||
);
|
||||
|
||||
return response()->json($attachment, 201);
|
||||
}
|
||||
|
||||
public function destroyAttachment(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteAttachment($id);
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 지식 API
|
||||
// ============================================================
|
||||
|
||||
public function knowledge(Request $request, int $projectId): JsonResponse
|
||||
{
|
||||
$filters = $request->only(['domain', 'is_verified', 'min_confidence']);
|
||||
|
||||
return response()->json($this->service->getKnowledge($projectId, $filters));
|
||||
}
|
||||
|
||||
public function storeKnowledge(Request $request, int $projectId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'domain' => 'required|string|in:product_classification,bom_structure,dimension_formula,component_config,pricing_structure,quantity_formula,conditional_logic,quote_format',
|
||||
'knowledge_type' => 'required|string|in:fact,rule,formula,mapping,range,table',
|
||||
'title' => 'required|string|max:300',
|
||||
'content' => 'required|array',
|
||||
'source_type' => 'nullable|string|in:interview_answer,voice_recording,document,manual',
|
||||
'source_id' => 'nullable|integer',
|
||||
'confidence' => 'nullable|numeric|min:0|max:1',
|
||||
]);
|
||||
|
||||
$knowledge = $this->service->createKnowledge($projectId, $validated);
|
||||
|
||||
return response()->json($knowledge, 201);
|
||||
}
|
||||
|
||||
public function updateKnowledge(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:300',
|
||||
'content' => 'sometimes|array',
|
||||
'confidence' => 'nullable|numeric|min:0|max:1',
|
||||
]);
|
||||
|
||||
$knowledge = $this->service->updateKnowledge($id, $validated);
|
||||
|
||||
return response()->json($knowledge);
|
||||
}
|
||||
|
||||
public function verifyKnowledge(int $id): JsonResponse
|
||||
{
|
||||
$knowledge = $this->service->verifyKnowledge($id);
|
||||
|
||||
return response()->json($knowledge);
|
||||
}
|
||||
|
||||
public function destroyKnowledge(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteKnowledge($id);
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 구조화 답변 저장 API
|
||||
// ============================================================
|
||||
|
||||
public function saveAnswer(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'session_id' => 'required|integer',
|
||||
'question_id' => 'required|integer',
|
||||
'is_checked' => 'nullable|boolean',
|
||||
'answer_text' => 'nullable|string',
|
||||
'answer_data' => 'nullable|array',
|
||||
'attachments' => 'nullable|array',
|
||||
'memo' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$answer = $this->service->saveAnswer($validated);
|
||||
|
||||
return response()->json($answer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use App\Services\TenantService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -45,7 +46,13 @@ public function edit(int $id): View
|
||||
abort(404, '테넌트를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return view('tenants.edit', compact('tenant'));
|
||||
$displayCompanyName = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $id)
|
||||
->where('setting_group', 'company')
|
||||
->where('setting_key', 'display_company_name')
|
||||
->first()?->setting_value ?? '';
|
||||
|
||||
return view('tenants.edit', compact('tenant', 'displayCompanyName'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
class TenantSettingController extends Controller
|
||||
{
|
||||
/**
|
||||
* 설정 목록 (재고 설정 페이지)
|
||||
* 설정 목록 (재고 설정 + 회사 표시명 설정)
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
@@ -30,21 +30,25 @@ public function index(Request $request): View|Response
|
||||
$itemTypeLabels = $tenantId ? CommonCode::getItemTypes($tenantId) : [];
|
||||
|
||||
// 테넌트 미선택 시 빈 설정
|
||||
$stockSettings = collect();
|
||||
$allSettings = collect();
|
||||
if ($tenantId) {
|
||||
$stockSettings = TenantSetting::withoutGlobalScopes()
|
||||
$allSettings = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'stock')
|
||||
->get()
|
||||
->keyBy('setting_key');
|
||||
->get();
|
||||
}
|
||||
|
||||
// 설정값 (저장된 값이 없으면 빈 배열/기본값)
|
||||
$stockSettings = $allSettings->where('setting_group', 'stock')->keyBy('setting_key');
|
||||
$companySettings = $allSettings->where('setting_group', 'company')->keyBy('setting_key');
|
||||
|
||||
// 재고 설정값
|
||||
$hasSettings = $stockSettings->isNotEmpty();
|
||||
$stockItemTypes = $stockSettings->get('stock_item_types')?->setting_value ?? [];
|
||||
$defaultSafetyStock = $stockSettings->get('default_safety_stock')?->setting_value ?? 10;
|
||||
$lowStockAlert = $stockSettings->get('low_stock_alert')?->setting_value ?? true;
|
||||
|
||||
// 회사 표시명 설정값
|
||||
$displayCompanyName = $companySettings->get('display_company_name')?->setting_value ?? '';
|
||||
|
||||
return view('tenant-settings.index', [
|
||||
'tenant' => $tenant,
|
||||
'hasSettings' => $hasSettings,
|
||||
@@ -52,6 +56,7 @@ public function index(Request $request): View|Response
|
||||
'stockItemTypes' => $stockItemTypes,
|
||||
'defaultSafetyStock' => $defaultSafetyStock,
|
||||
'lowStockAlert' => $lowStockAlert,
|
||||
'displayCompanyName' => $displayCompanyName,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -77,6 +82,7 @@ public function store(Request $request): RedirectResponse
|
||||
'stock_item_types.*' => 'string|in:'.implode(',', $validItemTypes),
|
||||
'default_safety_stock' => 'required|integer|min:0|max:9999',
|
||||
'low_stock_alert' => 'nullable|boolean',
|
||||
'display_company_name' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
// 재고관리 품목유형 저장
|
||||
@@ -121,6 +127,20 @@ public function store(Request $request): RedirectResponse
|
||||
]
|
||||
);
|
||||
|
||||
// 회사 표시명 저장
|
||||
TenantSetting::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'setting_group' => 'company',
|
||||
'setting_key' => 'display_company_name',
|
||||
],
|
||||
[
|
||||
'setting_value' => trim($validated['display_company_name'] ?? ''),
|
||||
'description' => '문서에 인쇄되는 회사 표시명',
|
||||
'updated_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
return redirect()->route('tenant-settings.index')
|
||||
->with('success', '설정이 저장되었습니다.');
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\HR\Position;
|
||||
use App\Models\Role;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
@@ -40,6 +42,10 @@ public function create(): View
|
||||
$roles = $tenantId ? Role::where('tenant_id', $tenantId)->orderBy('name')->get() : collect();
|
||||
$departments = $tenantId ? Department::where('tenant_id', $tenantId)->where('is_active', true)->orderBy('name')->get() : collect();
|
||||
|
||||
// 직급/직책 목록
|
||||
$ranks = $tenantId ? Position::forTenant($tenantId)->ranks()->where('is_active', true)->ordered()->get() : collect();
|
||||
$titles = $tenantId ? Position::forTenant($tenantId)->titles()->where('is_active', true)->ordered()->get() : collect();
|
||||
|
||||
// 본사 테넌트 여부 확인 (본사: 이메일 인증, 그 외: 비밀번호 직접 입력)
|
||||
$isHQ = false;
|
||||
if ($tenantId) {
|
||||
@@ -47,7 +53,7 @@ public function create(): View
|
||||
$isHQ = $tenant?->tenant_type === 'HQ';
|
||||
}
|
||||
|
||||
return view('users.create', compact('roles', 'departments', 'isHQ'));
|
||||
return view('users.create', compact('roles', 'departments', 'isHQ', 'ranks', 'titles'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +82,19 @@ public function edit(int $id): View
|
||||
$userRoleIds = $tenantId ? $user->userRoles()->where('tenant_id', $tenantId)->pluck('role_id')->toArray() : [];
|
||||
$userDepartmentIds = $tenantId ? $user->departmentUsers()->where('tenant_id', $tenantId)->pluck('department_id')->toArray() : [];
|
||||
|
||||
return view('users.edit', compact('user', 'roles', 'departments', 'userRoleIds', 'userDepartmentIds'));
|
||||
// 직급/직책 목록
|
||||
$ranks = $tenantId ? Position::forTenant($tenantId)->ranks()->where('is_active', true)->ordered()->get() : collect();
|
||||
$titles = $tenantId ? Position::forTenant($tenantId)->titles()->where('is_active', true)->ordered()->get() : collect();
|
||||
|
||||
// tenant_user_profiles에서 현재 position_key, job_title_key 조회
|
||||
$profile = $tenantId
|
||||
? DB::table('tenant_user_profiles')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $user->id)
|
||||
->first(['position_key', 'job_title_key', 'employee_status', 'department_id'])
|
||||
: null;
|
||||
|
||||
return view('users.edit', compact('user', 'roles', 'departments', 'userRoleIds', 'userDepartmentIds',
|
||||
'ranks', 'titles', 'profile'));
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Http/Requests/Rd/StoreAiQuotationRequest.php
Normal file
39
app/Http/Requests/Rd/StoreAiQuotationRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Rd;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAiQuotationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:200',
|
||||
'input_type' => 'required|in:text,voice,document',
|
||||
'input_text' => 'required_if:input_type,text|nullable|string',
|
||||
'ai_provider' => 'nullable|in:gemini,claude',
|
||||
'quote_mode' => 'nullable|in:module,manufacture',
|
||||
'product_category' => 'nullable|in:SCREEN,STEEL',
|
||||
'client_company' => 'nullable|string|max:200',
|
||||
'client_contact' => 'nullable|string|max:100',
|
||||
'client_phone' => 'nullable|string|max:50',
|
||||
'client_email' => 'nullable|email|max:200',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '견적 제목을 입력하세요.',
|
||||
'title.max' => '제목은 200자 이내로 입력하세요.',
|
||||
'input_type.required' => '입력 유형을 선택하세요.',
|
||||
'input_text.required_if' => '인터뷰 내용을 입력하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Roadmap/StoreMilestoneRequest.php
Normal file
46
app/Http/Requests/Roadmap/StoreMilestoneRequest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Roadmap;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreMilestoneRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'plan_id' => 'required|integer|exists:admin_roadmap_plans,id',
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:2000',
|
||||
'due_date' => 'nullable|date',
|
||||
'assignee_id' => 'nullable|integer|exists:users,id',
|
||||
'sort_order' => 'nullable|integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'plan_id' => '계획',
|
||||
'title' => '마일스톤 제목',
|
||||
'description' => '설명',
|
||||
'due_date' => '예정일',
|
||||
'assignee_id' => '담당자',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'plan_id.required' => '계획을 선택해주세요.',
|
||||
'plan_id.exists' => '유효하지 않은 계획입니다.',
|
||||
'title.required' => '마일스톤 제목은 필수입니다.',
|
||||
'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Http/Requests/Roadmap/StorePlanRequest.php
Normal file
76
app/Http/Requests/Roadmap/StorePlanRequest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Roadmap;
|
||||
|
||||
use App\Models\Admin\AdminRoadmapPlan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePlanRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:2000',
|
||||
'content' => 'nullable|string',
|
||||
'category' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getCategories())),
|
||||
'status' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getStatuses())),
|
||||
'priority' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPriorities())),
|
||||
'phase' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPhases())),
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'progress' => 'nullable|integer|min:0|max:100',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'sort_order' => 'nullable|integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'title' => '계획 제목',
|
||||
'description' => '설명',
|
||||
'content' => '상세 내용',
|
||||
'category' => '카테고리',
|
||||
'status' => '상태',
|
||||
'priority' => '우선순위',
|
||||
'phase' => 'Phase',
|
||||
'start_date' => '시작일',
|
||||
'end_date' => '종료일',
|
||||
'progress' => '진행률',
|
||||
'color' => '색상',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '계획 제목은 필수입니다.',
|
||||
'title.max' => '계획 제목은 최대 200자까지 입력 가능합니다.',
|
||||
'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.',
|
||||
'progress.min' => '진행률은 0 이상이어야 합니다.',
|
||||
'progress.max' => '진행률은 100 이하여야 합니다.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if (! $this->has('status')) {
|
||||
$this->merge(['status' => AdminRoadmapPlan::STATUS_PLANNED]);
|
||||
}
|
||||
if (! $this->has('category')) {
|
||||
$this->merge(['category' => AdminRoadmapPlan::CATEGORY_GENERAL]);
|
||||
}
|
||||
if (! $this->has('priority')) {
|
||||
$this->merge(['priority' => AdminRoadmapPlan::PRIORITY_MEDIUM]);
|
||||
}
|
||||
if (! $this->has('phase')) {
|
||||
$this->merge(['phase' => AdminRoadmapPlan::PHASE_1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Roadmap/UpdateMilestoneRequest.php
Normal file
42
app/Http/Requests/Roadmap/UpdateMilestoneRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Roadmap;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateMilestoneRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:2000',
|
||||
'due_date' => 'nullable|date',
|
||||
'assignee_id' => 'nullable|integer|exists:users,id',
|
||||
'sort_order' => 'nullable|integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'title' => '마일스톤 제목',
|
||||
'description' => '설명',
|
||||
'due_date' => '예정일',
|
||||
'assignee_id' => '담당자',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '마일스톤 제목은 필수입니다.',
|
||||
'title.max' => '마일스톤 제목은 최대 255자까지 입력 가능합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/Roadmap/UpdatePlanRequest.php
Normal file
60
app/Http/Requests/Roadmap/UpdatePlanRequest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Roadmap;
|
||||
|
||||
use App\Models\Admin\AdminRoadmapPlan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePlanRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:2000',
|
||||
'content' => 'nullable|string',
|
||||
'category' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getCategories())),
|
||||
'status' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getStatuses())),
|
||||
'priority' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPriorities())),
|
||||
'phase' => 'nullable|in:'.implode(',', array_keys(AdminRoadmapPlan::getPhases())),
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'progress' => 'nullable|integer|min:0|max:100',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'sort_order' => 'nullable|integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'title' => '계획 제목',
|
||||
'description' => '설명',
|
||||
'content' => '상세 내용',
|
||||
'category' => '카테고리',
|
||||
'status' => '상태',
|
||||
'priority' => '우선순위',
|
||||
'phase' => 'Phase',
|
||||
'start_date' => '시작일',
|
||||
'end_date' => '종료일',
|
||||
'progress' => '진행률',
|
||||
'color' => '색상',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '계획 제목은 필수입니다.',
|
||||
'title.max' => '계획 제목은 최대 200자까지 입력 가능합니다.',
|
||||
'end_date.after_or_equal' => '종료일은 시작일 이후여야 합니다.',
|
||||
'progress.min' => '진행률은 0 이상이어야 합니다.',
|
||||
'progress.max' => '진행률은 100 이하여야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/StoreEquipmentInspectionRequest.php
Normal file
31
app/Http/Requests/StoreEquipmentInspectionRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEquipmentInspectionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => 'required|exists:equipments,id',
|
||||
'template_item_id' => 'required|exists:equipment_inspection_templates,id',
|
||||
'check_date' => 'required|date',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => '설비',
|
||||
'template_item_id' => '점검항목',
|
||||
'check_date' => '점검일',
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/StoreEquipmentRepairRequest.php
Normal file
48
app/Http/Requests/StoreEquipmentRepairRequest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEquipmentRepairRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => 'required|exists:equipments,id',
|
||||
'repair_date' => 'required|date',
|
||||
'repair_type' => 'required|in:internal,external',
|
||||
'repair_hours' => 'nullable|numeric|min:0',
|
||||
'description' => 'nullable|string',
|
||||
'cost' => 'nullable|numeric|min:0',
|
||||
'vendor' => 'nullable|string|max:100',
|
||||
'repaired_by' => 'nullable|exists:users,id',
|
||||
'memo' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => '설비',
|
||||
'repair_date' => '수리일',
|
||||
'repair_type' => '보전구분',
|
||||
'repair_hours' => '수리시간',
|
||||
'cost' => '수리비용',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id.required' => '설비를 선택해주세요.',
|
||||
'repair_date.required' => '수리일은 필수입니다.',
|
||||
'repair_type.required' => '보전구분을 선택해주세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Http/Requests/StoreEquipmentRequest.php
Normal file
69
app/Http/Requests/StoreEquipmentRequest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreEquipmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
return [
|
||||
'equipment_code' => [
|
||||
'required', 'string', 'max:20',
|
||||
Rule::unique('equipments', 'equipment_code')
|
||||
->where('tenant_id', $tenantId),
|
||||
],
|
||||
'name' => 'required|string|max:100',
|
||||
'equipment_type' => 'nullable|string|max:50',
|
||||
'specification' => 'nullable|string|max:255',
|
||||
'manufacturer' => 'nullable|string|max:100',
|
||||
'model_name' => 'nullable|string|max:100',
|
||||
'serial_no' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:100',
|
||||
'production_line' => 'nullable|string|max:50',
|
||||
'purchase_date' => 'nullable|date',
|
||||
'install_date' => 'nullable|date',
|
||||
'purchase_price' => 'nullable|numeric|min:0',
|
||||
'useful_life' => 'nullable|integer|min:0',
|
||||
'status' => 'nullable|in:active,idle,disposed',
|
||||
'disposed_date' => 'nullable|date',
|
||||
'manager_id' => 'nullable|exists:users,id',
|
||||
'sub_manager_id' => 'nullable|exists:users,id',
|
||||
'photo_path' => 'nullable|string|max:500',
|
||||
'memo' => 'nullable|string',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'equipment_code' => '설비코드',
|
||||
'name' => '설비명',
|
||||
'equipment_type' => '설비유형',
|
||||
'manufacturer' => '제조사',
|
||||
'purchase_date' => '구입일',
|
||||
'install_date' => '설치일',
|
||||
'purchase_price' => '구입가격',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'equipment_code.required' => '설비코드는 필수입니다.',
|
||||
'equipment_code.unique' => '이미 존재하는 설비코드입니다.',
|
||||
'name.required' => '설비명은 필수입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ public function rules(): array
|
||||
'role_ids.*' => 'integer|exists:roles,id',
|
||||
'department_ids' => 'nullable|array',
|
||||
'department_ids.*' => 'integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:64',
|
||||
'job_title_key' => 'nullable|string|max:64',
|
||||
];
|
||||
|
||||
// 비본사 테넌트: 비밀번호 직접 입력 필수
|
||||
|
||||
57
app/Http/Requests/UpdateEquipmentRequest.php
Normal file
57
app/Http/Requests/UpdateEquipmentRequest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateEquipmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$id = $this->route('id');
|
||||
|
||||
return [
|
||||
'equipment_code' => [
|
||||
'required', 'string', 'max:20',
|
||||
Rule::unique('equipments', 'equipment_code')
|
||||
->where('tenant_id', $tenantId)
|
||||
->ignore($id),
|
||||
],
|
||||
'name' => 'required|string|max:100',
|
||||
'equipment_type' => 'nullable|string|max:50',
|
||||
'specification' => 'nullable|string|max:255',
|
||||
'manufacturer' => 'nullable|string|max:100',
|
||||
'model_name' => 'nullable|string|max:100',
|
||||
'serial_no' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:100',
|
||||
'production_line' => 'nullable|string|max:50',
|
||||
'purchase_date' => 'nullable|date',
|
||||
'install_date' => 'nullable|date',
|
||||
'purchase_price' => 'nullable|numeric|min:0',
|
||||
'useful_life' => 'nullable|integer|min:0',
|
||||
'status' => 'nullable|in:active,idle,disposed',
|
||||
'disposed_date' => 'nullable|date',
|
||||
'manager_id' => 'nullable|exists:users,id',
|
||||
'sub_manager_id' => 'nullable|exists:users,id',
|
||||
'photo_path' => 'nullable|string|max:500',
|
||||
'memo' => 'nullable|string',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'equipment_code' => '설비코드',
|
||||
'name' => '설비명',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,10 @@ public function rules(): array
|
||||
'role_ids.*' => 'integer|exists:roles,id',
|
||||
'department_ids' => 'nullable|array',
|
||||
'department_ids.*' => 'integer|exists:departments,id',
|
||||
'position_key' => 'nullable|string|max:64',
|
||||
'job_title_key' => 'nullable|string|max:64',
|
||||
'employee_status' => 'nullable|in:active,leave,resigned',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
102
app/Models/Admin/AdminRoadmapMilestone.php
Normal file
102
app/Models/Admin/AdminRoadmapMilestone.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AdminRoadmapMilestone extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'admin_roadmap_milestones';
|
||||
|
||||
protected $fillable = [
|
||||
'plan_id',
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'due_date',
|
||||
'completed_at',
|
||||
'assignee_id',
|
||||
'sort_order',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'plan_id' => 'integer',
|
||||
'due_date' => 'date',
|
||||
'completed_at' => 'datetime',
|
||||
'assignee_id' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'updated_by' => 'integer',
|
||||
'deleted_by' => 'integer',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public static function getStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_PENDING => '진행중',
|
||||
self::STATUS_COMPLETED => '완료',
|
||||
];
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AdminRoadmapPlan::class, 'plan_id');
|
||||
}
|
||||
|
||||
public function assignee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assignee_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::getStatuses()[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getIsCompletedAttribute(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function getDdayAttribute(): ?int
|
||||
{
|
||||
if (! $this->due_date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return now()->startOfDay()->diffInDays($this->due_date, false);
|
||||
}
|
||||
|
||||
public function getDueStatusAttribute(): ?string
|
||||
{
|
||||
if (! $this->due_date || $this->status === self::STATUS_COMPLETED) {
|
||||
return null;
|
||||
}
|
||||
$dday = $this->dday;
|
||||
if ($dday < 0) {
|
||||
return 'overdue';
|
||||
}
|
||||
if ($dday <= 7) {
|
||||
return 'due_soon';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
231
app/Models/Admin/AdminRoadmapPlan.php
Normal file
231
app/Models/Admin/AdminRoadmapPlan.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AdminRoadmapPlan extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'admin_roadmap_plans';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'content',
|
||||
'category',
|
||||
'status',
|
||||
'priority',
|
||||
'phase',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'progress',
|
||||
'color',
|
||||
'sort_order',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'progress' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'updated_by' => 'integer',
|
||||
'deleted_by' => 'integer',
|
||||
];
|
||||
|
||||
// 상태
|
||||
public const STATUS_PLANNED = 'planned';
|
||||
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_DELAYED = 'delayed';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
// 카테고리
|
||||
public const CATEGORY_GENERAL = 'general';
|
||||
|
||||
public const CATEGORY_PRODUCT = 'product';
|
||||
|
||||
public const CATEGORY_INFRASTRUCTURE = 'infrastructure';
|
||||
|
||||
public const CATEGORY_BUSINESS = 'business';
|
||||
|
||||
public const CATEGORY_HR = 'hr';
|
||||
|
||||
// 우선순위
|
||||
public const PRIORITY_LOW = 'low';
|
||||
|
||||
public const PRIORITY_MEDIUM = 'medium';
|
||||
|
||||
public const PRIORITY_HIGH = 'high';
|
||||
|
||||
public const PRIORITY_CRITICAL = 'critical';
|
||||
|
||||
// Phase
|
||||
public const PHASE_1 = 'phase_1';
|
||||
|
||||
public const PHASE_2 = 'phase_2';
|
||||
|
||||
public const PHASE_3 = 'phase_3';
|
||||
|
||||
public const PHASE_4 = 'phase_4';
|
||||
|
||||
public static function getStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_PLANNED => '계획',
|
||||
self::STATUS_IN_PROGRESS => '진행중',
|
||||
self::STATUS_COMPLETED => '완료',
|
||||
self::STATUS_DELAYED => '지연',
|
||||
self::STATUS_CANCELLED => '취소',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getCategories(): array
|
||||
{
|
||||
return [
|
||||
self::CATEGORY_GENERAL => '일반',
|
||||
self::CATEGORY_PRODUCT => '제품',
|
||||
self::CATEGORY_INFRASTRUCTURE => '인프라',
|
||||
self::CATEGORY_BUSINESS => '사업',
|
||||
self::CATEGORY_HR => '인사',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPriorities(): array
|
||||
{
|
||||
return [
|
||||
self::PRIORITY_LOW => '낮음',
|
||||
self::PRIORITY_MEDIUM => '보통',
|
||||
self::PRIORITY_HIGH => '높음',
|
||||
self::PRIORITY_CRITICAL => '긴급',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPhases(): array
|
||||
{
|
||||
return [
|
||||
self::PHASE_1 => 'Phase 1 — 코어 실증',
|
||||
self::PHASE_2 => 'Phase 2 — 3~5사 확장',
|
||||
self::PHASE_3 => 'Phase 3 — SaaS 전환',
|
||||
self::PHASE_4 => 'Phase 4 — 스케일업',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopeStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopePhase($query, string $phase)
|
||||
{
|
||||
return $query->where('phase', $phase);
|
||||
}
|
||||
|
||||
public function milestones(): HasMany
|
||||
{
|
||||
return $this->hasMany(AdminRoadmapMilestone::class, 'plan_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::getStatuses()[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PLANNED => 'bg-gray-100 text-gray-800',
|
||||
self::STATUS_IN_PROGRESS => 'bg-blue-100 text-blue-800',
|
||||
self::STATUS_COMPLETED => 'bg-green-100 text-green-800',
|
||||
self::STATUS_DELAYED => 'bg-red-100 text-red-800',
|
||||
self::STATUS_CANCELLED => 'bg-yellow-100 text-yellow-800',
|
||||
default => 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
}
|
||||
|
||||
public function getCategoryLabelAttribute(): string
|
||||
{
|
||||
return self::getCategories()[$this->category] ?? $this->category;
|
||||
}
|
||||
|
||||
public function getPriorityLabelAttribute(): string
|
||||
{
|
||||
return self::getPriorities()[$this->priority] ?? $this->priority;
|
||||
}
|
||||
|
||||
public function getPriorityColorAttribute(): string
|
||||
{
|
||||
return match ($this->priority) {
|
||||
self::PRIORITY_LOW => 'bg-gray-100 text-gray-600',
|
||||
self::PRIORITY_MEDIUM => 'bg-blue-100 text-blue-700',
|
||||
self::PRIORITY_HIGH => 'bg-orange-100 text-orange-700',
|
||||
self::PRIORITY_CRITICAL => 'bg-red-100 text-red-700',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
}
|
||||
|
||||
public function getPhaseLabelAttribute(): string
|
||||
{
|
||||
return self::getPhases()[$this->phase] ?? $this->phase;
|
||||
}
|
||||
|
||||
public function getCalculatedProgressAttribute(): int
|
||||
{
|
||||
$total = $this->milestones()->count();
|
||||
if ($total === 0) {
|
||||
return $this->progress;
|
||||
}
|
||||
$completed = $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_COMPLETED)->count();
|
||||
|
||||
return (int) round(($completed / $total) * 100);
|
||||
}
|
||||
|
||||
public function getMilestoneStatsAttribute(): array
|
||||
{
|
||||
return [
|
||||
'total' => $this->milestones()->count(),
|
||||
'pending' => $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_PENDING)->count(),
|
||||
'completed' => $this->milestones()->where('status', AdminRoadmapMilestone::STATUS_COMPLETED)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getPeriodAttribute(): string
|
||||
{
|
||||
if ($this->start_date && $this->end_date) {
|
||||
return $this->start_date->format('Y.m').' ~ '.$this->end_date->format('Y.m');
|
||||
}
|
||||
if ($this->start_date) {
|
||||
return $this->start_date->format('Y.m').' ~';
|
||||
}
|
||||
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
300
app/Models/Approvals/Approval.php
Normal file
300
app/Models/Approvals/Approval.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Approval extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approvals';
|
||||
|
||||
protected $casts = [
|
||||
'content' => 'array',
|
||||
'attachments' => 'array',
|
||||
'drafted_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'drafter_read_at' => 'datetime',
|
||||
'current_step' => 'integer',
|
||||
'resubmit_count' => 'integer',
|
||||
'rejection_history' => 'array',
|
||||
'is_urgent' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_number',
|
||||
'form_id',
|
||||
'line_id',
|
||||
'title',
|
||||
'content',
|
||||
'body',
|
||||
'status',
|
||||
'is_urgent',
|
||||
'drafter_id',
|
||||
'department_id',
|
||||
'drafted_at',
|
||||
'completed_at',
|
||||
'drafter_read_at',
|
||||
'current_step',
|
||||
'resubmit_count',
|
||||
'rejection_history',
|
||||
'attachments',
|
||||
'recall_reason',
|
||||
'parent_doc_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => 'draft',
|
||||
'current_step' => 0,
|
||||
'resubmit_count' => 0,
|
||||
'is_urgent' => false,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 상태 상수
|
||||
// =========================================================================
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUS_ON_HOLD = 'on_hold';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_REJECTED,
|
||||
self::STATUS_CANCELLED,
|
||||
self::STATUS_ON_HOLD,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function form(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApprovalForm::class, 'form_id');
|
||||
}
|
||||
|
||||
public function line(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApprovalLine::class, 'line_id');
|
||||
}
|
||||
|
||||
public function drafter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'drafter_id');
|
||||
}
|
||||
|
||||
public function steps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order');
|
||||
}
|
||||
|
||||
public function approverSteps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class, 'approval_id')
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->orderBy('step_order');
|
||||
}
|
||||
|
||||
public function referenceSteps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class, 'approval_id')
|
||||
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
||||
->orderBy('step_order');
|
||||
}
|
||||
|
||||
public function parentDocument(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_doc_id');
|
||||
}
|
||||
|
||||
public function childDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_doc_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeWithStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeDraft($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_DRAFT);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopeRejected($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_REJECTED);
|
||||
}
|
||||
|
||||
public function scopeOnHold($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ON_HOLD);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
|
||||
}
|
||||
|
||||
public function scopeByDrafter($query, int $userId)
|
||||
{
|
||||
return $query->where('drafter_id', $userId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
public function isSubmittable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
public function isActionable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isHoldable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isHoldReleasable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ON_HOLD;
|
||||
}
|
||||
|
||||
public function isCancellable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]);
|
||||
}
|
||||
|
||||
public function isCopyable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
|
||||
}
|
||||
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isDeletableBy(?User $user = null): bool
|
||||
{
|
||||
if (! $user) {
|
||||
return $this->isDeletable();
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->status === self::STATUS_DRAFT
|
||||
&& $this->drafter_id === $user->id;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => '임시저장',
|
||||
self::STATUS_PENDING => '진행',
|
||||
self::STATUS_APPROVED => '완료',
|
||||
self::STATUS_REJECTED => '반려',
|
||||
self::STATUS_CANCELLED => '회수',
|
||||
self::STATUS_ON_HOLD => '보류',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => 'gray',
|
||||
self::STATUS_PENDING => 'blue',
|
||||
self::STATUS_APPROVED => 'green',
|
||||
self::STATUS_REJECTED => 'red',
|
||||
self::STATUS_CANCELLED => 'yellow',
|
||||
self::STATUS_ON_HOLD => 'amber',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function getCurrentApproverStep(): ?ApprovalStep
|
||||
{
|
||||
return $this->steps()
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->orderBy('step_order')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function isCurrentApprover(int $userId): bool
|
||||
{
|
||||
$currentStep = $this->getCurrentApproverStep();
|
||||
|
||||
return $currentStep && $currentStep->approver_id === $userId;
|
||||
}
|
||||
|
||||
public function isReferee(int $userId): bool
|
||||
{
|
||||
return $this->referenceSteps()
|
||||
->where('approver_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function getProgressAttribute(): array
|
||||
{
|
||||
$totalSteps = $this->approverSteps()->count();
|
||||
$completedSteps = $this->approverSteps()
|
||||
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED])
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $totalSteps,
|
||||
'completed' => $completedSteps,
|
||||
'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Models/Approvals/ApprovalDelegation.php
Normal file
74
app/Models/Approvals/ApprovalDelegation.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ApprovalDelegation extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approval_delegations';
|
||||
|
||||
protected $casts = [
|
||||
'form_ids' => 'array',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'notify_delegator' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'delegator_id',
|
||||
'delegate_id',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'form_ids',
|
||||
'notify_delegator',
|
||||
'is_active',
|
||||
'reason',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function delegator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'delegator_id');
|
||||
}
|
||||
|
||||
public function delegate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'delegate_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForDelegator($query, int $userId)
|
||||
{
|
||||
return $query->where('delegator_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeCurrentlyActive($query)
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
|
||||
return $query->active()
|
||||
->where('start_date', '<=', $today)
|
||||
->where('end_date', '>=', $today);
|
||||
}
|
||||
}
|
||||
93
app/Models/Approvals/ApprovalForm.php
Normal file
93
app/Models/Approvals/ApprovalForm.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ApprovalForm extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approval_forms';
|
||||
|
||||
protected $casts = [
|
||||
'template' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'code',
|
||||
'category',
|
||||
'template',
|
||||
'body_template',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 카테고리 상수
|
||||
// =========================================================================
|
||||
|
||||
public const CATEGORY_REQUEST = 'request';
|
||||
|
||||
public const CATEGORY_EXPENSE = 'expense';
|
||||
|
||||
public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_REQUEST,
|
||||
self::CATEGORY_EXPENSE,
|
||||
self::CATEGORY_EXPENSE_ESTIMATE,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function approvals(): HasMany
|
||||
{
|
||||
return $this->hasMany(Approval::class, 'form_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function getCategoryLabelAttribute(): string
|
||||
{
|
||||
return match ($this->category) {
|
||||
self::CATEGORY_REQUEST => '품의서',
|
||||
self::CATEGORY_EXPENSE => '지출결의서',
|
||||
self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서',
|
||||
default => $this->category ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
83
app/Models/Approvals/ApprovalLine.php
Normal file
83
app/Models/Approvals/ApprovalLine.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ApprovalLine extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approval_lines';
|
||||
|
||||
protected $casts = [
|
||||
'steps' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'steps',
|
||||
'is_default',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 단계 유형 상수
|
||||
// =========================================================================
|
||||
|
||||
public const STEP_TYPE_APPROVAL = 'approval';
|
||||
|
||||
public const STEP_TYPE_AGREEMENT = 'agreement';
|
||||
|
||||
public const STEP_TYPE_REFERENCE = 'reference';
|
||||
|
||||
public const STEP_TYPES = [
|
||||
self::STEP_TYPE_APPROVAL,
|
||||
self::STEP_TYPE_AGREEMENT,
|
||||
self::STEP_TYPE_REFERENCE,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function getStepCountAttribute(): int
|
||||
{
|
||||
return count($this->steps ?? []);
|
||||
}
|
||||
|
||||
public function getApproverIdsAttribute(): array
|
||||
{
|
||||
return collect($this->steps ?? [])
|
||||
->pluck('user_id')
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
150
app/Models/Approvals/ApprovalStep.php
Normal file
150
app/Models/Approvals/ApprovalStep.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApprovalStep extends Model
|
||||
{
|
||||
protected $table = 'approval_steps';
|
||||
|
||||
protected $casts = [
|
||||
'step_order' => 'integer',
|
||||
'parallel_group' => 'integer',
|
||||
'acted_at' => 'datetime',
|
||||
'is_read' => 'boolean',
|
||||
'read_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'approval_id',
|
||||
'step_order',
|
||||
'step_type',
|
||||
'parallel_group',
|
||||
'approver_id',
|
||||
'acted_by',
|
||||
'approver_name',
|
||||
'approver_department',
|
||||
'approver_position',
|
||||
'status',
|
||||
'approval_type',
|
||||
'comment',
|
||||
'acted_at',
|
||||
'is_read',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
'is_read' => false,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 상태 상수
|
||||
// =========================================================================
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const STATUS_ON_HOLD = 'on_hold';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_REJECTED,
|
||||
self::STATUS_SKIPPED,
|
||||
self::STATUS_ON_HOLD,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function approval(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Approval::class, 'approval_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approver_id');
|
||||
}
|
||||
|
||||
public function actedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acted_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopeByApprover($query, int $userId)
|
||||
{
|
||||
return $query->where('approver_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeApprovalOnly($query)
|
||||
{
|
||||
return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
||||
}
|
||||
|
||||
public function scopeReferenceOnly($query)
|
||||
{
|
||||
return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function isActionable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING
|
||||
&& in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
||||
}
|
||||
|
||||
public function isReference(): bool
|
||||
{
|
||||
return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => '대기',
|
||||
self::STATUS_APPROVED => '승인',
|
||||
self::STATUS_REJECTED => '반려',
|
||||
self::STATUS_SKIPPED => '건너뜀',
|
||||
self::STATUS_ON_HOLD => '보류',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getStepTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->step_type) {
|
||||
ApprovalLine::STEP_TYPE_APPROVAL => '결재',
|
||||
ApprovalLine::STEP_TYPE_AGREEMENT => '합의',
|
||||
ApprovalLine::STEP_TYPE_REFERENCE => '참조',
|
||||
default => $this->step_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -49,19 +49,40 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 적요(summary)에서 상대계좌예금주명(cast/remark2) 중복 제거
|
||||
* 바로빌 API 응답에서 TransRemark1에 TransRemark2가 포함되는 경우 정리
|
||||
*/
|
||||
public static function cleanSummary(string $summary, string $remark): string
|
||||
{
|
||||
if (empty($remark) || empty($summary) || ! str_contains($summary, $remark)) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$result = rtrim($summary);
|
||||
|
||||
while (str_ends_with($result, $remark) && strlen($result) > strlen($remark)) {
|
||||
$result = rtrim(substr($result, 0, -strlen($remark)));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 고유 키 생성 (매칭용)
|
||||
* 숫자는 정수로 변환하여 형식 통일
|
||||
*/
|
||||
public function getUniqueKeyAttribute(): string
|
||||
{
|
||||
$cleanSummary = self::cleanSummary($this->summary ?? '', $this->cast ?? '');
|
||||
|
||||
return implode('|', [
|
||||
$this->bank_account_num,
|
||||
$this->trans_dt,
|
||||
(int) $this->deposit,
|
||||
(int) $this->withdraw,
|
||||
(int) $this->balance,
|
||||
$this->summary ?? '',
|
||||
$cleanSummary,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user