deploy: 2026-03-12 배포
- feat: [barobill] 바로빌 카드/은행/홈택스 REST API 구현 - feat: [equipment] 설비관리 API 백엔드 구현 - feat: [payroll] 급여관리 계산 엔진 및 일괄 처리 API - feat: [QMS] 점검표 템플릿 관리 + 로트심사 개선 - feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선 - feat: [receiving] 입고 성적서 파일 연결 - feat: [견적] 제어기 타입 체계 변경 - feat: [email] 테넌트 메일 설정 마이그레이션 및 모델 - feat: [pmis] 시공관리 테이블 마이그레이션 - feat: [R2] 파일 업로드 커맨드 + filesystems 설정 - feat: [배포] Jenkinsfile 롤백 기능 추가 - fix: [approval] SAM API 규칙 준수 코드 개선 - fix: [account-codes] 계정과목 중복 데이터 정리 - fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기 - fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 제거 - refactor: [barobill] 바로빌 연동 코드 전면 개선
This commit is contained in:
130
Jenkinsfile
vendored
130
Jenkinsfile
vendored
@@ -1,6 +1,12 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
parameters {
|
||||
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
|
||||
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
|
||||
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
@@ -8,10 +14,73 @@ pipeline {
|
||||
environment {
|
||||
DEPLOY_USER = 'hskwon'
|
||||
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
||||
PROD_SERVER = '211.117.60.189'
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
// ── 롤백: 릴리스 목록 조회 ──
|
||||
stage('Rollback: List Releases') {
|
||||
when { expression { params.ACTION == 'rollback' } }
|
||||
steps {
|
||||
script {
|
||||
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
|
||||
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
|
||||
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
|
||||
echo "현재 활성: ${current}"
|
||||
echo "사용 가능:\n${releases}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 롤백: symlink 전환 ──
|
||||
stage('Rollback: Switch Release') {
|
||||
when { expression { params.ACTION == 'rollback' } }
|
||||
steps {
|
||||
script {
|
||||
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
|
||||
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
def targetRelease = params.ROLLBACK_RELEASE
|
||||
if (!targetRelease?.trim()) {
|
||||
// 비워두면 직전 릴리스로 롤백
|
||||
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
|
||||
}
|
||||
|
||||
// 릴리스 존재 여부 확인
|
||||
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
|
||||
|
||||
slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token',
|
||||
message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
|
||||
cd ${basePath}/current &&
|
||||
php artisan config:cache &&
|
||||
php artisan route:cache &&
|
||||
php artisan view:cache &&
|
||||
sudo systemctl reload php8.4-fpm
|
||||
'
|
||||
"""
|
||||
|
||||
if (params.ROLLBACK_TARGET == 'production') {
|
||||
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'"
|
||||
}
|
||||
|
||||
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 일반 배포: Checkout ──
|
||||
stage('Checkout') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
@@ -24,17 +93,22 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Stage 배포 ──
|
||||
stage('Deploy Stage') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
--exclude='.git' --exclude='.env' \
|
||||
--exclude='storage/app' --exclude='storage/logs' \
|
||||
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
|
||||
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage bootstrap/cache &&
|
||||
@@ -71,17 +145,22 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Production 배포 ──
|
||||
stage('Deploy Production') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
--exclude='.git' --exclude='.env' \
|
||||
--exclude='storage/app' --exclude='storage/logs' \
|
||||
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
|
||||
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
cd /home/webservice/api/releases/${RELEASE_ID} &&
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage bootstrap/cache &&
|
||||
@@ -109,23 +188,32 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
script {
|
||||
if (params.ACTION == 'deploy') {
|
||||
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
failure {
|
||||
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
script {
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
|
||||
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
|
||||
sudo systemctl reload php8.4-fpm
|
||||
' || true
|
||||
"""
|
||||
if (params.ACTION == 'deploy') {
|
||||
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
|
||||
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
|
||||
sudo systemctl reload php8.4-fpm
|
||||
' || true
|
||||
"""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 논리적 데이터베이스 관계 문서
|
||||
|
||||
> **자동 생성**: 2026-03-07 02:57:21
|
||||
> **자동 생성**: 2026-03-12 13:58:25
|
||||
> **소스**: Eloquent 모델 관계 분석
|
||||
|
||||
## 📊 모델별 관계 현황
|
||||
@@ -26,6 +26,68 @@ ### bad_debt_memos
|
||||
- **badDebt()**: belongsTo → `bad_debts`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### barobill_bank_sync_status
|
||||
**모델**: `App\Models\Barobill\BarobillBankSyncStatus`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### barobill_bank_transactions
|
||||
**모델**: `App\Models\Barobill\BarobillBankTransaction`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### barobill_bank_transaction_splits
|
||||
**모델**: `App\Models\Barobill\BarobillBankTransactionSplit`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### barobill_billing_records
|
||||
**모델**: `App\Models\Barobill\BarobillBillingRecord`
|
||||
|
||||
- **member()**: belongsTo → `barobill_members`
|
||||
|
||||
### barobill_card_transactions
|
||||
**모델**: `App\Models\Barobill\BarobillCardTransaction`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### barobill_card_transaction_amount_logs
|
||||
**모델**: `App\Models\Barobill\BarobillCardTransactionAmountLog`
|
||||
|
||||
- **cardTransaction()**: belongsTo → `barobill_card_transactions`
|
||||
|
||||
### barobill_card_transaction_splits
|
||||
**모델**: `App\Models\Barobill\BarobillCardTransactionSplit`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### barobill_members
|
||||
**모델**: `App\Models\Barobill\BarobillMember`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### barobill_monthly_summarys
|
||||
**모델**: `App\Models\Barobill\BarobillMonthlySummary`
|
||||
|
||||
- **member()**: belongsTo → `barobill_members`
|
||||
|
||||
### barobill_subscriptions
|
||||
**모델**: `App\Models\Barobill\BarobillSubscription`
|
||||
|
||||
- **member()**: belongsTo → `barobill_members`
|
||||
|
||||
### hometax_invoices
|
||||
**모델**: `App\Models\Barobill\HometaxInvoice`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
- **journals()**: hasMany → `hometax_invoice_journals`
|
||||
|
||||
### hometax_invoice_journals
|
||||
**모델**: `App\Models\Barobill\HometaxInvoiceJournal`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
- **invoice()**: belongsTo → `hometax_invoices`
|
||||
|
||||
### biddings
|
||||
**모델**: `App\Models\Bidding\Bidding`
|
||||
|
||||
@@ -309,6 +371,43 @@ ### esign_signers
|
||||
- **contract()**: belongsTo → `esign_contracts`
|
||||
- **signFields()**: hasMany → `esign_sign_fields`
|
||||
|
||||
### equipments
|
||||
**모델**: `App\Models\Equipment\Equipment`
|
||||
|
||||
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
|
||||
- **inspections()**: hasMany → `equipment_inspections`
|
||||
- **repairs()**: hasMany → `equipment_repairs`
|
||||
- **photos()**: hasMany → `files`
|
||||
- **processes()**: belongsToMany → `processes`
|
||||
|
||||
### equipment_inspections
|
||||
**모델**: `App\Models\Equipment\EquipmentInspection`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
- **details()**: hasMany → `equipment_inspection_details`
|
||||
|
||||
### equipment_inspection_details
|
||||
**모델**: `App\Models\Equipment\EquipmentInspectionDetail`
|
||||
|
||||
- **inspection()**: belongsTo → `equipment_inspections`
|
||||
- **templateItem()**: belongsTo → `equipment_inspection_templates`
|
||||
|
||||
### equipment_inspection_templates
|
||||
**모델**: `App\Models\Equipment\EquipmentInspectionTemplate`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
|
||||
### equipment_process
|
||||
**모델**: `App\Models\Equipment\EquipmentProcess`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
- **process()**: belongsTo → `processes`
|
||||
|
||||
### equipment_repairs
|
||||
**모델**: `App\Models\Equipment\EquipmentRepair`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
|
||||
### estimates
|
||||
**모델**: `App\Models\Estimate\Estimate`
|
||||
|
||||
@@ -734,6 +833,36 @@ ### push_notification_settings
|
||||
**모델**: `App\Models\PushNotificationSetting`
|
||||
|
||||
|
||||
### audit_checklists
|
||||
**모델**: `App\Models\Qualitys\AuditChecklist`
|
||||
|
||||
- **categories()**: hasMany → `audit_checklist_categories`
|
||||
|
||||
### audit_checklist_categorys
|
||||
**모델**: `App\Models\Qualitys\AuditChecklistCategory`
|
||||
|
||||
- **checklist()**: belongsTo → `audit_checklists`
|
||||
- **items()**: hasMany → `audit_checklist_items`
|
||||
|
||||
### audit_checklist_items
|
||||
**모델**: `App\Models\Qualitys\AuditChecklistItem`
|
||||
|
||||
- **category()**: belongsTo → `audit_checklist_categories`
|
||||
- **standardDocuments()**: hasMany → `audit_standard_documents`
|
||||
|
||||
### audit_standard_documents
|
||||
**모델**: `App\Models\Qualitys\AuditStandardDocument`
|
||||
|
||||
- **checklistItem()**: belongsTo → `audit_checklist_items`
|
||||
- **document()**: belongsTo → `documents`
|
||||
|
||||
### checklist_templates
|
||||
**모델**: `App\Models\Qualitys\ChecklistTemplate`
|
||||
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **documents()**: morphMany → `files`
|
||||
|
||||
### inspections
|
||||
**모델**: `App\Models\Qualitys\Inspection`
|
||||
|
||||
@@ -838,6 +967,11 @@ ### quote_revisions
|
||||
- **quote()**: belongsTo → `quotes`
|
||||
- **reviser()**: belongsTo → `users`
|
||||
|
||||
### account_codes
|
||||
**모델**: `App\Models\Tenants\AccountCode`
|
||||
|
||||
- **children()**: hasMany → `account_codes`
|
||||
|
||||
### ai_reports
|
||||
**모델**: `App\Models\Tenants\AiReport`
|
||||
|
||||
@@ -857,14 +991,24 @@ ### approvals
|
||||
**모델**: `App\Models\Tenants\Approval`
|
||||
|
||||
- **form()**: belongsTo → `approval_forms`
|
||||
- **line()**: belongsTo → `approval_lines`
|
||||
- **drafter()**: belongsTo → `users`
|
||||
- **department()**: belongsTo → `departments`
|
||||
- **parentDocument()**: belongsTo → `approvals`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **childDocuments()**: hasMany → `approvals`
|
||||
- **steps()**: hasMany → `approval_steps`
|
||||
- **approverSteps()**: hasMany → `approval_steps`
|
||||
- **referenceSteps()**: hasMany → `approval_steps`
|
||||
- **linkable()**: morphTo → `(Polymorphic)`
|
||||
|
||||
### approval_delegations
|
||||
**모델**: `App\Models\Tenants\ApprovalDelegation`
|
||||
|
||||
- **delegator()**: belongsTo → `users`
|
||||
- **delegate()**: belongsTo → `users`
|
||||
|
||||
### approval_forms
|
||||
**모델**: `App\Models\Tenants\ApprovalForm`
|
||||
|
||||
@@ -883,6 +1027,7 @@ ### approval_steps
|
||||
|
||||
- **approval()**: belongsTo → `approvals`
|
||||
- **approver()**: belongsTo → `users`
|
||||
- **actedBy()**: belongsTo → `users`
|
||||
|
||||
### attendances
|
||||
**모델**: `App\Models\Tenants\Attendance`
|
||||
@@ -1004,6 +1149,11 @@ ### loans
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
|
||||
### mail_logs
|
||||
**모델**: `App\Models\Tenants\MailLog`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### payments
|
||||
**모델**: `App\Models\Tenants\Payment`
|
||||
|
||||
@@ -1046,6 +1196,7 @@ ### receivings
|
||||
**모델**: `App\Models\Tenants\Receiving`
|
||||
|
||||
- **item()**: belongsTo → `items`
|
||||
- **certificateFile()**: belongsTo → `files`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### salarys
|
||||
@@ -1167,6 +1318,11 @@ ### tenant_field_settings
|
||||
- **fieldDef()**: belongsTo → `setting_field_defs`
|
||||
- **optionGroup()**: belongsTo → `tenant_option_groups`
|
||||
|
||||
### tenant_mail_configs
|
||||
**모델**: `App\Models\Tenants\TenantMailConfig`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### tenant_option_groups
|
||||
**모델**: `App\Models\Tenants\TenantOptionGroup`
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ public function handle(): int
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
// Delete physical file
|
||||
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
if (Storage::disk('r2')->exists($file->file_path)) {
|
||||
Storage::disk('r2')->delete($file->file_path);
|
||||
}
|
||||
|
||||
// Force delete from DB
|
||||
|
||||
@@ -60,8 +60,8 @@ private function permanentDelete(File $file): void
|
||||
{
|
||||
DB::transaction(function () use ($file) {
|
||||
// Delete physical file
|
||||
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
if (Storage::disk('r2')->exists($file->file_path)) {
|
||||
Storage::disk('r2')->delete($file->file_path);
|
||||
}
|
||||
|
||||
// Update tenant storage usage
|
||||
|
||||
264
app/Console/Commands/UploadLocalFilesToR2.php
Normal file
264
app/Console/Commands/UploadLocalFilesToR2.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\File as FileFacade;
|
||||
|
||||
class UploadLocalFilesToR2 extends Command
|
||||
{
|
||||
protected $signature = 'r2:upload-local
|
||||
{--count=3 : Number of files to upload}
|
||||
{--source=db : Source: "db" (latest DB records) or "disk" (latest local files)}
|
||||
{--dry-run : Show files without uploading}
|
||||
{--fix : Delete wrong-path files from R2 before re-uploading}';
|
||||
|
||||
protected $description = 'Upload local files to Cloudflare R2 (by DB records or local disk)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = (int) $this->option('count');
|
||||
$source = $this->option('source');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info("=== R2 Upload Tool ===");
|
||||
$this->info("Source: {$source} | Count: {$count}");
|
||||
|
||||
return $source === 'db'
|
||||
? $this->uploadFromDb($count, $dryRun)
|
||||
: $this->uploadFromDisk($count, $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files based on DB records (latest by ID desc)
|
||||
*/
|
||||
private function uploadFromDb(int $count, bool $dryRun): int
|
||||
{
|
||||
$files = File::orderByDesc('id')->limit($count)->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
$this->warn('No files in DB.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($files as $f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
$r2Exists = Storage::disk('r2')->exists($f->file_path);
|
||||
$localExists = file_exists($localPath);
|
||||
|
||||
$rows[] = [
|
||||
$f->id,
|
||||
mb_strimwidth($f->display_name ?? '', 0, 25, '...'),
|
||||
$f->file_path,
|
||||
$r2Exists ? '✓ YES' : '✗ NO',
|
||||
$localExists ? '✓ YES' : '✗ NO',
|
||||
$f->file_size ? $this->formatSize($f->file_size) : '-',
|
||||
];
|
||||
}
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
// Filter: local exists but R2 doesn't
|
||||
$toUpload = $files->filter(function ($f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
|
||||
});
|
||||
|
||||
$alreadyInR2 = $files->filter(function ($f) {
|
||||
return Storage::disk('r2')->exists($f->file_path);
|
||||
});
|
||||
|
||||
if ($alreadyInR2->isNotEmpty()) {
|
||||
$this->info("Already in R2: {$alreadyInR2->count()} files (skipped)");
|
||||
}
|
||||
|
||||
if ($toUpload->isEmpty()) {
|
||||
$missingBoth = $files->filter(function ($f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
|
||||
});
|
||||
|
||||
if ($missingBoth->isNotEmpty()) {
|
||||
$this->warn("Missing both locally and in R2: {$missingBoth->count()} files");
|
||||
$this->warn("These files may exist on the dev server only.");
|
||||
}
|
||||
|
||||
$this->info('Nothing to upload.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("[DRY RUN] Would upload {$toUpload->count()} files.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Test R2 connection
|
||||
$this->info('Testing R2 connection...');
|
||||
try {
|
||||
Storage::disk('r2')->directories('/');
|
||||
$this->info('✓ R2 connection OK');
|
||||
} catch (\Exception $e) {
|
||||
$this->error('✗ R2 connection failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Upload
|
||||
$bar = $this->output->createProgressBar($toUpload->count());
|
||||
$bar->start();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($toUpload as $f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
try {
|
||||
$content = FileFacade::get($localPath);
|
||||
$mimeType = $f->mime_type ?: FileFacade::mimeType($localPath);
|
||||
|
||||
Storage::disk('r2')->put($f->file_path, $content, [
|
||||
'ContentType' => $mimeType,
|
||||
]);
|
||||
$success++;
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$this->newLine();
|
||||
$this->error(" ✗ ID {$f->id}: {$e->getMessage()}");
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("=== Upload Complete ===");
|
||||
$this->info("✓ Success: {$success}");
|
||||
if ($failed > 0) {
|
||||
$this->error("✗ Failed: {$failed}");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files based on local disk (newest files by mtime)
|
||||
*/
|
||||
private function uploadFromDisk(int $count, bool $dryRun): int
|
||||
{
|
||||
$storagePath = storage_path('app/tenants');
|
||||
|
||||
if (!is_dir($storagePath)) {
|
||||
$this->error("Path not found: {$storagePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$allFiles = $this->collectFiles($storagePath);
|
||||
if (empty($allFiles)) {
|
||||
$this->warn('No files found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a));
|
||||
$filesToUpload = array_slice($allFiles, 0, $count);
|
||||
|
||||
$this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:");
|
||||
$this->newLine();
|
||||
|
||||
$headers = ['#', 'File', 'Size', 'Modified', 'R2 Path'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($filesToUpload as $i => $filePath) {
|
||||
$r2Path = $this->toR2Path($filePath);
|
||||
$rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path];
|
||||
}
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No files uploaded.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('Testing R2 connection...');
|
||||
try {
|
||||
Storage::disk('r2')->directories('/');
|
||||
$this->info('✓ R2 connection OK');
|
||||
} catch (\Exception $e) {
|
||||
$this->error('✗ R2 connection failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar(count($filesToUpload));
|
||||
$bar->start();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$fix = $this->option('fix');
|
||||
|
||||
foreach ($filesToUpload as $filePath) {
|
||||
$r2Path = $this->toR2Path($filePath);
|
||||
try {
|
||||
if ($fix) {
|
||||
$wrongPath = $this->toRelativePath($filePath);
|
||||
if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) {
|
||||
Storage::disk('r2')->delete($wrongPath);
|
||||
}
|
||||
}
|
||||
|
||||
$content = FileFacade::get($filePath);
|
||||
$mimeType = FileFacade::mimeType($filePath);
|
||||
|
||||
Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]);
|
||||
$success++;
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$this->newLine();
|
||||
$this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}");
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("=== Upload Complete ===");
|
||||
$this->info("✓ Success: {$success}");
|
||||
if ($failed > 0) {
|
||||
$this->error("✗ Failed: {$failed}");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function toR2Path(string $filePath): string
|
||||
{
|
||||
$relative = $this->toRelativePath($filePath);
|
||||
return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative;
|
||||
}
|
||||
|
||||
private function toRelativePath(string $filePath): string
|
||||
{
|
||||
return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath));
|
||||
}
|
||||
|
||||
private function collectFiles(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getFilename() !== '.gitignore') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
}
|
||||
232
app/Enums/InspectionCycle.php
Normal file
232
app/Enums/InspectionCycle.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Models\Commons\Holiday;
|
||||
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';
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
public static function getHolidayDates(string $cycle, string $period, int $tenantId): array
|
||||
{
|
||||
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 = 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
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Services\BarobillBankTransactionService;
|
||||
use App\Services\JournalSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 바로빌 은행 거래 API 컨트롤러 (React 연동용)
|
||||
*
|
||||
* MNG에서 동기화된 은행 거래 데이터를 React에서 조회/관리
|
||||
*/
|
||||
class BarobillBankTransactionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BarobillBankTransactionService $service,
|
||||
protected JournalSyncService $journalSyncService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 은행 거래 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'bank_account_num' => 'nullable|string|max:50',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
return $this->service->index($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 목록 (필터용)
|
||||
*/
|
||||
public function accounts(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->accounts();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔액 요약
|
||||
*/
|
||||
public function balanceSummary(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return $this->service->balanceSummary($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분할 (Splits)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 거래 분할 조회
|
||||
*/
|
||||
public function getSplits(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->getSplits($validated['unique_key']);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 분할 저장
|
||||
*/
|
||||
public function saveSplits(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.split_amount' => 'required|numeric',
|
||||
'items.*.account_code' => 'nullable|string|max:20',
|
||||
'items.*.account_name' => 'nullable|string|max:100',
|
||||
'items.*.deduction_type' => 'nullable|string|max:20',
|
||||
'items.*.evidence_name' => 'nullable|string|max:100',
|
||||
'items.*.description' => 'nullable|string|max:500',
|
||||
'items.*.memo' => 'nullable|string|max:500',
|
||||
'items.*.bank_account_num' => 'nullable|string|max:50',
|
||||
'items.*.trans_dt' => 'nullable|string|max:20',
|
||||
'items.*.trans_date' => 'nullable|date',
|
||||
'items.*.original_deposit' => 'nullable|numeric',
|
||||
'items.*.original_withdraw' => 'nullable|numeric',
|
||||
'items.*.summary' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->saveSplits($validated['unique_key'], $validated['items']);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 분할 삭제
|
||||
*/
|
||||
public function deleteSplits(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->deleteSplits($validated['unique_key']);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 오버라이드 (Override)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 적요/분류 오버라이드 저장
|
||||
*/
|
||||
public function saveOverride(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
'modified_summary' => 'nullable|string|max:500',
|
||||
'modified_cast' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
return $this->service->saveOverride(
|
||||
$validated['unique_key'],
|
||||
$validated['modified_summary'] ?? null,
|
||||
$validated['modified_cast'] ?? null
|
||||
);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 수동 입력 (Manual)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수동 은행 거래 등록
|
||||
*/
|
||||
public function storeManual(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'bank_account_num' => 'required|string|max:50',
|
||||
'bank_code' => 'nullable|string|max:10',
|
||||
'bank_name' => 'nullable|string|max:50',
|
||||
'trans_date' => 'required|date',
|
||||
'trans_time' => 'nullable|string|max:10',
|
||||
'trans_dt' => 'nullable|string|max:20',
|
||||
'deposit' => 'nullable|numeric|min:0',
|
||||
'withdraw' => 'nullable|numeric|min:0',
|
||||
'balance' => 'nullable|numeric',
|
||||
'summary' => 'nullable|string|max:500',
|
||||
'cast' => 'nullable|string|max:100',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
'trans_office' => 'nullable|string|max:100',
|
||||
'account_code' => 'nullable|string|max:20',
|
||||
'account_name' => 'nullable|string|max:100',
|
||||
'client_code' => 'nullable|string|max:20',
|
||||
'client_name' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
return $this->service->storeManual($validated);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 은행 거래 수정
|
||||
*/
|
||||
public function updateManual(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'deposit' => 'nullable|numeric|min:0',
|
||||
'withdraw' => 'nullable|numeric|min:0',
|
||||
'balance' => 'nullable|numeric',
|
||||
'summary' => 'nullable|string|max:500',
|
||||
'cast' => 'nullable|string|max:100',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
'account_code' => 'nullable|string|max:20',
|
||||
'account_name' => 'nullable|string|max:100',
|
||||
'client_code' => 'nullable|string|max:20',
|
||||
'client_name' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
return $this->service->updateManual($id, $validated);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 은행 거래 삭제
|
||||
*/
|
||||
public function destroyManual(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->destroyManual($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분개 (Journal Entries)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 은행 거래 분개 조회
|
||||
*/
|
||||
public function getJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "barobill_bank_{$id}";
|
||||
|
||||
return $this->journalSyncService->getForSource(
|
||||
JournalEntry::SOURCE_BAROBILL_BANK,
|
||||
$sourceKey
|
||||
) ?? ['items' => []];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 거래 분개 저장
|
||||
*/
|
||||
public function storeJournalEntries(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.side' => 'required|in:debit,credit',
|
||||
'items.*.account_code' => 'required|string|max:20',
|
||||
'items.*.account_name' => 'nullable|string|max:100',
|
||||
'items.*.debit_amount' => 'required|integer|min:0',
|
||||
'items.*.credit_amount' => 'required|integer|min:0',
|
||||
'items.*.vendor_name' => 'nullable|string|max:200',
|
||||
'items.*.memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$bankTx = \App\Models\Barobill\BarobillBankTransaction::find($id);
|
||||
if (! $bankTx) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
}
|
||||
|
||||
$entryDate = $bankTx->trans_date ?? now()->format('Y-m-d');
|
||||
$sourceKey = "barobill_bank_{$id}";
|
||||
|
||||
return $this->journalSyncService->saveForSource(
|
||||
JournalEntry::SOURCE_BAROBILL_BANK,
|
||||
$sourceKey,
|
||||
$entryDate,
|
||||
"바로빌 은행거래 분개 (#{$id})",
|
||||
$validated['items'],
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 거래 분개 삭제
|
||||
*/
|
||||
public function deleteJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "barobill_bank_{$id}";
|
||||
|
||||
return $this->journalSyncService->deleteForSource(
|
||||
JournalEntry::SOURCE_BAROBILL_BANK,
|
||||
$sourceKey
|
||||
);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Services\BarobillCardTransactionService;
|
||||
use App\Services\JournalSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 바로빌 카드 거래 API 컨트롤러 (React 연동용)
|
||||
*
|
||||
* MNG에서 동기화된 카드 거래 데이터를 React에서 조회/관리
|
||||
*/
|
||||
class BarobillCardTransactionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BarobillCardTransactionService $service,
|
||||
protected JournalSyncService $journalSyncService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 카드 거래 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'card_num' => 'nullable|string|max:50',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'include_hidden' => 'nullable|boolean',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
return $this->service->index($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 카드 거래 상세
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$tx = $this->service->show($id);
|
||||
if (! $tx) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
}
|
||||
|
||||
return $tx;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 번호 목록 (필터용)
|
||||
*/
|
||||
public function cardNumbers(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->cardNumbers();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분할 (Splits)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 카드 거래 분할 조회
|
||||
*/
|
||||
public function getSplits(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->getSplits($validated['unique_key']);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분할 저장
|
||||
*/
|
||||
public function saveSplits(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.split_amount' => 'required|numeric',
|
||||
'items.*.split_supply_amount' => 'nullable|numeric',
|
||||
'items.*.split_tax' => 'nullable|numeric',
|
||||
'items.*.account_code' => 'nullable|string|max:20',
|
||||
'items.*.account_name' => 'nullable|string|max:100',
|
||||
'items.*.deduction_type' => 'nullable|string|max:20',
|
||||
'items.*.evidence_name' => 'nullable|string|max:100',
|
||||
'items.*.description' => 'nullable|string|max:500',
|
||||
'items.*.memo' => 'nullable|string|max:500',
|
||||
'items.*.card_num' => 'nullable|string|max:50',
|
||||
'items.*.use_dt' => 'nullable|string|max:20',
|
||||
'items.*.use_date' => 'nullable|date',
|
||||
'items.*.approval_num' => 'nullable|string|max:50',
|
||||
'items.*.original_amount' => 'nullable|numeric',
|
||||
'items.*.merchant_name' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
return $this->service->saveSplits($validated['unique_key'], $validated['items']);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분할 삭제
|
||||
*/
|
||||
public function deleteSplits(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'unique_key' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->deleteSplits($validated['unique_key']);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 수동 입력 (Manual)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수동 카드 거래 등록
|
||||
*/
|
||||
public function storeManual(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'card_num' => 'required|string|max:50',
|
||||
'card_company' => 'nullable|string|max:10',
|
||||
'card_company_name' => 'nullable|string|max:50',
|
||||
'use_dt' => 'required|string|max:20',
|
||||
'use_date' => 'required|date',
|
||||
'use_time' => 'nullable|string|max:10',
|
||||
'approval_num' => 'nullable|string|max:50',
|
||||
'approval_type' => 'nullable|string|max:10',
|
||||
'approval_amount' => 'required|numeric',
|
||||
'tax' => 'nullable|numeric',
|
||||
'service_charge' => 'nullable|numeric',
|
||||
'merchant_name' => 'required|string|max:200',
|
||||
'merchant_biz_num' => 'nullable|string|max:20',
|
||||
'account_code' => 'nullable|string|max:20',
|
||||
'account_name' => 'nullable|string|max:100',
|
||||
'deduction_type' => 'nullable|string|max:20',
|
||||
'evidence_name' => 'nullable|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->storeManual($validated);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 카드 거래 수정
|
||||
*/
|
||||
public function updateManual(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'approval_amount' => 'nullable|numeric',
|
||||
'tax' => 'nullable|numeric',
|
||||
'merchant_name' => 'nullable|string|max:200',
|
||||
'account_code' => 'nullable|string|max:20',
|
||||
'account_name' => 'nullable|string|max:100',
|
||||
'deduction_type' => 'nullable|string|max:20',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->updateManual($id, $validated);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 카드 거래 삭제
|
||||
*/
|
||||
public function destroyManual(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->destroyManual($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 숨김/복원 (Hide/Restore)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 카드 거래 숨김
|
||||
*/
|
||||
public function hide(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->hide($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 숨김 복원
|
||||
*/
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->restore($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 숨겨진 거래 목록
|
||||
*/
|
||||
public function hiddenList(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
]);
|
||||
|
||||
return $this->service->hiddenList($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 금액 수정
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 공급가액/세액 수정
|
||||
*/
|
||||
public function updateAmount(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'supply_amount' => 'required|numeric',
|
||||
'tax' => 'required|numeric',
|
||||
'modified_by_name' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
return $this->service->updateAmount($id, $validated);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분개 (Journal Entries)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 카드 거래 분개 조회
|
||||
*/
|
||||
public function getJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "barobill_card_{$id}";
|
||||
|
||||
return $this->journalSyncService->getForSource(
|
||||
JournalEntry::SOURCE_BAROBILL_CARD,
|
||||
$sourceKey
|
||||
) ?? ['items' => []];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분개 저장
|
||||
*/
|
||||
public function storeJournalEntries(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.side' => 'required|in:debit,credit',
|
||||
'items.*.account_code' => 'required|string|max:20',
|
||||
'items.*.account_name' => 'nullable|string|max:100',
|
||||
'items.*.debit_amount' => 'required|integer|min:0',
|
||||
'items.*.credit_amount' => 'required|integer|min:0',
|
||||
'items.*.vendor_name' => 'nullable|string|max:200',
|
||||
'items.*.memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$tx = $this->service->show($id);
|
||||
if (! $tx) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
}
|
||||
|
||||
$entryDate = $tx->use_date ?? now()->format('Y-m-d');
|
||||
$sourceKey = "barobill_card_{$id}";
|
||||
|
||||
return $this->journalSyncService->saveForSource(
|
||||
JournalEntry::SOURCE_BAROBILL_CARD,
|
||||
$sourceKey,
|
||||
$entryDate,
|
||||
"바로빌 카드거래 분개 (#{$id})",
|
||||
$validated['items'],
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분개 삭제
|
||||
*/
|
||||
public function deleteJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "barobill_card_{$id}";
|
||||
|
||||
return $this->journalSyncService->deleteForSource(
|
||||
JournalEntry::SOURCE_BAROBILL_CARD,
|
||||
$sourceKey
|
||||
);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public function status()
|
||||
'barobill_id' => $setting->barobill_id,
|
||||
'biz_no' => $setting->corp_num,
|
||||
'status' => $setting->isVerified() ? 'active' : 'inactive',
|
||||
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
|
||||
'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production',
|
||||
] : null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
@@ -86,17 +86,21 @@ public function signup(Request $request)
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 서비스 URL 조회 (공통)
|
||||
*/
|
||||
private function getServiceUrl(string $path): array
|
||||
{
|
||||
return ['url' => $this->barobillService->getBaseUrl().$path];
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 빠른조회 서비스 URL 조회
|
||||
*/
|
||||
public function bankServiceUrl(Request $request)
|
||||
public function bankServiceUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/BankAccountService'];
|
||||
return $this->getServiceUrl('/BANKACCOUNT.asmx');
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -106,11 +110,7 @@ public function bankServiceUrl(Request $request)
|
||||
public function accountLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/AccountLink'];
|
||||
return $this->getServiceUrl('/BANKACCOUNT.asmx');
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -120,11 +120,7 @@ public function accountLinkUrl()
|
||||
public function cardLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Card/CardLink'];
|
||||
return $this->getServiceUrl('/CARD.asmx');
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -134,11 +130,7 @@ public function cardLinkUrl()
|
||||
public function certificateUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Certificate/Register'];
|
||||
return $this->getServiceUrl('/CORPSTATE.asmx');
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
92
app/Http/Controllers/Api/V1/ChecklistTemplateController.php
Normal file
92
app/Http/Controllers/Api/V1/ChecklistTemplateController.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\SaveChecklistTemplateRequest;
|
||||
use App\Services\ChecklistTemplateService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChecklistTemplateController extends Controller
|
||||
{
|
||||
public function __construct(private ChecklistTemplateService $service) {}
|
||||
|
||||
/**
|
||||
* 템플릿 조회 (type별)
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$type = $request->query('type', 'day1_audit');
|
||||
|
||||
return $this->service->getByType($type);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 저장 (전체 덮어쓰기)
|
||||
*/
|
||||
public function update(SaveChecklistTemplateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->save($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료 토글
|
||||
*/
|
||||
public function toggleItem(int $id, string $subItemId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $subItemId) {
|
||||
return $this->service->toggleItem($id, $subItemId);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 파일 목록 조회
|
||||
*/
|
||||
public function documents(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$templateId = (int) $request->query('template_id');
|
||||
$subItemId = $request->query('sub_item_id');
|
||||
|
||||
return $this->service->getDocuments($templateId, $subItemId);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
*/
|
||||
public function uploadDocument(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'template_id' => ['required', 'integer'],
|
||||
'sub_item_id' => ['required', 'string', 'max:50'],
|
||||
'file' => ['required', 'file', 'max:10240'], // 10MB
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->uploadDocument(
|
||||
(int) $request->input('template_id'),
|
||||
$request->input('sub_item_id'),
|
||||
$request->file('file')
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteDocument(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN);
|
||||
$this->service->deleteDocument($id, $replace);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -83,14 +83,25 @@ public function trash()
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file
|
||||
* Download file (attachment)
|
||||
*/
|
||||
public function download(int $id)
|
||||
{
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
return $file->download();
|
||||
return $file->download(inline: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* View file inline (이미지/PDF 브라우저에서 바로 표시)
|
||||
*/
|
||||
public function view(int $id)
|
||||
{
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
return $file->download(inline: true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
278
app/Http/Controllers/Api/V1/HometaxInvoiceController.php
Normal file
278
app/Http/Controllers/Api/V1/HometaxInvoiceController.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Services\HometaxInvoiceService;
|
||||
use App\Services\JournalSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 홈택스 세금계산서 API 컨트롤러 (React 연동용)
|
||||
*
|
||||
* MNG에서 동기화된 홈택스 세금계산서를 React에서 조회/관리
|
||||
*/
|
||||
class HometaxInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected HometaxInvoiceService $service,
|
||||
protected JournalSyncService $journalSyncService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 매출 세금계산서 목록
|
||||
*/
|
||||
public function sales(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
return $this->service->sales($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 세금계산서 목록
|
||||
*/
|
||||
public function purchases(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
return $this->service->purchases($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$invoice = $this->service->show($id);
|
||||
if (! $invoice) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
}
|
||||
|
||||
return $invoice;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계 (매출/매입 합계)
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->validate([
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
]);
|
||||
|
||||
return $this->service->summary($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 수동 입력 (Manual)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수동 세금계산서 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$validated = $request->validate([
|
||||
'invoice_type' => 'required|in:sales,purchase',
|
||||
'nts_confirm_num' => 'nullable|string|max:50',
|
||||
'write_date' => 'required|date',
|
||||
'issue_date' => 'nullable|date',
|
||||
'invoicer_corp_num' => 'nullable|string|max:20',
|
||||
'invoicer_corp_name' => 'nullable|string|max:200',
|
||||
'invoicer_ceo_name' => 'nullable|string|max:100',
|
||||
'invoicee_corp_num' => 'nullable|string|max:20',
|
||||
'invoicee_corp_name' => 'nullable|string|max:200',
|
||||
'invoicee_ceo_name' => 'nullable|string|max:100',
|
||||
'supply_amount' => 'required|integer',
|
||||
'tax_amount' => 'required|integer',
|
||||
'total_amount' => 'required|integer',
|
||||
'tax_type' => 'nullable|string|max:10',
|
||||
'purpose_type' => 'nullable|string|max:10',
|
||||
'issue_type' => 'nullable|string|max:10',
|
||||
'item_name' => 'nullable|string|max:200',
|
||||
'account_code' => 'nullable|string|max:20',
|
||||
'account_name' => 'nullable|string|max:100',
|
||||
'deduction_type' => 'nullable|string|max:20',
|
||||
'remark1' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->storeManual($validated);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 세금계산서 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'write_date' => 'nullable|date',
|
||||
'issue_date' => 'nullable|date',
|
||||
'invoicer_corp_num' => 'nullable|string|max:20',
|
||||
'invoicer_corp_name' => 'nullable|string|max:200',
|
||||
'invoicee_corp_num' => 'nullable|string|max:20',
|
||||
'invoicee_corp_name' => 'nullable|string|max:200',
|
||||
'supply_amount' => 'nullable|integer',
|
||||
'tax_amount' => 'nullable|integer',
|
||||
'total_amount' => 'nullable|integer',
|
||||
'item_name' => 'nullable|string|max:200',
|
||||
'account_code' => 'nullable|string|max:20',
|
||||
'account_name' => 'nullable|string|max:100',
|
||||
'deduction_type' => 'nullable|string|max:20',
|
||||
'remark1' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->updateManual($id, $validated);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 세금계산서 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->destroyManual($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분개 (자체 테이블: hometax_invoice_journals)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 분개 조회
|
||||
*/
|
||||
public function getJournals(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getJournals($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 저장
|
||||
*/
|
||||
public function saveJournals(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.dc_type' => 'required|in:debit,credit',
|
||||
'items.*.account_code' => 'required|string|max:20',
|
||||
'items.*.account_name' => 'nullable|string|max:100',
|
||||
'items.*.debit_amount' => 'required|integer|min:0',
|
||||
'items.*.credit_amount' => 'required|integer|min:0',
|
||||
'items.*.description' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
return $this->service->saveJournals($id, $validated['items']);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 삭제
|
||||
*/
|
||||
public function deleteJournals(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->deleteJournals($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 통합 분개 (JournalSyncService - CEO 대시보드 연동)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 통합 분개 조회
|
||||
*/
|
||||
public function getJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "hometax_{$id}";
|
||||
|
||||
return $this->journalSyncService->getForSource(
|
||||
JournalEntry::SOURCE_HOMETAX_INVOICE,
|
||||
$sourceKey
|
||||
) ?? ['items' => []];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 분개 저장
|
||||
*/
|
||||
public function storeJournalEntries(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.side' => 'required|in:debit,credit',
|
||||
'items.*.account_code' => 'required|string|max:20',
|
||||
'items.*.account_name' => 'nullable|string|max:100',
|
||||
'items.*.debit_amount' => 'required|integer|min:0',
|
||||
'items.*.credit_amount' => 'required|integer|min:0',
|
||||
'items.*.vendor_name' => 'nullable|string|max:200',
|
||||
'items.*.memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$invoice = $this->service->show($id);
|
||||
if (! $invoice) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
}
|
||||
|
||||
$entryDate = $invoice->write_date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$sourceKey = "hometax_{$id}";
|
||||
|
||||
return $this->journalSyncService->saveForSource(
|
||||
JournalEntry::SOURCE_HOMETAX_INVOICE,
|
||||
$sourceKey,
|
||||
$entryDate,
|
||||
"홈택스 세금계산서 분개 (#{$id})",
|
||||
$validated['items'],
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 분개 삭제
|
||||
*/
|
||||
public function deleteJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "hometax_{$id}";
|
||||
|
||||
return $this->journalSyncService->deleteForSource(
|
||||
JournalEntry::SOURCE_HOMETAX_INVOICE,
|
||||
$sourceKey
|
||||
);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
$filePath = $directory.'/'.$storedName;
|
||||
|
||||
// 파일 저장 (tenant 디스크)
|
||||
Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName);
|
||||
Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
|
||||
|
||||
// file_type 자동 분류 (MIME 타입 기반)
|
||||
$mimeType = $uploadedFile->getMimeType();
|
||||
|
||||
@@ -4,18 +4,24 @@
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Payroll\BulkGeneratePayrollRequest;
|
||||
use App\Http\Requests\V1\Payroll\CalculatePayrollRequest;
|
||||
use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest;
|
||||
use App\Http\Requests\V1\Payroll\PayPayrollRequest;
|
||||
use App\Http\Requests\V1\Payroll\StorePayrollJournalRequest;
|
||||
use App\Http\Requests\V1\Payroll\StorePayrollRequest;
|
||||
use App\Http\Requests\V1\Payroll\UpdatePayrollRequest;
|
||||
use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest;
|
||||
use App\Services\ExportService;
|
||||
use App\Services\PayrollService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class PayrollController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PayrollService $service
|
||||
private readonly PayrollService $service,
|
||||
private readonly ExportService $exportService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -28,6 +34,7 @@ public function index(Request $request)
|
||||
'month',
|
||||
'user_id',
|
||||
'status',
|
||||
'department_id',
|
||||
'search',
|
||||
'sort_by',
|
||||
'sort_dir',
|
||||
@@ -103,6 +110,16 @@ public function confirm(int $id)
|
||||
return ApiResponse::success($payroll, __('message.payroll.confirmed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 확정 취소
|
||||
*/
|
||||
public function unconfirm(int $id)
|
||||
{
|
||||
$payroll = $this->service->unconfirm($id);
|
||||
|
||||
return ApiResponse::success($payroll, __('message.payroll.unconfirmed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 지급 처리
|
||||
*/
|
||||
@@ -113,6 +130,16 @@ public function pay(int $id, PayPayrollRequest $request)
|
||||
return ApiResponse::success($payroll, __('message.payroll.paid'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 지급 취소 (슈퍼관리자)
|
||||
*/
|
||||
public function unpay(int $id)
|
||||
{
|
||||
$payroll = $this->service->unpay($id);
|
||||
|
||||
return ApiResponse::success($payroll, __('message.payroll.unpaid'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 확정
|
||||
*/
|
||||
@@ -127,13 +154,29 @@ public function bulkConfirm(Request $request)
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여명세서 조회
|
||||
* 재직사원 일괄 생성
|
||||
*/
|
||||
public function payslip(int $id)
|
||||
public function bulkGenerate(BulkGeneratePayrollRequest $request)
|
||||
{
|
||||
$payslip = $this->service->payslip($id);
|
||||
$year = (int) $request->input('year');
|
||||
$month = (int) $request->input('month');
|
||||
|
||||
return ApiResponse::success($payslip, __('message.fetched'));
|
||||
$result = $this->service->bulkGenerate($year, $month);
|
||||
|
||||
return ApiResponse::success($result, __('message.payroll.bulk_generated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전월 급여 복사
|
||||
*/
|
||||
public function copyFromPrevious(CopyFromPreviousPayrollRequest $request)
|
||||
{
|
||||
$year = (int) $request->input('year');
|
||||
$month = (int) $request->input('month');
|
||||
|
||||
$result = $this->service->copyFromPreviousMonth($year, $month);
|
||||
|
||||
return ApiResponse::success($result, __('message.payroll.copied'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,6 +193,76 @@ public function calculate(CalculatePayrollRequest $request)
|
||||
return ApiResponse::success($payrolls, __('message.payroll.calculated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 계산 미리보기
|
||||
*/
|
||||
public function calculatePreview(Request $request)
|
||||
{
|
||||
$data = $request->only([
|
||||
'user_id',
|
||||
'base_salary',
|
||||
'overtime_pay',
|
||||
'bonus',
|
||||
'allowances',
|
||||
'deductions',
|
||||
]);
|
||||
|
||||
$result = $this->service->calculatePreview($data);
|
||||
|
||||
return ApiResponse::success($result, __('message.calculated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여명세서 조회
|
||||
*/
|
||||
public function payslip(int $id)
|
||||
{
|
||||
$payslip = $this->service->payslip($id);
|
||||
|
||||
return ApiResponse::success($payslip, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 엑셀 내보내기
|
||||
*/
|
||||
public function export(Request $request): BinaryFileResponse
|
||||
{
|
||||
$params = $request->only([
|
||||
'year',
|
||||
'month',
|
||||
'status',
|
||||
'user_id',
|
||||
'department_id',
|
||||
'search',
|
||||
'sort_by',
|
||||
'sort_dir',
|
||||
]);
|
||||
|
||||
$exportData = $this->service->getExportData($params);
|
||||
$filename = '급여현황_'.date('Ymd_His');
|
||||
|
||||
return $this->exportService->download(
|
||||
$exportData['data'],
|
||||
$exportData['headings'],
|
||||
$filename,
|
||||
'급여현황'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 전표 생성
|
||||
*/
|
||||
public function journalEntries(StorePayrollJournalRequest $request)
|
||||
{
|
||||
$year = (int) $request->input('year');
|
||||
$month = (int) $request->input('month');
|
||||
$entryDate = $request->input('entry_date');
|
||||
|
||||
$entry = $this->service->createJournalEntries($year, $month, $entryDate);
|
||||
|
||||
return ApiResponse::success($entry, __('message.payroll.journal_created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 조회
|
||||
*/
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
use App\Http\Requests\Shipment\ShipmentUpdateRequest;
|
||||
use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest;
|
||||
use App\Services\ShipmentService;
|
||||
use App\Services\WorkOrderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ShipmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShipmentService $service
|
||||
private readonly ShipmentService $service,
|
||||
private readonly WorkOrderService $workOrderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse
|
||||
{
|
||||
$shipment = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($shipment, __('message.created'), 201);
|
||||
return ApiResponse::success($shipment, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 기반 출하 생성
|
||||
*/
|
||||
public function createFromOrder(int $orderId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$shipment = $this->workOrderService->createShipmentForOrder($orderId);
|
||||
|
||||
return ApiResponse::success($shipment, __('message.created'), [], 201);
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) {
|
||||
return ApiResponse::error($e->getMessage(), 400);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.order.not_found'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LOT 옵션 조회
|
||||
*/
|
||||
|
||||
91
app/Http/Controllers/V1/Equipment/EquipmentController.php
Normal file
91
app/Http/Controllers/V1/Equipment/EquipmentController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Equipment;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Equipment\StoreEquipmentRequest;
|
||||
use App\Http\Requests\V1\Equipment\UpdateEquipmentRequest;
|
||||
use App\Services\Equipment\EquipmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EquipmentService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->index($request->only([
|
||||
'search', 'status', 'production_line', 'equipment_type',
|
||||
'sort_by', 'sort_direction', 'per_page',
|
||||
])),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->show($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreEquipmentRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($request->validated()),
|
||||
__('message.equipment.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateEquipmentRequest $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->update($id, $request->validated()),
|
||||
__('message.equipment.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->destroy($id),
|
||||
__('message.equipment.deleted')
|
||||
);
|
||||
}
|
||||
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->restore($id),
|
||||
__('message.equipment.restored')
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleActive(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->toggleActive($id),
|
||||
__('message.toggled')
|
||||
);
|
||||
}
|
||||
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->stats(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function options(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->options(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Equipment;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Equipment\StoreInspectionTemplateRequest;
|
||||
use App\Http\Requests\V1\Equipment\ToggleInspectionDetailRequest;
|
||||
use App\Http\Requests\V1\Equipment\UpdateInspectionNotesRequest;
|
||||
use App\Services\Equipment\EquipmentInspectionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EquipmentInspectionController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EquipmentInspectionService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->getInspections(
|
||||
$request->input('cycle', 'daily'),
|
||||
$request->input('period', now()->format('Y-m')),
|
||||
$request->input('production_line'),
|
||||
$request->input('equipment_id') ? (int) $request->input('equipment_id') : null
|
||||
),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleDetail(ToggleInspectionDetailRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->toggleDetail(
|
||||
$data['equipment_id'],
|
||||
$data['template_item_id'],
|
||||
$data['check_date'],
|
||||
$data['cycle'] ?? 'daily'
|
||||
),
|
||||
__('message.equipment.inspection_saved')
|
||||
);
|
||||
}
|
||||
|
||||
public function setResult(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->setResult(
|
||||
(int) $request->input('equipment_id'),
|
||||
(int) $request->input('template_item_id'),
|
||||
$request->input('check_date'),
|
||||
$request->input('cycle', 'daily'),
|
||||
$request->input('result')
|
||||
),
|
||||
__('message.equipment.inspection_saved')
|
||||
);
|
||||
}
|
||||
|
||||
public function updateNotes(UpdateInspectionNotesRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->updateNotes(
|
||||
$data['equipment_id'],
|
||||
$data['year_month'],
|
||||
collect($data)->only(['overall_judgment', 'inspector_id', 'repair_note', 'issue_note'])->toArray(),
|
||||
$data['cycle'] ?? 'daily'
|
||||
),
|
||||
__('message.equipment.inspection_saved')
|
||||
);
|
||||
}
|
||||
|
||||
public function resetInspection(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->resetInspection(
|
||||
(int) $request->input('equipment_id'),
|
||||
$request->input('cycle', 'daily'),
|
||||
$request->input('period')
|
||||
),
|
||||
__('message.equipment.inspection_reset')
|
||||
);
|
||||
}
|
||||
|
||||
public function templates(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->getActiveCycles($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function storeTemplate(StoreInspectionTemplateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->saveTemplate($id, $request->validated()),
|
||||
__('message.equipment.template_created')
|
||||
);
|
||||
}
|
||||
|
||||
public function updateTemplate(Request $request, int $templateId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->updateTemplate($templateId, $request->all()),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteTemplate(int $templateId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->deleteTemplate($templateId),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
|
||||
public function copyTemplates(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->copyTemplates(
|
||||
$id,
|
||||
$request->input('source_cycle'),
|
||||
$request->input('target_cycles', [])
|
||||
),
|
||||
__('message.equipment.template_copied')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Equipment;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Equipment\StoreEquipmentRepairRequest;
|
||||
use App\Services\Equipment\EquipmentRepairService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EquipmentRepairController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EquipmentRepairService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->index($request->only([
|
||||
'equipment_id', 'repair_type', 'date_from', 'date_to', 'search', 'per_page',
|
||||
])),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreEquipmentRepairRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($request->validated()),
|
||||
__('message.equipment.repair_created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(StoreEquipmentRepairRequest $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->update($id, $request->validated()),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->destroy($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,7 @@ public function handle(Request $request, Closure $next)
|
||||
// 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
|
||||
$allowWithoutAuth = [
|
||||
'api/v1/login',
|
||||
'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요)
|
||||
'api/v1/signup',
|
||||
'api/v1/register',
|
||||
'api/v1/refresh',
|
||||
|
||||
40
app/Http/Requests/Quality/SaveChecklistTemplateRequest.php
Normal file
40
app/Http/Requests/Quality/SaveChecklistTemplateRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SaveChecklistTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'categories' => ['required', 'array', 'min:1'],
|
||||
'categories.*.id' => ['required', 'string', 'max:50'],
|
||||
'categories.*.title' => ['required', 'string', 'max:255'],
|
||||
'categories.*.subItems' => ['required', 'array'],
|
||||
'categories.*.subItems.*.id' => ['required', 'string', 'max:50'],
|
||||
'categories.*.subItems.*.name' => ['required', 'string', 'max:255'],
|
||||
'options' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
|
||||
'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]),
|
||||
'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']),
|
||||
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
|
||||
'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']),
|
||||
'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']),
|
||||
'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public function rules(): array
|
||||
'items.*.productCategory' => 'nullable|string|in:SCREEN,STEEL',
|
||||
'items.*.guideRailType' => 'nullable|string|in:wall,ceiling,floor,mixed',
|
||||
'items.*.motorPower' => 'nullable|string|in:single,three',
|
||||
'items.*.controller' => 'nullable|string|in:basic,smart,premium',
|
||||
'items.*.controller' => 'nullable|string|in:exposed,embedded,embedded_no_box',
|
||||
'items.*.wingSize' => 'nullable|numeric|min:0|max:500',
|
||||
'items.*.inspectionFee' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -45,7 +45,7 @@ public function rules(): array
|
||||
'items.*.PC' => 'nullable|string|in:SCREEN,STEEL',
|
||||
'items.*.GT' => 'nullable|string|in:wall,ceiling,floor,mixed',
|
||||
'items.*.MP' => 'nullable|string|in:single,three',
|
||||
'items.*.CT' => 'nullable|string|in:basic,smart,premium',
|
||||
'items.*.CT' => 'nullable|string|in:exposed,embedded,embedded_no_box',
|
||||
'items.*.WS' => 'nullable|numeric|min:0|max:500',
|
||||
'items.*.INSP' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -128,7 +128,7 @@ private function normalizeInputVariables(array $item): array
|
||||
'PC' => $item['productCategory'] ?? $item['PC'] ?? 'SCREEN',
|
||||
'GT' => $item['guideRailType'] ?? $item['GT'] ?? 'wall',
|
||||
'MP' => $item['motorPower'] ?? $item['MP'] ?? 'single',
|
||||
'CT' => $item['controller'] ?? $item['CT'] ?? 'basic',
|
||||
'CT' => $item['controller'] ?? $item['CT'] ?? 'exposed',
|
||||
'WS' => (float) ($item['wingSize'] ?? $item['WS'] ?? 50),
|
||||
'INSP' => (float) ($item['inspectionFee'] ?? $item['INSP'] ?? 50000),
|
||||
];
|
||||
|
||||
@@ -30,7 +30,7 @@ public function rules(): array
|
||||
'PC' => 'nullable|string|in:SCREEN,STEEL',
|
||||
'GT' => 'nullable|string|in:wall,ceiling,floor,mixed',
|
||||
'MP' => 'nullable|string|in:single,three',
|
||||
'CT' => 'nullable|string|in:basic,smart,premium',
|
||||
'CT' => 'nullable|string|in:exposed,embedded,embedded_no_box',
|
||||
'WS' => 'nullable|numeric|min:0|max:500',
|
||||
'INSP' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -82,7 +82,7 @@ public function getInputVariables(): array
|
||||
'PC' => $validated['PC'] ?? 'SCREEN',
|
||||
'GT' => $validated['GT'] ?? 'wall',
|
||||
'MP' => $validated['MP'] ?? 'single',
|
||||
'CT' => $validated['CT'] ?? 'basic',
|
||||
'CT' => $validated['CT'] ?? 'exposed',
|
||||
'WS' => (float) ($validated['WS'] ?? 50),
|
||||
'INSP' => (float) ($validated['INSP'] ?? 50000),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Equipment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEquipmentRepairRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => 'required|integer|exists:equipments,id',
|
||||
'repair_date' => 'required|date',
|
||||
'repair_type' => 'nullable|string|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|integer|exists:users,id',
|
||||
'memo' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php
Normal file
41
app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Equipment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEquipmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_code' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'equipment_type' => 'nullable|string|max:50',
|
||||
'specification' => 'nullable|string|max:200',
|
||||
'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|integer|exists:users,id',
|
||||
'sub_manager_id' => 'nullable|integer|exists:users,id',
|
||||
'photo_path' => 'nullable|string|max:500',
|
||||
'memo' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Equipment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInspectionTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'inspection_cycle' => 'required|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual',
|
||||
'item_no' => 'required|string|max:20',
|
||||
'check_point' => 'required|string|max:100',
|
||||
'check_item' => 'required|string|max:200',
|
||||
'check_timing' => 'nullable|string|max:50',
|
||||
'check_frequency' => 'nullable|string|max:50',
|
||||
'check_method' => 'nullable|string|max:200',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable|boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Equipment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ToggleInspectionDetailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => 'required|integer|exists:equipments,id',
|
||||
'template_item_id' => 'required|integer|exists:equipment_inspection_templates,id',
|
||||
'check_date' => 'required|date',
|
||||
'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual',
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php
Normal file
41
app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Equipment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateEquipmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_code' => 'sometimes|string|max:50',
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'equipment_type' => 'nullable|string|max:50',
|
||||
'specification' => 'nullable|string|max:200',
|
||||
'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|integer|exists:users,id',
|
||||
'sub_manager_id' => 'nullable|integer|exists:users,id',
|
||||
'photo_path' => 'nullable|string|max:500',
|
||||
'memo' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Equipment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateInspectionNotesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'equipment_id' => 'required|integer|exists:equipments,id',
|
||||
'year_month' => 'required|string',
|
||||
'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual',
|
||||
'overall_judgment' => 'nullable|string|in:OK,NG',
|
||||
'inspector_id' => 'nullable|integer|exists:users,id',
|
||||
'repair_note' => 'nullable|string',
|
||||
'issue_note' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php
Normal file
29
app/Http/Requests/V1/Payroll/BulkGeneratePayrollRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Payroll;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BulkGeneratePayrollRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'year' => __('validation.attributes.pay_year'),
|
||||
'month' => __('validation.attributes.pay_month'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Payroll;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CopyFromPreviousPayrollRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'year' => __('validation.attributes.pay_year'),
|
||||
'month' => __('validation.attributes.pay_month'),
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php
Normal file
31
app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Payroll;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StorePayrollJournalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
'entry_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'year' => __('validation.attributes.pay_year'),
|
||||
'month' => __('validation.attributes.pay_month'),
|
||||
'entry_date' => __('validation.attributes.entry_date'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ public function rules(): array
|
||||
'deductions' => ['nullable', 'array'],
|
||||
'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'],
|
||||
'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'],
|
||||
'deduction_overrides' => ['nullable', 'array'],
|
||||
'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'],
|
||||
'family_count' => ['nullable', 'integer', 'min:1', 'max:11'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ public function rules(): array
|
||||
'deductions' => ['nullable', 'array'],
|
||||
'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'],
|
||||
'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'],
|
||||
'deduction_overrides' => ['nullable', 'array'],
|
||||
'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'],
|
||||
'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'],
|
||||
'_is_super_admin' => ['nullable', 'boolean'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public function rules(): array
|
||||
'remark' => ['nullable', 'string', 'max:1000'],
|
||||
'manufacturer' => ['nullable', 'string', 'max:100'],
|
||||
'material_no' => ['nullable', 'string', 'max:50'],
|
||||
'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public function rules(): array
|
||||
'inspection_result' => ['nullable', 'string', 'max:20'],
|
||||
'manufacturer' => ['nullable', 'string', 'max:100'],
|
||||
'material_no' => ['nullable', 'string', 'max:50'],
|
||||
'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
34
app/Models/Barobill/BarobillBankSyncStatus.php
Normal file
34
app/Models/Barobill/BarobillBankSyncStatus.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillBankSyncStatus extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_bank_sync_status';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'bank_account_num',
|
||||
'synced_year_month',
|
||||
'synced_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'synced_at' => 'datetime',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
}
|
||||
97
app/Models/Barobill/BarobillBankTransaction.php
Normal file
97
app/Models/Barobill/BarobillBankTransaction.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillBankTransaction extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_bank_transactions';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'bank_account_num',
|
||||
'bank_code',
|
||||
'bank_name',
|
||||
'trans_date',
|
||||
'trans_time',
|
||||
'trans_dt',
|
||||
'deposit',
|
||||
'withdraw',
|
||||
'balance',
|
||||
'summary',
|
||||
'cast',
|
||||
'memo',
|
||||
'trans_office',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'is_manual',
|
||||
'client_code',
|
||||
'client_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'deposit' => 'decimal:2',
|
||||
'withdraw' => 'decimal:2',
|
||||
'balance' => 'decimal:2',
|
||||
'is_manual' => 'boolean',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 접근자
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 거래 고유 키 (계좌번호|거래일시|입금|출금|잔액)
|
||||
*/
|
||||
public function getUniqueKeyAttribute(): string
|
||||
{
|
||||
return static::generateUniqueKey([
|
||||
'bank_account_num' => $this->bank_account_num,
|
||||
'trans_dt' => $this->trans_dt,
|
||||
'deposit' => $this->deposit,
|
||||
'withdraw' => $this->withdraw,
|
||||
'balance' => $this->balance,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function generateUniqueKey(array $data): string
|
||||
{
|
||||
return implode('|', [
|
||||
$data['bank_account_num'] ?? '',
|
||||
$data['trans_dt'] ?? '',
|
||||
$data['deposit'] ?? '0',
|
||||
$data['withdraw'] ?? '0',
|
||||
$data['balance'] ?? '0',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $accountNum = null)
|
||||
{
|
||||
$query = static::where('tenant_id', $tenantId)
|
||||
->whereBetween('trans_date', [$startDate, $endDate]);
|
||||
|
||||
if ($accountNum) {
|
||||
$query->where('bank_account_num', $accountNum);
|
||||
}
|
||||
|
||||
return $query->orderBy('trans_date')->orderBy('trans_dt')->get();
|
||||
}
|
||||
}
|
||||
49
app/Models/Barobill/BarobillBankTransactionOverride.php
Normal file
49
app/Models/Barobill/BarobillBankTransactionOverride.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BarobillBankTransactionOverride extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_bank_transaction_overrides';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'unique_key',
|
||||
'modified_summary',
|
||||
'modified_cast',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeByUniqueKey($query, string $uniqueKey)
|
||||
{
|
||||
return $query->where('unique_key', $uniqueKey);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getByUniqueKeys(int $tenantId, array $uniqueKeys)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->whereIn('unique_key', $uniqueKeys)
|
||||
->get()
|
||||
->keyBy('unique_key');
|
||||
}
|
||||
|
||||
public static function saveOverride(int $tenantId, string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): self
|
||||
{
|
||||
return static::updateOrCreate(
|
||||
['tenant_id' => $tenantId, 'unique_key' => $uniqueKey],
|
||||
['modified_summary' => $modifiedSummary, 'modified_cast' => $modifiedCast]
|
||||
);
|
||||
}
|
||||
}
|
||||
69
app/Models/Barobill/BarobillBankTransactionSplit.php
Normal file
69
app/Models/Barobill/BarobillBankTransactionSplit.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillBankTransactionSplit extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_bank_transaction_splits';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'original_unique_key',
|
||||
'split_amount',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'description',
|
||||
'memo',
|
||||
'sort_order',
|
||||
'bank_account_num',
|
||||
'trans_dt',
|
||||
'trans_date',
|
||||
'original_deposit',
|
||||
'original_withdraw',
|
||||
'summary',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'split_amount' => 'decimal:2',
|
||||
'original_deposit' => 'decimal:2',
|
||||
'original_withdraw' => 'decimal:2',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getByDateRange(int $tenantId, string $startDate, string $endDate)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->whereBetween('trans_date', [$startDate, $endDate])
|
||||
->orderBy('original_unique_key')
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy('original_unique_key');
|
||||
}
|
||||
|
||||
public static function getByUniqueKey(int $tenantId, string $uniqueKey)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
82
app/Models/Barobill/BarobillBillingRecord.php
Normal file
82
app/Models/Barobill/BarobillBillingRecord.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillBillingRecord extends Model
|
||||
{
|
||||
protected $table = 'barobill_billing_records';
|
||||
|
||||
public const SERVICE_TYPES = ['tax_invoice', 'bank_account', 'card', 'hometax'];
|
||||
|
||||
public const BILLING_TYPES = ['subscription', 'usage'];
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'billing_month',
|
||||
'service_type',
|
||||
'billing_type',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'total_amount',
|
||||
'billed_at',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'unit_price' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'billed_at' => 'date',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillMember::class, 'member_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeOfMonth($query, string $billingMonth)
|
||||
{
|
||||
return $query->where('billing_month', $billingMonth);
|
||||
}
|
||||
|
||||
public function scopeSubscription($query)
|
||||
{
|
||||
return $query->where('billing_type', 'subscription');
|
||||
}
|
||||
|
||||
public function scopeUsage($query)
|
||||
{
|
||||
return $query->where('billing_type', 'usage');
|
||||
}
|
||||
|
||||
public function scopeOfService($query, string $serviceType)
|
||||
{
|
||||
return $query->where('service_type', $serviceType);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 접근자
|
||||
// =========================================================================
|
||||
|
||||
public function getServiceTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->service_type) {
|
||||
'tax_invoice' => '전자세금계산서',
|
||||
'bank_account' => '계좌조회',
|
||||
'card' => '카드조회',
|
||||
'hometax' => '홈택스',
|
||||
default => $this->service_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
108
app/Models/Barobill/BarobillCardTransaction.php
Normal file
108
app/Models/Barobill/BarobillCardTransaction.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillCardTransaction extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_card_transactions';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'card_num',
|
||||
'card_company',
|
||||
'card_company_name',
|
||||
'use_dt',
|
||||
'use_date',
|
||||
'use_time',
|
||||
'approval_num',
|
||||
'approval_type',
|
||||
'approval_amount',
|
||||
'tax',
|
||||
'service_charge',
|
||||
'payment_plan',
|
||||
'currency_code',
|
||||
'merchant_name',
|
||||
'merchant_biz_num',
|
||||
'merchant_addr',
|
||||
'merchant_ceo',
|
||||
'merchant_biz_type',
|
||||
'merchant_tel',
|
||||
'memo',
|
||||
'use_key',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'deduction_type',
|
||||
'evidence_name',
|
||||
'description',
|
||||
'modified_supply_amount',
|
||||
'modified_tax',
|
||||
'is_manual',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'approval_amount' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'service_charge' => 'decimal:2',
|
||||
'modified_supply_amount' => 'decimal:2',
|
||||
'modified_tax' => 'decimal:2',
|
||||
'is_manual' => 'boolean',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 접근자
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 거래 고유 키 (cardNum|useDt|approvalNum|approvalAmount)
|
||||
*/
|
||||
public function getUniqueKeyAttribute(): string
|
||||
{
|
||||
return static::generateUniqueKey([
|
||||
'card_num' => $this->card_num,
|
||||
'use_dt' => $this->use_dt,
|
||||
'approval_num' => $this->approval_num,
|
||||
'approval_amount' => $this->approval_amount,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function generateUniqueKey(array $data): string
|
||||
{
|
||||
return implode('|', [
|
||||
$data['card_num'] ?? '',
|
||||
$data['use_dt'] ?? '',
|
||||
$data['approval_num'] ?? '',
|
||||
$data['approval_amount'] ?? '0',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $cardNum = null)
|
||||
{
|
||||
$query = static::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate]);
|
||||
|
||||
if ($cardNum) {
|
||||
$query->where('card_num', $cardNum);
|
||||
}
|
||||
|
||||
return $query->orderBy('use_date')->orderBy('use_dt')->get();
|
||||
}
|
||||
}
|
||||
41
app/Models/Barobill/BarobillCardTransactionAmountLog.php
Normal file
41
app/Models/Barobill/BarobillCardTransactionAmountLog.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillCardTransactionAmountLog extends Model
|
||||
{
|
||||
protected $table = 'barobill_card_transaction_amount_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'card_transaction_id',
|
||||
'original_unique_key',
|
||||
'before_supply_amount',
|
||||
'before_tax',
|
||||
'after_supply_amount',
|
||||
'after_tax',
|
||||
'modified_by',
|
||||
'modified_by_name',
|
||||
'ip_address',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'before_supply_amount' => 'decimal:2',
|
||||
'before_tax' => 'decimal:2',
|
||||
'after_supply_amount' => 'decimal:2',
|
||||
'after_tax' => 'decimal:2',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function cardTransaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillCardTransaction::class, 'card_transaction_id');
|
||||
}
|
||||
}
|
||||
61
app/Models/Barobill/BarobillCardTransactionHide.php
Normal file
61
app/Models/Barobill/BarobillCardTransactionHide.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BarobillCardTransactionHide extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_card_transaction_hides';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'original_unique_key',
|
||||
'card_num',
|
||||
'use_date',
|
||||
'approval_num',
|
||||
'original_amount',
|
||||
'merchant_name',
|
||||
'hidden_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'original_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getHiddenKeys(int $tenantId, string $startDate, string $endDate)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate])
|
||||
->pluck('original_unique_key')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public static function hideTransaction(int $tenantId, string $uniqueKey, array $originalData, int $userId): self
|
||||
{
|
||||
return static::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'original_unique_key' => $uniqueKey,
|
||||
'card_num' => $originalData['card_num'] ?? '',
|
||||
'use_date' => $originalData['use_date'] ?? '',
|
||||
'approval_num' => $originalData['approval_num'] ?? '',
|
||||
'original_amount' => $originalData['approval_amount'] ?? 0,
|
||||
'merchant_name' => $originalData['merchant_name'] ?? '',
|
||||
'hidden_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function restoreTransaction(int $tenantId, string $uniqueKey): bool
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->delete() > 0;
|
||||
}
|
||||
}
|
||||
74
app/Models/Barobill/BarobillCardTransactionSplit.php
Normal file
74
app/Models/Barobill/BarobillCardTransactionSplit.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillCardTransactionSplit extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'barobill_card_transaction_splits';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'original_unique_key',
|
||||
'split_amount',
|
||||
'split_supply_amount',
|
||||
'split_tax',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'deduction_type',
|
||||
'evidence_name',
|
||||
'description',
|
||||
'memo',
|
||||
'sort_order',
|
||||
'card_num',
|
||||
'use_dt',
|
||||
'use_date',
|
||||
'approval_num',
|
||||
'original_amount',
|
||||
'merchant_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'split_amount' => 'decimal:2',
|
||||
'split_supply_amount' => 'decimal:2',
|
||||
'split_tax' => 'decimal:2',
|
||||
'original_amount' => 'decimal:2',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getByDateRange(int $tenantId, string $startDate, string $endDate)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate])
|
||||
->orderBy('original_unique_key')
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy('original_unique_key');
|
||||
}
|
||||
|
||||
public static function getByUniqueKey(int $tenantId, string $uniqueKey)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
61
app/Models/Barobill/BarobillConfig.php
Normal file
61
app/Models/Barobill/BarobillConfig.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BarobillConfig extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'barobill_configs';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'environment',
|
||||
'cert_key',
|
||||
'corp_num',
|
||||
'base_url',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getActiveTest(): ?self
|
||||
{
|
||||
return static::where('environment', 'test')->where('is_active', true)->first();
|
||||
}
|
||||
|
||||
public static function getActiveProduction(): ?self
|
||||
{
|
||||
return static::where('environment', 'production')->where('is_active', true)->first();
|
||||
}
|
||||
|
||||
public static function getActive(bool $isTestMode): ?self
|
||||
{
|
||||
return $isTestMode ? static::getActiveTest() : static::getActiveProduction();
|
||||
}
|
||||
|
||||
public function getEnvironmentLabelAttribute(): string
|
||||
{
|
||||
return $this->environment === 'test' ? '테스트' : '운영';
|
||||
}
|
||||
|
||||
public function getMaskedCertKeyAttribute(): string
|
||||
{
|
||||
$key = $this->cert_key;
|
||||
if (strlen($key) <= 8) {
|
||||
return str_repeat('*', strlen($key));
|
||||
}
|
||||
|
||||
return substr($key, 0, 4).'****'.substr($key, -4);
|
||||
}
|
||||
}
|
||||
82
app/Models/Barobill/BarobillMember.php
Normal file
82
app/Models/Barobill/BarobillMember.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BarobillMember extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'barobill_members';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'biz_no',
|
||||
'corp_name',
|
||||
'ceo_name',
|
||||
'addr',
|
||||
'biz_type',
|
||||
'biz_class',
|
||||
'barobill_id',
|
||||
'barobill_pwd',
|
||||
'manager_name',
|
||||
'manager_email',
|
||||
'manager_hp',
|
||||
'status',
|
||||
'server_mode',
|
||||
'last_sales_fetch_at',
|
||||
'last_purchases_fetch_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'barobill_pwd' => 'encrypted',
|
||||
'last_sales_fetch_at' => 'datetime',
|
||||
'last_purchases_fetch_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'barobill_pwd',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 접근자
|
||||
// =========================================================================
|
||||
|
||||
public function getFormattedBizNoAttribute(): string
|
||||
{
|
||||
$num = $this->biz_no;
|
||||
if (strlen($num) === 10) {
|
||||
return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5);
|
||||
}
|
||||
|
||||
return $num ?? '';
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'active' => '활성',
|
||||
'inactive' => '비활성',
|
||||
'pending' => '대기',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function isTestMode(): bool
|
||||
{
|
||||
return $this->server_mode === 'test';
|
||||
}
|
||||
}
|
||||
53
app/Models/Barobill/BarobillMonthlySummary.php
Normal file
53
app/Models/Barobill/BarobillMonthlySummary.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BarobillMonthlySummary extends Model
|
||||
{
|
||||
protected $table = 'barobill_monthly_summaries';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'billing_month',
|
||||
'bank_account_fee',
|
||||
'card_fee',
|
||||
'hometax_fee',
|
||||
'subscription_total',
|
||||
'tax_invoice_count',
|
||||
'tax_invoice_amount',
|
||||
'usage_total',
|
||||
'grand_total',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bank_account_fee' => 'integer',
|
||||
'card_fee' => 'integer',
|
||||
'hometax_fee' => 'integer',
|
||||
'subscription_total' => 'integer',
|
||||
'tax_invoice_count' => 'integer',
|
||||
'tax_invoice_amount' => 'integer',
|
||||
'usage_total' => 'integer',
|
||||
'grand_total' => 'integer',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillMember::class, 'member_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeOfMonth($query, string $billingMonth)
|
||||
{
|
||||
return $query->where('billing_month', $billingMonth);
|
||||
}
|
||||
}
|
||||
82
app/Models/Barobill/BarobillPricingPolicy.php
Normal file
82
app/Models/Barobill/BarobillPricingPolicy.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BarobillPricingPolicy extends Model
|
||||
{
|
||||
protected $table = 'barobill_pricing_policies';
|
||||
|
||||
public const TYPE_CARD = 'card';
|
||||
|
||||
public const TYPE_TAX_INVOICE = 'tax_invoice';
|
||||
|
||||
public const TYPE_BANK_ACCOUNT = 'bank_account';
|
||||
|
||||
protected $fillable = [
|
||||
'service_type',
|
||||
'name',
|
||||
'description',
|
||||
'free_quota',
|
||||
'free_quota_unit',
|
||||
'additional_unit',
|
||||
'additional_unit_label',
|
||||
'additional_price',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'free_quota' => 'integer',
|
||||
'additional_unit' => 'integer',
|
||||
'additional_price' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getByServiceType(string $serviceType): ?self
|
||||
{
|
||||
return static::active()->where('service_type', $serviceType)->first();
|
||||
}
|
||||
|
||||
public static function getAllActive()
|
||||
{
|
||||
return static::active()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
public function getServiceTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->service_type) {
|
||||
self::TYPE_CARD => '카드조회',
|
||||
self::TYPE_TAX_INVOICE => '전자세금계산서',
|
||||
self::TYPE_BANK_ACCOUNT => '계좌조회',
|
||||
default => $this->service_type,
|
||||
};
|
||||
}
|
||||
|
||||
public function calculateBilling(int $usageCount): int
|
||||
{
|
||||
if ($usageCount <= $this->free_quota) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$excess = $usageCount - $this->free_quota;
|
||||
$units = (int) ceil($excess / max($this->additional_unit, 1));
|
||||
|
||||
return $units * $this->additional_price;
|
||||
}
|
||||
}
|
||||
76
app/Models/Barobill/BarobillSubscription.php
Normal file
76
app/Models/Barobill/BarobillSubscription.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BarobillSubscription extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'barobill_subscriptions';
|
||||
|
||||
public const SERVICE_TYPES = ['bank_account', 'card', 'hometax'];
|
||||
|
||||
public const DEFAULT_MONTHLY_FEES = [
|
||||
'bank_account' => 10000,
|
||||
'card' => 10000,
|
||||
'hometax' => 0,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'service_type',
|
||||
'monthly_fee',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'is_active',
|
||||
'memo',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'monthly_fee' => 'integer',
|
||||
'started_at' => 'date',
|
||||
'ended_at' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BarobillMember::class, 'member_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOfService($query, string $serviceType)
|
||||
{
|
||||
return $query->where('service_type', $serviceType);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 접근자
|
||||
// =========================================================================
|
||||
|
||||
public function getServiceTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->service_type) {
|
||||
'bank_account' => '계좌조회',
|
||||
'card' => '카드조회',
|
||||
'hometax' => '홈택스',
|
||||
default => $this->service_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
158
app/Models/Barobill/HometaxInvoice.php
Normal file
158
app/Models/Barobill/HometaxInvoice.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
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 HometaxInvoice extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'hometax_invoices';
|
||||
|
||||
// 과세유형
|
||||
public const TAX_TYPE_TAXABLE = '01'; // 과세
|
||||
|
||||
public const TAX_TYPE_ZERO = '02'; // 영세
|
||||
|
||||
public const TAX_TYPE_EXEMPT = '03'; // 면세
|
||||
|
||||
// 영수/청구
|
||||
public const PURPOSE_TYPE_RECEIPT = '01'; // 영수
|
||||
|
||||
public const PURPOSE_TYPE_CLAIM = '02'; // 청구
|
||||
|
||||
// 발급유형
|
||||
public const ISSUE_TYPE_NORMAL = '01'; // 정발행
|
||||
|
||||
public const ISSUE_TYPE_REVERSE = '02'; // 역발행
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'nts_confirm_num',
|
||||
'invoice_type',
|
||||
'write_date',
|
||||
'issue_date',
|
||||
'send_date',
|
||||
'invoicer_corp_num',
|
||||
'invoicer_tax_reg_id',
|
||||
'invoicer_corp_name',
|
||||
'invoicer_ceo_name',
|
||||
'invoicer_addr',
|
||||
'invoicer_biz_type',
|
||||
'invoicer_biz_class',
|
||||
'invoicer_contact_id',
|
||||
'invoicee_corp_num',
|
||||
'invoicee_tax_reg_id',
|
||||
'invoicee_corp_name',
|
||||
'invoicee_ceo_name',
|
||||
'invoicee_addr',
|
||||
'invoicee_biz_type',
|
||||
'invoicee_biz_class',
|
||||
'invoicee_contact_id',
|
||||
'supply_amount',
|
||||
'tax_amount',
|
||||
'total_amount',
|
||||
'tax_type',
|
||||
'purpose_type',
|
||||
'issue_type',
|
||||
'is_modified',
|
||||
'original_nts_confirm_num',
|
||||
'modify_code',
|
||||
'remark1',
|
||||
'remark2',
|
||||
'remark3',
|
||||
'item_name',
|
||||
'item_count',
|
||||
'item_unit_price',
|
||||
'item_supply_amount',
|
||||
'item_tax_amount',
|
||||
'item_remark',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'deduction_type',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'supply_amount' => 'integer',
|
||||
'tax_amount' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'item_count' => 'integer',
|
||||
'item_unit_price' => 'integer',
|
||||
'item_supply_amount' => 'integer',
|
||||
'item_tax_amount' => 'integer',
|
||||
'is_modified' => 'boolean',
|
||||
'write_date' => 'date',
|
||||
'issue_date' => 'date',
|
||||
'send_date' => 'date',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
public function journals(): HasMany
|
||||
{
|
||||
return $this->hasMany(HometaxInvoiceJournal::class, 'hometax_invoice_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeSales($query)
|
||||
{
|
||||
return $query->where('invoice_type', 'sales');
|
||||
}
|
||||
|
||||
public function scopePurchase($query)
|
||||
{
|
||||
return $query->where('invoice_type', 'purchase');
|
||||
}
|
||||
|
||||
public function scopePeriod($query, string $startDate, string $endDate)
|
||||
{
|
||||
return $query->whereBetween('write_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 접근자
|
||||
// =========================================================================
|
||||
|
||||
public function getTaxTypeNameAttribute(): string
|
||||
{
|
||||
return match ($this->tax_type) {
|
||||
self::TAX_TYPE_TAXABLE => '과세',
|
||||
self::TAX_TYPE_ZERO => '영세',
|
||||
self::TAX_TYPE_EXEMPT => '면세',
|
||||
default => $this->tax_type ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
public function getPurposeTypeNameAttribute(): string
|
||||
{
|
||||
return match ($this->purpose_type) {
|
||||
self::PURPOSE_TYPE_RECEIPT => '영수',
|
||||
self::PURPOSE_TYPE_CLAIM => '청구',
|
||||
default => $this->purpose_type ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
public function getIssueTypeNameAttribute(): string
|
||||
{
|
||||
return match ($this->issue_type) {
|
||||
self::ISSUE_TYPE_NORMAL => '정발행',
|
||||
self::ISSUE_TYPE_REVERSE => '역발행',
|
||||
default => $this->issue_type ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
78
app/Models/Barobill/HometaxInvoiceJournal.php
Normal file
78
app/Models/Barobill/HometaxInvoiceJournal.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Barobill;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HometaxInvoiceJournal extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'hometax_invoice_journals';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'hometax_invoice_id',
|
||||
'nts_confirm_num',
|
||||
'dc_type',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'debit_amount',
|
||||
'credit_amount',
|
||||
'description',
|
||||
'sort_order',
|
||||
'invoice_type',
|
||||
'write_date',
|
||||
'supply_amount',
|
||||
'tax_amount',
|
||||
'total_amount',
|
||||
'trading_partner_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'debit_amount' => 'integer',
|
||||
'credit_amount' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'supply_amount' => 'integer',
|
||||
'tax_amount' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'write_date' => 'date',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
|
||||
}
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HometaxInvoice::class, 'hometax_invoice_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public static function getByInvoiceId(int $tenantId, int $invoiceId)
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->where('hometax_invoice_id', $invoiceId)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
public static function getJournaledInvoiceIds(int $tenantId, array $invoiceIds): array
|
||||
{
|
||||
return static::where('tenant_id', $tenantId)
|
||||
->whereIn('hometax_invoice_id', $invoiceIds)
|
||||
->distinct()
|
||||
->pluck('hometax_invoice_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ public function fileable()
|
||||
*/
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return Storage::disk('tenant')->path($this->file_path);
|
||||
return $this->file_path;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,22 +111,38 @@ public function getStoragePath(): string
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return Storage::disk('tenant')->exists($this->file_path);
|
||||
return Storage::disk('r2')->exists($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download response
|
||||
* Get download response (streaming from R2)
|
||||
*
|
||||
* @param bool $inline true = 브라우저에서 바로 표시 (이미지/PDF), false = 다운로드
|
||||
*/
|
||||
public function download()
|
||||
public function download(bool $inline = false)
|
||||
{
|
||||
if (! $this->exists()) {
|
||||
abort(404, 'File not found in storage');
|
||||
}
|
||||
|
||||
return response()->download(
|
||||
$this->getStoragePath(),
|
||||
$this->display_name ?? $this->original_name
|
||||
);
|
||||
$fileName = $this->display_name ?? $this->original_name;
|
||||
$mimeType = $this->mime_type ?? 'application/octet-stream';
|
||||
$disposition = $inline ? 'inline' : 'attachment';
|
||||
|
||||
// Stream from R2 (메모리에 전체 로드하지 않음)
|
||||
$stream = Storage::disk('r2')->readStream($this->file_path);
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => $disposition . '; filename="' . $fileName . '"',
|
||||
'Content-Length' => $this->file_size,
|
||||
'Cache-Control' => 'private, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,9 +165,9 @@ public function moveToFolder(Folder $folder): bool
|
||||
$this->stored_name ?? $this->file_name
|
||||
);
|
||||
|
||||
// Move physical file
|
||||
if (Storage::disk('tenant')->exists($this->file_path)) {
|
||||
Storage::disk('tenant')->move($this->file_path, $newPath);
|
||||
// Move physical file in R2
|
||||
if (Storage::disk('r2')->exists($this->file_path)) {
|
||||
Storage::disk('r2')->move($this->file_path, $newPath);
|
||||
}
|
||||
|
||||
// Update DB
|
||||
@@ -182,9 +198,9 @@ public function softDeleteFile(int $userId): void
|
||||
*/
|
||||
public function permanentDelete(): void
|
||||
{
|
||||
// Delete physical file
|
||||
// Delete physical file from R2
|
||||
if ($this->exists()) {
|
||||
Storage::disk('tenant')->delete($this->file_path);
|
||||
Storage::disk('r2')->delete($this->file_path);
|
||||
}
|
||||
|
||||
// Decrement tenant storage
|
||||
|
||||
43
app/Models/Equipment/EquipmentInspection.php
Normal file
43
app/Models/Equipment/EquipmentInspection.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Equipment;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class EquipmentInspection extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'equipment_id',
|
||||
'inspection_cycle',
|
||||
'year_month',
|
||||
'overall_judgment',
|
||||
'inspector_id',
|
||||
'repair_note',
|
||||
'issue_note',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
public function equipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Equipment::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function inspector(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'inspector_id');
|
||||
}
|
||||
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(EquipmentInspectionDetail::class, 'inspection_id');
|
||||
}
|
||||
}
|
||||
55
app/Models/Equipment/EquipmentInspectionDetail.php
Normal file
55
app/Models/Equipment/EquipmentInspectionDetail.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Equipment;
|
||||
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EquipmentInspectionDetail extends Model
|
||||
{
|
||||
use ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'inspection_id',
|
||||
'template_item_id',
|
||||
'check_date',
|
||||
'result',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'check_date' => 'date',
|
||||
];
|
||||
|
||||
public function inspection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EquipmentInspection::class, 'inspection_id');
|
||||
}
|
||||
|
||||
public function templateItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EquipmentInspectionTemplate::class, 'template_item_id');
|
||||
}
|
||||
|
||||
public static function getNextResult(?string $current): ?string
|
||||
{
|
||||
return match ($current) {
|
||||
null, '' => 'good',
|
||||
'good' => 'bad',
|
||||
'bad' => 'repaired',
|
||||
'repaired' => null,
|
||||
default => 'good',
|
||||
};
|
||||
}
|
||||
|
||||
public static function getResultSymbol(?string $result): string
|
||||
{
|
||||
return match ($result) {
|
||||
'good' => '○',
|
||||
'bad' => 'X',
|
||||
'repaired' => '△',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
42
app/Models/Equipment/EquipmentInspectionTemplate.php
Normal file
42
app/Models/Equipment/EquipmentInspectionTemplate.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Equipment;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EquipmentInspectionTemplate extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'equipment_id',
|
||||
'inspection_cycle',
|
||||
'item_no',
|
||||
'check_point',
|
||||
'check_item',
|
||||
'check_timing',
|
||||
'check_frequency',
|
||||
'check_method',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function equipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Equipment::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function scopeByCycle($query, string $cycle)
|
||||
{
|
||||
return $query->where('inspection_cycle', $cycle);
|
||||
}
|
||||
}
|
||||
31
app/Models/Equipment/EquipmentProcess.php
Normal file
31
app/Models/Equipment/EquipmentProcess.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Equipment;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EquipmentProcess extends Model
|
||||
{
|
||||
protected $table = 'equipment_process';
|
||||
|
||||
protected $fillable = [
|
||||
'equipment_id',
|
||||
'process_id',
|
||||
'is_primary',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
];
|
||||
|
||||
public function equipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Equipment::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function process(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Process::class, 'process_id');
|
||||
}
|
||||
}
|
||||
62
app/Models/Equipment/EquipmentRepair.php
Normal file
62
app/Models/Equipment/EquipmentRepair.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Equipment;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class EquipmentRepair extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'equipment_id',
|
||||
'repair_date',
|
||||
'repair_type',
|
||||
'repair_hours',
|
||||
'description',
|
||||
'cost',
|
||||
'vendor',
|
||||
'repaired_by',
|
||||
'memo',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'repair_date' => 'date',
|
||||
'repair_hours' => 'decimal:1',
|
||||
'cost' => 'decimal:2',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): self
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function equipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Equipment::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function repairer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'repaired_by');
|
||||
}
|
||||
}
|
||||
76
app/Models/Qualitys/ChecklistTemplate.php
Normal file
76
app/Models/Qualitys/ChecklistTemplate.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ChecklistTemplate extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'checklist_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'type',
|
||||
'categories',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'categories' => 'array',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검항목별 연결 파일 (files 테이블 polymorphic)
|
||||
* document_type = 'checklist_template', document_id = this.id
|
||||
* field_key = sub_item_id (e.g. 'cat-1-1')
|
||||
*/
|
||||
public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'document', 'document_type', 'document_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 항목의 파일 조회
|
||||
*/
|
||||
public function documentsForItem(string $subItemId)
|
||||
{
|
||||
return $this->documents()->where('field_key', $subItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* categories JSON에서 모든 sub_item_id 추출
|
||||
*/
|
||||
public function getAllSubItemIds(): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($this->categories ?? [] as $category) {
|
||||
foreach ($category['subItems'] ?? [] as $subItem) {
|
||||
$ids[] = $subItem['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class BarobillSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'corp_num',
|
||||
@@ -23,6 +26,10 @@ class BarobillSetting extends Model
|
||||
'contact_tel',
|
||||
'is_active',
|
||||
'auto_issue',
|
||||
'use_tax_invoice',
|
||||
'use_bank_account',
|
||||
'use_card_usage',
|
||||
'use_hometax',
|
||||
'verified_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
@@ -31,6 +38,10 @@ class BarobillSetting extends Model
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'auto_issue' => 'boolean',
|
||||
'use_tax_invoice' => 'boolean',
|
||||
'use_bank_account' => 'boolean',
|
||||
'use_card_usage' => 'boolean',
|
||||
'use_hometax' => 'boolean',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
|
||||
@@ -129,4 +140,26 @@ public function getFormattedCorpNumAttribute(): string
|
||||
|
||||
return $num;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 서비스 목록
|
||||
*/
|
||||
public function getActiveServicesAttribute(): array
|
||||
{
|
||||
$services = [];
|
||||
if ($this->use_tax_invoice) {
|
||||
$services[] = 'tax_invoice';
|
||||
}
|
||||
if ($this->use_bank_account) {
|
||||
$services[] = 'bank_account';
|
||||
}
|
||||
if ($this->use_card_usage) {
|
||||
$services[] = 'card_usage';
|
||||
}
|
||||
if ($this->use_hometax) {
|
||||
$services[] = 'hometax';
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Models/Tenants/IncomeTaxBracket.php
Normal file
64
app/Models/Tenants/IncomeTaxBracket.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IncomeTaxBracket extends Model
|
||||
{
|
||||
protected $table = 'income_tax_brackets';
|
||||
|
||||
protected $fillable = [
|
||||
'tax_year',
|
||||
'salary_from',
|
||||
'salary_to',
|
||||
'family_count',
|
||||
'tax_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tax_year' => 'integer',
|
||||
'salary_from' => 'integer',
|
||||
'salary_to' => 'integer',
|
||||
'family_count' => 'integer',
|
||||
'tax_amount' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeForYear(Builder $query, int $year): Builder
|
||||
{
|
||||
return $query->where('tax_year', $year);
|
||||
}
|
||||
|
||||
public function scopeForSalaryRange(Builder $query, int $salaryThousand): Builder
|
||||
{
|
||||
return $query->where('salary_from', '<=', $salaryThousand)
|
||||
->where(function ($q) use ($salaryThousand) {
|
||||
$q->where('salary_to', '>', $salaryThousand)
|
||||
->orWhere(function ($q2) use ($salaryThousand) {
|
||||
$q2->whereColumn('salary_from', 'salary_to')
|
||||
->where('salary_from', $salaryThousand);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForFamilyCount(Builder $query, int $count): Builder
|
||||
{
|
||||
return $query->where('family_count', $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 간이세액표에서 세액 조회
|
||||
*/
|
||||
public static function lookupTax(int $year, int $salaryThousand, int $familyCount): int
|
||||
{
|
||||
$familyCount = max(1, min(11, $familyCount));
|
||||
|
||||
$bracket = static::forYear($year)
|
||||
->forSalaryRange($salaryThousand)
|
||||
->forFamilyCount($familyCount)
|
||||
->first();
|
||||
|
||||
return $bracket ? $bracket->tax_amount : 0;
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,26 @@ class JournalEntry extends Model
|
||||
|
||||
// Status
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
// Source type
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
|
||||
public const SOURCE_BANK_TRANSACTION = 'bank_transaction';
|
||||
|
||||
public const SOURCE_TAX_INVOICE = 'tax_invoice';
|
||||
|
||||
public const SOURCE_CARD_TRANSACTION = 'card_transaction';
|
||||
|
||||
public const SOURCE_BAROBILL_CARD = 'barobill_card';
|
||||
|
||||
public const SOURCE_BAROBILL_BANK = 'barobill_bank';
|
||||
|
||||
public const SOURCE_HOMETAX_INVOICE = 'hometax_invoice';
|
||||
|
||||
public const SOURCE_PAYROLL = 'payroll';
|
||||
|
||||
// Entry type
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
|
||||
47
app/Models/Tenants/MailLog.php
Normal file
47
app/Models/Tenants/MailLog.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MailLog extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'mailable_type',
|
||||
'to_address',
|
||||
'from_address',
|
||||
'subject',
|
||||
'status',
|
||||
'sent_at',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sent_at' => 'datetime',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): static
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -49,17 +49,19 @@ class Payroll extends Model
|
||||
protected $casts = [
|
||||
'allowances' => 'array',
|
||||
'deductions' => 'array',
|
||||
'base_salary' => 'decimal:2',
|
||||
'overtime_pay' => 'decimal:2',
|
||||
'bonus' => 'decimal:2',
|
||||
'gross_salary' => 'decimal:2',
|
||||
'income_tax' => 'decimal:2',
|
||||
'resident_tax' => 'decimal:2',
|
||||
'health_insurance' => 'decimal:2',
|
||||
'pension' => 'decimal:2',
|
||||
'employment_insurance' => 'decimal:2',
|
||||
'total_deductions' => 'decimal:2',
|
||||
'net_salary' => 'decimal:2',
|
||||
'options' => 'array',
|
||||
'base_salary' => 'decimal:0',
|
||||
'overtime_pay' => 'decimal:0',
|
||||
'bonus' => 'decimal:0',
|
||||
'gross_salary' => 'decimal:0',
|
||||
'income_tax' => 'decimal:0',
|
||||
'resident_tax' => 'decimal:0',
|
||||
'health_insurance' => 'decimal:0',
|
||||
'long_term_care' => 'decimal:0',
|
||||
'pension' => 'decimal:0',
|
||||
'employment_insurance' => 'decimal:0',
|
||||
'total_deductions' => 'decimal:0',
|
||||
'net_salary' => 'decimal:0',
|
||||
'confirmed_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'pay_year' => 'integer',
|
||||
@@ -79,9 +81,11 @@ class Payroll extends Model
|
||||
'income_tax',
|
||||
'resident_tax',
|
||||
'health_insurance',
|
||||
'long_term_care',
|
||||
'pension',
|
||||
'employment_insurance',
|
||||
'deductions',
|
||||
'options',
|
||||
'total_deductions',
|
||||
'net_salary',
|
||||
'status',
|
||||
@@ -104,6 +108,7 @@ class Payroll extends Model
|
||||
'income_tax' => 0,
|
||||
'resident_tax' => 0,
|
||||
'health_insurance' => 0,
|
||||
'long_term_care' => 0,
|
||||
'pension' => 0,
|
||||
'employment_insurance' => 0,
|
||||
'total_deductions' => 0,
|
||||
@@ -227,13 +232,33 @@ public function scopeForUser($query, int $userId)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 (작성중 상태만)
|
||||
* 수정 가능 여부 (작성중, 슈퍼관리자는 모든 상태)
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
public function isEditable(bool $isSuperAdmin = false): bool
|
||||
{
|
||||
if ($isSuperAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정 취소 가능 여부
|
||||
*/
|
||||
public function isUnconfirmable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CONFIRMED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지급 취소 가능 여부 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function isUnpayable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정 가능 여부
|
||||
*/
|
||||
@@ -322,6 +347,7 @@ public function calculateTotalDeductions(): float
|
||||
return $this->income_tax
|
||||
+ $this->resident_tax
|
||||
+ $this->health_insurance
|
||||
+ $this->long_term_care
|
||||
+ $this->pension
|
||||
+ $this->employment_insurance
|
||||
+ $this->deductions_total;
|
||||
|
||||
@@ -34,6 +34,7 @@ class Receiving extends Model
|
||||
'status',
|
||||
'remark',
|
||||
'options',
|
||||
'certificate_file_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -47,6 +48,7 @@ class Receiving extends Model
|
||||
'receiving_qty' => 'decimal:2',
|
||||
'item_id' => 'integer',
|
||||
'options' => 'array',
|
||||
'certificate_file_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,14 @@ public function item(): BelongsTo
|
||||
return $this->belongsTo(\App\Models\Items\Item::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 제공 성적서 파일 관계
|
||||
*/
|
||||
public function certificateFile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Commons\File::class, 'certificate_file_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 관계
|
||||
*/
|
||||
|
||||
@@ -283,6 +283,12 @@ public function getOrderContactAttribute(): ?string
|
||||
*/
|
||||
public function getOrderInfoAttribute(): array
|
||||
{
|
||||
$orderOptions = $this->order?->options;
|
||||
if (is_string($orderOptions)) {
|
||||
$orderOptions = json_decode($orderOptions, true) ?? [];
|
||||
}
|
||||
$orderOptions = $orderOptions ?? [];
|
||||
|
||||
return [
|
||||
'order_id' => $this->order_id,
|
||||
'order_no' => $this->order?->order_no,
|
||||
@@ -290,10 +296,16 @@ public function getOrderInfoAttribute(): array
|
||||
'client_id' => $this->order_client_id,
|
||||
'customer_name' => $this->order_customer_name,
|
||||
'site_name' => $this->order_site_name,
|
||||
'delivery_address' => $this->order_delivery_address,
|
||||
'delivery_address' => $orderOptions['shipping_address'] ?? $this->order_delivery_address,
|
||||
'delivery_address_detail' => $orderOptions['shipping_address_detail'] ?? null,
|
||||
'contact' => $this->order_contact,
|
||||
// 수신자 정보 (수주 options에서)
|
||||
'receiver' => $orderOptions['receiver'] ?? null,
|
||||
'receiver_contact' => $orderOptions['receiver_contact'] ?? $this->order_contact,
|
||||
// 추가 정보
|
||||
'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), 'writer_id' => $this->order?->writer_id,
|
||||
'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'),
|
||||
'delivery_method' => $this->order?->delivery_method_code,
|
||||
'writer_id' => $this->order?->writer_id,
|
||||
'writer_name' => $this->order?->writer?->name,
|
||||
];
|
||||
}
|
||||
|
||||
103
app/Models/Tenants/TenantMailConfig.php
Normal file
103
app/Models/Tenants/TenantMailConfig.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TenantMailConfig extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'provider',
|
||||
'from_address',
|
||||
'from_name',
|
||||
'reply_to',
|
||||
'is_verified',
|
||||
'daily_limit',
|
||||
'is_active',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_verified' => 'boolean',
|
||||
'daily_limit' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
// Options 키 상수
|
||||
public const OPTION_SMTP = 'smtp';
|
||||
|
||||
public const OPTION_PRESET = 'preset';
|
||||
|
||||
public const OPTION_BRANDING = 'branding';
|
||||
|
||||
public const OPTION_CONNECTION_TEST = 'connection_test';
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): static
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpHost(): ?string
|
||||
{
|
||||
return $this->getOption('smtp.host');
|
||||
}
|
||||
|
||||
public function getSmtpPort(): int
|
||||
{
|
||||
return (int) $this->getOption('smtp.port', 587);
|
||||
}
|
||||
|
||||
public function getSmtpEncryption(): string
|
||||
{
|
||||
return $this->getOption('smtp.encryption', 'tls');
|
||||
}
|
||||
|
||||
public function getSmtpUsername(): ?string
|
||||
{
|
||||
return $this->getOption('smtp.username');
|
||||
}
|
||||
|
||||
public function getSmtpPassword(): ?string
|
||||
{
|
||||
$encrypted = $this->getOption('smtp.password');
|
||||
if (! $encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return decrypt($encrypted);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getPreset(): ?string
|
||||
{
|
||||
return $this->getOption('preset');
|
||||
}
|
||||
}
|
||||
249
app/Services/BarobillBankTransactionService.php
Normal file
249
app/Services/BarobillBankTransactionService.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Barobill\BarobillBankTransaction;
|
||||
use App\Models\Barobill\BarobillBankTransactionOverride;
|
||||
use App\Models\Barobill\BarobillBankTransactionSplit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 바로빌 은행 거래 서비스 (React 연동용)
|
||||
*
|
||||
* MNG에서 동기화된 barobill_bank_transactions 데이터를
|
||||
* React 프론트엔드에서 조회/분개/수정 등 처리
|
||||
*/
|
||||
class BarobillBankTransactionService extends Service
|
||||
{
|
||||
/**
|
||||
* 은행 거래 목록 조회 (기간별, 계좌번호별)
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
|
||||
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
|
||||
$accountNum = $params['bank_account_num'] ?? null;
|
||||
$search = $params['search'] ?? null;
|
||||
$perPage = $params['per_page'] ?? 50;
|
||||
|
||||
$query = BarobillBankTransaction::where('tenant_id', $tenantId)
|
||||
->whereBetween('trans_date', [$startDate, $endDate]);
|
||||
|
||||
if ($accountNum) {
|
||||
$query->where('bank_account_num', $accountNum);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('summary', 'like', "%{$search}%")
|
||||
->orWhere('memo', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$query->orderByDesc('trans_date')->orderByDesc('trans_dt');
|
||||
|
||||
$transactions = $query->paginate($perPage);
|
||||
|
||||
// 분할/오버라이드 정보 로드
|
||||
$uniqueKeys = $transactions->getCollection()->map->unique_key->toArray();
|
||||
|
||||
$splits = BarobillBankTransactionSplit::where('tenant_id', $tenantId)
|
||||
->whereIn('original_unique_key', $uniqueKeys)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy('original_unique_key');
|
||||
|
||||
$overrides = BarobillBankTransactionOverride::getByUniqueKeys($tenantId, $uniqueKeys);
|
||||
|
||||
$transactions->getCollection()->transform(function ($tx) use ($splits, $overrides) {
|
||||
$tx->splits = $splits->get($tx->unique_key, collect());
|
||||
$tx->has_splits = $tx->splits->isNotEmpty();
|
||||
$tx->override = $overrides->get($tx->unique_key);
|
||||
|
||||
return $tx;
|
||||
});
|
||||
|
||||
return [
|
||||
'data' => $transactions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 목록 (필터용)
|
||||
*/
|
||||
public function accounts(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$accounts = BarobillBankTransaction::where('tenant_id', $tenantId)
|
||||
->select('bank_account_num', 'bank_name')
|
||||
->distinct()
|
||||
->orderBy('bank_account_num')
|
||||
->get();
|
||||
|
||||
return ['items' => $accounts];
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 분할 조회
|
||||
*/
|
||||
public function getSplits(string $uniqueKey): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$splits = BarobillBankTransactionSplit::getByUniqueKey($tenantId, $uniqueKey);
|
||||
|
||||
return ['items' => $splits];
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 분할 저장
|
||||
*/
|
||||
public function saveSplits(string $uniqueKey, array $items): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($tenantId, $uniqueKey, $items) {
|
||||
BarobillBankTransactionSplit::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->delete();
|
||||
|
||||
$created = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$created[] = BarobillBankTransactionSplit::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'original_unique_key' => $uniqueKey,
|
||||
'split_amount' => $item['split_amount'],
|
||||
'account_code' => $item['account_code'] ?? null,
|
||||
'account_name' => $item['account_name'] ?? null,
|
||||
'deduction_type' => $item['deduction_type'] ?? null,
|
||||
'evidence_name' => $item['evidence_name'] ?? null,
|
||||
'description' => $item['description'] ?? null,
|
||||
'memo' => $item['memo'] ?? null,
|
||||
'sort_order' => $index + 1,
|
||||
'bank_account_num' => $item['bank_account_num'] ?? null,
|
||||
'trans_dt' => $item['trans_dt'] ?? null,
|
||||
'trans_date' => $item['trans_date'] ?? null,
|
||||
'original_deposit' => $item['original_deposit'] ?? 0,
|
||||
'original_withdraw' => $item['original_withdraw'] ?? 0,
|
||||
'summary' => $item['summary'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['items' => $created, 'count' => count($created)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 분할 삭제
|
||||
*/
|
||||
public function deleteSplits(string $uniqueKey): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$deleted = BarobillBankTransactionSplit::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->delete();
|
||||
|
||||
return ['deleted_count' => $deleted];
|
||||
}
|
||||
|
||||
/**
|
||||
* 적요/분류 오버라이드 저장
|
||||
*/
|
||||
public function saveOverride(string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): BarobillBankTransactionOverride
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return BarobillBankTransactionOverride::saveOverride($tenantId, $uniqueKey, $modifiedSummary, $modifiedCast);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 은행 거래 등록
|
||||
*/
|
||||
public function storeManual(array $data): BarobillBankTransaction
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return BarobillBankTransaction::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'bank_account_num' => $data['bank_account_num'],
|
||||
'bank_code' => $data['bank_code'] ?? null,
|
||||
'bank_name' => $data['bank_name'] ?? null,
|
||||
'trans_date' => $data['trans_date'],
|
||||
'trans_time' => $data['trans_time'] ?? null,
|
||||
'trans_dt' => $data['trans_dt'] ?? $data['trans_date'].($data['trans_time'] ?? '000000'),
|
||||
'deposit' => $data['deposit'] ?? 0,
|
||||
'withdraw' => $data['withdraw'] ?? 0,
|
||||
'balance' => $data['balance'] ?? 0,
|
||||
'summary' => $data['summary'] ?? null,
|
||||
'cast' => $data['cast'] ?? null,
|
||||
'memo' => $data['memo'] ?? null,
|
||||
'trans_office' => $data['trans_office'] ?? null,
|
||||
'account_code' => $data['account_code'] ?? null,
|
||||
'account_name' => $data['account_name'] ?? null,
|
||||
'client_code' => $data['client_code'] ?? null,
|
||||
'client_name' => $data['client_name'] ?? null,
|
||||
'is_manual' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 은행 거래 수정
|
||||
*/
|
||||
public function updateManual(int $id, array $data): BarobillBankTransaction
|
||||
{
|
||||
$tx = BarobillBankTransaction::where('tenant_id', $this->tenantId())
|
||||
->where('is_manual', true)
|
||||
->findOrFail($id);
|
||||
|
||||
$tx->update($data);
|
||||
|
||||
return $tx->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 은행 거래 삭제
|
||||
*/
|
||||
public function destroyManual(int $id): bool
|
||||
{
|
||||
$tx = BarobillBankTransaction::where('tenant_id', $this->tenantId())
|
||||
->where('is_manual', true)
|
||||
->findOrFail($id);
|
||||
|
||||
return $tx->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔액 요약
|
||||
*/
|
||||
public function balanceSummary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$date = $params['date'] ?? now()->format('Y-m-d');
|
||||
|
||||
$accounts = BarobillBankTransaction::where('tenant_id', $tenantId)
|
||||
->select('bank_account_num', 'bank_name')
|
||||
->distinct()
|
||||
->get();
|
||||
|
||||
$summary = [];
|
||||
foreach ($accounts as $account) {
|
||||
$lastTx = BarobillBankTransaction::where('tenant_id', $tenantId)
|
||||
->where('bank_account_num', $account->bank_account_num)
|
||||
->where('trans_date', '<=', $date)
|
||||
->orderByDesc('trans_date')
|
||||
->orderByDesc('trans_dt')
|
||||
->first();
|
||||
|
||||
$summary[] = [
|
||||
'bank_account_num' => $account->bank_account_num,
|
||||
'bank_name' => $account->bank_name,
|
||||
'balance' => $lastTx ? $lastTx->balance : 0,
|
||||
'last_trans_date' => $lastTx?->trans_date,
|
||||
];
|
||||
}
|
||||
|
||||
return ['items' => $summary];
|
||||
}
|
||||
}
|
||||
308
app/Services/BarobillCardTransactionService.php
Normal file
308
app/Services/BarobillCardTransactionService.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Barobill\BarobillCardTransaction;
|
||||
use App\Models\Barobill\BarobillCardTransactionAmountLog;
|
||||
use App\Models\Barobill\BarobillCardTransactionHide;
|
||||
use App\Models\Barobill\BarobillCardTransactionSplit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 바로빌 카드 거래 서비스 (React 연동용)
|
||||
*
|
||||
* MNG에서 동기화된 barobill_card_transactions 데이터를
|
||||
* React 프론트엔드에서 조회/분개/숨김 등 처리
|
||||
*/
|
||||
class BarobillCardTransactionService extends Service
|
||||
{
|
||||
/**
|
||||
* 카드 거래 목록 조회 (기간별, 카드번호별)
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
|
||||
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
|
||||
$cardNum = $params['card_num'] ?? null;
|
||||
$search = $params['search'] ?? null;
|
||||
$includeHidden = $params['include_hidden'] ?? false;
|
||||
$perPage = $params['per_page'] ?? 50;
|
||||
|
||||
$query = BarobillCardTransaction::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate]);
|
||||
|
||||
if ($cardNum) {
|
||||
$query->where('card_num', $cardNum);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('merchant_name', 'like', "%{$search}%")
|
||||
->orWhere('memo', 'like', "%{$search}%")
|
||||
->orWhere('approval_num', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 숨김 거래 필터링
|
||||
if (! $includeHidden) {
|
||||
$hiddenKeys = BarobillCardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate);
|
||||
if (! empty($hiddenKeys)) {
|
||||
$query->whereNotIn(
|
||||
DB::raw("CONCAT(card_num, '|', use_dt, '|', approval_num, '|', approval_amount)"),
|
||||
$hiddenKeys
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$query->orderByDesc('use_date')->orderByDesc('use_dt');
|
||||
|
||||
$transactions = $query->paginate($perPage);
|
||||
|
||||
// 분할 거래 정보 로드
|
||||
$uniqueKeys = $transactions->getCollection()->map->unique_key->toArray();
|
||||
$splits = BarobillCardTransactionSplit::where('tenant_id', $tenantId)
|
||||
->whereIn('original_unique_key', $uniqueKeys)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->groupBy('original_unique_key');
|
||||
|
||||
$transactions->getCollection()->transform(function ($tx) use ($splits) {
|
||||
$tx->splits = $splits->get($tx->unique_key, collect());
|
||||
$tx->has_splits = $tx->splits->isNotEmpty();
|
||||
|
||||
return $tx;
|
||||
});
|
||||
|
||||
return [
|
||||
'data' => $transactions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 카드 거래 상세
|
||||
*/
|
||||
public function show(int $id): ?BarobillCardTransaction
|
||||
{
|
||||
return BarobillCardTransaction::where('tenant_id', $this->tenantId())
|
||||
->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분할 조회
|
||||
*/
|
||||
public function getSplits(string $uniqueKey): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$splits = BarobillCardTransactionSplit::getByUniqueKey($tenantId, $uniqueKey);
|
||||
|
||||
return ['items' => $splits];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분할 저장
|
||||
*/
|
||||
public function saveSplits(string $uniqueKey, array $items): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($tenantId, $uniqueKey, $items) {
|
||||
// 기존 분할 삭제
|
||||
BarobillCardTransactionSplit::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->delete();
|
||||
|
||||
$created = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$created[] = BarobillCardTransactionSplit::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'original_unique_key' => $uniqueKey,
|
||||
'split_amount' => $item['split_amount'],
|
||||
'split_supply_amount' => $item['split_supply_amount'] ?? 0,
|
||||
'split_tax' => $item['split_tax'] ?? 0,
|
||||
'account_code' => $item['account_code'] ?? null,
|
||||
'account_name' => $item['account_name'] ?? null,
|
||||
'deduction_type' => $item['deduction_type'] ?? null,
|
||||
'evidence_name' => $item['evidence_name'] ?? null,
|
||||
'description' => $item['description'] ?? null,
|
||||
'memo' => $item['memo'] ?? null,
|
||||
'sort_order' => $index + 1,
|
||||
'card_num' => $item['card_num'] ?? null,
|
||||
'use_dt' => $item['use_dt'] ?? null,
|
||||
'use_date' => $item['use_date'] ?? null,
|
||||
'approval_num' => $item['approval_num'] ?? null,
|
||||
'original_amount' => $item['original_amount'] ?? 0,
|
||||
'merchant_name' => $item['merchant_name'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['items' => $created, 'count' => count($created)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분할 삭제
|
||||
*/
|
||||
public function deleteSplits(string $uniqueKey): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$deleted = BarobillCardTransactionSplit::where('tenant_id', $tenantId)
|
||||
->where('original_unique_key', $uniqueKey)
|
||||
->delete();
|
||||
|
||||
return ['deleted_count' => $deleted];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 카드 거래 등록
|
||||
*/
|
||||
public function storeManual(array $data): BarobillCardTransaction
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return BarobillCardTransaction::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'card_num' => $data['card_num'],
|
||||
'card_company' => $data['card_company'] ?? null,
|
||||
'card_company_name' => $data['card_company_name'] ?? null,
|
||||
'use_dt' => $data['use_dt'],
|
||||
'use_date' => $data['use_date'],
|
||||
'use_time' => $data['use_time'] ?? null,
|
||||
'approval_num' => $data['approval_num'] ?? 'MANUAL-'.now()->format('YmdHis'),
|
||||
'approval_type' => $data['approval_type'] ?? '1',
|
||||
'approval_amount' => $data['approval_amount'],
|
||||
'tax' => $data['tax'] ?? 0,
|
||||
'service_charge' => $data['service_charge'] ?? 0,
|
||||
'payment_plan' => $data['payment_plan'] ?? null,
|
||||
'merchant_name' => $data['merchant_name'],
|
||||
'merchant_biz_num' => $data['merchant_biz_num'] ?? null,
|
||||
'account_code' => $data['account_code'] ?? null,
|
||||
'account_name' => $data['account_name'] ?? null,
|
||||
'deduction_type' => $data['deduction_type'] ?? null,
|
||||
'evidence_name' => $data['evidence_name'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'memo' => $data['memo'] ?? null,
|
||||
'is_manual' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 카드 거래 수정
|
||||
*/
|
||||
public function updateManual(int $id, array $data): BarobillCardTransaction
|
||||
{
|
||||
$tx = BarobillCardTransaction::where('tenant_id', $this->tenantId())
|
||||
->where('is_manual', true)
|
||||
->findOrFail($id);
|
||||
|
||||
$tx->update($data);
|
||||
|
||||
return $tx->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 카드 거래 삭제
|
||||
*/
|
||||
public function destroyManual(int $id): bool
|
||||
{
|
||||
$tx = BarobillCardTransaction::where('tenant_id', $this->tenantId())
|
||||
->where('is_manual', true)
|
||||
->findOrFail($id);
|
||||
|
||||
return $tx->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 숨김
|
||||
*/
|
||||
public function hide(int $id): BarobillCardTransactionHide
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
return BarobillCardTransactionHide::hideTransaction($tenantId, $tx->unique_key, [
|
||||
'card_num' => $tx->card_num,
|
||||
'use_date' => $tx->use_date,
|
||||
'approval_num' => $tx->approval_num,
|
||||
'approval_amount' => $tx->approval_amount,
|
||||
'merchant_name' => $tx->merchant_name,
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 숨김 복원
|
||||
*/
|
||||
public function restore(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
return BarobillCardTransactionHide::restoreTransaction($tenantId, $tx->unique_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숨겨진 거래 목록
|
||||
*/
|
||||
public function hiddenList(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
|
||||
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
|
||||
|
||||
$hiddenItems = BarobillCardTransactionHide::where('tenant_id', $tenantId)
|
||||
->whereBetween('use_date', [$startDate, $endDate])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return ['items' => $hiddenItems];
|
||||
}
|
||||
|
||||
/**
|
||||
* 금액 수정 (공급가액/세액 수정)
|
||||
*/
|
||||
public function updateAmount(int $id, array $data): BarobillCardTransaction
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// 변경 이력 기록
|
||||
BarobillCardTransactionAmountLog::create([
|
||||
'card_transaction_id' => $tx->id,
|
||||
'original_unique_key' => $tx->unique_key,
|
||||
'before_supply_amount' => $tx->modified_supply_amount ?? $tx->approval_amount,
|
||||
'before_tax' => $tx->modified_tax ?? $tx->tax,
|
||||
'after_supply_amount' => $data['supply_amount'],
|
||||
'after_tax' => $data['tax'],
|
||||
'modified_by' => $userId,
|
||||
'modified_by_name' => $data['modified_by_name'] ?? '',
|
||||
'ip_address' => request()->ip(),
|
||||
]);
|
||||
|
||||
$tx->update([
|
||||
'modified_supply_amount' => $data['supply_amount'],
|
||||
'modified_tax' => $data['tax'],
|
||||
]);
|
||||
|
||||
return $tx->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 번호 목록 (필터용)
|
||||
*/
|
||||
public function cardNumbers(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$cards = BarobillCardTransaction::where('tenant_id', $tenantId)
|
||||
->select('card_num', 'card_company_name')
|
||||
->distinct()
|
||||
->orderBy('card_num')
|
||||
->get();
|
||||
|
||||
return ['items' => $cards];
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,41 @@
|
||||
* 바로빌 API 연동 서비스
|
||||
*
|
||||
* 바로빌 개발자센터: https://dev.barobill.co.kr/
|
||||
* SOAP 서비스 URL: https://ws.baroservice.com/ (운영) / https://testws.baroservice.com/ (테스트)
|
||||
*/
|
||||
class BarobillService extends Service
|
||||
{
|
||||
/**
|
||||
* 바로빌 API 기본 URL
|
||||
* 바로빌 SOAP 서비스 기본 URL (운영)
|
||||
*/
|
||||
private const API_BASE_URL = 'https://ws.barobill.co.kr';
|
||||
private const API_BASE_URL = 'https://ws.baroservice.com';
|
||||
|
||||
/**
|
||||
* 바로빌 API 테스트 URL
|
||||
* 바로빌 SOAP 서비스 테스트 URL
|
||||
*/
|
||||
private const API_TEST_URL = 'https://testws.barobill.co.kr';
|
||||
private const API_TEST_URL = 'https://testws.baroservice.com';
|
||||
|
||||
/**
|
||||
* API 서비스 경로 매핑
|
||||
*/
|
||||
private const SERVICE_PATHS = [
|
||||
'TI' => '/TI.asmx', // 세금계산서
|
||||
'CORPSTATE' => '/CORPSTATE.asmx', // 회원/사업자 관리
|
||||
'BANKACCOUNT' => '/BANKACCOUNT.asmx', // 계좌 조회
|
||||
'CARD' => '/CARD.asmx', // 카드 조회
|
||||
];
|
||||
|
||||
/**
|
||||
* 메서드별 서비스 매핑
|
||||
*/
|
||||
private const METHOD_SERVICE_MAP = [
|
||||
'GetAccessToken' => 'CORPSTATE',
|
||||
'CheckCorpNum' => 'CORPSTATE',
|
||||
'RegistCorp' => 'CORPSTATE',
|
||||
'RegistAndIssueTaxInvoice' => 'TI',
|
||||
'CancelTaxInvoice' => 'TI',
|
||||
'GetTaxInvoiceState' => 'TI',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테스트 모드 여부
|
||||
@@ -32,7 +55,7 @@ class BarobillService extends Service
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->testMode = config('services.barobill.test_mode', true);
|
||||
$this->testMode = (bool) config('services.barobill.test_mode', true);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -44,11 +67,7 @@ public function __construct()
|
||||
*/
|
||||
public function getSetting(): ?BarobillSetting
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return BarobillSetting::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
return BarobillSetting::query()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,9 +78,7 @@ public function saveSetting(array $data): BarobillSetting
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$setting = BarobillSetting::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
$setting = BarobillSetting::query()->first();
|
||||
|
||||
if ($setting) {
|
||||
$setting->fill(array_merge($data, ['updated_by' => $userId]));
|
||||
@@ -89,7 +106,6 @@ public function testConnection(): array
|
||||
}
|
||||
|
||||
try {
|
||||
// 바로빌 API 토큰 조회로 연동 테스트
|
||||
$response = $this->callApi('GetAccessToken', [
|
||||
'CERTKEY' => $setting->cert_key,
|
||||
'CorpNum' => $setting->corp_num,
|
||||
@@ -97,7 +113,6 @@ public function testConnection(): array
|
||||
]);
|
||||
|
||||
if (! empty($response['AccessToken'])) {
|
||||
// 검증 성공 시 verified_at 업데이트
|
||||
$setting->verified_at = now();
|
||||
$setting->save();
|
||||
|
||||
@@ -108,7 +123,10 @@ public function testConnection(): array
|
||||
];
|
||||
}
|
||||
|
||||
throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed'));
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $response['Message'] ?? __('error.barobill.connection_failed'),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('바로빌 연동 테스트 실패', [
|
||||
'tenant_id' => $this->tenantId(),
|
||||
@@ -125,19 +143,11 @@ public function testConnection(): array
|
||||
|
||||
/**
|
||||
* 사업자등록번호 유효성 검사 (휴폐업 조회)
|
||||
*
|
||||
* 바로빌 API를 통해 사업자등록번호의 유효성을 검증합니다.
|
||||
* 바로빌 설정이 없는 경우 기본 형식 검증만 수행합니다.
|
||||
*
|
||||
* @param string $businessNumber 사업자등록번호 (10자리, 하이픈 제거)
|
||||
* @return array{valid: bool, status: string, status_label: string, corp_name: ?string, ceo_name: ?string, message: string}
|
||||
*/
|
||||
public function checkBusinessNumber(string $businessNumber): array
|
||||
{
|
||||
// 하이픈 제거 및 숫자만 추출
|
||||
$businessNumber = preg_replace('/[^0-9]/', '', $businessNumber);
|
||||
|
||||
// 기본 형식 검증 (10자리)
|
||||
if (strlen($businessNumber) !== 10) {
|
||||
return [
|
||||
'valid' => false,
|
||||
@@ -149,7 +159,6 @@ public function checkBusinessNumber(string $businessNumber): array
|
||||
];
|
||||
}
|
||||
|
||||
// 체크섬 검증 (사업자등록번호 자체 유효성)
|
||||
if (! $this->validateBusinessNumberChecksum($businessNumber)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
@@ -161,16 +170,14 @@ public function checkBusinessNumber(string $businessNumber): array
|
||||
];
|
||||
}
|
||||
|
||||
// 바로빌 API 조회 시도
|
||||
try {
|
||||
$response = $this->callApi('CheckCorpNum', [
|
||||
'CorpNum' => $businessNumber,
|
||||
]);
|
||||
|
||||
// 바로빌 응답 해석
|
||||
if (isset($response['CorpState'])) {
|
||||
$state = $response['CorpState'];
|
||||
$isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업
|
||||
$isValid = in_array($state, ['01', '02']);
|
||||
$statusLabel = match ($state) {
|
||||
'01' => '사업중',
|
||||
'02' => '휴업',
|
||||
@@ -190,7 +197,6 @@ public function checkBusinessNumber(string $businessNumber): array
|
||||
];
|
||||
}
|
||||
|
||||
// 응답 형식이 다른 경우 (결과 코드 방식)
|
||||
if (isset($response['Result'])) {
|
||||
$isValid = $response['Result'] >= 0;
|
||||
|
||||
@@ -206,7 +212,6 @@ public function checkBusinessNumber(string $businessNumber): array
|
||||
];
|
||||
}
|
||||
|
||||
// 기본 응답 (체크섬만 통과한 경우)
|
||||
return [
|
||||
'valid' => true,
|
||||
'status' => 'format_valid',
|
||||
@@ -216,7 +221,6 @@ public function checkBusinessNumber(string $businessNumber): array
|
||||
'message' => __('message.company.business_number_format_valid'),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
// API 호출 실패 시 형식 검증 결과만 반환
|
||||
Log::warning('바로빌 사업자번호 조회 실패', [
|
||||
'business_number' => $businessNumber,
|
||||
'error' => $e->getMessage(),
|
||||
@@ -235,8 +239,6 @@ public function checkBusinessNumber(string $businessNumber): array
|
||||
|
||||
/**
|
||||
* 사업자등록번호 체크섬 검증
|
||||
*
|
||||
* @param string $businessNumber 10자리 사업자등록번호
|
||||
*/
|
||||
private function validateBusinessNumberChecksum(string $businessNumber): bool
|
||||
{
|
||||
@@ -252,7 +254,6 @@ private function validateBusinessNumberChecksum(string $businessNumber): bool
|
||||
$sum += intval($digits[$i]) * $multipliers[$i];
|
||||
}
|
||||
|
||||
// 8번째 자리 (인덱스 8)에 대한 추가 처리
|
||||
$sum += intval(floor(intval($digits[8]) * 5 / 10));
|
||||
|
||||
$remainder = $sum % 10;
|
||||
@@ -277,14 +278,11 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
|
||||
}
|
||||
|
||||
try {
|
||||
// 바로빌 API 호출을 위한 데이터 구성
|
||||
$apiData = $this->buildTaxInvoiceData($taxInvoice, $setting);
|
||||
|
||||
// 세금계산서 발행 API 호출
|
||||
$response = $this->callApi('RegistAndIssueTaxInvoice', $apiData);
|
||||
|
||||
if (! empty($response['InvoiceID'])) {
|
||||
// 발행 성공
|
||||
$taxInvoice->barobill_invoice_id = $response['InvoiceID'];
|
||||
$taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? null;
|
||||
$taxInvoice->status = TaxInvoice::STATUS_ISSUED;
|
||||
@@ -301,9 +299,10 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
|
||||
return $taxInvoice->fresh();
|
||||
}
|
||||
|
||||
throw new \Exception($response['Message'] ?? '발행 실패');
|
||||
throw new \RuntimeException($response['Message'] ?? '발행 실패');
|
||||
} catch (BadRequestHttpException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
// 발행 실패
|
||||
$taxInvoice->status = TaxInvoice::STATUS_FAILED;
|
||||
$taxInvoice->error_message = $e->getMessage();
|
||||
$taxInvoice->save();
|
||||
@@ -334,7 +333,6 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
|
||||
}
|
||||
|
||||
try {
|
||||
// 세금계산서 취소 API 호출
|
||||
$response = $this->callApi('CancelTaxInvoice', [
|
||||
'CERTKEY' => $setting->cert_key,
|
||||
'CorpNum' => $setting->corp_num,
|
||||
@@ -358,7 +356,9 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
|
||||
return $taxInvoice->fresh();
|
||||
}
|
||||
|
||||
throw new \Exception($response['Message'] ?? '취소 실패');
|
||||
throw new \RuntimeException($response['Message'] ?? '취소 실패');
|
||||
} catch (BadRequestHttpException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('세금계산서 취소 실패', [
|
||||
'tenant_id' => $this->tenantId(),
|
||||
@@ -396,7 +396,6 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
|
||||
if (! empty($response['State'])) {
|
||||
$taxInvoice->nts_send_status = $response['State'];
|
||||
|
||||
// 국세청 전송 완료 시 상태 업데이트
|
||||
if ($response['State'] === '전송완료' && ! $taxInvoice->sent_at) {
|
||||
$taxInvoice->status = TaxInvoice::STATUS_SENT;
|
||||
$taxInvoice->sent_at = now();
|
||||
@@ -418,6 +417,26 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// URL 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 바로빌 API base URL 반환
|
||||
*/
|
||||
public function getBaseUrl(): string
|
||||
{
|
||||
return $this->testMode ? self::API_TEST_URL : self::API_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 모드 여부
|
||||
*/
|
||||
public function isTestMode(): bool
|
||||
{
|
||||
return $this->testMode;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private 메서드
|
||||
// =========================================================================
|
||||
@@ -427,8 +446,10 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
|
||||
*/
|
||||
private function callApi(string $method, array $data): array
|
||||
{
|
||||
$baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL;
|
||||
$url = $baseUrl.'/TI/'.$method;
|
||||
$baseUrl = $this->getBaseUrl();
|
||||
$servicePath = self::METHOD_SERVICE_MAP[$method] ?? 'TI';
|
||||
$path = self::SERVICE_PATHS[$servicePath] ?? '/TI.asmx';
|
||||
$url = $baseUrl.$path.'/'.$method;
|
||||
|
||||
$response = Http::timeout(30)
|
||||
->withHeaders([
|
||||
@@ -437,7 +458,7 @@ private function callApi(string $method, array $data): array
|
||||
->post($url, $data);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \Exception('API 호출 실패: '.$response->status());
|
||||
throw new \RuntimeException('API 호출 실패: '.$response->status());
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
@@ -448,7 +469,6 @@ private function callApi(string $method, array $data): array
|
||||
*/
|
||||
private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array
|
||||
{
|
||||
// 품목 데이터 구성
|
||||
$items = [];
|
||||
foreach ($taxInvoice->items ?? [] as $index => $item) {
|
||||
$items[] = [
|
||||
@@ -463,7 +483,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
|
||||
];
|
||||
}
|
||||
|
||||
// 품목이 없는 경우 기본 품목 추가
|
||||
if (empty($items)) {
|
||||
$items[] = [
|
||||
'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'),
|
||||
@@ -487,8 +506,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
|
||||
'TaxType' => '과세',
|
||||
'PurposeType' => '영수',
|
||||
'WriteDate' => $taxInvoice->issue_date->format('Ymd'),
|
||||
|
||||
// 공급자 정보
|
||||
'InvoicerCorpNum' => $taxInvoice->supplier_corp_num,
|
||||
'InvoicerCorpName' => $taxInvoice->supplier_corp_name,
|
||||
'InvoicerCEOName' => $taxInvoice->supplier_ceo_name,
|
||||
@@ -496,8 +513,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
|
||||
'InvoicerBizType' => $taxInvoice->supplier_biz_type,
|
||||
'InvoicerBizClass' => $taxInvoice->supplier_biz_class,
|
||||
'InvoicerContactID' => $taxInvoice->supplier_contact_id,
|
||||
|
||||
// 공급받는자 정보
|
||||
'InvoiceeCorpNum' => $taxInvoice->buyer_corp_num,
|
||||
'InvoiceeCorpName' => $taxInvoice->buyer_corp_name,
|
||||
'InvoiceeCEOName' => $taxInvoice->buyer_ceo_name,
|
||||
@@ -505,16 +520,10 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
|
||||
'InvoiceeBizType' => $taxInvoice->buyer_biz_type,
|
||||
'InvoiceeBizClass' => $taxInvoice->buyer_biz_class,
|
||||
'InvoiceeContactID' => $taxInvoice->buyer_contact_id,
|
||||
|
||||
// 금액 정보
|
||||
'SupplyCostTotal' => (int) $taxInvoice->supply_amount,
|
||||
'TaxTotal' => (int) $taxInvoice->tax_amount,
|
||||
'TotalAmount' => (int) $taxInvoice->total_amount,
|
||||
|
||||
// 품목 정보
|
||||
'TaxInvoiceTradeLineItems' => $items,
|
||||
|
||||
// 비고
|
||||
'Remark1' => $taxInvoice->description ?? '',
|
||||
],
|
||||
];
|
||||
|
||||
256
app/Services/ChecklistTemplateService.php
Normal file
256
app/Services/ChecklistTemplateService.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Qualitys\ChecklistTemplate;
|
||||
use App\Services\Audit\AuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ChecklistTemplateService extends Service
|
||||
{
|
||||
private const AUDIT_TARGET = 'checklist_template';
|
||||
|
||||
private const DOCUMENT_TYPE = 'checklist_template';
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 템플릿 조회 (type별)
|
||||
*/
|
||||
public function getByType(string $type): array
|
||||
{
|
||||
$template = ChecklistTemplate::query()
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// 각 항목별 파일 수 포함
|
||||
$fileCounts = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $template->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('field_key, COUNT(*) as count')
|
||||
->groupBy('field_key')
|
||||
->pluck('count', 'field_key')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'type' => $template->type,
|
||||
'categories' => $template->categories,
|
||||
'options' => $template->options,
|
||||
'file_counts' => $fileCounts,
|
||||
'updated_at' => $template->updated_at?->toIso8601String(),
|
||||
'updated_by' => $template->updater?->name,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 저장 (전체 덮어쓰기)
|
||||
*/
|
||||
public function save(int $id, array $data): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($id);
|
||||
$before = $template->toArray();
|
||||
|
||||
// 삭제된 항목의 파일 처리
|
||||
$oldSubItemIds = $template->getAllSubItemIds();
|
||||
$newSubItemIds = $this->extractSubItemIds($data['categories']);
|
||||
$removedIds = array_diff($oldSubItemIds, $newSubItemIds);
|
||||
|
||||
DB::transaction(function () use ($template, $data, $removedIds) {
|
||||
// 템플릿 업데이트
|
||||
$template->update([
|
||||
'name' => $data['name'] ?? $template->name,
|
||||
'categories' => $data['categories'],
|
||||
'options' => $data['options'] ?? $template->options,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
// 삭제된 항목의 파일 → soft delete
|
||||
if (! empty($removedIds)) {
|
||||
$orphanFiles = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $template->id)
|
||||
->whereIn('field_key', $removedIds)
|
||||
->get();
|
||||
|
||||
foreach ($orphanFiles as $file) {
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$template->refresh();
|
||||
|
||||
$this->auditLogger->log(
|
||||
$this->tenantId(),
|
||||
self::AUDIT_TARGET,
|
||||
$template->id,
|
||||
'updated',
|
||||
$before,
|
||||
$template->toArray()
|
||||
);
|
||||
|
||||
return $this->getByType($template->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료 토글
|
||||
*/
|
||||
public function toggleItem(int $id, string $subItemId): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($id);
|
||||
$categories = $template->categories;
|
||||
$toggled = null;
|
||||
|
||||
foreach ($categories as &$category) {
|
||||
if (empty($category['subItems'])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($category['subItems'] as &$subItem) {
|
||||
if ($subItem['id'] === $subItemId) {
|
||||
$subItem['is_completed'] = ! ($subItem['is_completed'] ?? false);
|
||||
$subItem['completed_at'] = $subItem['is_completed'] ? now()->toIso8601String() : null;
|
||||
$toggled = $subItem;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
unset($subItem);
|
||||
}
|
||||
unset($category);
|
||||
|
||||
if (! $toggled) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$template->update([
|
||||
'categories' => $categories,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $toggled['id'],
|
||||
'name' => $toggled['name'],
|
||||
'is_completed' => $toggled['is_completed'],
|
||||
'completed_at' => $toggled['completed_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 파일 목록 조회
|
||||
*/
|
||||
public function getDocuments(int $templateId, ?string $subItemId = null): array
|
||||
{
|
||||
$query = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $templateId)
|
||||
->with('uploader:id,name');
|
||||
|
||||
if ($subItemId) {
|
||||
$query->where('field_key', $subItemId);
|
||||
}
|
||||
|
||||
$files = $query->orderBy('field_key')->orderByDesc('id')->get();
|
||||
|
||||
return $files->map(fn (File $file) => [
|
||||
'id' => $file->id,
|
||||
'field_key' => $file->field_key,
|
||||
'display_name' => $file->display_name ?? $file->original_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'uploaded_by' => $file->uploader?->name,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 (polymorphic)
|
||||
*/
|
||||
public function uploadDocument(int $templateId, string $subItemId, $uploadedFile): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($templateId);
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 저장 경로: {tenant_id}/checklist-templates/{year}/{month}/{stored_name}
|
||||
$date = now();
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension();
|
||||
$filePath = sprintf(
|
||||
'%d/checklist-templates/%s/%s/%s',
|
||||
$tenantId,
|
||||
$date->format('Y'),
|
||||
$date->format('m'),
|
||||
$storedName
|
||||
);
|
||||
|
||||
// 파일 저장
|
||||
Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname()));
|
||||
|
||||
// DB 레코드 생성
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => self::DOCUMENT_TYPE,
|
||||
'document_id' => $template->id,
|
||||
'field_key' => $subItemId,
|
||||
'display_name' => $uploadedFile->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $uploadedFile->getSize(),
|
||||
'mime_type' => $uploadedFile->getClientMimeType(),
|
||||
'uploaded_by' => $userId,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'field_key' => $file->field_key,
|
||||
'display_name' => $file->display_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* - 교체(replace=true): hard delete (물리 파일 + DB)
|
||||
* - 일반 삭제: soft delete (휴지통)
|
||||
*/
|
||||
public function deleteDocument(int $fileId, bool $replace = false): void
|
||||
{
|
||||
$file = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->findOrFail($fileId);
|
||||
|
||||
if ($replace) {
|
||||
$file->permanentDelete();
|
||||
} else {
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* categories JSON에서 sub_item_id 목록 추출
|
||||
*/
|
||||
private function extractSubItemIds(array $categories): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($categories as $category) {
|
||||
foreach ($category['subItems'] ?? [] as $subItem) {
|
||||
$ids[] = $subItem['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
376
app/Services/Equipment/EquipmentInspectionService.php
Normal file
376
app/Services/Equipment/EquipmentInspectionService.php
Normal file
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Equipment;
|
||||
|
||||
use App\Enums\InspectionCycle;
|
||||
use App\Models\Equipment\Equipment;
|
||||
use App\Models\Equipment\EquipmentInspection;
|
||||
use App\Models\Equipment\EquipmentInspectionDetail;
|
||||
use App\Models\Equipment\EquipmentInspectionTemplate;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class EquipmentInspectionService extends Service
|
||||
{
|
||||
public function getInspections(string $cycle, string $period, ?string $productionLine = null, ?int $equipmentId = null): array
|
||||
{
|
||||
$equipmentQuery = Equipment::active()
|
||||
->where('status', '!=', 'disposed')
|
||||
->with(['manager', 'subManager']);
|
||||
|
||||
if ($productionLine) {
|
||||
$equipmentQuery->byLine($productionLine);
|
||||
}
|
||||
|
||||
if ($equipmentId) {
|
||||
$equipmentQuery->where('id', $equipmentId);
|
||||
}
|
||||
|
||||
$equipments = $equipmentQuery->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
$labels = InspectionCycle::columnLabels($cycle, $period);
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($equipments as $equipment) {
|
||||
$templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
|
||||
->byCycle($cycle)
|
||||
->active()
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
if ($templates->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inspection = EquipmentInspection::where('equipment_id', $equipment->id)
|
||||
->where('inspection_cycle', $cycle)
|
||||
->where('year_month', $period)
|
||||
->first();
|
||||
|
||||
$details = [];
|
||||
if ($inspection) {
|
||||
$details = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
|
||||
->get()
|
||||
->groupBy(function ($d) {
|
||||
return $d->template_item_id.'_'.$d->check_date->format('Y-m-d');
|
||||
});
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'equipment' => $equipment,
|
||||
'templates' => $templates,
|
||||
'inspection' => $inspection,
|
||||
'details' => $details,
|
||||
'labels' => $labels,
|
||||
'can_inspect' => $equipment->canInspect($userId),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function toggleDetail(int $equipmentId, int $templateItemId, string $checkDate, string $cycle = 'daily'): array
|
||||
{
|
||||
return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle) {
|
||||
$equipment = Equipment::find($equipmentId);
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
$userId = $this->apiUserId();
|
||||
if (! $equipment->canInspect($userId)) {
|
||||
throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission'));
|
||||
}
|
||||
|
||||
$period = InspectionCycle::resolvePeriod($cycle, $checkDate);
|
||||
if ($cycle === InspectionCycle::DAILY) {
|
||||
$tenantId = $this->tenantId();
|
||||
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId);
|
||||
if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) {
|
||||
throw new BadRequestHttpException(__('error.equipment.non_working_day'));
|
||||
}
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$inspection = EquipmentInspection::firstOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'equipment_id' => $equipmentId,
|
||||
'inspection_cycle' => $cycle,
|
||||
'year_month' => $period,
|
||||
],
|
||||
[
|
||||
'created_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
$detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
|
||||
->where('template_item_id', $templateItemId)
|
||||
->where('check_date', $checkDate)
|
||||
->first();
|
||||
|
||||
if ($detail) {
|
||||
$nextResult = EquipmentInspectionDetail::getNextResult($detail->result);
|
||||
if ($nextResult === null) {
|
||||
$detail->delete();
|
||||
|
||||
return ['result' => null, 'symbol' => ''];
|
||||
}
|
||||
$detail->update(['result' => $nextResult]);
|
||||
} else {
|
||||
$detail = EquipmentInspectionDetail::create([
|
||||
'inspection_id' => $inspection->id,
|
||||
'template_item_id' => $templateItemId,
|
||||
'check_date' => $checkDate,
|
||||
'result' => 'good',
|
||||
]);
|
||||
$nextResult = 'good';
|
||||
}
|
||||
|
||||
return [
|
||||
'result' => $nextResult,
|
||||
'symbol' => EquipmentInspectionDetail::getResultSymbol($nextResult),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function setResult(int $equipmentId, int $templateItemId, string $checkDate, string $cycle, ?string $result): array
|
||||
{
|
||||
return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle, $result) {
|
||||
$equipment = Equipment::find($equipmentId);
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
$userId = $this->apiUserId();
|
||||
if (! $equipment->canInspect($userId)) {
|
||||
throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission'));
|
||||
}
|
||||
|
||||
$period = InspectionCycle::resolvePeriod($cycle, $checkDate);
|
||||
if ($cycle === InspectionCycle::DAILY) {
|
||||
$tenantId = $this->tenantId();
|
||||
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId);
|
||||
if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) {
|
||||
throw new BadRequestHttpException(__('error.equipment.non_working_day'));
|
||||
}
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$inspection = EquipmentInspection::firstOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'equipment_id' => $equipmentId,
|
||||
'inspection_cycle' => $cycle,
|
||||
'year_month' => $period,
|
||||
],
|
||||
[
|
||||
'created_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
$detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
|
||||
->where('template_item_id', $templateItemId)
|
||||
->where('check_date', $checkDate)
|
||||
->first();
|
||||
|
||||
if ($result === null) {
|
||||
if ($detail) {
|
||||
$detail->delete();
|
||||
}
|
||||
|
||||
return ['result' => null, 'symbol' => ''];
|
||||
}
|
||||
|
||||
if ($detail) {
|
||||
$detail->update(['result' => $result]);
|
||||
} else {
|
||||
$detail = EquipmentInspectionDetail::create([
|
||||
'inspection_id' => $inspection->id,
|
||||
'template_item_id' => $templateItemId,
|
||||
'check_date' => $checkDate,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'result' => $result,
|
||||
'symbol' => EquipmentInspectionDetail::getResultSymbol($result),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function updateNotes(int $equipmentId, string $yearMonth, array $data, string $cycle = 'daily'): EquipmentInspection
|
||||
{
|
||||
return DB::transaction(function () use ($equipmentId, $yearMonth, $data, $cycle) {
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$inspection = EquipmentInspection::firstOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'equipment_id' => $equipmentId,
|
||||
'inspection_cycle' => $cycle,
|
||||
'year_month' => $yearMonth,
|
||||
],
|
||||
[
|
||||
'created_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
$inspection->update($data);
|
||||
|
||||
return $inspection->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function resetInspection(int $equipmentId, string $cycle, string $period): int
|
||||
{
|
||||
return DB::transaction(function () use ($equipmentId, $cycle, $period) {
|
||||
$equipment = Equipment::find($equipmentId);
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
$userId = $this->apiUserId();
|
||||
if (! $equipment->canInspect($userId)) {
|
||||
throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission'));
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$inspection = EquipmentInspection::where('tenant_id', $tenantId)
|
||||
->where('equipment_id', $equipmentId)
|
||||
->where('inspection_cycle', $cycle)
|
||||
->where('year_month', $period)
|
||||
->first();
|
||||
|
||||
if (! $inspection) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$deleted = EquipmentInspectionDetail::where('inspection_id', $inspection->id)->delete();
|
||||
$inspection->update([
|
||||
'overall_judgment' => null,
|
||||
'repair_note' => null,
|
||||
'issue_note' => null,
|
||||
'inspector_id' => null,
|
||||
]);
|
||||
|
||||
return $deleted;
|
||||
});
|
||||
}
|
||||
|
||||
public function saveTemplate(int $equipmentId, array $data): EquipmentInspectionTemplate
|
||||
{
|
||||
return DB::transaction(function () use ($equipmentId, $data) {
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return EquipmentInspectionTemplate::create(array_merge($data, [
|
||||
'tenant_id' => $tenantId,
|
||||
'equipment_id' => $equipmentId,
|
||||
]));
|
||||
});
|
||||
}
|
||||
|
||||
public function updateTemplate(int $id, array $data): EquipmentInspectionTemplate
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$template = EquipmentInspectionTemplate::find($id);
|
||||
|
||||
if (! $template) {
|
||||
throw new NotFoundHttpException(__('error.equipment.template_not_found'));
|
||||
}
|
||||
|
||||
$template->update($data);
|
||||
|
||||
return $template->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteTemplate(int $id): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$template = EquipmentInspectionTemplate::find($id);
|
||||
|
||||
if (! $template) {
|
||||
throw new NotFoundHttpException(__('error.equipment.template_not_found'));
|
||||
}
|
||||
|
||||
return $template->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function copyTemplates(int $equipmentId, string $sourceCycle, array $targetCycles): array
|
||||
{
|
||||
return DB::transaction(function () use ($equipmentId, $sourceCycle, $targetCycles) {
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$sourceTemplates = EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
|
||||
->byCycle($sourceCycle)
|
||||
->active()
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
if ($sourceTemplates->isEmpty()) {
|
||||
throw new BadRequestHttpException(__('error.equipment.no_source_templates'));
|
||||
}
|
||||
|
||||
$copiedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($targetCycles as $targetCycle) {
|
||||
foreach ($sourceTemplates as $template) {
|
||||
$exists = EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
|
||||
->where('inspection_cycle', $targetCycle)
|
||||
->where('item_no', $template->item_no)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
EquipmentInspectionTemplate::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'equipment_id' => $equipmentId,
|
||||
'inspection_cycle' => $targetCycle,
|
||||
'item_no' => $template->item_no,
|
||||
'check_point' => $template->check_point,
|
||||
'check_item' => $template->check_item,
|
||||
'check_timing' => $template->check_timing,
|
||||
'check_frequency' => $template->check_frequency,
|
||||
'check_method' => $template->check_method,
|
||||
'sort_order' => $template->sort_order,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$copiedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'copied' => $copiedCount,
|
||||
'skipped' => $skippedCount,
|
||||
'source_count' => $sourceTemplates->count(),
|
||||
'target_cycles' => $targetCycles,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function getActiveCycles(int $equipmentId): array
|
||||
{
|
||||
return EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
|
||||
->active()
|
||||
->distinct()
|
||||
->pluck('inspection_cycle')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
102
app/Services/Equipment/EquipmentRepairService.php
Normal file
102
app/Services/Equipment/EquipmentRepairService.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Equipment;
|
||||
|
||||
use App\Models\Equipment\EquipmentRepair;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class EquipmentRepairService extends Service
|
||||
{
|
||||
public function index(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = EquipmentRepair::query()->with('equipment', 'repairer');
|
||||
|
||||
if (! empty($filters['equipment_id'])) {
|
||||
$query->where('equipment_id', $filters['equipment_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['repair_type'])) {
|
||||
$query->where('repair_type', $filters['repair_type']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from'])) {
|
||||
$query->where('repair_date', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_to'])) {
|
||||
$query->where('repair_date', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhereHas('equipment', function ($eq) use ($search) {
|
||||
$eq->where('name', 'like', "%{$search}%")
|
||||
->orWhere('equipment_code', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderBy('repair_date', 'desc')->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function show(int $id): EquipmentRepair
|
||||
{
|
||||
$repair = EquipmentRepair::with('equipment', 'repairer')->find($id);
|
||||
|
||||
if (! $repair) {
|
||||
throw new NotFoundHttpException(__('error.equipment.repair_not_found'));
|
||||
}
|
||||
|
||||
return $repair;
|
||||
}
|
||||
|
||||
public function store(array $data): EquipmentRepair
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$data['tenant_id'] = $this->tenantId();
|
||||
|
||||
return EquipmentRepair::create($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): EquipmentRepair
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$repair = EquipmentRepair::find($id);
|
||||
|
||||
if (! $repair) {
|
||||
throw new NotFoundHttpException(__('error.equipment.repair_not_found'));
|
||||
}
|
||||
|
||||
$repair->update($data);
|
||||
|
||||
return $repair->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$repair = EquipmentRepair::find($id);
|
||||
|
||||
if (! $repair) {
|
||||
throw new NotFoundHttpException(__('error.equipment.repair_not_found'));
|
||||
}
|
||||
|
||||
return $repair->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function recentRepairs(int $limit = 5): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return EquipmentRepair::with('equipment')
|
||||
->orderBy('repair_date', 'desc')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
153
app/Services/Equipment/EquipmentService.php
Normal file
153
app/Services/Equipment/EquipmentService.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Equipment;
|
||||
|
||||
use App\Models\Equipment\Equipment;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class EquipmentService extends Service
|
||||
{
|
||||
public function index(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = Equipment::query()->with(['manager', 'subManager']);
|
||||
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('equipment_code', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->byStatus($filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['production_line'])) {
|
||||
$query->byLine($filters['production_line']);
|
||||
}
|
||||
|
||||
if (! empty($filters['equipment_type'])) {
|
||||
$query->byType($filters['equipment_type']);
|
||||
}
|
||||
|
||||
$sortBy = $filters['sort_by'] ?? 'sort_order';
|
||||
$sortDir = $filters['sort_direction'] ?? 'asc';
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
return $query->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function show(int $id): Equipment
|
||||
{
|
||||
$equipment = Equipment::with(['manager', 'subManager', 'inspectionTemplates', 'repairs', 'processes', 'photos'])->find($id);
|
||||
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
return $equipment;
|
||||
}
|
||||
|
||||
public function store(array $data): Equipment
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$data['tenant_id'] = $this->tenantId();
|
||||
|
||||
return Equipment::create($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): Equipment
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$equipment = Equipment::find($id);
|
||||
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
$equipment->update($data);
|
||||
|
||||
return $equipment->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$equipment = Equipment::find($id);
|
||||
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
return $equipment->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function restore(int $id): Equipment
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$equipment = Equipment::onlyTrashed()->find($id);
|
||||
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
$equipment->restore();
|
||||
|
||||
return $equipment->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function toggleActive(int $id): Equipment
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$equipment = Equipment::find($id);
|
||||
|
||||
if (! $equipment) {
|
||||
throw new NotFoundHttpException(__('error.equipment.not_found'));
|
||||
}
|
||||
|
||||
$equipment->update(['is_active' => ! $equipment->is_active]);
|
||||
|
||||
return $equipment->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function stats(): array
|
||||
{
|
||||
$total = Equipment::count();
|
||||
$active = Equipment::where('status', 'active')->count();
|
||||
$idle = Equipment::where('status', 'idle')->count();
|
||||
$disposed = Equipment::where('status', 'disposed')->count();
|
||||
|
||||
return compact('total', 'active', 'idle', 'disposed');
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
{
|
||||
return [
|
||||
'equipment_types' => Equipment::getEquipmentTypes(),
|
||||
'production_lines' => Equipment::getProductionLines(),
|
||||
'statuses' => Equipment::getStatuses(),
|
||||
'equipment_list' => Equipment::active()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id', 'equipment_code', 'name', 'equipment_type', 'production_line']),
|
||||
];
|
||||
}
|
||||
|
||||
public function typeStats(): array
|
||||
{
|
||||
return Equipment::where('status', '!=', 'disposed')
|
||||
->selectRaw('equipment_type, count(*) as count')
|
||||
->groupBy('equipment_type')
|
||||
->pluck('count', 'equipment_type')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -54,18 +54,16 @@ public function upload(UploadedFile $file, ?string $description = null): File
|
||||
$storedName
|
||||
);
|
||||
|
||||
// Store file
|
||||
Storage::disk('tenant')->putFileAs(
|
||||
dirname($tempPath),
|
||||
$file,
|
||||
basename($tempPath)
|
||||
);
|
||||
// Store file to R2 (Cloudflare R2, S3 compatible)
|
||||
Storage::disk('r2')->put($tempPath, file_get_contents($file->getRealPath()), [
|
||||
'ContentType' => $file->getMimeType(),
|
||||
]);
|
||||
|
||||
// Determine file type
|
||||
$mimeType = $file->getMimeType();
|
||||
$fileType = $this->determineFileType($mimeType);
|
||||
|
||||
// Create DB record
|
||||
// Create DB record (file_path = R2 key path)
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $file->getClientOriginalName(),
|
||||
|
||||
222
app/Services/HometaxInvoiceService.php
Normal file
222
app/Services/HometaxInvoiceService.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Barobill\HometaxInvoice;
|
||||
use App\Models\Barobill\HometaxInvoiceJournal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 홈택스 세금계산서 서비스 (React 연동용)
|
||||
*
|
||||
* MNG에서 동기화된 hometax_invoices 데이터를
|
||||
* React 프론트엔드에서 조회/분개 등 처리
|
||||
*/
|
||||
class HometaxInvoiceService extends Service
|
||||
{
|
||||
/**
|
||||
* 매출 세금계산서 목록
|
||||
*/
|
||||
public function sales(array $params): array
|
||||
{
|
||||
return $this->listByType('sales', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 세금계산서 목록
|
||||
*/
|
||||
public function purchases(array $params): array
|
||||
{
|
||||
return $this->listByType('purchase', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 상세 조회
|
||||
*/
|
||||
public function show(int $id): ?HometaxInvoice
|
||||
{
|
||||
return HometaxInvoice::where('tenant_id', $this->tenantId())
|
||||
->with('journals')
|
||||
->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 저장 (홈택스 자체 분개 테이블 사용)
|
||||
*/
|
||||
public function saveJournals(int $invoiceId, array $items): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$invoice = HometaxInvoice::where('tenant_id', $tenantId)->findOrFail($invoiceId);
|
||||
|
||||
return DB::transaction(function () use ($tenantId, $invoice, $items) {
|
||||
// 기존 분개 삭제
|
||||
HometaxInvoiceJournal::where('tenant_id', $tenantId)
|
||||
->where('hometax_invoice_id', $invoice->id)
|
||||
->delete();
|
||||
|
||||
$created = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$created[] = HometaxInvoiceJournal::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'hometax_invoice_id' => $invoice->id,
|
||||
'nts_confirm_num' => $invoice->nts_confirm_num,
|
||||
'dc_type' => $item['dc_type'],
|
||||
'account_code' => $item['account_code'],
|
||||
'account_name' => $item['account_name'] ?? null,
|
||||
'debit_amount' => $item['debit_amount'] ?? 0,
|
||||
'credit_amount' => $item['credit_amount'] ?? 0,
|
||||
'description' => $item['description'] ?? null,
|
||||
'sort_order' => $index + 1,
|
||||
'invoice_type' => $invoice->invoice_type,
|
||||
'write_date' => $invoice->write_date,
|
||||
'supply_amount' => $invoice->supply_amount,
|
||||
'tax_amount' => $invoice->tax_amount,
|
||||
'total_amount' => $invoice->total_amount,
|
||||
'trading_partner_name' => $invoice->invoice_type === 'sales'
|
||||
? $invoice->invoicee_corp_name
|
||||
: $invoice->invoicer_corp_name,
|
||||
]);
|
||||
}
|
||||
|
||||
return ['items' => $created, 'count' => count($created)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 조회
|
||||
*/
|
||||
public function getJournals(int $invoiceId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$journals = HometaxInvoiceJournal::getByInvoiceId($tenantId, $invoiceId);
|
||||
|
||||
return ['items' => $journals];
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 삭제
|
||||
*/
|
||||
public function deleteJournals(int $invoiceId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$deleted = HometaxInvoiceJournal::where('tenant_id', $tenantId)
|
||||
->where('hometax_invoice_id', $invoiceId)
|
||||
->delete();
|
||||
|
||||
return ['deleted_count' => $deleted];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 세금계산서 등록
|
||||
*/
|
||||
public function storeManual(array $data): HometaxInvoice
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return HometaxInvoice::create(array_merge($data, [
|
||||
'tenant_id' => $tenantId,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 세금계산서 수정
|
||||
*/
|
||||
public function updateManual(int $id, array $data): HometaxInvoice
|
||||
{
|
||||
$invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||
$invoice->update($data);
|
||||
|
||||
return $invoice->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 세금계산서 삭제 (soft delete)
|
||||
*/
|
||||
public function destroyManual(int $id): bool
|
||||
{
|
||||
$invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||
|
||||
return $invoice->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계
|
||||
*/
|
||||
public function summary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
|
||||
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
|
||||
|
||||
$salesQuery = HometaxInvoice::where('tenant_id', $tenantId)
|
||||
->sales()
|
||||
->period($startDate, $endDate);
|
||||
|
||||
$purchaseQuery = HometaxInvoice::where('tenant_id', $tenantId)
|
||||
->purchase()
|
||||
->period($startDate, $endDate);
|
||||
|
||||
return [
|
||||
'sales' => [
|
||||
'count' => (clone $salesQuery)->count(),
|
||||
'supply_amount' => (int) (clone $salesQuery)->sum('supply_amount'),
|
||||
'tax_amount' => (int) (clone $salesQuery)->sum('tax_amount'),
|
||||
'total_amount' => (int) (clone $salesQuery)->sum('total_amount'),
|
||||
],
|
||||
'purchase' => [
|
||||
'count' => (clone $purchaseQuery)->count(),
|
||||
'supply_amount' => (int) (clone $purchaseQuery)->sum('supply_amount'),
|
||||
'tax_amount' => (int) (clone $purchaseQuery)->sum('tax_amount'),
|
||||
'total_amount' => (int) (clone $purchaseQuery)->sum('total_amount'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 타입별 목록 조회 (공통)
|
||||
*/
|
||||
private function listByType(string $invoiceType, array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
|
||||
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
|
||||
$search = $params['search'] ?? null;
|
||||
$perPage = $params['per_page'] ?? 50;
|
||||
|
||||
$query = HometaxInvoice::where('tenant_id', $tenantId)
|
||||
->where('invoice_type', $invoiceType)
|
||||
->period($startDate, $endDate);
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search, $invoiceType) {
|
||||
if ($invoiceType === 'sales') {
|
||||
$q->where('invoicee_corp_name', 'like', "%{$search}%")
|
||||
->orWhere('invoicee_corp_num', 'like', "%{$search}%");
|
||||
} else {
|
||||
$q->where('invoicer_corp_name', 'like', "%{$search}%")
|
||||
->orWhere('invoicer_corp_num', 'like', "%{$search}%");
|
||||
}
|
||||
$q->orWhere('nts_confirm_num', 'like', "%{$search}%")
|
||||
->orWhere('item_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$query->orderByDesc('write_date')->orderByDesc('issue_date');
|
||||
|
||||
$invoices = $query->paginate($perPage);
|
||||
|
||||
// 분개 존재 여부 로드
|
||||
$invoiceIds = $invoices->getCollection()->pluck('id')->toArray();
|
||||
$journaledIds = HometaxInvoiceJournal::getJournaledInvoiceIds($tenantId, $invoiceIds);
|
||||
|
||||
$invoices->getCollection()->transform(function ($invoice) use ($journaledIds) {
|
||||
$invoice->has_journal = in_array($invoice->id, $journaledIds);
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
|
||||
return [
|
||||
'data' => $invoices,
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,7 @@ class QmsLotAuditService extends Service
|
||||
public function index(array $params): array
|
||||
{
|
||||
$query = QualityDocument::with([
|
||||
'documentOrders.order.nodes' => fn ($q) => $q->whereNull('parent_id'),
|
||||
'documentOrders.order.nodes.items.item',
|
||||
'documentOrders.order.item',
|
||||
'locations',
|
||||
'performanceReport',
|
||||
])
|
||||
@@ -83,13 +82,14 @@ public function show(int $id): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 루트별 8종 서류 목록 (Document[])
|
||||
* 수주 로트별 8종 서류 목록 (Document[])
|
||||
*/
|
||||
public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
{
|
||||
$docOrder = QualityDocumentOrder::with([
|
||||
'order.workOrders.process',
|
||||
'locations',
|
||||
'locations.orderItem',
|
||||
'locations.document',
|
||||
'qualityDocument',
|
||||
])->findOrFail($qualityDocumentOrderId);
|
||||
|
||||
@@ -119,17 +119,15 @@ public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
// 2. 수주서
|
||||
$documents[] = $this->formatDocument('order', '수주서', collect([$order]));
|
||||
|
||||
// 3. 작업일지 (subType: process.process_name 기반)
|
||||
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders);
|
||||
// 3. 작업일지 & 4. 중간검사 성적서 (인식 가능한 공정만 — 공정별 1개씩)
|
||||
$recognizedWorkOrders = $workOrders->filter(function ($wo) {
|
||||
$subType = $this->mapProcessToSubType($wo->process?->process_name);
|
||||
|
||||
// 4. 중간검사 성적서 (PQC)
|
||||
$pqcInspections = Inspection::where('inspection_type', 'PQC')
|
||||
->whereIn('work_order_id', $workOrders->pluck('id'))
|
||||
->where('status', 'completed')
|
||||
->with('workOrder.process')
|
||||
->get();
|
||||
return $subType !== null;
|
||||
})->groupBy('process_id')->map(fn ($group) => $group->first());
|
||||
|
||||
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder');
|
||||
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $recognizedWorkOrders);
|
||||
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $recognizedWorkOrders);
|
||||
|
||||
// 5. 납품확인서
|
||||
$shipments = $order->shipments()->get();
|
||||
@@ -138,9 +136,11 @@ public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
// 6. 출고증
|
||||
$documents[] = $this->formatDocument('shipping', '출고증', $shipments);
|
||||
|
||||
// 7. 제품검사 성적서
|
||||
$locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id);
|
||||
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc);
|
||||
// 7. 제품검사 성적서 (FQC 문서 또는 inspection_data 완료건)
|
||||
$locationsWithInspection = $docOrder->locations->filter(
|
||||
fn ($loc) => $loc->document_id || $loc->inspection_status === 'completed'
|
||||
);
|
||||
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection);
|
||||
|
||||
// 8. 품질관리서
|
||||
$documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
|
||||
@@ -157,7 +157,7 @@ public function documentDetail(string $type, int $id): array
|
||||
'import' => $this->getInspectionDetail($id, 'IQC'),
|
||||
'order' => $this->getOrderDetail($id),
|
||||
'log' => $this->getWorkOrderLogDetail($id),
|
||||
'report' => $this->getInspectionDetail($id, 'PQC'),
|
||||
'report' => $this->getWorkOrderLogDetail($id),
|
||||
'confirmation', 'shipping' => $this->getShipmentDetail($id),
|
||||
'product' => $this->getLocationDetail($id),
|
||||
'quality' => $this->getQualityDocDetail($id),
|
||||
@@ -220,30 +220,14 @@ private function transformReportToFrontend(QualityDocument $doc): array
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 최상위(FG) 제품명 추출
|
||||
* Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name
|
||||
* 수주 대표 제품명 추출
|
||||
* Order.item_id → Item.name
|
||||
*/
|
||||
private function getFgProductName(QualityDocument $doc): string
|
||||
{
|
||||
$firstDocOrder = $doc->documentOrders->first();
|
||||
if (! $firstDocOrder) {
|
||||
return '';
|
||||
}
|
||||
$order = $doc->documentOrders->first()?->order;
|
||||
|
||||
$order = $firstDocOrder->order;
|
||||
if (! $order) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// eager loaded with whereNull('parent_id') filter
|
||||
$rootNode = $order->nodes->first();
|
||||
if (! $rootNode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$representativeItem = $rootNode->items->first();
|
||||
|
||||
return $representativeItem?->item?->name ?? '';
|
||||
return $order?->item?->name ?? '';
|
||||
}
|
||||
|
||||
private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array
|
||||
@@ -252,6 +236,7 @@ private function transformRouteToFrontend(QualityDocumentOrder $docOrder, Qualit
|
||||
'id' => (string) $docOrder->id,
|
||||
'code' => $docOrder->order->order_no,
|
||||
'date' => $docOrder->order->received_at?->toDateString(),
|
||||
'client' => $docOrder->order->client_name ?? '',
|
||||
'site' => $docOrder->order->site_name ?? '',
|
||||
'location_count' => $docOrder->locations->count(),
|
||||
'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [
|
||||
@@ -313,11 +298,12 @@ private function formatDocumentWithSubType(string $type, string $title, $collect
|
||||
'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) {
|
||||
$formatted = $this->formatDocumentItem($type, $item);
|
||||
|
||||
// subType: process.process_name 기반
|
||||
// subType: process.process_name 기반 + work_order_id 전달
|
||||
$workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item;
|
||||
if ($workOrder instanceof WorkOrder) {
|
||||
$processName = $workOrder->process?->process_name;
|
||||
$formatted['sub_type'] = $this->mapProcessToSubType($processName);
|
||||
$formatted['work_order_id'] = $workOrder->id;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
@@ -328,12 +314,18 @@ private function formatDocumentWithSubType(string $type, string $title, $collect
|
||||
private function formatDocumentItem(string $type, $item): array
|
||||
{
|
||||
return match ($type) {
|
||||
'import', 'report' => [
|
||||
'import' => [
|
||||
'id' => (string) $item->id,
|
||||
'title' => $item->inspection_no ?? '',
|
||||
'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '',
|
||||
'code' => $item->inspection_no ?? '',
|
||||
],
|
||||
'report' => [
|
||||
'id' => (string) $item->id,
|
||||
'title' => $item->process?->process_name ?? '중간검사 성적서',
|
||||
'date' => $item->created_at?->toDateString() ?? '',
|
||||
'code' => $item->work_order_no ?? '',
|
||||
],
|
||||
'order' => [
|
||||
'id' => (string) $item->id,
|
||||
'title' => $item->order_no,
|
||||
@@ -342,9 +334,9 @@ private function formatDocumentItem(string $type, $item): array
|
||||
],
|
||||
'log' => [
|
||||
'id' => (string) $item->id,
|
||||
'title' => $item->project_name ?? '작업일지',
|
||||
'title' => $item->process?->process_name ?? '작업일지',
|
||||
'date' => $item->created_at?->toDateString() ?? '',
|
||||
'code' => $item->id,
|
||||
'code' => $item->work_order_no ?? '',
|
||||
],
|
||||
'confirmation', 'shipping' => [
|
||||
'id' => (string) $item->id,
|
||||
@@ -354,9 +346,9 @@ private function formatDocumentItem(string $type, $item): array
|
||||
],
|
||||
'product' => [
|
||||
'id' => (string) $item->id,
|
||||
'title' => '제품검사 성적서',
|
||||
'title' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서',
|
||||
'date' => $item->updated_at?->toDateString() ?? '',
|
||||
'code' => '',
|
||||
'code' => $item->document?->document_no ?? '',
|
||||
],
|
||||
'quality' => [
|
||||
'id' => (string) $item->id,
|
||||
@@ -479,9 +471,16 @@ private function getShipmentDetail(int $id): array
|
||||
|
||||
private function getLocationDetail(int $id): array
|
||||
{
|
||||
$location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id);
|
||||
$location = QualityDocumentLocation::with([
|
||||
'orderItem',
|
||||
'document.template.sections.items',
|
||||
'document.template.columns',
|
||||
'document.template.approvalLines',
|
||||
'document.template.basicFields',
|
||||
'document.data',
|
||||
])->findOrFail($id);
|
||||
|
||||
return [
|
||||
$result = [
|
||||
'type' => 'product',
|
||||
'data' => [
|
||||
'id' => $location->id,
|
||||
@@ -494,6 +493,86 @@ private function getLocationDetail(int $id): array
|
||||
'document_id' => $location->document_id,
|
||||
],
|
||||
];
|
||||
|
||||
// FQC 문서가 있으면 template + data 포함
|
||||
if ($location->document) {
|
||||
$doc = $location->document;
|
||||
$result['data']['fqc_document'] = [
|
||||
'id' => $doc->id,
|
||||
'template_id' => $doc->template_id,
|
||||
'document_no' => $doc->document_no,
|
||||
'title' => $doc->title,
|
||||
'status' => $doc->status,
|
||||
'created_at' => $doc->created_at?->toIso8601String(),
|
||||
'template' => $this->formatFqcTemplate($doc->template),
|
||||
'data' => $doc->data->map(fn ($d) => [
|
||||
'section_id' => $d->section_id,
|
||||
'column_id' => $d->column_id,
|
||||
'row_index' => $d->row_index,
|
||||
'field_key' => $d->field_key,
|
||||
'field_value' => $d->field_value,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function formatFqcTemplate($template): ?array
|
||||
{
|
||||
if (! $template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'category' => $template->category,
|
||||
'title' => $template->title,
|
||||
'approval_lines' => $template->approvalLines->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'department' => $a->department,
|
||||
'sort_order' => $a->sort_order,
|
||||
])->all(),
|
||||
'basic_fields' => $template->basicFields->map(fn ($f) => [
|
||||
'id' => $f->id,
|
||||
'label' => $f->label,
|
||||
'field_key' => $f->field_key,
|
||||
'field_type' => $f->field_type,
|
||||
'default_value' => $f->default_value,
|
||||
'is_required' => $f->is_required,
|
||||
'sort_order' => $f->sort_order,
|
||||
])->all(),
|
||||
'sections' => $template->sections->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
'title' => $s->title,
|
||||
'description' => $s->description,
|
||||
'image_path' => $s->image_path,
|
||||
'sort_order' => $s->sort_order,
|
||||
'items' => $s->items->map(fn ($i) => [
|
||||
'id' => $i->id,
|
||||
'section_id' => $i->section_id,
|
||||
'item_name' => $i->item ?? '',
|
||||
'standard' => $i->standard,
|
||||
'tolerance' => $i->tolerance,
|
||||
'measurement_type' => $i->measurement_type,
|
||||
'frequency' => $i->frequency,
|
||||
'sort_order' => $i->sort_order,
|
||||
'category' => $i->category,
|
||||
'method' => $i->method,
|
||||
])->all(),
|
||||
])->all(),
|
||||
'columns' => $template->columns->map(fn ($c) => [
|
||||
'id' => $c->id,
|
||||
'label' => $c->label,
|
||||
'column_type' => $c->column_type,
|
||||
'width' => $c->width,
|
||||
'group_name' => $c->group_name,
|
||||
'sort_order' => $c->sort_order,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getQualityDocDetail(int $id): array
|
||||
|
||||
@@ -633,7 +633,7 @@ public function calculateBomWithDebug(
|
||||
'PC' => $inputVariables['PC'] ?? '',
|
||||
'GT' => $inputVariables['GT'] ?? 'wall',
|
||||
'MP' => $inputVariables['MP'] ?? 'single',
|
||||
'CT' => $inputVariables['CT'] ?? 'basic',
|
||||
'CT' => $inputVariables['CT'] ?? 'exposed',
|
||||
'WS' => $inputVariables['WS'] ?? 50,
|
||||
'INSP' => $inputVariables['INSP'] ?? 50000,
|
||||
'finished_goods' => $finishedGoodsCode,
|
||||
@@ -1708,6 +1708,22 @@ private function calculateTenantBom(
|
||||
default => '220V',
|
||||
};
|
||||
|
||||
// 제어기 타입: 프론트 CT(exposed/embedded/embedded_no_box) → controller_type(노출형/매립형) 매핑
|
||||
// - exposed: 노출형 (뒷박스 불필요)
|
||||
// - embedded: 매립형 (뒷박스 포함)
|
||||
// - embedded_no_box: 매립형 (뒷박스 제외 — 업체 자체 보유)
|
||||
$ctValue = $inputVariables['CT'] ?? 'exposed';
|
||||
$controllerType = $inputVariables['controller_type'] ?? match ($ctValue) {
|
||||
'embedded', 'embedded_no_box' => '매립형',
|
||||
'exposed' => '노출형',
|
||||
default => '노출형',
|
||||
};
|
||||
// 뒷박스: embedded만 포함, exposed/embedded_no_box는 제외
|
||||
$backboxQty = (int) ($inputVariables['backbox_qty'] ?? match ($ctValue) {
|
||||
'embedded' => 1,
|
||||
default => 0,
|
||||
});
|
||||
|
||||
$calculatedVariables = array_merge($inputVariables, [
|
||||
'W0' => $W0,
|
||||
'H0' => $H0,
|
||||
@@ -1724,6 +1740,8 @@ private function calculateTenantBom(
|
||||
'finishing_type' => $finishingType,
|
||||
'installation_type' => $installationType,
|
||||
'motor_voltage' => $motorVoltage,
|
||||
'controller_type' => $controllerType,
|
||||
'backbox_qty' => $backboxQty,
|
||||
]);
|
||||
|
||||
$this->addDebugStep(3, '변수계산', [
|
||||
|
||||
@@ -237,7 +237,7 @@ private function calculateBomMaterials(Quote $quote): array
|
||||
'PC' => $input['productCategory'] ?? 'SCREEN',
|
||||
'GT' => $input['guideRailType'] ?? 'wall',
|
||||
'MP' => $input['motorPower'] ?? 'single',
|
||||
'CT' => $input['controller'] ?? 'basic',
|
||||
'CT' => $input['controller'] ?? 'exposed',
|
||||
'WS' => (float) ($input['wingSize'] ?? 50),
|
||||
'INSP' => (float) ($input['inspectionFee'] ?? 50000),
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = Receiving::query()
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name'])
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path'])
|
||||
->where('tenant_id', $tenantId);
|
||||
|
||||
// 검색어 필터
|
||||
@@ -162,7 +162,7 @@ public function show(int $id): Receiving
|
||||
|
||||
return Receiving::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name'])
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path'])
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,11 @@ public function store(array $data): Receiving
|
||||
$receiving->status = $data['status'] ?? 'receiving_pending';
|
||||
$receiving->remark = $data['remark'] ?? null;
|
||||
|
||||
// 성적서 파일 ID
|
||||
if (isset($data['certificate_file_id'])) {
|
||||
$receiving->certificate_file_id = $data['certificate_file_id'];
|
||||
}
|
||||
|
||||
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
|
||||
$receiving->options = $this->buildOptions($data);
|
||||
|
||||
@@ -299,6 +304,11 @@ public function update(int $id, array $data): Receiving
|
||||
}
|
||||
}
|
||||
|
||||
// 성적서 파일 ID
|
||||
if (array_key_exists('certificate_file_id', $data)) {
|
||||
$receiving->certificate_file_id = $data['certificate_file_id'];
|
||||
}
|
||||
|
||||
// options 필드 업데이트 (제조사, 수입검사 등 확장 필드)
|
||||
$receiving->options = $this->mergeOptions($receiving->options, $data);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
|
||||
$query = Shipment::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']);
|
||||
->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', 'creator']);
|
||||
|
||||
// 검색어 필터
|
||||
if (! empty($params['search'])) {
|
||||
|
||||
@@ -602,13 +602,9 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
|
||||
// 연결된 수주(Order) 상태 동기화
|
||||
$this->syncOrderStatus($workOrder, $tenantId);
|
||||
|
||||
// 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고
|
||||
if ($status === WorkOrder::STATUS_COMPLETED) {
|
||||
if ($workOrder->sales_order_id) {
|
||||
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
||||
} else {
|
||||
$this->stockInFromProduction($workOrder);
|
||||
}
|
||||
// 작업완료 시: 선생산(수주 없음) → 재고 입고
|
||||
if ($status === WorkOrder::STATUS_COMPLETED && ! $workOrder->sales_order_id) {
|
||||
$this->stockInFromProduction($workOrder);
|
||||
}
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
||||
@@ -659,11 +655,167 @@ private function shouldStockIn(WorkOrderItem $woItem): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 완료 시 자동 출하 생성
|
||||
* PRODUCED 수주에 출하가 없으면 재생성
|
||||
*
|
||||
* 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다.
|
||||
* 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다.
|
||||
* (Shipment 모델의 accessor 메서드로 수주 정보 참조)
|
||||
* syncOrderStatus에서 이미 PRODUCED인데 출하가 삭제된 경우 호출됩니다.
|
||||
*/
|
||||
private function ensureShipmentExists(Order $order, $mainWorkOrders, int $tenantId): void
|
||||
{
|
||||
$hasShipment = Shipment::where('tenant_id', $tenantId)
|
||||
->where('order_id', $order->id)
|
||||
->exists();
|
||||
|
||||
if (! $hasShipment) {
|
||||
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 기반 출하 수동 생성 (API 엔드포인트용)
|
||||
*
|
||||
* 출하관리 UI에서 수주를 선택하여 출하를 수동 생성할 때 사용합니다.
|
||||
* PRODUCED 이상 상태의 수주만 가능합니다.
|
||||
*/
|
||||
public function createShipmentForOrder(int $orderId): Shipment
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$order = Order::where('tenant_id', $tenantId)->findOrFail($orderId);
|
||||
|
||||
// PRODUCED 또는 SHIPPED 상태만 출하 생성 가능
|
||||
$allowedStatuses = [Order::STATUS_PRODUCED, Order::STATUS_SHIPPED];
|
||||
if (! in_array($order->status_code, $allowedStatuses)) {
|
||||
throw new BadRequestHttpException(__('error.shipment.order_not_produced'));
|
||||
}
|
||||
|
||||
// 메인 작업지시 조회
|
||||
$allWorkOrders = WorkOrder::where('tenant_id', $tenantId)
|
||||
->where('sales_order_id', $orderId)
|
||||
->where('status', '!=', WorkOrder::STATUS_CANCELLED)
|
||||
->get();
|
||||
|
||||
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null);
|
||||
|
||||
if ($mainWorkOrders->isEmpty()) {
|
||||
throw new BadRequestHttpException(__('error.shipment.no_work_orders'));
|
||||
}
|
||||
|
||||
$shipment = $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $userId);
|
||||
|
||||
if (! $shipment) {
|
||||
throw new BadRequestHttpException(__('error.shipment.already_exists'));
|
||||
}
|
||||
|
||||
return $shipment->load('items');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 단위 자동 출하 생성 (생산완료 시)
|
||||
*
|
||||
* 수주의 모든 메인 작업지시가 완료되면, 전체 WO 품목을 합쳐서 출하 1건을 생성합니다.
|
||||
* - 이미 수주에 연결된 출하가 있으면 스킵 (중복 방지)
|
||||
* - 부분 출고는 출하관리 UI에서 수동 생성
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $mainWorkOrders 메인 작업지시 컬렉션
|
||||
*/
|
||||
private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $tenantId, int $userId): ?Shipment
|
||||
{
|
||||
// 이미 이 수주에 연결된 출하가 있으면 스킵
|
||||
$existingShipment = Shipment::where('tenant_id', $tenantId)
|
||||
->where('order_id', $order->id)
|
||||
->first();
|
||||
|
||||
if ($existingShipment) {
|
||||
return $existingShipment;
|
||||
}
|
||||
|
||||
$shipmentNo = Shipment::generateShipmentNo($tenantId);
|
||||
|
||||
$shipment = Shipment::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_no' => $shipmentNo,
|
||||
'work_order_id' => null, // 수주 단위이므로 개별 WO 연결 안함
|
||||
'order_id' => $order->id,
|
||||
'scheduled_date' => $order->delivery_date ?? now()->toDateString(),
|
||||
'status' => 'scheduled',
|
||||
'priority' => 'normal',
|
||||
'delivery_method' => $order->delivery_method_code ?? 'pickup',
|
||||
'can_ship' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 모든 메인 작업지시의 품목을 출하 품목으로 복사
|
||||
$seq = 0;
|
||||
foreach ($mainWorkOrders as $wo) {
|
||||
$workOrderItems = $wo->items()->get();
|
||||
|
||||
foreach ($workOrderItems as $woItem) {
|
||||
$result = $woItem->options['result'] ?? [];
|
||||
$lotNo = $result['lot_no'] ?? null;
|
||||
$floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId);
|
||||
|
||||
ShipmentItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_id' => $shipment->id,
|
||||
'seq' => ++$seq,
|
||||
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
|
||||
'item_name' => $woItem->item_name,
|
||||
'floor_unit' => $floorUnit,
|
||||
'specification' => $woItem->specification,
|
||||
'quantity' => $result['good_qty'] ?? $woItem->quantity,
|
||||
'unit' => $woItem->unit,
|
||||
'lot_no' => $lotNo,
|
||||
'remarks' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// WO에 품목이 없으면 수주 품목에서 fallback (해당 WO의 공정에 매칭되는 품목)
|
||||
if ($workOrderItems->isEmpty() && $wo->salesOrder) {
|
||||
$orderItems = $wo->salesOrder->items()->get();
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId);
|
||||
ShipmentItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_id' => $shipment->id,
|
||||
'seq' => ++$seq,
|
||||
'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null,
|
||||
'item_name' => $orderItem->item_name,
|
||||
'floor_unit' => $floorUnit,
|
||||
'specification' => $orderItem->specification,
|
||||
'quantity' => $orderItem->quantity,
|
||||
'unit' => $orderItem->unit,
|
||||
'lot_no' => null,
|
||||
'remarks' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
$tenantId,
|
||||
'shipment',
|
||||
$shipment->id,
|
||||
'auto_created_from_order',
|
||||
null,
|
||||
[
|
||||
'order_id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'shipment_no' => $shipmentNo,
|
||||
'work_order_count' => $mainWorkOrders->count(),
|
||||
'items_count' => $shipment->items()->count(),
|
||||
]
|
||||
);
|
||||
|
||||
return $shipment;
|
||||
}
|
||||
|
||||
/**
|
||||
* [DEPRECATED] 작업지시 단위 자동 출하 생성
|
||||
*
|
||||
* 수주 단위 출하(createShipmentFromOrder)로 대체됨.
|
||||
* 부분 출고 등 특수 케이스에서 개별 WO 기반 출하가 필요할 경우를 위해 유지.
|
||||
*/
|
||||
private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment
|
||||
{
|
||||
@@ -836,17 +988,50 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업지시 상태 → 수주 상태 매핑
|
||||
$statusMap = [
|
||||
WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION,
|
||||
WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED,
|
||||
WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED,
|
||||
];
|
||||
// 해당 수주의 모든 비보조 작업지시 상태 집계
|
||||
$allWorkOrders = WorkOrder::where('tenant_id', $tenantId)
|
||||
->where('sales_order_id', $workOrder->sales_order_id)
|
||||
->where('status', '!=', WorkOrder::STATUS_CANCELLED)
|
||||
->get();
|
||||
|
||||
$newOrderStatus = $statusMap[$workOrder->status] ?? null;
|
||||
// 보조 공정 및 공정 미지정 작업지시 제외
|
||||
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null);
|
||||
|
||||
if ($mainWorkOrders->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$totalCount = $mainWorkOrders->count();
|
||||
$statusCounts = $mainWorkOrders->groupBy('status')->map->count();
|
||||
|
||||
$shippedCount = $statusCounts->get(WorkOrder::STATUS_SHIPPED, 0);
|
||||
$completedCount = $statusCounts->get(WorkOrder::STATUS_COMPLETED, 0);
|
||||
$inProgressCount = $statusCounts->get(WorkOrder::STATUS_IN_PROGRESS, 0);
|
||||
|
||||
// 집계 기반 수주 상태 결정
|
||||
// 전부 출하 → SHIPPED
|
||||
// 전부 완료(또는 완료+출하) → PRODUCED
|
||||
// 하나라도 진행중/완료/출하 → IN_PRODUCTION
|
||||
$newOrderStatus = null;
|
||||
if ($shippedCount === $totalCount) {
|
||||
$newOrderStatus = Order::STATUS_SHIPPED;
|
||||
} elseif (($completedCount + $shippedCount) === $totalCount) {
|
||||
$newOrderStatus = Order::STATUS_PRODUCED;
|
||||
} elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) {
|
||||
$newOrderStatus = Order::STATUS_IN_PRODUCTION;
|
||||
}
|
||||
|
||||
// 매핑되는 상태가 없으면 스킵
|
||||
if (! $newOrderStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 동일한 상태면 상태 변경은 스킵하되, PRODUCED인데 출하 없으면 재생성
|
||||
if ($order->status_code === $newOrderStatus) {
|
||||
if ($newOrderStatus === Order::STATUS_PRODUCED) {
|
||||
$this->ensureShipmentExists($order, $mainWorkOrders, $tenantId);
|
||||
}
|
||||
|
||||
// 매핑되는 상태가 없거나 이미 동일한 상태면 스킵
|
||||
if (! $newOrderStatus || $order->status_code === $newOrderStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -861,9 +1046,19 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
|
||||
'order',
|
||||
$order->id,
|
||||
'status_synced_from_work_order',
|
||||
['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id],
|
||||
['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id]
|
||||
['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id, 'aggregated' => true],
|
||||
['status_code' => $newOrderStatus, 'work_order_counts' => [
|
||||
'total' => $totalCount,
|
||||
'shipped' => $shippedCount,
|
||||
'completed' => $completedCount,
|
||||
'in_progress' => $inProgressCount,
|
||||
]]
|
||||
);
|
||||
|
||||
// 생산완료(PRODUCED) 전환 시 → 수주 단위 출하 자동 생성
|
||||
if ($newOrderStatus === Order::STATUS_PRODUCED) {
|
||||
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -952,7 +1147,7 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
|
||||
*/
|
||||
private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool
|
||||
{
|
||||
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []);
|
||||
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options ?? '{}', true) ?? []);
|
||||
|
||||
return ! empty($options['is_auxiliary']);
|
||||
}
|
||||
@@ -1846,15 +2041,92 @@ public function toggleStepProgress(int $workOrderId, int $progressId): array
|
||||
$after
|
||||
);
|
||||
|
||||
// 모든 공정 단계 완료 시 → 작업지시 자동 완료
|
||||
$workOrderStatusChanged = false;
|
||||
if ($progress->isCompleted()) {
|
||||
$workOrderStatusChanged = $this->autoCompleteWorkOrderIfAllStepsDone($workOrder, $tenantId, $userId);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $progress->id,
|
||||
'status' => $progress->status,
|
||||
'is_completed' => $progress->isCompleted(),
|
||||
'completed_at' => $progress->completed_at?->toDateTimeString(),
|
||||
'completed_by' => $progress->completed_by,
|
||||
'work_order_status_changed' => $workOrderStatusChanged,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 공정 단계 완료 시 작업지시를 자동으로 완료 처리
|
||||
*
|
||||
* 트리거: 마지막 공정 단계(포장 등) 완료 체크 시
|
||||
* 흐름: 전 단계 완료 → 작업지시 completed → 수주 상태 동기화 → 출하 자동 생성
|
||||
*/
|
||||
private function autoCompleteWorkOrderIfAllStepsDone(WorkOrder $workOrder, int $tenantId, int $userId): bool
|
||||
{
|
||||
// 이미 완료/출하 상태면 스킵
|
||||
if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 해당 작업지시의 모든 공정 단계 조회
|
||||
$allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get();
|
||||
|
||||
if ($allSteps->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 미완료 step 자동 보정: 같은 개소(work_order_item)의 다른 step이 모두 완료된 경우
|
||||
// 자재투입 등 모달 방식 step이 DB에 waiting으로 남아있을 수 있음
|
||||
$incompleteSteps = $allSteps->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED);
|
||||
if ($incompleteSteps->isNotEmpty()) {
|
||||
$this->autoCompleteOrphanedSteps($allSteps, $incompleteSteps, $userId);
|
||||
|
||||
// 보정 후 다시 확인
|
||||
$allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get();
|
||||
$allCompleted = $allSteps->every(fn ($step) => $step->status === WorkOrderStepProgress::STATUS_COMPLETED);
|
||||
|
||||
if (! $allCompleted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업지시 완료 처리 (updateStatus 재사용으로 출하 생성/수주 동기화 모두 트리거)
|
||||
$this->updateStatus($workOrder->id, WorkOrder::STATUS_COMPLETED);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 개소(work_order_item)의 나머지 step이 모두 완료되었으면
|
||||
* 남은 미완료 step(자재투입 등)도 자동 완료 처리
|
||||
*/
|
||||
private function autoCompleteOrphanedSteps($allSteps, $incompleteSteps, int $userId): void
|
||||
{
|
||||
// 개소(item)별로 그룹핑
|
||||
$stepsByItem = $allSteps->groupBy('work_order_item_id');
|
||||
|
||||
foreach ($incompleteSteps as $incomplete) {
|
||||
$itemSteps = $stepsByItem->get($incomplete->work_order_item_id);
|
||||
if (! $itemSteps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이 개소에서 이 step만 미완료인지 확인
|
||||
$otherIncomplete = $itemSteps->where('id', '!=', $incomplete->id)
|
||||
->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED);
|
||||
|
||||
if ($otherIncomplete->isEmpty()) {
|
||||
// 이 step만 남았으면 자동 완료
|
||||
$incomplete->status = WorkOrderStepProgress::STATUS_COMPLETED;
|
||||
$incomplete->completed_at = now();
|
||||
$incomplete->completed_by = $userId;
|
||||
$incomplete->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 이력 조회
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"laravel/mcp": "^0.1.1",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/flysystem-aws-s3-v3": "^3.32",
|
||||
"livewire/livewire": "^3.0",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"spatie/laravel-permission": "^6.21"
|
||||
|
||||
346
composer.lock
generated
346
composer.lock
generated
@@ -4,8 +4,159 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "340d586f1c4e3f7bd0728229300967da",
|
||||
"content-hash": "f39a7807cc0a6aa991e31a6acffc9508",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.372.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d207d2ca972c9b10674e535dacd4a5d956a80bad",
|
||||
"reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.8.0",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.4.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.372.3"
|
||||
},
|
||||
"time": "2026-03-10T18:07:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.13.1",
|
||||
@@ -2512,6 +2663,61 @@
|
||||
},
|
||||
"time": "2025-06-25T13:29:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.32.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.295.10",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0"
|
||||
},
|
||||
"time": "2026-02-25T16:46:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.30.0",
|
||||
@@ -3236,6 +3442,72 @@
|
||||
],
|
||||
"time": "2025-03-24T10:02:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.33"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
|
||||
},
|
||||
"time": "2024-09-04T18:46:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.10.1",
|
||||
@@ -5230,6 +5502,76 @@
|
||||
],
|
||||
"time": "2024-09-25T14:21:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v8.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "7bf9162d7a0dff98d079b72948508fa48018a770"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770",
|
||||
"reference": "7bf9162d7a0dff98d079b72948508fa48018a770",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v8.0.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-25T16:59:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.3.0",
|
||||
@@ -10773,5 +11115,5 @@
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'r2' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('R2_ACCESS_KEY_ID'),
|
||||
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||
'region' => env('R2_REGION', 'auto'),
|
||||
'bucket' => env('R2_BUCKET'),
|
||||
'endpoint' => env('R2_ENDPOINT'),
|
||||
'use_path_style_endpoint' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -58,4 +58,17 @@
|
||||
'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| BaroBill (바로빌 전자세금계산서/회계 연동)
|
||||
|--------------------------------------------------------------------------
|
||||
| MNG와 동일한 설정 구조를 사용한다.
|
||||
*/
|
||||
'barobill' => [
|
||||
'cert_key_test' => env('BAROBILL_CERT_KEY_TEST', ''),
|
||||
'cert_key_prod' => env('BAROBILL_CERT_KEY_PROD', ''),
|
||||
'corp_num' => env('BAROBILL_CORP_NUM', ''),
|
||||
'test_mode' => env('BAROBILL_TEST_MODE', true),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('receivings', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('certificate_file_id')
|
||||
->nullable()
|
||||
->after('options')
|
||||
->comment('업체 제공 성적서 파일 ID');
|
||||
|
||||
$table->foreign('certificate_file_id')
|
||||
->references('id')
|
||||
->on('files')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('receivings', function (Blueprint $table) {
|
||||
$table->dropForeign(['certificate_file_id']);
|
||||
$table->dropColumn('certificate_file_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 바로빌 관련 테이블에 options JSON 컬럼 추가
|
||||
*
|
||||
* SAM options 컬럼 정책에 따라 모든 비즈니스 테이블에
|
||||
* 확장 가능한 options JSON 컬럼을 추가한다.
|
||||
*
|
||||
* @see docs/standards/options-column-policy.md
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* options 컬럼을 추가할 테이블 목록
|
||||
*/
|
||||
private array $tables = [
|
||||
'barobill_settings',
|
||||
'barobill_configs',
|
||||
'barobill_members',
|
||||
'barobill_subscriptions',
|
||||
'barobill_billing_records',
|
||||
'barobill_monthly_summaries',
|
||||
'barobill_pricing_policies',
|
||||
'barobill_bank_transactions',
|
||||
'barobill_bank_transaction_overrides',
|
||||
'barobill_bank_transaction_splits',
|
||||
'barobill_bank_sync_status',
|
||||
'barobill_card_transactions',
|
||||
'barobill_card_transaction_splits',
|
||||
'barobill_card_transaction_amount_logs',
|
||||
'barobill_card_transaction_hides',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach ($this->tables as $table) {
|
||||
if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'options')) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->json('options')->nullable()->after('id');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach ($this->tables as $table) {
|
||||
if (Schema::hasTable($table) && Schema::hasColumn($table, 'options')) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->dropColumn('options');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('checklist_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->string('name', 255)->default('품질인정심사 점검표')->comment('템플릿명');
|
||||
$table->string('type', 50)->default('day1_audit')->comment('심사유형: day1_audit, day2_lot 등');
|
||||
$table->json('categories')->comment('카테고리/항목 JSON');
|
||||
$table->json('options')->nullable()->comment('확장 속성');
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['tenant_id', 'type'], 'uq_checklist_templates_tenant_type');
|
||||
$table->index(['tenant_id', 'type'], 'idx_checklist_templates_tenant_type');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
|
||||
// 기존 테넌트에 기본 템플릿 시딩
|
||||
$this->seedDefaultTemplates();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('checklist_templates');
|
||||
}
|
||||
|
||||
private function seedDefaultTemplates(): void
|
||||
{
|
||||
$defaultCategories = json_encode([
|
||||
[
|
||||
'id' => 'cat-1',
|
||||
'title' => '원재료 품질관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-1-1', 'name' => '수입검사 기준 확인'],
|
||||
['id' => 'cat-1-2', 'name' => '불합격품 처리 기준 확인'],
|
||||
['id' => 'cat-1-3', 'name' => '자재 보관 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-2',
|
||||
'title' => '제조공정 관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-2-1', 'name' => '작업표준서 확인'],
|
||||
['id' => 'cat-2-2', 'name' => '공정검사 기준 확인'],
|
||||
['id' => 'cat-2-3', 'name' => '부적합품 처리 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-3',
|
||||
'title' => '제품 품질관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-3-1', 'name' => '제품검사 기준 확인'],
|
||||
['id' => 'cat-3-2', 'name' => '출하검사 기준 확인'],
|
||||
['id' => 'cat-3-3', 'name' => '클레임 처리 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-4',
|
||||
'title' => '제조설비 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-4-1', 'name' => '설비관리 기준 확인'],
|
||||
['id' => 'cat-4-2', 'name' => '설비점검 이력 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-5',
|
||||
'title' => '검사설비 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-5-1', 'name' => '검사설비 관리 기준 확인'],
|
||||
['id' => 'cat-5-2', 'name' => '교정 이력 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-6',
|
||||
'title' => '문서 및 인증 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-6-1', 'name' => '문서관리 기준 확인'],
|
||||
['id' => 'cat-6-2', 'name' => 'KS/인증 관리 현황 확인'],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$tenantIds = DB::table('tenants')->pluck('id');
|
||||
$now = now();
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::table('checklist_templates')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => '품질인정심사 점검표',
|
||||
'type' => 'day1_audit',
|
||||
'categories' => $defaultCategories,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->string('mime_type', 150)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('files', function (Blueprint $table) {
|
||||
$table->string('mime_type', 50)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('equipments') && ! Schema::hasColumn('equipments', 'options')) {
|
||||
Schema::table('equipments', function (Blueprint $table) {
|
||||
$table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('equipment_repairs') && ! Schema::hasColumn('equipment_repairs', 'options')) {
|
||||
Schema::table('equipment_repairs', function (Blueprint $table) {
|
||||
$table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('equipments') && Schema::hasColumn('equipments', 'options')) {
|
||||
Schema::table('equipments', function (Blueprint $table) {
|
||||
$table->dropColumn('options');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('equipment_repairs') && Schema::hasColumn('equipment_repairs', 'options')) {
|
||||
Schema::table('equipment_repairs', function (Blueprint $table) {
|
||||
$table->dropColumn('options');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_mail_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->string('provider', 20)->default('platform')->comment('발송 방식: platform, smtp, ses, mailgun');
|
||||
$table->string('from_address', 255)->comment('발신 이메일');
|
||||
$table->string('from_name', 255)->comment('발신자명');
|
||||
$table->string('reply_to', 255)->nullable()->comment('회신 주소');
|
||||
$table->boolean('is_verified')->default(false)->comment('도메인 검증 여부');
|
||||
$table->unsignedInteger('daily_limit')->default(500)->comment('일일 발송 한도');
|
||||
$table->boolean('is_active')->default(true)->comment('활성 여부');
|
||||
$table->json('options')->nullable()->comment('SMTP 설정, 브랜딩, 연결 테스트 결과');
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique('tenant_id', 'uq_tenant_mail_configs');
|
||||
$table->index(['tenant_id', 'is_active']);
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_mail_configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('mail_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->string('mailable_type', 100)->comment('Mailable 클래스명');
|
||||
$table->string('to_address', 255)->comment('수신자');
|
||||
$table->string('from_address', 255)->comment('발신자');
|
||||
$table->string('subject', 500)->comment('제목');
|
||||
$table->string('status', 20)->default('queued')->comment('상태: queued, sent, failed, bounced');
|
||||
$table->timestamp('sent_at')->nullable()->comment('발송 시각');
|
||||
$table->json('options')->nullable()->comment('에러 메시지, 재시도 횟수, 관련 모델');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'status'], 'idx_mail_logs_tenant_status');
|
||||
$table->index(['tenant_id', 'created_at'], 'idx_mail_logs_tenant_date');
|
||||
$table->index(['tenant_id', 'mailable_type'], 'idx_mail_logs_mailable');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mail_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* equipment 테이블이 기본 DB(sam)에 존재하는지 확인하고, 없으면 생성한다.
|
||||
*
|
||||
* 배경: 기존 마이그레이션이 codebridge DB에 테이블을 생성했으나,
|
||||
* 운영서버(API+React)에서는 기본 DB(sam/sam_prod)에 테이블이 필요하다.
|
||||
* 이미 테이블이 존재하는 환경에서는 모든 생성을 건너뛴다.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1. equipments (메인 설비 마스터)
|
||||
if (! Schema::hasTable('equipments')) {
|
||||
Schema::create('equipments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->string('equipment_code', 20)->comment('설비코드 (KD-M-001 형식)');
|
||||
$table->string('name', 100)->comment('설비명');
|
||||
$table->string('equipment_type', 50)->nullable()->comment('설비유형');
|
||||
$table->string('specification', 255)->nullable()->comment('규격');
|
||||
$table->string('manufacturer', 100)->nullable()->comment('제조사');
|
||||
$table->string('model_name', 100)->nullable()->comment('모델명');
|
||||
$table->string('serial_no', 100)->nullable()->comment('제조번호');
|
||||
$table->string('location', 100)->nullable()->comment('위치');
|
||||
$table->string('production_line', 50)->nullable()->comment('생산라인');
|
||||
$table->date('purchase_date')->nullable()->comment('구입일');
|
||||
$table->date('install_date')->nullable()->comment('설치일');
|
||||
$table->decimal('purchase_price', 15, 2)->nullable()->comment('구입가격');
|
||||
$table->integer('useful_life')->nullable()->comment('내용연수');
|
||||
$table->string('status', 20)->default('active')->comment('상태: active/idle/disposed');
|
||||
$table->date('disposed_date')->nullable()->comment('폐기일');
|
||||
$table->foreignId('manager_id')->nullable()->comment('담당자 ID');
|
||||
$table->foreignId('sub_manager_id')->nullable()->comment('부 담당자 ID');
|
||||
$table->string('photo_path', 500)->nullable()->comment('설비사진 경로');
|
||||
$table->text('memo')->nullable()->comment('비고');
|
||||
$table->json('options')->nullable()->comment('확장 속성 JSON');
|
||||
$table->tinyInteger('is_active')->default(1)->comment('사용여부');
|
||||
$table->integer('sort_order')->default(0)->comment('정렬순서');
|
||||
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->foreignId('deleted_by')->nullable()->comment('삭제자 ID');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['tenant_id', 'equipment_code'], 'uq_equipment_code');
|
||||
$table->index(['tenant_id', 'status'], 'idx_equipment_status');
|
||||
$table->index(['tenant_id', 'production_line'], 'idx_equipment_line');
|
||||
$table->index(['tenant_id', 'equipment_type'], 'idx_equipment_type');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. equipment_inspection_templates (점검항목 템플릿)
|
||||
if (! Schema::hasTable('equipment_inspection_templates')) {
|
||||
Schema::create('equipment_inspection_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
|
||||
$table->string('inspection_cycle', 20)->default('daily')
|
||||
->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual');
|
||||
$table->integer('item_no')->comment('항목번호');
|
||||
$table->string('check_point', 50)->comment('점검개소');
|
||||
$table->string('check_item', 100)->comment('점검항목');
|
||||
$table->string('check_timing', 20)->nullable()->comment('시기: operating/stopped');
|
||||
$table->string('check_frequency', 50)->nullable()->comment('주기');
|
||||
$table->text('check_method')->nullable()->comment('점검방법 및 기준');
|
||||
$table->integer('sort_order')->default(0)->comment('정렬순서');
|
||||
$table->tinyInteger('is_active')->default(1)->comment('사용여부');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['equipment_id', 'inspection_cycle', 'item_no'], 'uq_equipment_cycle_item_no');
|
||||
$table->index('tenant_id', 'idx_insp_tmpl_tenant');
|
||||
$table->index('inspection_cycle', 'idx_insp_tmpl_cycle');
|
||||
|
||||
$table->foreign('equipment_id')
|
||||
->references('id')
|
||||
->on('equipments')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
// 3. equipment_inspections (점검 헤더)
|
||||
if (! Schema::hasTable('equipment_inspections')) {
|
||||
Schema::create('equipment_inspections', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
|
||||
$table->string('inspection_cycle', 20)->default('daily')
|
||||
->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual');
|
||||
$table->string('year_month', 7)->comment('점검년월 (2026-02)');
|
||||
$table->string('overall_judgment', 10)->nullable()->comment('종합판정: OK/NG');
|
||||
$table->foreignId('inspector_id')->nullable()->comment('점검자 ID');
|
||||
$table->text('repair_note')->nullable()->comment('수리내역');
|
||||
$table->text('issue_note')->nullable()->comment('이상내용');
|
||||
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'equipment_id', 'inspection_cycle', 'year_month'], 'uq_inspection_cycle_period');
|
||||
$table->index(['tenant_id', 'inspection_cycle', 'year_month'], 'idx_inspection_cycle_period');
|
||||
|
||||
$table->foreign('equipment_id')
|
||||
->references('id')
|
||||
->on('equipments')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
// 4. equipment_inspection_details (점검 상세)
|
||||
if (! Schema::hasTable('equipment_inspection_details')) {
|
||||
Schema::create('equipment_inspection_details', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('inspection_id')->comment('점검 헤더 ID');
|
||||
$table->unsignedBigInteger('template_item_id')->comment('점검항목 템플릿 ID');
|
||||
$table->date('check_date')->comment('점검일');
|
||||
$table->string('result', 10)->nullable()->comment('결과: good/bad/repaired');
|
||||
$table->string('note', 500)->nullable()->comment('비고');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['inspection_id', 'template_item_id', 'check_date'], 'uq_inspection_detail');
|
||||
|
||||
$table->foreign('inspection_id')
|
||||
->references('id')
|
||||
->on('equipment_inspections')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('template_item_id')
|
||||
->references('id')
|
||||
->on('equipment_inspection_templates')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
// 5. equipment_repairs (수리이력)
|
||||
if (! Schema::hasTable('equipment_repairs')) {
|
||||
Schema::create('equipment_repairs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
|
||||
$table->date('repair_date')->comment('수리일');
|
||||
$table->string('repair_type', 20)->comment('보전구분: internal/external');
|
||||
$table->decimal('repair_hours', 5, 1)->nullable()->comment('수리시간');
|
||||
$table->text('description')->nullable()->comment('수리내용');
|
||||
$table->decimal('cost', 15, 2)->nullable()->comment('수리비용');
|
||||
$table->string('vendor', 100)->nullable()->comment('외주업체');
|
||||
$table->foreignId('repaired_by')->nullable()->comment('수리자 ID');
|
||||
$table->text('memo')->nullable()->comment('비고');
|
||||
$table->json('options')->nullable()->comment('확장 속성 JSON');
|
||||
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['tenant_id', 'repair_date'], 'idx_repair_date');
|
||||
$table->index(['tenant_id', 'equipment_id'], 'idx_repair_equipment');
|
||||
|
||||
$table->foreign('equipment_id')
|
||||
->references('id')
|
||||
->on('equipments')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
// 6. equipment_process (설비-공정 매핑)
|
||||
if (! Schema::hasTable('equipment_process')) {
|
||||
Schema::create('equipment_process', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
|
||||
$table->unsignedBigInteger('process_id')->comment('공정 ID');
|
||||
$table->tinyInteger('is_primary')->default(0)->comment('주 설비 여부');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['equipment_id', 'process_id'], 'uq_equipment_process');
|
||||
|
||||
$table->foreign('equipment_id')
|
||||
->references('id')
|
||||
->on('equipments')
|
||||
->onDelete('cascade');
|
||||
|
||||
$table->foreign('process_id')
|
||||
->references('id')
|
||||
->on('processes')
|
||||
->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 역순으로 삭제 (FK 의존성)
|
||||
Schema::dropIfExists('equipment_process');
|
||||
Schema::dropIfExists('equipment_inspection_details');
|
||||
Schema::dropIfExists('equipment_repairs');
|
||||
Schema::dropIfExists('equipment_inspections');
|
||||
Schema::dropIfExists('equipment_inspection_templates');
|
||||
Schema::dropIfExists('equipments');
|
||||
}
|
||||
};
|
||||
@@ -99,6 +99,9 @@
|
||||
'cannot_delete' => '현재 상태에서는 삭제할 수 없습니다.',
|
||||
'invalid_status' => '유효하지 않은 상태입니다.',
|
||||
'cannot_ship' => '출하 가능 상태가 아닙니다.',
|
||||
'order_not_produced' => '생산완료 상태의 수주만 출하를 생성할 수 있습니다.',
|
||||
'no_work_orders' => '해당 수주에 유효한 작업지시가 없습니다.',
|
||||
'already_exists' => '이미 해당 수주에 출하가 존재합니다.',
|
||||
],
|
||||
|
||||
// 파일 관리 관련
|
||||
@@ -282,10 +285,14 @@
|
||||
'not_editable' => '작성중 상태의 급여만 수정할 수 있습니다.',
|
||||
'not_deletable' => '작성중 상태의 급여만 삭제할 수 있습니다.',
|
||||
'not_confirmable' => '작성중 상태의 급여만 확정할 수 있습니다.',
|
||||
'not_unconfirmable' => '확정된 급여만 확정 취소할 수 있습니다.',
|
||||
'not_payable' => '확정된 급여만 지급 처리할 수 있습니다.',
|
||||
'not_unpayable' => '지급완료된 급여만 지급 취소할 수 있습니다.',
|
||||
'no_previous_month' => '전월 급여 데이터가 없습니다.',
|
||||
'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.',
|
||||
'user_not_found' => '직원 정보를 찾을 수 없습니다.',
|
||||
'no_base_salary' => '기본급이 설정되지 않았습니다.',
|
||||
'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.',
|
||||
],
|
||||
|
||||
// 세금계산서 관련
|
||||
@@ -469,6 +476,17 @@
|
||||
'invalid_status' => '유효하지 않은 입찰 상태입니다.',
|
||||
],
|
||||
|
||||
// 설비 관리
|
||||
'equipment' => [
|
||||
'not_found' => '설비 정보를 찾을 수 없습니다.',
|
||||
'template_not_found' => '점검항목을 찾을 수 없습니다.',
|
||||
'inspection_not_found' => '점검 데이터를 찾을 수 없습니다.',
|
||||
'repair_not_found' => '수리이력을 찾을 수 없습니다.',
|
||||
'photo_not_found' => '사진을 찾을 수 없습니다.',
|
||||
'invalid_cycle' => '유효하지 않은 점검주기입니다.',
|
||||
'no_source_templates' => '복사할 점검항목이 없습니다.',
|
||||
],
|
||||
|
||||
// 전자계약 (E-Sign)
|
||||
'esign' => [
|
||||
'invalid_token' => '유효하지 않은 서명 링크입니다.',
|
||||
|
||||
@@ -320,10 +320,16 @@
|
||||
'updated' => '급여가 수정되었습니다.',
|
||||
'deleted' => '급여가 삭제되었습니다.',
|
||||
'confirmed' => '급여가 확정되었습니다.',
|
||||
'unconfirmed' => '급여 확정이 취소되었습니다.',
|
||||
'paid' => '급여가 지급 처리되었습니다.',
|
||||
'unpaid' => '급여 지급이 취소되었습니다.',
|
||||
'bulk_confirmed' => '급여가 일괄 확정되었습니다.',
|
||||
'bulk_generated' => '급여가 일괄 생성되었습니다.',
|
||||
'copied' => '전월 급여가 복사되었습니다.',
|
||||
'calculated' => '급여가 일괄 계산되었습니다.',
|
||||
'payslip_fetched' => '급여명세서를 조회했습니다.',
|
||||
'exported' => '급여 현황이 내보내기되었습니다.',
|
||||
'journal_created' => '급여 전표가 생성되었습니다.',
|
||||
],
|
||||
|
||||
// 급여 설정 관리
|
||||
@@ -571,6 +577,20 @@
|
||||
'downloaded' => '문서가 다운로드되었습니다.',
|
||||
],
|
||||
|
||||
// 설비 관리
|
||||
'equipment' => [
|
||||
'created' => '설비가 등록되었습니다.',
|
||||
'updated' => '설비 정보가 수정되었습니다.',
|
||||
'deleted' => '설비가 삭제되었습니다.',
|
||||
'restored' => '설비가 복원되었습니다.',
|
||||
'inspection_saved' => '점검 정보가 저장되었습니다.',
|
||||
'inspection_reset' => '점검 데이터가 초기화되었습니다.',
|
||||
'template_created' => '점검항목이 추가되었습니다.',
|
||||
'template_copied' => '점검항목이 복사되었습니다.',
|
||||
'repair_created' => '수리이력이 등록되었습니다.',
|
||||
'photo_uploaded' => '사진이 업로드되었습니다.',
|
||||
],
|
||||
|
||||
// 일반전표입력
|
||||
'journal_entry' => [
|
||||
'fetched' => '전표 조회 성공',
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
require __DIR__.'/api/v1/audit.php';
|
||||
require __DIR__.'/api/v1/esign.php';
|
||||
require __DIR__.'/api/v1/quality.php';
|
||||
require __DIR__.'/api/v1/equipment.php';
|
||||
|
||||
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
|
||||
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');
|
||||
|
||||
45
routes/api/v1/equipment.php
Normal file
45
routes/api/v1/equipment.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\V1\Equipment\EquipmentController;
|
||||
use App\Http\Controllers\V1\Equipment\EquipmentInspectionController;
|
||||
use App\Http\Controllers\V1\Equipment\EquipmentPhotoController;
|
||||
use App\Http\Controllers\V1\Equipment\EquipmentRepairController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('equipment')->group(function () {
|
||||
// 설비 CRUD
|
||||
Route::get('', [EquipmentController::class, 'index'])->name('v1.equipment.index');
|
||||
Route::get('/options', [EquipmentController::class, 'options'])->name('v1.equipment.options');
|
||||
Route::get('/stats', [EquipmentController::class, 'stats'])->name('v1.equipment.stats');
|
||||
Route::post('', [EquipmentController::class, 'store'])->name('v1.equipment.store');
|
||||
Route::get('/{id}', [EquipmentController::class, 'show'])->whereNumber('id')->name('v1.equipment.show');
|
||||
Route::put('/{id}', [EquipmentController::class, 'update'])->whereNumber('id')->name('v1.equipment.update');
|
||||
Route::delete('/{id}', [EquipmentController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.destroy');
|
||||
Route::post('/{id}/restore', [EquipmentController::class, 'restore'])->whereNumber('id')->name('v1.equipment.restore');
|
||||
Route::patch('/{id}/toggle', [EquipmentController::class, 'toggleActive'])->whereNumber('id')->name('v1.equipment.toggle');
|
||||
|
||||
// 점검 템플릿
|
||||
Route::get('/{id}/templates', [EquipmentInspectionController::class, 'templates'])->whereNumber('id')->name('v1.equipment.templates');
|
||||
Route::post('/{id}/templates', [EquipmentInspectionController::class, 'storeTemplate'])->whereNumber('id')->name('v1.equipment.templates.store');
|
||||
Route::put('/templates/{templateId}', [EquipmentInspectionController::class, 'updateTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.update');
|
||||
Route::delete('/templates/{templateId}', [EquipmentInspectionController::class, 'deleteTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.destroy');
|
||||
Route::post('/{id}/templates/copy', [EquipmentInspectionController::class, 'copyTemplates'])->whereNumber('id')->name('v1.equipment.templates.copy');
|
||||
|
||||
// 점검
|
||||
Route::get('/inspections', [EquipmentInspectionController::class, 'index'])->name('v1.equipment.inspections.index');
|
||||
Route::patch('/inspections/toggle', [EquipmentInspectionController::class, 'toggleDetail'])->name('v1.equipment.inspections.toggle');
|
||||
Route::patch('/inspections/set-result', [EquipmentInspectionController::class, 'setResult'])->name('v1.equipment.inspections.set-result');
|
||||
Route::patch('/inspections/notes', [EquipmentInspectionController::class, 'updateNotes'])->name('v1.equipment.inspections.notes');
|
||||
Route::delete('/inspections/reset', [EquipmentInspectionController::class, 'resetInspection'])->name('v1.equipment.inspections.reset');
|
||||
|
||||
// 수리이력
|
||||
Route::get('/repairs', [EquipmentRepairController::class, 'index'])->name('v1.equipment.repairs.index');
|
||||
Route::post('/repairs', [EquipmentRepairController::class, 'store'])->name('v1.equipment.repairs.store');
|
||||
Route::put('/repairs/{id}', [EquipmentRepairController::class, 'update'])->whereNumber('id')->name('v1.equipment.repairs.update');
|
||||
Route::delete('/repairs/{id}', [EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.repairs.destroy');
|
||||
|
||||
// 사진
|
||||
Route::get('/{id}/photos', [EquipmentPhotoController::class, 'index'])->whereNumber('id')->name('v1.equipment.photos.index');
|
||||
Route::post('/{id}/photos', [EquipmentPhotoController::class, 'store'])->whereNumber('id')->name('v1.equipment.photos.store');
|
||||
Route::delete('/{id}/photos/{fileId}', [EquipmentPhotoController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.photos.destroy');
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
Route::get('/trash', [FileStorageController::class, 'trash'])->name('v1.files.trash'); // 휴지통 목록
|
||||
Route::get('/{id}', [FileStorageController::class, 'show'])->name('v1.files.show'); // 파일 상세
|
||||
Route::get('/{id}/download', [FileStorageController::class, 'download'])->name('v1.files.download'); // 파일 다운로드
|
||||
Route::get('/{id}/view', [FileStorageController::class, 'view'])->name('v1.files.view'); // 파일 인라인 보기 (이미지/PDF)
|
||||
Route::delete('/{id}', [FileStorageController::class, 'destroy'])->name('v1.files.destroy'); // 파일 삭제 (soft)
|
||||
Route::post('/{id}/restore', [FileStorageController::class, 'restore'])->name('v1.files.restore'); // 파일 복구
|
||||
Route::delete('/{id}/permanent', [FileStorageController::class, 'permanentDelete'])->name('v1.files.permanent'); // 파일 영구 삭제
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
use App\Http\Controllers\Api\V1\BadDebtController;
|
||||
use App\Http\Controllers\Api\V1\BankAccountController;
|
||||
use App\Http\Controllers\Api\V1\BankTransactionController;
|
||||
use App\Http\Controllers\Api\V1\BarobillBankTransactionController;
|
||||
use App\Http\Controllers\Api\V1\BarobillCardTransactionController;
|
||||
use App\Http\Controllers\Api\V1\BarobillController;
|
||||
use App\Http\Controllers\Api\V1\BarobillSettingController;
|
||||
use App\Http\Controllers\Api\V1\BillController;
|
||||
@@ -28,6 +30,7 @@
|
||||
use App\Http\Controllers\Api\V1\EntertainmentController;
|
||||
use App\Http\Controllers\Api\V1\ExpectedExpenseController;
|
||||
use App\Http\Controllers\Api\V1\GeneralJournalEntryController;
|
||||
use App\Http\Controllers\Api\V1\HometaxInvoiceController;
|
||||
use App\Http\Controllers\Api\V1\LoanController;
|
||||
use App\Http\Controllers\Api\V1\PaymentController;
|
||||
use App\Http\Controllers\Api\V1\PayrollController;
|
||||
@@ -96,12 +99,19 @@
|
||||
Route::post('', [PayrollController::class, 'store'])->name('v1.payrolls.store');
|
||||
Route::get('/summary', [PayrollController::class, 'summary'])->name('v1.payrolls.summary');
|
||||
Route::post('/calculate', [PayrollController::class, 'calculate'])->name('v1.payrolls.calculate');
|
||||
Route::post('/calculate-preview', [PayrollController::class, 'calculatePreview'])->name('v1.payrolls.calculate-preview');
|
||||
Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm');
|
||||
Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate');
|
||||
Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous');
|
||||
Route::get('/export', [PayrollController::class, 'export'])->name('v1.payrolls.export');
|
||||
Route::post('/journal-entries', [PayrollController::class, 'journalEntries'])->name('v1.payrolls.journal-entries');
|
||||
Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show');
|
||||
Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update');
|
||||
Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy');
|
||||
Route::post('/{id}/confirm', [PayrollController::class, 'confirm'])->whereNumber('id')->name('v1.payrolls.confirm');
|
||||
Route::post('/{id}/unconfirm', [PayrollController::class, 'unconfirm'])->whereNumber('id')->name('v1.payrolls.unconfirm');
|
||||
Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay');
|
||||
Route::post('/{id}/unpay', [PayrollController::class, 'unpay'])->whereNumber('id')->name('v1.payrolls.unpay');
|
||||
Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip');
|
||||
});
|
||||
|
||||
@@ -277,6 +287,60 @@
|
||||
Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url');
|
||||
});
|
||||
|
||||
// Barobill Card Transaction API (바로빌 카드 거래 - React 연동)
|
||||
Route::prefix('barobill-card-transactions')->group(function () {
|
||||
Route::get('', [BarobillCardTransactionController::class, 'index'])->name('v1.barobill-card-transactions.index');
|
||||
Route::get('/card-numbers', [BarobillCardTransactionController::class, 'cardNumbers'])->name('v1.barobill-card-transactions.card-numbers');
|
||||
Route::get('/hidden', [BarobillCardTransactionController::class, 'hiddenList'])->name('v1.barobill-card-transactions.hidden');
|
||||
Route::get('/splits', [BarobillCardTransactionController::class, 'getSplits'])->name('v1.barobill-card-transactions.splits.show');
|
||||
Route::post('/splits', [BarobillCardTransactionController::class, 'saveSplits'])->name('v1.barobill-card-transactions.splits.store');
|
||||
Route::delete('/splits', [BarobillCardTransactionController::class, 'deleteSplits'])->name('v1.barobill-card-transactions.splits.destroy');
|
||||
Route::post('/manual', [BarobillCardTransactionController::class, 'storeManual'])->name('v1.barobill-card-transactions.manual.store');
|
||||
Route::put('/manual/{id}', [BarobillCardTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.update');
|
||||
Route::delete('/manual/{id}', [BarobillCardTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.destroy');
|
||||
Route::get('/{id}', [BarobillCardTransactionController::class, 'show'])->whereNumber('id')->name('v1.barobill-card-transactions.show');
|
||||
Route::post('/{id}/hide', [BarobillCardTransactionController::class, 'hide'])->whereNumber('id')->name('v1.barobill-card-transactions.hide');
|
||||
Route::post('/{id}/restore', [BarobillCardTransactionController::class, 'restore'])->whereNumber('id')->name('v1.barobill-card-transactions.restore');
|
||||
Route::put('/{id}/amount', [BarobillCardTransactionController::class, 'updateAmount'])->whereNumber('id')->name('v1.barobill-card-transactions.update-amount');
|
||||
Route::get('/{id}/journal-entries', [BarobillCardTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.show');
|
||||
Route::post('/{id}/journal-entries', [BarobillCardTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.store');
|
||||
Route::delete('/{id}/journal-entries', [BarobillCardTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.destroy');
|
||||
});
|
||||
|
||||
// Barobill Bank Transaction API (바로빌 은행 거래 - React 연동)
|
||||
Route::prefix('barobill-bank-transactions')->group(function () {
|
||||
Route::get('', [BarobillBankTransactionController::class, 'index'])->name('v1.barobill-bank-transactions.index');
|
||||
Route::get('/accounts', [BarobillBankTransactionController::class, 'accounts'])->name('v1.barobill-bank-transactions.accounts');
|
||||
Route::get('/balance-summary', [BarobillBankTransactionController::class, 'balanceSummary'])->name('v1.barobill-bank-transactions.balance-summary');
|
||||
Route::get('/splits', [BarobillBankTransactionController::class, 'getSplits'])->name('v1.barobill-bank-transactions.splits.show');
|
||||
Route::post('/splits', [BarobillBankTransactionController::class, 'saveSplits'])->name('v1.barobill-bank-transactions.splits.store');
|
||||
Route::delete('/splits', [BarobillBankTransactionController::class, 'deleteSplits'])->name('v1.barobill-bank-transactions.splits.destroy');
|
||||
Route::post('/override', [BarobillBankTransactionController::class, 'saveOverride'])->name('v1.barobill-bank-transactions.override');
|
||||
Route::post('/manual', [BarobillBankTransactionController::class, 'storeManual'])->name('v1.barobill-bank-transactions.manual.store');
|
||||
Route::put('/manual/{id}', [BarobillBankTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.update');
|
||||
Route::delete('/manual/{id}', [BarobillBankTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.destroy');
|
||||
Route::get('/{id}/journal-entries', [BarobillBankTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.show');
|
||||
Route::post('/{id}/journal-entries', [BarobillBankTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.store');
|
||||
Route::delete('/{id}/journal-entries', [BarobillBankTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.destroy');
|
||||
});
|
||||
|
||||
// Hometax Invoice API (홈택스 세금계산서 - React 연동)
|
||||
Route::prefix('hometax-invoices')->group(function () {
|
||||
Route::get('/sales', [HometaxInvoiceController::class, 'sales'])->name('v1.hometax-invoices.sales');
|
||||
Route::get('/purchases', [HometaxInvoiceController::class, 'purchases'])->name('v1.hometax-invoices.purchases');
|
||||
Route::get('/summary', [HometaxInvoiceController::class, 'summary'])->name('v1.hometax-invoices.summary');
|
||||
Route::post('', [HometaxInvoiceController::class, 'store'])->name('v1.hometax-invoices.store');
|
||||
Route::get('/{id}', [HometaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.hometax-invoices.show');
|
||||
Route::put('/{id}', [HometaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.hometax-invoices.update');
|
||||
Route::delete('/{id}', [HometaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.hometax-invoices.destroy');
|
||||
Route::get('/{id}/journals', [HometaxInvoiceController::class, 'getJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.show');
|
||||
Route::post('/{id}/journals', [HometaxInvoiceController::class, 'saveJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.store');
|
||||
Route::delete('/{id}/journals', [HometaxInvoiceController::class, 'deleteJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.destroy');
|
||||
Route::get('/{id}/journal-entries', [HometaxInvoiceController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.show');
|
||||
Route::post('/{id}/journal-entries', [HometaxInvoiceController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.store');
|
||||
Route::delete('/{id}/journal-entries', [HometaxInvoiceController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.destroy');
|
||||
});
|
||||
|
||||
// Tax Invoice API (세금계산서)
|
||||
Route::prefix('tax-invoices')->group(function () {
|
||||
Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user